mirror of
https://github.com/Tencent/tmagic-editor.git
synced 2026-10-28 13:30:46 +00:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c4cf3b9525 | ||
|
|
d01bc45b17 | ||
|
|
7e8cb1f9db | ||
|
|
d017f43573 | ||
|
|
cbd0442ffc | ||
|
|
8ac1a38601 | ||
|
|
7f0e78bf6a | ||
|
|
c6deac8524 | ||
|
|
f12f78c9e0 | ||
|
|
94fae10ae0 |
11
CHANGELOG.md
11
CHANGELOG.md
@ -1,3 +1,14 @@
|
|||||||
|
## [1.8.3](https://github.com/Tencent/tmagic-editor/compare/v1.8.2...v1.8.3) (2026-10-13)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **form:** 日期/时间字段支持 valueFormat 为 X 的秒级时间戳 ([f12f78c](https://github.com/Tencent/tmagic-editor/commit/f12f78c9e045d41cf03be3e867d15aec5b50d202))
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* **editor:** 新增 SDK 层并将核心服务迁移至 sdk 目录 ([94fae10](https://github.com/Tencent/tmagic-editor/commit/94fae10ae00b822ca0e2219f9e9bbc2c51ea0a1c))
|
||||||
## [1.8.2](https://github.com/Tencent/tmagic-editor/compare/v1.8.1...v1.8.2) (2026-09-23)
|
## [1.8.2](https://github.com/Tencent/tmagic-editor/compare/v1.8.1...v1.8.2) (2026-09-23)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -228,6 +228,10 @@ export default defineConfig({
|
|||||||
text: 'storageService',
|
text: 'storageService',
|
||||||
link: '/api/editor/storageServiceMethods.md',
|
link: '/api/editor/storageServiceMethods.md',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
text: 'DSL SDK(Node)',
|
||||||
|
link: '/api/editor/sdk.md',
|
||||||
|
},
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@ -503,6 +503,32 @@ const renderFunction = async (stage) => {
|
|||||||
</template>
|
</template>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## iframeSandbox
|
||||||
|
|
||||||
|
- **详情:**
|
||||||
|
|
||||||
|
画布 iframe 的 sandbox 属性值,仅 `renderType` 为 `iframe` 时生效。不传或传 `false` 时不设置 sandbox 属性。
|
||||||
|
|
||||||
|
token 用空格分隔,缺哪个 token,画布内对应能力就会静默失效。编辑器自身依赖 `allow-scripts` + `allow-same-origin`(需要读写 iframe 内的 DOM);组件内需要下载、弹窗、表单提交时,分别要放开 `allow-downloads`、`allow-popups`、`allow-forms` 等。
|
||||||
|
|
||||||
|
注意:
|
||||||
|
|
||||||
|
- 仅在编辑器初始化时读取,运行期修改不会生效。
|
||||||
|
- runtime 与编辑器不同域时,会通过 `srcdoc` 加载,同样受 sandbox 约束;缺少 `allow-same-origin` 会导致编辑器无法访问 iframe 内的 DOM。
|
||||||
|
- `allow-scripts` 与 `allow-same-origin` 同时存在且 iframe 同源时,iframe 内脚本可以移除自身的 sandbox 属性,因此该配置只用于限制 `allow-top-navigation`、`allow-popups` 等能力,不能作为安全隔离手段。
|
||||||
|
|
||||||
|
- **默认值:** `undefined`(不设置 sandbox 属性)
|
||||||
|
|
||||||
|
- **类型:** `string | false`
|
||||||
|
|
||||||
|
- **示例:**
|
||||||
|
|
||||||
|
```html
|
||||||
|
<template>
|
||||||
|
<m-editor iframe-sandbox="allow-scripts allow-same-origin allow-downloads"></m-editor>
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
## autoScrollIntoView
|
## autoScrollIntoView
|
||||||
|
|
||||||
- **详情:**
|
- **详情:**
|
||||||
|
|||||||
154
docs/api/editor/sdk.md
Normal file
154
docs/api/editor/sdk.md
Normal file
@ -0,0 +1,154 @@
|
|||||||
|
# DSL SDK(`@tmagic/editor/sdk`)
|
||||||
|
|
||||||
|
不依赖 DOM / Vue 界面 / 画布的 DSL 操作 SDK,只负责对 DSL 做增删改查、历史撤销重做、依赖收集与合法性校验,可以直接在 Node 中运行(比如给 AI agent 调用的命令行工具使用)。
|
||||||
|
|
||||||
|
浏览器端的 `editorService`、`historyService` 等单例就是用同一个工厂创建的默认实例,因此 `import { editorService } from '@tmagic/editor'` 的用法不受影响。
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { createDslSdk } from '@tmagic/editor/sdk';
|
||||||
|
```
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { createDslSdk } from '@tmagic/editor/sdk';
|
||||||
|
|
||||||
|
const sdk = createDslSdk({
|
||||||
|
// 初始 DSL,会被深拷贝,不会修改传入的对象
|
||||||
|
dsl: { type: 'app', id: 'app_1', items: [{ type: 'page', id: 'page_1', name: 'index', items: [] }] },
|
||||||
|
// 此前通过 sdk.serializeHistory() 导出的历史快照(可选)
|
||||||
|
// history,
|
||||||
|
// 组件列表:用来判断新增的组件类型是否合法
|
||||||
|
componentGroupList: [{ title: '基础', items: [{ text: '文本', type: 'text' }] }],
|
||||||
|
// 组件属性表单配置,key 是组件 type,值是该组件的表单配置(FormConfig,即表单项数组):用来校验属性是否合法
|
||||||
|
propsConfigs: {
|
||||||
|
text: [{ type: 'text', name: 'text', text: '文本', rules: [{ required: true, message: '文本不能为空' }] }],
|
||||||
|
},
|
||||||
|
// 组件被添加时的初始值,与 propsConfigs 的 key 一一对应
|
||||||
|
propsValues: { text: { text: '文本' } },
|
||||||
|
});
|
||||||
|
|
||||||
|
await sdk.ready; // 登记表(含异步的属性配置)全部就绪
|
||||||
|
|
||||||
|
// 没有选中节点时,默认添加到第一个页面;id 由 SDK 生成,后续操作使用返回值里的 id
|
||||||
|
const node = await sdk.editor.add({ type: 'text', text: 'hello' });
|
||||||
|
await sdk.editor.update({ id: node.id, text: 'world' });
|
||||||
|
await sdk.editor.undo();
|
||||||
|
|
||||||
|
// 属性不合法时(strict 模式)会抛出 DslValidationError,且不会修改 DSL
|
||||||
|
await sdk.editor.update({ id: node.id, text: '' }).catch((e) => console.log(e.errors)); // [{ ..., message: '文本 -> 文本不能为空' }]
|
||||||
|
|
||||||
|
const newDsl = sdk.getDsl(); // 深拷贝,已包含依赖收集结果
|
||||||
|
const newHistory = sdk.serializeHistory(); // 可 JSON.stringify,下次作为 history 传回
|
||||||
|
```
|
||||||
|
|
||||||
|
::: tip
|
||||||
|
`propsConfigs` 的格式与 [Editor 的 propsConfigs](./props.md#propsconfigs) 相同。实际项目中通常直接使用 runtime 构建出来的 `magicPresetConfigs` / `magicPresetValues`(参考[快速开始](../../guide/index.md#propsconfigs-propsvalues)),在 Node 中加载构建产物后传入即可。
|
||||||
|
:::
|
||||||
|
|
||||||
|
## 无状态使用
|
||||||
|
|
||||||
|
每次调用传入「DSL + 历史快照」,操作完成后导出新的「DSL + 历史快照」:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const sdk = createDslSdk({ dsl: readJson('dsl.json'), history: readJson('history.json') });
|
||||||
|
await sdk.editor.update({ id: 'button_1', text: 'new' });
|
||||||
|
writeJson('dsl.json', sdk.getDsl());
|
||||||
|
writeJson('history.json', sdk.serializeHistory());
|
||||||
|
```
|
||||||
|
|
||||||
|
::: tip
|
||||||
|
- `serializeHistory()` 导出的快照可以直接 `JSON.stringify`;浏览器端 `historyService.serialize()` 默认保留 `Map`,适合写入 IndexedDB。
|
||||||
|
- Node 下默认的 `parseDSL` 只解析 JSON,不会执行代码;如果历史里包含函数 / 正则,请自行传入 `parseDSL`。
|
||||||
|
:::
|
||||||
|
|
||||||
|
## createDslSdk(options)
|
||||||
|
|
||||||
|
每次调用返回一组相互连线、互相独立的 service,多个实例互不影响。
|
||||||
|
|
||||||
|
### options
|
||||||
|
|
||||||
|
| 参数 | 说明 | 默认值 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `dsl` | 初始 DSL | - |
|
||||||
|
| `history` | 历史快照 | - |
|
||||||
|
| `validateMode` | 校验模式,见[校验](#校验) | `'strict'` |
|
||||||
|
| `autoWiring` | 是否自动绑定依赖收集连线 | `true` |
|
||||||
|
| `reactivity` | 响应式实现:`reactive` / `shallowReactive` / `toRaw` / `nextTick`,默认使用 Vue 的实现,可覆盖 | Vue |
|
||||||
|
| `storage` | 存储适配器(`localStorage` 天然满足),用于复制粘贴、代码块草稿 | 内存实现 `MemoryStorage` |
|
||||||
|
| `parseDSL` | 把序列化字符串还原成值 | 只解析 JSON |
|
||||||
|
| `historyPersistence` | 历史持久化适配器 `{ get, set }`(浏览器端对应 IndexedDB),不提供时 `saveToIndexedDB` / `restoreFromIndexedDB` 会 reject | - |
|
||||||
|
| `collectWorker` | 依赖收集 worker 适配器,不提供则在主线程收集 | - |
|
||||||
|
| `copyText` / `notify` | 复制到剪贴板、提示信息,不提供则不做任何事 | - |
|
||||||
|
| `componentGroupList` / `propsConfigs` / `propsValues` / `eventMethodList` / `datasourceList` / `datasourceConfigs` / `datasourceValues` / `datasourceEventMethodList` / `disabledCodeBlock` / `disabledDataSource` / `disabledPageFragment` / `disabledMultiSelect` / `alwaysMultiSelect` / `canDropIn` | 登记表,与 [Editor props](./props.md) 同名项一一对应 | - |
|
||||||
|
|
||||||
|
### 返回值
|
||||||
|
|
||||||
|
| 属性 | 说明 |
|
||||||
|
| --- | --- |
|
||||||
|
| `editor` / `history` / `props` / `dataSource` / `codeBlock` / `dep` / `storage` / `events` / `componentList` | 各 service 实例,方法与浏览器端同名 service 一致 |
|
||||||
|
| `validate` | 校验器,见[校验](#校验) |
|
||||||
|
| `ctx` | service 之间共享的上下文 |
|
||||||
|
| `ready` | 登记表全部就绪后 resolve |
|
||||||
|
| `getDsl()` | 导出当前 DSL(深拷贝) |
|
||||||
|
| `setDsl(dsl)` | 整体替换 DSL,会写入历史记录,返回的 Promise 在自动选中节点完成后 resolve |
|
||||||
|
| `serializeHistory()` | 导出可 JSON 序列化的历史快照 |
|
||||||
|
| `bindWiring()` | 绑定依赖收集连线(`autoWiring: false` 时手动调用),返回解绑函数。绑定时如果 DSL 已经设置,会立即补做一次依赖收集 |
|
||||||
|
| `destroy()` | 销毁 |
|
||||||
|
|
||||||
|
## 校验
|
||||||
|
|
||||||
|
`validateMode` 决定修改 DSL 时如何处理不合法的内容:
|
||||||
|
|
||||||
|
- `strict`:修改前校验,不通过抛出 `DslValidationError`(`errors` 里是全部问题),**DSL 不会被修改**,适合给 agent 调用,agent 可以根据 `errors` 修正后重试。Node 下默认。
|
||||||
|
- `record`:不抛错,把校验结果写进 `editor.state.invalidNodeIds`。
|
||||||
|
- `off`:不做额外校验。浏览器端默认实例使用这个模式(属性面板自己负责校验)。
|
||||||
|
|
||||||
|
可以通过 `sdk.ctx.validateMode = 'off'` 在运行时切换。
|
||||||
|
|
||||||
|
校验内容:
|
||||||
|
|
||||||
|
| 类别 | 内容 |
|
||||||
|
| --- | --- |
|
||||||
|
| 结构(`kind: 'structure'`) | 组件类型是否在组件列表中(`page` / `page-fragment` / `container` 内置合法,组件列表为空时不校验)、父节点是否是容器、`canDropIn`、是否禁用页面片、id 是否重复、不允许修改节点类型、数据源类型是否可用 |
|
||||||
|
| 属性(`kind: 'props'`) | 用 `@tmagic/form/headless` 的 `validateForm` 校验节点 / 数据源的属性表单配置,包含 `typeMatch` |
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { DslValidationError } from '@tmagic/editor/sdk';
|
||||||
|
|
||||||
|
try {
|
||||||
|
await sdk.editor.add({ type: 'unknown' });
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof DslValidationError) {
|
||||||
|
console.log(e.errors); // [{ target, id, path, kind, message, suggestion }]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 主动校验
|
||||||
|
await sdk.validate.node('text_1');
|
||||||
|
await sdk.validate.dataSource('ds_1');
|
||||||
|
await sdk.validate.dsl();
|
||||||
|
```
|
||||||
|
|
||||||
|
::: warning
|
||||||
|
- 数据源的 `add` / `update` 是同步方法,只做同步的结构校验(数据源类型是否可用);属性校验请用 `sdk.validate.dataSource(id)`。
|
||||||
|
- SDK 内只注册了 `@tmagic/form/headless` 的内置字段,编辑器自定义字段(`data-source-select`、`code-select` 等)的 `typeMatch` 依赖编辑器的单例 service,不在 SDK 校验范围内;需要时可在浏览器环境下通过 `@tmagic/editor/headless` 的 `editorFields` 注册。
|
||||||
|
:::
|
||||||
|
|
||||||
|
## 依赖收集
|
||||||
|
|
||||||
|
`autoWiring` 为 `true` 时,节点、数据源、代码块发生变化会同步收集依赖,并把结果写回 DSL 的 `dataSourceDeps` / `dataSourceCondDeps` / `dataSourceMethodDeps`,所以操作返回时导出的 DSL 依赖信息已经是最新的。
|
||||||
|
|
||||||
|
不需要依赖收集(只做 DSL 增删改)时可以传 `autoWiring: false`,之后需要时再调用 `sdk.bindWiring()`;注意关闭期间不会同步数据源 / 代码块与依赖目标,也不会自动选中页面。
|
||||||
|
|
||||||
|
## 插件
|
||||||
|
|
||||||
|
所有 service 都支持 `usePlugin`,与浏览器端一致:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
sdk.editor.usePlugin({
|
||||||
|
beforeAdd(addNode, parent) {
|
||||||
|
return [addNode, parent];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
@ -29,7 +29,7 @@
|
|||||||
| `display` / `hidden` | 不校验 |
|
| `display` / `hidden` | 不校验 |
|
||||||
| `number` | `number`(非 NaN) |
|
| `number` | `number`(非 NaN) |
|
||||||
| `number-range` | 长度为 2 的数字数组 |
|
| `number-range` | 长度为 2 的数字数组 |
|
||||||
| `date` / `datetime` / `time` | 按 `valueFormat` 校验;`x` / `timestamp` 期望 `number`,其余按 [Day.js format](https://day.js.org/docs/en/display/format) 严格解析字符串 |
|
| `date` / `datetime` / `time` | 按 `valueFormat` 校验;`x` / `X` / `timestamp` 期望 `number`(`x`、`timestamp` 为毫秒,`X` 为秒)。日期组件会把选择器写出的秒字符串收成 number。其余格式按 [Day.js format](https://day.js.org/docs/en/display/format) 严格解析字符串 |
|
||||||
| `daterange` / `timerange` | 无 `names` 时为长度为 2 的数组,元素按 `valueFormat` 校验;有 `names` 时跳过 |
|
| `daterange` / `timerange` | 无 `names` 时为长度为 2 的数组,元素按 `valueFormat` 校验;有 `names` 时跳过 |
|
||||||
| `switch` / `checkbox` | 值必须是解析后的 `activeValue` / `inactiveValue` 之一(显式配置优先;未配置且 `filter === 'number'` 时为 `1`/`0`;否则为 `true`/`false`) |
|
| `switch` / `checkbox` | 值必须是解析后的 `activeValue` / `inactiveValue` 之一(显式配置优先;未配置且 `filter === 'number'` 时为 `1`/`0`;否则为 `true`/`false`) |
|
||||||
| `select` | 单选:值 ∈ options;`multiple`:数组且每项 ∈ options;`allowCreate` / `remote`:只校验基础形态,不做 options 枚举 |
|
| `select` | 单选:值 ∈ options;`multiple`:数组且每项 ∈ options;`allowCreate` / `remote`:只校验基础形态,不做 options 枚举 |
|
||||||
|
|||||||
40
package.json
40
package.json
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"version": "1.8.2",
|
"version": "1.8.3",
|
||||||
"name": "tmagic",
|
"name": "tmagic",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@ -41,12 +41,12 @@
|
|||||||
"url": "https://github.com/Tencent/tmagic-editor.git"
|
"url": "https://github.com/Tencent/tmagic-editor.git"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@commitlint/cli": "^21.2.3",
|
"@commitlint/cli": "^21.2.4",
|
||||||
"@commitlint/config-conventional": "^21.2.3",
|
"@commitlint/config-conventional": "^21.2.4",
|
||||||
"@tmagic/eslint-config": "workspace:*",
|
"@tmagic/eslint-config": "workspace:*",
|
||||||
"@types/node": "26.6.2",
|
"@types/node": "26.6.5",
|
||||||
"@vitejs/plugin-vue": "^6.0.9",
|
"@vitejs/plugin-vue": "^6.0.10",
|
||||||
"@vitest/coverage-v8": "^5.0.1",
|
"@vitest/coverage-v8": "^5.0.5",
|
||||||
"@vue/compiler-sfc": "catalog:",
|
"@vue/compiler-sfc": "catalog:",
|
||||||
"c8": "^12.0.0",
|
"c8": "^12.0.0",
|
||||||
"commitizen": "^4.3.2",
|
"commitizen": "^4.3.2",
|
||||||
@ -55,30 +55,30 @@
|
|||||||
"cz-conventional-changelog": "^3.3.0",
|
"cz-conventional-changelog": "^3.3.0",
|
||||||
"element-plus": "catalog:",
|
"element-plus": "catalog:",
|
||||||
"enquirer": "^2.4.1",
|
"enquirer": "^2.4.1",
|
||||||
"eslint": "^10.11.0",
|
"eslint": "^10.13.0",
|
||||||
"execa": "^10.0.1",
|
"execa": "^10.2.0",
|
||||||
"happy-dom": "^20.14.5",
|
"happy-dom": "^21.0.3",
|
||||||
"highlight.js": "^11.12.0",
|
"highlight.js": "^11.12.0",
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"jsdom": "^30.1.0",
|
"jsdom": "^30.1.3",
|
||||||
"lint-staged": "^17.5.1",
|
"lint-staged": "^17.6.0",
|
||||||
"minimist": "^1.2.8",
|
"minimist": "^1.2.8",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
"prettier": "^3.9.8",
|
"prettier": "^3.9.10",
|
||||||
"recast": "^0.23.20",
|
"recast": "^0.24.0",
|
||||||
"rimraf": "^6.1.3",
|
"rimraf": "^6.1.3",
|
||||||
"rolldown": "^1.2.9",
|
"rolldown": "^1.2.14",
|
||||||
"rolldown-plugin-dts": "^0.28.6",
|
"rolldown-plugin-dts": "^0.28.8",
|
||||||
"sass-embedded": "^1.104.1",
|
"sass-embedded": "^1.105.2",
|
||||||
"semver": "^7.8.5",
|
"semver": "^7.8.5",
|
||||||
"serialize-javascript": "^7.1.1",
|
"serialize-javascript": "^7.1.2",
|
||||||
"shx": "^0.4.0",
|
"shx": "^0.4.0",
|
||||||
"typescript": "catalog:",
|
"typescript": "catalog:",
|
||||||
"vite": "catalog:",
|
"vite": "catalog:",
|
||||||
"vitepress": "2.0.0-alpha.20",
|
"vitepress": "2.0.0-alpha.20",
|
||||||
"vitest": "^5.0.1",
|
"vitest": "^5.0.5",
|
||||||
"vue": "^3.5.43",
|
"vue": "^3.5.44",
|
||||||
"vue-tsc": "^3.3.11"
|
"vue-tsc": "^3.3.13"
|
||||||
},
|
},
|
||||||
"config": {
|
"config": {
|
||||||
"commitizen": {
|
"commitizen": {
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"version": "1.8.2",
|
"version": "1.8.3",
|
||||||
"name": "@tmagic/cli",
|
"name": "@tmagic/cli",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "lib/index.js",
|
"main": "lib/index.js",
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"version": "1.8.2",
|
"version": "1.8.3",
|
||||||
"name": "@tmagic/core",
|
"name": "@tmagic/core",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"version": "1.8.2",
|
"version": "1.8.3",
|
||||||
"name": "@tmagic/data-source",
|
"name": "@tmagic/data-source",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"version": "1.8.2",
|
"version": "1.8.3",
|
||||||
"name": "@tmagic/dep",
|
"name": "@tmagic/dep",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
|
|||||||
@ -133,9 +133,13 @@ export default class Watcher {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 整棵树只遍历一次、在每个属性上检查所有 target,把结构遍历开销从 ×targets 降到 ×1(详见 collectItems)
|
// 先清掉本次节点上的旧依赖,再统一收集。
|
||||||
|
// 复制出的页面片会共用节点 id。如果在收集每一棵树之前立刻删除,
|
||||||
|
// 后一棵树会按相同 id 把前一棵树刚写入的依赖清掉。
|
||||||
for (const node of nodes) {
|
for (const node of nodes) {
|
||||||
this.removeTargetsDep(targets, node);
|
this.removeTargetsDep(targets, node);
|
||||||
|
}
|
||||||
|
for (const node of nodes) {
|
||||||
this.collectItems(node, targets, depExtendedData, deep);
|
this.collectItems(node, targets, depExtendedData, deep);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -249,4 +249,86 @@ describe('Watcher', () => {
|
|||||||
expect(target1?.deps?.node_1).toBeUndefined();
|
expect(target1?.deps?.node_1).toBeUndefined();
|
||||||
expect(target1?.deps?.node_2).toBeUndefined();
|
expect(target1?.deps?.node_2).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('collect keeps deps when roots share node ids', () => {
|
||||||
|
const watcher = new Watcher();
|
||||||
|
|
||||||
|
watcher.addTarget(
|
||||||
|
new Target({
|
||||||
|
type: 'target',
|
||||||
|
id: 'ds_a',
|
||||||
|
isTarget: (key: string | number, value: any) => key === 'text' && value === 'a',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
watcher.addTarget(
|
||||||
|
new Target({
|
||||||
|
type: 'target',
|
||||||
|
id: 'ds_b',
|
||||||
|
isTarget: (key: string | number, value: any) => key === 'text' && value === 'b',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
watcher.collect(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
id: 'fragment_a',
|
||||||
|
name: 'fragmentA',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: 'shared',
|
||||||
|
name: 'shared-a',
|
||||||
|
text: 'a',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'fragment_b',
|
||||||
|
name: 'fragmentB',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: 'shared',
|
||||||
|
name: 'shared-b',
|
||||||
|
text: 'b',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{},
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
const targetA = watcher.getTarget('ds_a', 'target');
|
||||||
|
const targetB = watcher.getTarget('ds_b', 'target');
|
||||||
|
|
||||||
|
expect(targetA?.deps?.shared.keys).toEqual(['text']);
|
||||||
|
expect(targetA?.deps?.shared.name).toBe('shared-a');
|
||||||
|
expect(targetB?.deps?.shared.keys).toEqual(['text']);
|
||||||
|
expect(targetB?.deps?.shared.name).toBe('shared-b');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('collect merges keys when one target matches shared-id nodes in several roots', () => {
|
||||||
|
const watcher = new Watcher();
|
||||||
|
|
||||||
|
watcher.addTarget(
|
||||||
|
new Target({
|
||||||
|
type: 'target',
|
||||||
|
id: 'ds',
|
||||||
|
isTarget: (key: string | number, value: any) => key === 'text' && value === 'x',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
watcher.collect(
|
||||||
|
[
|
||||||
|
{ id: 'root_a', items: [{ id: 'shared', name: 'shared-a', text: 'x' }] },
|
||||||
|
{ id: 'root_b', items: [{ id: 'shared', name: 'shared-b', title: 'y', text: 'x' }] },
|
||||||
|
],
|
||||||
|
{},
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
const dep = watcher.getTarget('ds', 'target')?.deps?.shared;
|
||||||
|
expect(dep?.keys).toEqual(['text']);
|
||||||
|
// 同 id 的依赖只保留一条,name 取最后收集的节点
|
||||||
|
expect(dep?.name).toBe('shared-b');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"version": "1.8.2",
|
"version": "1.8.3",
|
||||||
"name": "@tmagic/design",
|
"name": "@tmagic/design",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"sideEffects": [
|
"sideEffects": [
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"version": "1.8.2",
|
"version": "1.8.3",
|
||||||
"name": "@tmagic/editor",
|
"name": "@tmagic/editor",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"sideEffects": [
|
"sideEffects": [
|
||||||
@ -22,6 +22,11 @@
|
|||||||
"import": "./dist/es/headless.js",
|
"import": "./dist/es/headless.js",
|
||||||
"require": "./dist/tmagic-editor-headless.umd.cjs"
|
"require": "./dist/tmagic-editor-headless.umd.cjs"
|
||||||
},
|
},
|
||||||
|
"./sdk": {
|
||||||
|
"types": "./types/sdk.d.ts",
|
||||||
|
"import": "./dist/es/sdk.js",
|
||||||
|
"require": "./dist/tmagic-editor-sdk.umd.cjs"
|
||||||
|
},
|
||||||
"./dist/style.css": {
|
"./dist/style.css": {
|
||||||
"import": "./dist/style.css",
|
"import": "./dist/style.css",
|
||||||
"require": "./dist/style.css"
|
"require": "./dist/style.css"
|
||||||
|
|||||||
@ -148,17 +148,19 @@ import FormPanel from './layouts/props-panel/FormPanel.vue';
|
|||||||
import PropsPanel from './layouts/props-panel/PropsPanel.vue';
|
import PropsPanel from './layouts/props-panel/PropsPanel.vue';
|
||||||
import Sidebar from './layouts/sidebar/Sidebar.vue';
|
import Sidebar from './layouts/sidebar/Sidebar.vue';
|
||||||
import Workspace from './layouts/workspace/Workspace.vue';
|
import Workspace from './layouts/workspace/Workspace.vue';
|
||||||
import codeBlockService from './services/codeBlock';
|
|
||||||
import componentListService from './services/componentList';
|
|
||||||
import dataSourceService from './services/dataSource';
|
|
||||||
import depService from './services/dep';
|
|
||||||
import editorService from './services/editor';
|
|
||||||
import eventsService from './services/events';
|
|
||||||
import historyService from './services/history';
|
|
||||||
import keybindingService from './services/keybinding';
|
import keybindingService from './services/keybinding';
|
||||||
import propsService from './services/props';
|
import {
|
||||||
|
codeBlockService,
|
||||||
|
componentListService,
|
||||||
|
dataSourceService,
|
||||||
|
depService,
|
||||||
|
editorService,
|
||||||
|
eventsService,
|
||||||
|
historyService,
|
||||||
|
propsService,
|
||||||
|
storageService,
|
||||||
|
} from './services/sdk-instance';
|
||||||
import stageOverlayService from './services/stageOverlay';
|
import stageOverlayService from './services/stageOverlay';
|
||||||
import storageService from './services/storage';
|
|
||||||
import uiService from './services/ui';
|
import uiService from './services/ui';
|
||||||
import keybindingConfig from './utils/keybinding-config';
|
import keybindingConfig from './utils/keybinding-config';
|
||||||
import { defaultEditorProps, EditorProps, ENABLE_PROPS_FORM_VALIDATE } from './editorProps';
|
import { defaultEditorProps, EditorProps, ENABLE_PROPS_FORM_VALIDATE } from './editorProps';
|
||||||
@ -222,6 +224,7 @@ const stageOptions: StageOptions = {
|
|||||||
containerHighlightAddOnly: props.containerHighlightAddOnly,
|
containerHighlightAddOnly: props.containerHighlightAddOnly,
|
||||||
disabledDragStart: props.disabledDragStart,
|
disabledDragStart: props.disabledDragStart,
|
||||||
renderType: props.renderType,
|
renderType: props.renderType,
|
||||||
|
iframeSandbox: props.iframeSandbox,
|
||||||
guidesOptions: props.guidesOptions,
|
guidesOptions: props.guidesOptions,
|
||||||
disabledMultiSelect: props.disabledMultiSelect,
|
disabledMultiSelect: props.disabledMultiSelect,
|
||||||
alwaysMultiSelect: props.alwaysMultiSelect,
|
alwaysMultiSelect: props.alwaysMultiSelect,
|
||||||
|
|||||||
@ -62,6 +62,16 @@ export interface EditorProps {
|
|||||||
runtimeUrl?: string;
|
runtimeUrl?: string;
|
||||||
/** 是用iframe渲染还是直接渲染 */
|
/** 是用iframe渲染还是直接渲染 */
|
||||||
renderType?: RenderType;
|
renderType?: RenderType;
|
||||||
|
/**
|
||||||
|
* 画布 iframe 的 sandbox 属性值(仅 iframe 渲染时生效)
|
||||||
|
*
|
||||||
|
* - 不传 / `false`:不设置 sandbox 属性(默认)
|
||||||
|
* - 字符串:原样写入,token 空格分隔,如 `'allow-scripts allow-same-origin allow-downloads'`
|
||||||
|
*
|
||||||
|
* 需要注意编辑器自身依赖 `allow-scripts` + `allow-same-origin`(读写 iframe 内 DOM),
|
||||||
|
* 其余 token(`allow-downloads`、`allow-popups`、`allow-forms` 等)按业务需要放开。
|
||||||
|
*/
|
||||||
|
iframeSandbox?: string | false;
|
||||||
/** 选中时是否自动滚动到可视区域 */
|
/** 选中时是否自动滚动到可视区域 */
|
||||||
autoScrollIntoView?: boolean;
|
autoScrollIntoView?: boolean;
|
||||||
/** 组件的属性配置表单的dsl */
|
/** 组件的属性配置表单的dsl */
|
||||||
|
|||||||
@ -19,10 +19,7 @@
|
|||||||
import { isEmpty } from 'lodash-es';
|
import { isEmpty } from 'lodash-es';
|
||||||
|
|
||||||
import { HookCodeType, HookType } from '@tmagic/core';
|
import { HookCodeType, HookType } from '@tmagic/core';
|
||||||
import type { CodeSelectConfig, FormValue, GroupListConfig } from '@tmagic/form/headless';
|
import type { CodeSelectConfig, FormState, FormValue, GroupListConfig } from '@tmagic/form/headless';
|
||||||
|
|
||||||
import codeBlockService from '@editor/services/codeBlock';
|
|
||||||
import dataSourceService from '@editor/services/dataSource';
|
|
||||||
|
|
||||||
import { stickyAddButton } from './stickyAddButton';
|
import { stickyAddButton } from './stickyAddButton';
|
||||||
|
|
||||||
@ -42,21 +39,21 @@ export const createCodeSelectConfig = (config: CodeSelectConfig): GroupListConfi
|
|||||||
codeId: '',
|
codeId: '',
|
||||||
}),
|
}),
|
||||||
...stickyAddButton(`添加${config.text || ''}`),
|
...stickyAddButton(`添加${config.text || ''}`),
|
||||||
title: (_mForm: any, { model, index }: any) => {
|
title: (mForm: FormState | undefined, { model, index }: any) => {
|
||||||
if (model.codeType === HookCodeType.DATA_SOURCE_METHOD) {
|
if (model.codeType === HookCodeType.DATA_SOURCE_METHOD) {
|
||||||
if (Array.isArray(model.codeId)) {
|
if (Array.isArray(model.codeId)) {
|
||||||
if (model.codeId.length < 2) {
|
if (model.codeId.length < 2) {
|
||||||
return index;
|
return index;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ds = dataSourceService.getDataSourceById(model.codeId[0]);
|
const ds = mForm?.services?.dataSourceService.getDataSourceById(model.codeId[0]);
|
||||||
return `${ds?.title} / ${model.codeId[1]}`;
|
return `${ds?.title} / ${model.codeId[1]}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return Array.isArray(model.codeId) ? model.codeId.join('/') : index;
|
return Array.isArray(model.codeId) ? model.codeId.join('/') : index;
|
||||||
}
|
}
|
||||||
|
|
||||||
const codeContent = codeBlockService.getCodeContentById(model.codeId);
|
const codeContent = mForm?.services?.codeBlockService.getCodeContentById(model.codeId);
|
||||||
|
|
||||||
if (codeContent) {
|
if (codeContent) {
|
||||||
return codeContent.name;
|
return codeContent.name;
|
||||||
@ -92,7 +89,7 @@ export const createCodeSelectConfig = (config: CodeSelectConfig): GroupListConfi
|
|||||||
rules: [{ typeMatch: true, trigger: 'change' }],
|
rules: [{ typeMatch: true, trigger: 'change' }],
|
||||||
|
|
||||||
display: (_mForm: any, { model }: any) => model.codeType !== HookCodeType.DATA_SOURCE_METHOD,
|
display: (_mForm: any, { model }: any) => model.codeType !== HookCodeType.DATA_SOURCE_METHOD,
|
||||||
notEditable: () => !codeBlockService.getEditStatus(),
|
notEditable: (mForm: FormState | undefined) => !mForm?.services?.codeBlockService.getEditStatus(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'data-source-method-select',
|
type: 'data-source-method-select',
|
||||||
@ -100,7 +97,7 @@ export const createCodeSelectConfig = (config: CodeSelectConfig): GroupListConfi
|
|||||||
text: '数据源字段',
|
text: '数据源字段',
|
||||||
rules: [{ typeMatch: true, trigger: 'change' }],
|
rules: [{ typeMatch: true, trigger: 'change' }],
|
||||||
display: (_mForm: any, { model }: any) => model.codeType === HookCodeType.DATA_SOURCE_METHOD,
|
display: (_mForm: any, { model }: any) => model.codeType === HookCodeType.DATA_SOURCE_METHOD,
|
||||||
notEditable: () => !dataSourceService.get('editable'),
|
notEditable: (mForm: FormState | undefined) => !mForm?.services?.dataSourceService.get('editable'),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
} as any as GroupListConfig;
|
} as any as GroupListConfig;
|
||||||
|
|||||||
@ -19,7 +19,6 @@
|
|||||||
import type { DisplayCondsConfig, FormState, GroupListConfig } from '@tmagic/form/headless';
|
import type { DisplayCondsConfig, FormState, GroupListConfig } from '@tmagic/form/headless';
|
||||||
import { removeDataSourceFieldPrefix } from '@tmagic/utils';
|
import { removeDataSourceFieldPrefix } from '@tmagic/utils';
|
||||||
|
|
||||||
import dataSourceService from '@editor/services/dataSource';
|
|
||||||
import { getCascaderOptionsFromFields, getFieldType, resolveFieldByPath } from '@editor/utils/data-source';
|
import { getCascaderOptionsFromFields, getFieldType, resolveFieldByPath } from '@editor/utils/data-source';
|
||||||
|
|
||||||
import { stickyAddButton } from './stickyAddButton';
|
import { stickyAddButton } from './stickyAddButton';
|
||||||
@ -38,15 +37,17 @@ export const createDisplayCondsConfig = (
|
|||||||
name: string,
|
name: string,
|
||||||
parentFields: string[],
|
parentFields: string[],
|
||||||
): GroupListConfig => {
|
): GroupListConfig => {
|
||||||
const resolveFieldPath = (path: Array<string | number>) => {
|
const resolveFieldPath = (formState: FormState | undefined, path: Array<string | number>) => {
|
||||||
const [id, ...fieldNames] = path;
|
const [id, ...fieldNames] = path;
|
||||||
const ds = id ? dataSourceService.getDataSourceById(removeDataSourceFieldPrefix(`${id}`)) : undefined;
|
const ds = id
|
||||||
|
? formState?.services?.dataSourceService.getDataSourceById(removeDataSourceFieldPrefix(`${id}`))
|
||||||
|
: undefined;
|
||||||
return { ds, fieldNames };
|
return { ds, fieldNames };
|
||||||
};
|
};
|
||||||
|
|
||||||
// 字段变更后按新字段的类型把已填的值转成对应类型,避免类型校验与运行期取值不一致
|
// 字段变更后按新字段的类型把已填的值转成对应类型,避免类型校验与运行期取值不一致
|
||||||
const fieldOnChange = (_formState: FormState | undefined, v: string[], { model }: { model: Record<string, any> }) => {
|
const fieldOnChange = (formState: FormState | undefined, v: string[], { model }: { model: Record<string, any> }) => {
|
||||||
const { ds, fieldNames } = resolveFieldPath([...parentFields, ...v]);
|
const { ds, fieldNames } = resolveFieldPath(formState, [...parentFields, ...v]);
|
||||||
const type = getFieldType(ds, fieldNames);
|
const type = getFieldType(ds, fieldNames);
|
||||||
if (type === 'number') {
|
if (type === 'number') {
|
||||||
model.value = Number(model.value);
|
model.value = Number(model.value);
|
||||||
@ -63,8 +64,8 @@ export const createDisplayCondsConfig = (
|
|||||||
const fieldItem = parentFields.length
|
const fieldItem = parentFields.length
|
||||||
? {
|
? {
|
||||||
type: 'cascader',
|
type: 'cascader',
|
||||||
options: () => {
|
options: (formState: FormState | undefined) => {
|
||||||
const { ds, fieldNames } = resolveFieldPath(parentFields);
|
const { ds, fieldNames } = resolveFieldPath(formState, parentFields);
|
||||||
if (!ds) {
|
if (!ds) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@ -141,8 +142,8 @@ export const createDisplayCondsConfig = (
|
|||||||
{
|
{
|
||||||
name: 'value',
|
name: 'value',
|
||||||
text: '值',
|
text: '值',
|
||||||
type: (_mForm: FormState | undefined, { model }: any) => {
|
type: (mForm: FormState | undefined, { model }: any) => {
|
||||||
const { ds, fieldNames } = resolveFieldPath([...parentFields, ...(model.field || [])]);
|
const { ds, fieldNames } = resolveFieldPath(mForm, [...parentFields, ...(model.field || [])]);
|
||||||
const type = getFieldType(ds, fieldNames);
|
const type = getFieldType(ds, fieldNames);
|
||||||
|
|
||||||
if (type === 'number') {
|
if (type === 'number') {
|
||||||
|
|||||||
@ -31,18 +31,14 @@ import type {
|
|||||||
} from '@tmagic/form/headless';
|
} from '@tmagic/form/headless';
|
||||||
import { defineFormItem } from '@tmagic/form/headless';
|
import { defineFormItem } from '@tmagic/form/headless';
|
||||||
|
|
||||||
import codeBlockService from '@editor/services/codeBlock';
|
import type { Services } from '@editor/type';
|
||||||
import dataSourceService from '@editor/services/dataSource';
|
|
||||||
import editorService from '@editor/services/editor';
|
|
||||||
import eventsService from '@editor/services/events';
|
|
||||||
import propsService from '@editor/services/props';
|
|
||||||
import {
|
import {
|
||||||
getCompActionAllowedValues,
|
getCompActionAllowedValues,
|
||||||
getCompActionOptions,
|
getCompActionOptions,
|
||||||
getEventNameAllowedValues,
|
getEventNameAllowedValues,
|
||||||
getEventNameOptions,
|
getEventNameOptions,
|
||||||
normalizeCompActionValue,
|
normalizeCompActionValue,
|
||||||
} from '@editor/utils';
|
} from '@editor/utils/event';
|
||||||
|
|
||||||
import { stickyAddButton } from './stickyAddButton';
|
import { stickyAddButton } from './stickyAddButton';
|
||||||
|
|
||||||
@ -67,11 +63,14 @@ export const createEventNameConfig = (config: EventSelectConfig) => {
|
|||||||
labelWidth: '70px',
|
labelWidth: '70px',
|
||||||
checkStrictly: () => config.src !== 'component',
|
checkStrictly: () => config.src !== 'component',
|
||||||
valueSeparator: '.',
|
valueSeparator: '.',
|
||||||
options: (_mForm: FormState, { formValue }: any) => getEventNameOptions(config.src, formValue),
|
options: (mForm: FormState, { formValue }: any) =>
|
||||||
|
mForm?.services ? getEventNameOptions(config.src, formValue, mForm.services) : [],
|
||||||
rules: [
|
rules: [
|
||||||
{
|
{
|
||||||
validator: ({ value, callback }: any, { formValue }: any) => {
|
validator: ({ value, callback }: any, { formValue }: any, mForm?: FormState) => {
|
||||||
const allowedNames = getEventNameAllowedValues(config as any, formValue);
|
const allowedNames = mForm?.services
|
||||||
|
? getEventNameAllowedValues(config as any, formValue, mForm.services)
|
||||||
|
: null;
|
||||||
if (allowedNames && allowedNames.size > 0 && value && !allowedNames.has(value)) {
|
if (allowedNames && allowedNames.size > 0 && value && !allowedNames.has(value)) {
|
||||||
return callback(`事件名(${value})不存在`);
|
return callback(`事件名(${value})不存在`);
|
||||||
}
|
}
|
||||||
@ -84,7 +83,7 @@ export const createEventNameConfig = (config: EventSelectConfig) => {
|
|||||||
return { ...defaultEventNameConfig, ...config.eventNameConfig };
|
return { ...defaultEventNameConfig, ...config.eventNameConfig };
|
||||||
};
|
};
|
||||||
|
|
||||||
const createActionTypeOptions = () => {
|
const createActionTypeOptions = (services?: Services) => {
|
||||||
const o: {
|
const o: {
|
||||||
text: string;
|
text: string;
|
||||||
label: string;
|
label: string;
|
||||||
@ -98,6 +97,12 @@ const createActionTypeOptions = () => {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
if (!services) {
|
||||||
|
return o;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { propsService, codeBlockService } = services;
|
||||||
|
|
||||||
if (!propsService.getDisabledCodeBlock()) {
|
if (!propsService.getDisabledCodeBlock()) {
|
||||||
o.push({
|
o.push({
|
||||||
text: '代码',
|
text: '代码',
|
||||||
@ -125,7 +130,7 @@ const createActionTypeConfig = (config: EventSelectConfig) => {
|
|||||||
text: '联动类型',
|
text: '联动类型',
|
||||||
type: 'select',
|
type: 'select',
|
||||||
defaultValue: ActionType.COMP,
|
defaultValue: ActionType.COMP,
|
||||||
options: createActionTypeOptions(),
|
options: (mForm: FormState | undefined) => createActionTypeOptions(mForm?.services),
|
||||||
rules: [
|
rules: [
|
||||||
{
|
{
|
||||||
required: true,
|
required: true,
|
||||||
@ -171,8 +176,8 @@ const createCompActionConfig = (config: EventSelectConfig) => {
|
|||||||
const defaultCompActionConfig: DynamicTypeConfig = {
|
const defaultCompActionConfig: DynamicTypeConfig = {
|
||||||
name: 'method',
|
name: 'method',
|
||||||
text: '动作',
|
text: '动作',
|
||||||
type: (_mForm: FormState | undefined, { model }: any) => {
|
type: (mForm: FormState | undefined, { model }: any) => {
|
||||||
const to = editorService.getNodeById(model.to);
|
const to = mForm?.services?.editorService.getNodeById(model.to);
|
||||||
|
|
||||||
if (to?.type === 'page-fragment-container' && to.pageFragmentId) {
|
if (to?.type === 'page-fragment-container' && to.pageFragmentId) {
|
||||||
return 'cascader';
|
return 'cascader';
|
||||||
@ -182,12 +187,15 @@ const createCompActionConfig = (config: EventSelectConfig) => {
|
|||||||
},
|
},
|
||||||
checkStrictly: () => config.src !== 'component',
|
checkStrictly: () => config.src !== 'component',
|
||||||
display: (_mForm: FormState | undefined, { model }: any) => model.actionType === ActionType.COMP,
|
display: (_mForm: FormState | undefined, { model }: any) => model.actionType === ActionType.COMP,
|
||||||
options: (_mForm: FormState, { model }: any) => getCompActionOptions(model.to),
|
options: (mForm: FormState, { model }: any) =>
|
||||||
|
mForm?.services ? getCompActionOptions(model.to, mForm.services) : [],
|
||||||
rules: [
|
rules: [
|
||||||
{
|
{
|
||||||
trigger: 'blur',
|
trigger: 'blur',
|
||||||
validator: ({ value, callback }: any, { model }: any) => {
|
validator: ({ value, callback }: any, { model }: any, mForm?: FormState) => {
|
||||||
const allowedMethods = getCompActionAllowedValues(config as any, model);
|
const allowedMethods = mForm?.services
|
||||||
|
? getCompActionAllowedValues(config as any, model, mForm.services)
|
||||||
|
: null;
|
||||||
const normalized = normalizeCompActionValue(value);
|
const normalized = normalizeCompActionValue(value);
|
||||||
if (allowedMethods && allowedMethods.size > 0 && normalized && !allowedMethods.has(normalized)) {
|
if (allowedMethods && allowedMethods.size > 0 && normalized && !allowedMethods.has(normalized)) {
|
||||||
return callback(`动作名(${normalized})不存在`);
|
return callback(`动作名(${normalized})不存在`);
|
||||||
@ -206,7 +214,7 @@ const createCodeActionConfig = (config: EventSelectConfig) => {
|
|||||||
type: 'code-select-col',
|
type: 'code-select-col',
|
||||||
text: '代码块',
|
text: '代码块',
|
||||||
name: 'codeId',
|
name: 'codeId',
|
||||||
notEditable: () => !codeBlockService.getEditStatus(),
|
notEditable: (mForm: FormState | undefined) => !mForm?.services?.codeBlockService.getEditStatus(),
|
||||||
display: (_mForm, { model }) => model.actionType === ActionType.CODE,
|
display: (_mForm, { model }) => model.actionType === ActionType.CODE,
|
||||||
};
|
};
|
||||||
return { ...defaultCodeActionConfig, ...config.codeActionConfig };
|
return { ...defaultCodeActionConfig, ...config.codeActionConfig };
|
||||||
@ -218,7 +226,7 @@ const createDataSourceActionConfig = (config: EventSelectConfig) => {
|
|||||||
type: 'data-source-method-select',
|
type: 'data-source-method-select',
|
||||||
text: '数据源方法',
|
text: '数据源方法',
|
||||||
name: 'dataSourceMethod',
|
name: 'dataSourceMethod',
|
||||||
notEditable: () => !dataSourceService.get('editable'),
|
notEditable: (mForm: FormState | undefined) => !mForm?.services?.dataSourceService.get('editable'),
|
||||||
display: (_mForm, { model }) => model.actionType === ActionType.DATA_SOURCE,
|
display: (_mForm, { model }) => model.actionType === ActionType.DATA_SOURCE,
|
||||||
};
|
};
|
||||||
return { ...defaultDataSourceActionConfig, ...config.dataSourceActionConfig };
|
return { ...defaultDataSourceActionConfig, ...config.dataSourceActionConfig };
|
||||||
@ -272,13 +280,17 @@ export const createLegacyTableConfig = (config: EventSelectConfig): TableConfig
|
|||||||
name: 'name',
|
name: 'name',
|
||||||
label: '事件名',
|
label: '事件名',
|
||||||
type: createEventNameConfig(config).type,
|
type: createEventNameConfig(config).type,
|
||||||
options: (_mForm: FormState, { formValue }: any) =>
|
options: (mForm: FormState, { formValue }: any) => {
|
||||||
eventsService
|
const services = mForm?.services;
|
||||||
.getEvent(formValue.type, { node: editorService.getNodeById(formValue.id) })
|
if (!services) return [];
|
||||||
|
|
||||||
|
return services.eventsService
|
||||||
|
.getEvent(formValue.type, { node: services.editorService.getNodeById(formValue.id) })
|
||||||
.map((option: any) => ({
|
.map((option: any) => ({
|
||||||
text: option.label,
|
text: option.label,
|
||||||
value: option.value,
|
value: option.value,
|
||||||
})),
|
}));
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'to',
|
name: 'to',
|
||||||
@ -289,11 +301,14 @@ export const createLegacyTableConfig = (config: EventSelectConfig): TableConfig
|
|||||||
name: 'method',
|
name: 'method',
|
||||||
label: '动作',
|
label: '动作',
|
||||||
type: createCompActionConfig(config).type,
|
type: createCompActionConfig(config).type,
|
||||||
options: (_mForm: FormState, { model, formValue }: any) => {
|
options: (mForm: FormState, { model, formValue }: any) => {
|
||||||
const node = editorService.getNodeById(model.to) || (formValue as MNode);
|
const services = mForm?.services;
|
||||||
|
if (!services) return [];
|
||||||
|
|
||||||
|
const node = services.editorService.getNodeById(model.to) || (formValue as MNode);
|
||||||
if (!node?.type) return [];
|
if (!node?.type) return [];
|
||||||
|
|
||||||
return eventsService.getMethod(node.type, { targetId: model.to, node }).map((option: any) => ({
|
return services.eventsService.getMethod(node.type, { targetId: model.to, node }).map((option: any) => ({
|
||||||
text: option.label,
|
text: option.label,
|
||||||
value: option.value,
|
value: option.value,
|
||||||
}));
|
}));
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import type { MNode } from '@tmagic/core';
|
|||||||
import StageCore, { GuidesType, RemoveEventData, SortEventData, UpdateEventData } from '@tmagic/stage';
|
import StageCore, { GuidesType, RemoveEventData, SortEventData, UpdateEventData } from '@tmagic/stage';
|
||||||
import { getIdFromEl } from '@tmagic/utils';
|
import { getIdFromEl } from '@tmagic/utils';
|
||||||
|
|
||||||
import editorService from '@editor/services/editor';
|
import { editorService } from '@editor/services/sdk-instance';
|
||||||
import uiService from '@editor/services/ui';
|
import uiService from '@editor/services/ui';
|
||||||
import type { StageOptions } from '@editor/type';
|
import type { StageOptions } from '@editor/type';
|
||||||
import { H_GUIDE_LINE_STORAGE_KEY, UI_SELECT_MODE_EVENT_NAME, V_GUIDE_LINE_STORAGE_KEY } from '@editor/utils/const';
|
import { H_GUIDE_LINE_STORAGE_KEY, UI_SELECT_MODE_EVENT_NAME, V_GUIDE_LINE_STORAGE_KEY } from '@editor/utils/const';
|
||||||
@ -31,6 +31,7 @@ export const useStage = (stageOptions: StageOptions) => {
|
|||||||
containerHighlightAddOnly: stageOptions.containerHighlightAddOnly,
|
containerHighlightAddOnly: stageOptions.containerHighlightAddOnly,
|
||||||
disabledDragStart: stageOptions.disabledDragStart,
|
disabledDragStart: stageOptions.disabledDragStart,
|
||||||
renderType: stageOptions.renderType,
|
renderType: stageOptions.renderType,
|
||||||
|
iframeSandbox: stageOptions.iframeSandbox,
|
||||||
canSelect: (el, event, stop) => {
|
canSelect: (el, event, stop) => {
|
||||||
if (!stageOptions.canSelect) return true;
|
if (!stageOptions.canSelect) return true;
|
||||||
|
|
||||||
|
|||||||
@ -37,17 +37,19 @@ export * from './utils';
|
|||||||
export { isGlobalFlat } from './utils/config';
|
export { isGlobalFlat } from './utils/config';
|
||||||
export { default as TMagicEditor } from './Editor.vue';
|
export { default as TMagicEditor } from './Editor.vue';
|
||||||
export { default as TMagicCodeEditor } from './layouts/CodeEditor.vue';
|
export { default as TMagicCodeEditor } from './layouts/CodeEditor.vue';
|
||||||
export { default as editorService } from './services/editor';
|
export {
|
||||||
export { default as propsService } from './services/props';
|
codeBlockService,
|
||||||
export { default as historyService } from './services/history';
|
componentListService,
|
||||||
export { default as storageService } from './services/storage';
|
dataSourceService,
|
||||||
export { default as eventsService } from './services/events';
|
depService,
|
||||||
export { default as dataSourceService } from './services/dataSource';
|
editorService,
|
||||||
|
eventsService,
|
||||||
|
historyService,
|
||||||
|
propsService,
|
||||||
|
storageService,
|
||||||
|
} from './services/sdk-instance';
|
||||||
export { default as stageOverlayService } from './services/stageOverlay';
|
export { default as stageOverlayService } from './services/stageOverlay';
|
||||||
export { default as uiService } from './services/ui';
|
export { default as uiService } from './services/ui';
|
||||||
export { default as codeBlockService } from './services/codeBlock';
|
|
||||||
export { default as depService } from './services/dep';
|
|
||||||
export { default as componentListService } from './services/componentList';
|
|
||||||
export { default as keybindingService } from './services/keybinding';
|
export { default as keybindingService } from './services/keybinding';
|
||||||
export { default as ComponentListPanel } from './layouts/sidebar/ComponentListPanel.vue';
|
export { default as ComponentListPanel } from './layouts/sidebar/ComponentListPanel.vue';
|
||||||
export { default as LayerPanel } from './layouts/sidebar/layer/LayerPanel.vue';
|
export { default as LayerPanel } from './layouts/sidebar/layer/LayerPanel.vue';
|
||||||
|
|||||||
@ -12,23 +12,22 @@ import type {
|
|||||||
MPage,
|
MPage,
|
||||||
MPageFragment,
|
MPageFragment,
|
||||||
} from '@tmagic/core';
|
} from '@tmagic/core';
|
||||||
import {
|
import { createCodeBlockTarget, DepTargetType, NodeType, Target, updateNode } from '@tmagic/core';
|
||||||
createCodeBlockTarget,
|
|
||||||
createDataSourceCondTarget,
|
|
||||||
createDataSourceMethodTarget,
|
|
||||||
createDataSourceTarget,
|
|
||||||
DepTargetType,
|
|
||||||
NODE_CONDS_KEY,
|
|
||||||
NodeType,
|
|
||||||
Target,
|
|
||||||
updateNode,
|
|
||||||
} from '@tmagic/core';
|
|
||||||
import { ChangeRecord } from '@tmagic/form';
|
import { ChangeRecord } from '@tmagic/form';
|
||||||
import StageCore from '@tmagic/stage';
|
import StageCore from '@tmagic/stage';
|
||||||
import { getDepNodeIds, getNodes, isPage, isValueIncludeDataSource } from '@tmagic/utils';
|
import { getDepNodeIds, getNodes, isPage } from '@tmagic/utils';
|
||||||
|
|
||||||
import PropsPanel from './layouts/PropsPanel.vue';
|
import PropsPanel from './layouts/PropsPanel.vue';
|
||||||
import { isIncludeDataSource } from './utils/editor';
|
import type { Reactivity } from './sdk/type';
|
||||||
|
import {
|
||||||
|
addDataSourceTargets,
|
||||||
|
classifyUpdatedNodes,
|
||||||
|
getDataSourceRecollectTypes,
|
||||||
|
removeDataSourceTargets,
|
||||||
|
removeTargetDeps,
|
||||||
|
resetDepTargets,
|
||||||
|
writeTargetDeps,
|
||||||
|
} from './sdk/utils/dep';
|
||||||
import { error as logError } from './utils/logger';
|
import { error as logError } from './utils/logger';
|
||||||
import { EditorProps } from './editorProps';
|
import { EditorProps } from './editorProps';
|
||||||
import { Services } from './type';
|
import { Services } from './type';
|
||||||
@ -247,6 +246,16 @@ export const initServiceState = (
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 同步给 SDK 的结构合法性校验(浏览器端默认 validateMode 为 off,主动调用 `validate` 时才会用到)
|
||||||
|
watch(
|
||||||
|
() => [props.disabledPageFragment, props.canDropIn] as const,
|
||||||
|
([disabledPageFragment, canDropIn]) =>
|
||||||
|
editorService.setConfig({ disabledPageFragment: disabledPageFragment ?? false, canDropIn }),
|
||||||
|
{
|
||||||
|
immediate: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
editorService.resetState();
|
editorService.resetState();
|
||||||
historyService.resetState();
|
historyService.resetState();
|
||||||
@ -476,12 +485,6 @@ export const initServiceEvents = (
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const initDataSourceDepTarget = (ds: DataSourceSchema) => {
|
|
||||||
depService.addTarget(createDataSourceTarget(ds, reactive({})));
|
|
||||||
depService.addTarget(createDataSourceMethodTarget(ds, reactive({})));
|
|
||||||
depService.addTarget(createDataSourceCondTarget(ds, reactive({})));
|
|
||||||
};
|
|
||||||
|
|
||||||
const rootChangeHandler = (value: MApp | null) => {
|
const rootChangeHandler = (value: MApp | null) => {
|
||||||
if (!value) return;
|
if (!value) return;
|
||||||
|
|
||||||
@ -491,15 +494,12 @@ export const initServiceEvents = (
|
|||||||
codeBlockService.setCodeDsl(value.codeBlocks);
|
codeBlockService.setCodeDsl(value.codeBlocks);
|
||||||
dataSourceService.set('dataSources', value.dataSources);
|
dataSourceService.set('dataSources', value.dataSources);
|
||||||
|
|
||||||
depService.clearTargets();
|
resetDepTargets(
|
||||||
|
depService,
|
||||||
for (const [id, code] of Object.entries(value.codeBlocks)) {
|
value.codeBlocks,
|
||||||
depService.addTarget(createCodeBlockTarget(id, code));
|
dataSourceService.get('dataSources'),
|
||||||
}
|
reactive as Reactivity['reactive'],
|
||||||
|
);
|
||||||
for (const ds of dataSourceService.get('dataSources')) {
|
|
||||||
initDataSourceDepTarget(ds);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Array.isArray(value.items)) {
|
if (Array.isArray(value.items)) {
|
||||||
updateStageDsl(value);
|
updateStageDsl(value);
|
||||||
@ -549,49 +549,7 @@ export const initServiceEvents = (
|
|||||||
// 节点更新,收集依赖
|
// 节点更新,收集依赖
|
||||||
// 仅当修改到数据源相关的才收集
|
// 仅当修改到数据源相关的才收集
|
||||||
const nodeUpdateHandler = (data: { newNode: MComponent; oldNode: MComponent; changeRecords?: ChangeRecord[] }[]) => {
|
const nodeUpdateHandler = (data: { newNode: MComponent; oldNode: MComponent; changeRecords?: ChangeRecord[] }[]) => {
|
||||||
const needRecollectNodes: MComponent[] = [];
|
const { needRecollectNodes, normalNodes } = classifyUpdatedNodes({ dep: depService }, data);
|
||||||
const normalNodes: MComponent[] = [];
|
|
||||||
for (const { newNode, oldNode, changeRecords } of data) {
|
|
||||||
if (newNode.type === NodeType.ROOT) {
|
|
||||||
normalNodes.push(newNode);
|
|
||||||
} else if (changeRecords?.length) {
|
|
||||||
// eslint-disable-next-line no-restricted-syntax
|
|
||||||
forChangeRecords: for (const record of changeRecords) {
|
|
||||||
if (!record.propPath) {
|
|
||||||
needRecollectNodes.push(newNode);
|
|
||||||
break forChangeRecords;
|
|
||||||
}
|
|
||||||
|
|
||||||
// NODE_CONDS_KEY为显示条件key
|
|
||||||
if (
|
|
||||||
new RegExp(`${NODE_CONDS_KEY}.(\\d)+.cond`).test(record.propPath) ||
|
|
||||||
new RegExp(`${NODE_CONDS_KEY}.(\\d)+.cond.(\\d)+.value`).test(record.propPath) ||
|
|
||||||
record.propPath === NODE_CONDS_KEY ||
|
|
||||||
isValueIncludeDataSource(record.value)
|
|
||||||
) {
|
|
||||||
needRecollectNodes.push(newNode);
|
|
||||||
break forChangeRecords;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 修改的key在收集的依赖中,则需要触发重新收集
|
|
||||||
for (const target of Object.values(depService.getTargets(DepTargetType.DATA_SOURCE))) {
|
|
||||||
if (!target.deps[newNode.id]) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (target.deps[newNode.id].keys.includes(record.propPath)) {
|
|
||||||
needRecollectNodes.push(newNode);
|
|
||||||
break forChangeRecords;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
normalNodes.push(newNode);
|
|
||||||
}
|
|
||||||
} else if (isIncludeDataSource(newNode, oldNode)) {
|
|
||||||
needRecollectNodes.push(newNode);
|
|
||||||
} else {
|
|
||||||
normalNodes.push(newNode);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (needRecollectNodes.length) {
|
if (needRecollectNodes.length) {
|
||||||
// 有数据源依赖,需要等依赖重新收集完才更新stage
|
// 有数据源依赖,需要等依赖重新收集完才更新stage
|
||||||
@ -603,13 +561,14 @@ export const initServiceEvents = (
|
|||||||
handler();
|
handler();
|
||||||
} else {
|
} else {
|
||||||
updateStageNodes(normalNodes);
|
updateStageNodes(normalNodes);
|
||||||
|
|
||||||
// 在上面判断是否需要收集数据源依赖中已经更新stage
|
|
||||||
Promise.all([
|
|
||||||
collectIdle(normalNodes, true, DepTargetType.CODE_BLOCK),
|
|
||||||
collectIdle(normalNodes, true, DepTargetType.DATA_SOURCE_METHOD),
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 代码块、数据源方法依赖对所有被更新的节点都要收集:同一批里既改了数据源引用又改了方法时不能漏掉
|
||||||
|
const updatedNodes = [...needRecollectNodes, ...normalNodes];
|
||||||
|
Promise.all([
|
||||||
|
collectIdle(updatedNodes, true, DepTargetType.CODE_BLOCK),
|
||||||
|
collectIdle(updatedNodes, true, DepTargetType.DATA_SOURCE_METHOD),
|
||||||
|
]);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 节点删除,清除对齐的依赖收集
|
// 节点删除,清除对齐的依赖收集
|
||||||
@ -634,7 +593,7 @@ export const initServiceEvents = (
|
|||||||
|
|
||||||
const dataSourceAddHandler = (config: DataSourceSchema) => {
|
const dataSourceAddHandler = (config: DataSourceSchema) => {
|
||||||
const handler = async () => {
|
const handler = async () => {
|
||||||
initDataSourceDepTarget(config);
|
addDataSourceTargets(depService, config, reactive as Reactivity['reactive']);
|
||||||
const app = await getTMagicApp();
|
const app = await getTMagicApp();
|
||||||
|
|
||||||
if (!app?.dataSourceManager) {
|
if (!app?.dataSourceManager) {
|
||||||
@ -669,84 +628,31 @@ export const initServiceEvents = (
|
|||||||
ds.setData(config.mocks?.find((mock) => mock.useInEditor)?.data || ds.getDefaultData());
|
ds.setData(config.mocks?.find((mock) => mock.useInEditor)?.data || ds.getDefaultData());
|
||||||
};
|
};
|
||||||
|
|
||||||
let needRecollectDep = false;
|
const types = getDataSourceRecollectTypes(changeRecords);
|
||||||
let isModifyField = false;
|
|
||||||
let isModifyMock = false;
|
|
||||||
let isModifyMethod = false;
|
|
||||||
for (const changeRecord of changeRecords) {
|
|
||||||
if (!changeRecord.propPath) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
isModifyField =
|
|
||||||
changeRecord.propPath === 'fields' ||
|
|
||||||
/fields.(\d)+.name/.test(changeRecord.propPath) ||
|
|
||||||
/fields.(\d)+.defaultValue/.test(changeRecord.propPath) ||
|
|
||||||
/fields.(\d)+$/.test(changeRecord.propPath);
|
|
||||||
|
|
||||||
isModifyMock = changeRecord.propPath === 'mocks';
|
|
||||||
|
|
||||||
isModifyMethod =
|
|
||||||
changeRecord.propPath === 'methods' ||
|
|
||||||
/methods.(\d)+.name/.test(changeRecord.propPath) ||
|
|
||||||
/methods.(\d)+$/.test(changeRecord.propPath);
|
|
||||||
|
|
||||||
needRecollectDep = isModifyField || isModifyMock || isModifyMethod;
|
|
||||||
|
|
||||||
if (needRecollectDep) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const root = editorService.get('root');
|
const root = editorService.get('root');
|
||||||
if (needRecollectDep) {
|
|
||||||
if (Array.isArray(root?.items)) {
|
|
||||||
depService.clearIdleTasks();
|
|
||||||
|
|
||||||
let collectIdlePromises: Promise<boolean>[] = [];
|
if (!types) {
|
||||||
if (isModifyField) {
|
if (root?.dataSources) updateDsData();
|
||||||
depService.removeTarget(config.id, DepTargetType.DATA_SOURCE);
|
return;
|
||||||
depService.removeTarget(config.id, DepTargetType.DATA_SOURCE_COND);
|
|
||||||
|
|
||||||
depService.addTarget(createDataSourceTarget(config, reactive({})));
|
|
||||||
depService.addTarget(createDataSourceCondTarget(config, reactive({})));
|
|
||||||
|
|
||||||
collectIdlePromises = [
|
|
||||||
collectIdle(root.items, true, DepTargetType.DATA_SOURCE),
|
|
||||||
collectIdle(root.items, true, DepTargetType.DATA_SOURCE_COND),
|
|
||||||
];
|
|
||||||
} else if (isModifyMock) {
|
|
||||||
depService.removeTarget(config.id, DepTargetType.DATA_SOURCE);
|
|
||||||
|
|
||||||
depService.addTarget(createDataSourceTarget(config, reactive({})));
|
|
||||||
|
|
||||||
collectIdlePromises = [collectIdle(root.items, true, DepTargetType.DATA_SOURCE)];
|
|
||||||
} else if (isModifyMethod) {
|
|
||||||
depService.removeTarget(config.id, DepTargetType.DATA_SOURCE_METHOD);
|
|
||||||
|
|
||||||
depService.addTarget(createDataSourceMethodTarget(config, reactive({})));
|
|
||||||
|
|
||||||
collectIdlePromises = [collectIdle(root.items, true, DepTargetType.DATA_SOURCE_METHOD)];
|
|
||||||
}
|
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
const removeDataSourceTarget = (id: string) => {
|
if (!Array.isArray(root?.items)) return;
|
||||||
depService.removeTarget(id, DepTargetType.DATA_SOURCE);
|
|
||||||
depService.removeTarget(id, DepTargetType.DATA_SOURCE_COND);
|
depService.clearIdleTasks();
|
||||||
depService.removeTarget(id, DepTargetType.DATA_SOURCE_METHOD);
|
removeDataSourceTargets(depService, config.id, types);
|
||||||
|
addDataSourceTargets(depService, config, reactive as Reactivity['reactive'], types);
|
||||||
|
|
||||||
|
const collectIdlePromises = types.map((type) => collectIdle(root.items, true, type));
|
||||||
|
|
||||||
|
const handler = async () => {
|
||||||
|
const results = await Promise.all(collectIdlePromises);
|
||||||
|
if (!results.every(Boolean)) return;
|
||||||
|
|
||||||
|
updateDataSourceSchema();
|
||||||
|
await updateDsData();
|
||||||
|
updateStageNodes(root.items);
|
||||||
|
};
|
||||||
|
handler();
|
||||||
};
|
};
|
||||||
|
|
||||||
const dataSourceRemoveHandler = (id: string) => {
|
const dataSourceRemoveHandler = (id: string) => {
|
||||||
@ -773,7 +679,7 @@ export const initServiceEvents = (
|
|||||||
app?.dataSourceManager?.removeDataSource(id);
|
app?.dataSourceManager?.removeDataSource(id);
|
||||||
|
|
||||||
updateStageNodes(nodes);
|
updateStageNodes(nodes);
|
||||||
removeDataSourceTarget(id);
|
removeDataSourceTargets(depService, id);
|
||||||
};
|
};
|
||||||
|
|
||||||
handler();
|
handler();
|
||||||
@ -799,45 +705,10 @@ export const initServiceEvents = (
|
|||||||
codeBlockService.on('addOrUpdate', codeBlockAddOrUpdateHandler);
|
codeBlockService.on('addOrUpdate', codeBlockAddOrUpdateHandler);
|
||||||
codeBlockService.on('remove', codeBlockRemoveHandler);
|
codeBlockService.on('remove', codeBlockRemoveHandler);
|
||||||
|
|
||||||
const targetAddHandler = (target: Target) => {
|
const targetAddHandler = (target: Target) => writeTargetDeps(editorService.get('root'), target);
|
||||||
const root = editorService.get('root');
|
|
||||||
if (!root) return;
|
|
||||||
|
|
||||||
if (target.type === DepTargetType.DATA_SOURCE) {
|
const targetRemoveHandler = (id: string | number, type: DepTargetType) =>
|
||||||
if (!root.dataSourceDeps) {
|
removeTargetDeps(editorService.get('root'), id, type);
|
||||||
root.dataSourceDeps = {};
|
|
||||||
}
|
|
||||||
root.dataSourceDeps[target.id] = target.deps;
|
|
||||||
} else if (target.type === DepTargetType.DATA_SOURCE_COND) {
|
|
||||||
if (!root.dataSourceCondDeps) {
|
|
||||||
root.dataSourceCondDeps = {};
|
|
||||||
}
|
|
||||||
root.dataSourceCondDeps[target.id] = target.deps;
|
|
||||||
} else if (target.type === DepTargetType.DATA_SOURCE_METHOD) {
|
|
||||||
if (!root.dataSourceMethodDeps) {
|
|
||||||
root.dataSourceMethodDeps = {};
|
|
||||||
}
|
|
||||||
root.dataSourceMethodDeps[target.id] = target.deps;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const targetRemoveHandler = (id: string | number, type: DepTargetType) => {
|
|
||||||
const root = editorService.get('root');
|
|
||||||
|
|
||||||
if (!root) return;
|
|
||||||
|
|
||||||
if (root.dataSourceDeps && type === DepTargetType.DATA_SOURCE) {
|
|
||||||
delete root.dataSourceDeps[id];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (root.dataSourceCondDeps && type === DepTargetType.DATA_SOURCE_COND) {
|
|
||||||
delete root.dataSourceCondDeps[id];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (root.dataSourceMethodDeps && type === DepTargetType.DATA_SOURCE_METHOD) {
|
|
||||||
delete root.dataSourceMethodDeps[id];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
depService.on('add-target', targetAddHandler);
|
depService.on('add-target', targetAddHandler);
|
||||||
depService.on('remove-target', targetRemoveHandler);
|
depService.on('remove-target', targetRemoveHandler);
|
||||||
|
|||||||
@ -76,7 +76,7 @@ import type { MPage, MPageFragment } from '@tmagic/core';
|
|||||||
|
|
||||||
import SplitView from '@editor/components/SplitView.vue';
|
import SplitView from '@editor/components/SplitView.vue';
|
||||||
import { useServices } from '@editor/hooks/use-services';
|
import { useServices } from '@editor/hooks/use-services';
|
||||||
import { Protocol } from '@editor/services/storage';
|
import { Protocol } from '@editor/services/sdk-instance';
|
||||||
import type { FrameworkSlots, GetColumnWidth, PageBarSortOptions } from '@editor/type';
|
import type { FrameworkSlots, GetColumnWidth, PageBarSortOptions } from '@editor/type';
|
||||||
import { getEditorConfig } from '@editor/utils/config';
|
import { getEditorConfig } from '@editor/utils/config';
|
||||||
import {
|
import {
|
||||||
|
|||||||
@ -62,13 +62,14 @@ const props = withDefaults(
|
|||||||
width?: number;
|
width?: number;
|
||||||
/** 子元素之间的间距,需与 SCSS gap 保持一致 */
|
/** 子元素之间的间距,需与 SCSS gap 保持一致 */
|
||||||
gap?: number;
|
gap?: number;
|
||||||
/** Popover 内容宽度 */
|
/**
|
||||||
popoverWidth?: number;
|
* Popover 内容宽度。不传时按内容撑开,避免下拉框超出菜单。
|
||||||
|
*/
|
||||||
|
popoverWidth?: number | string;
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
items: () => [],
|
items: () => [],
|
||||||
gap: 3,
|
gap: 3,
|
||||||
popoverWidth: 180,
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -110,7 +111,18 @@ const containerWidth = ref(0);
|
|||||||
const hiddenIndexSet = ref<Set<number>>(new Set());
|
const hiddenIndexSet = ref<Set<number>>(new Set());
|
||||||
|
|
||||||
const hasOverflow = computed(() => hiddenIndexSet.value.size > 0);
|
const hasOverflow = computed(() => hiddenIndexSet.value.size > 0);
|
||||||
const overflowItems = computed(() => props.items.filter((_, index) => hiddenIndexSet.value.has(index)));
|
// 更多菜单是纵向列表:分割线改成横线;收进去的首尾分割线没有分组意义,直接去掉。
|
||||||
|
const overflowItems = computed(() => {
|
||||||
|
const items = props.items
|
||||||
|
.filter((_, index) => hiddenIndexSet.value.has(index))
|
||||||
|
.map((item) => (item.type === 'divider' ? { ...item, direction: 'horizontal' as const } : item));
|
||||||
|
|
||||||
|
let start = 0;
|
||||||
|
let end = items.length;
|
||||||
|
while (start < end && items[start].type === 'divider') start += 1;
|
||||||
|
while (end > start && items[end - 1].type === 'divider') end -= 1;
|
||||||
|
return items.slice(start, end);
|
||||||
|
});
|
||||||
|
|
||||||
const measureAndCompute = () => {
|
const measureAndCompute = () => {
|
||||||
if (!columnEl.value) return;
|
if (!columnEl.value) return;
|
||||||
|
|||||||
@ -13,18 +13,14 @@
|
|||||||
:data="{
|
:data="{
|
||||||
type: 'button',
|
type: 'button',
|
||||||
text: '页面',
|
text: '页面',
|
||||||
handler: () => {
|
handler: addNormalPage,
|
||||||
addPage(NodeType.PAGE);
|
|
||||||
},
|
|
||||||
}"
|
}"
|
||||||
></ToolButton>
|
></ToolButton>
|
||||||
<ToolButton
|
<ToolButton
|
||||||
:data="{
|
:data="{
|
||||||
type: 'button',
|
type: 'button',
|
||||||
text: '页面片',
|
text: '页面片',
|
||||||
handler: () => {
|
handler: addPageFragment,
|
||||||
addPage(NodeType.PAGE_FRAGMENT);
|
|
||||||
},
|
|
||||||
}"
|
}"
|
||||||
></ToolButton>
|
></ToolButton>
|
||||||
</TMagicPopover>
|
</TMagicPopover>
|
||||||
@ -62,4 +58,7 @@ const addPage = (type: NodeType.PAGE | NodeType.PAGE_FRAGMENT) => {
|
|||||||
};
|
};
|
||||||
editorService.add(pageConfig);
|
editorService.add(pageConfig);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const addNormalPage = () => addPage(NodeType.PAGE);
|
||||||
|
const addPageFragment = () => addPage(NodeType.PAGE_FRAGMENT);
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -66,7 +66,7 @@ import MIcon from '@editor/components/Icon.vue';
|
|||||||
import Resizer from '@editor/components/Resizer.vue';
|
import Resizer from '@editor/components/Resizer.vue';
|
||||||
import { ENABLE_PROPS_FORM_VALIDATE } from '@editor/editorProps';
|
import { ENABLE_PROPS_FORM_VALIDATE } from '@editor/editorProps';
|
||||||
import { useServices } from '@editor/hooks/use-services';
|
import { useServices } from '@editor/hooks/use-services';
|
||||||
import { Protocol } from '@editor/services/storage';
|
import { Protocol } from '@editor/services/sdk-instance';
|
||||||
import type { NodeInvalidSource, PropsPanelSlots } from '@editor/type';
|
import type { NodeInvalidSource, PropsPanelSlots } from '@editor/type';
|
||||||
import { styleTabConfig } from '@editor/utils';
|
import { styleTabConfig } from '@editor/utils';
|
||||||
import { PROPS_PANEL_WIDTH_STORAGE_KEY } from '@editor/utils/const';
|
import { PROPS_PANEL_WIDTH_STORAGE_KEY } from '@editor/utils/const';
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { computed, type Ref, watch } from 'vue';
|
import { computed, type Ref, watch } from 'vue';
|
||||||
|
|
||||||
import { Protocol } from '@editor/services/storage';
|
import { Protocol } from '@editor/services/sdk-instance';
|
||||||
import { Services } from '@editor/type';
|
import { Services } from '@editor/type';
|
||||||
import { RIGHT_COLUMN_WIDTH_STORAGE_KEY } from '@editor/utils/const';
|
import { RIGHT_COLUMN_WIDTH_STORAGE_KEY } from '@editor/utils/const';
|
||||||
|
|
||||||
|
|||||||
19
packages/editor/src/sdk.ts
Normal file
19
packages/editor/src/sdk.ts
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
/*
|
||||||
|
* Tencent is pleased to support the open source community by making TMagicEditor available.
|
||||||
|
*
|
||||||
|
* Copyright (C) 2025 Tencent. All rights reserved.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export * from './sdk/index';
|
||||||
264
packages/editor/src/sdk/index.ts
Normal file
264
packages/editor/src/sdk/index.ts
Normal file
@ -0,0 +1,264 @@
|
|||||||
|
/*
|
||||||
|
* Tencent is pleased to support the open source community by making TMagicEditor available.
|
||||||
|
*
|
||||||
|
* Copyright (C) 2025 Tencent. All rights reserved.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @fileoverview `@tmagic/editor/sdk`:不依赖 DOM 的 DSL 操作 SDK。
|
||||||
|
*
|
||||||
|
* 每次调用 {@link createDslSdk} 得到一组独立的 service(editor / history / props / dataSource /
|
||||||
|
* codeBlock / dep / storage / events / componentList),可在 Node 中运行。浏览器端的
|
||||||
|
* `editorService` 等单例就是用同一个工厂创建的默认实例。
|
||||||
|
*
|
||||||
|
* @module @tmagic/editor/sdk
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { nextTick, reactive, shallowReactive, toRaw } from 'vue';
|
||||||
|
import { cloneDeep, isEmpty } from 'lodash-es';
|
||||||
|
|
||||||
|
import type { MApp } from '@tmagic/core';
|
||||||
|
|
||||||
|
import type { PersistedHistoryState } from '@editor/type';
|
||||||
|
|
||||||
|
import CodeBlock from './services/CodeBlock';
|
||||||
|
import ComponentList from './services/ComponentList';
|
||||||
|
import DataSource from './services/DataSource';
|
||||||
|
import Dep from './services/Dep';
|
||||||
|
import Editor from './services/Editor';
|
||||||
|
import Events from './services/Events';
|
||||||
|
import History from './services/History';
|
||||||
|
import Props from './services/Props';
|
||||||
|
import WebStorage from './services/WebStorage';
|
||||||
|
import type { Reactivity, SdkContext, SdkOptions } from './type';
|
||||||
|
import { createValidator, type Validator } from './validate';
|
||||||
|
import { bindWiring, whenWiringSettled, type WiringHandle } from './wiring';
|
||||||
|
|
||||||
|
const SERVICE_NAMES = [
|
||||||
|
'editor',
|
||||||
|
'props',
|
||||||
|
'history',
|
||||||
|
'dataSource',
|
||||||
|
'codeBlock',
|
||||||
|
'dep',
|
||||||
|
'storage',
|
||||||
|
'events',
|
||||||
|
'componentList',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Node 下默认的 DSL 反序列化:序列化出来的内容是被括号包起来的 JSON(函数、正则等需要自己传入 `parseDSL`)。
|
||||||
|
* 刻意不使用 `eval`,避免执行调用方传入的历史快照里的任意代码。
|
||||||
|
*/
|
||||||
|
export const defaultParseDSL = (code: string): any => {
|
||||||
|
const text = code.trim();
|
||||||
|
const body = text.startsWith('(') && text.endsWith(')') ? text.slice(1, -1) : text;
|
||||||
|
try {
|
||||||
|
return JSON.parse(body);
|
||||||
|
} catch (e: any) {
|
||||||
|
throw new Error(
|
||||||
|
`默认的 parseDSL 只能解析 JSON,无法还原含函数 / undefined 等的内容(${e?.message ?? e});请在 createDslSdk 的 parseDSL 选项中传入自己的实现`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface DslSdk {
|
||||||
|
readonly editor: Editor;
|
||||||
|
readonly history: History;
|
||||||
|
readonly props: Props;
|
||||||
|
readonly dataSource: DataSource;
|
||||||
|
readonly codeBlock: CodeBlock;
|
||||||
|
readonly dep: Dep;
|
||||||
|
readonly storage: WebStorage;
|
||||||
|
readonly events: Events;
|
||||||
|
readonly componentList: ComponentList;
|
||||||
|
readonly validate: Validator;
|
||||||
|
readonly ctx: SdkContext;
|
||||||
|
|
||||||
|
/** 登记表(组件属性配置等,其中一部分是异步填充的)全部就绪后 resolve */
|
||||||
|
ready: Promise<void>;
|
||||||
|
|
||||||
|
/** 导出当前 DSL(深拷贝,含依赖收集结果) */
|
||||||
|
getDsl(): MApp | null;
|
||||||
|
/** 整体替换 DSL,会按 `set root` 的语义写入历史记录 */
|
||||||
|
setDsl(dsl: MApp | null): Promise<void>;
|
||||||
|
/** 绑定依赖收集连线;`autoWiring` 为 true 时创建时已绑定。返回解绑函数 */
|
||||||
|
bindWiring(): () => void;
|
||||||
|
/** 导出历史快照,下次 `createDslSdk({ history })` 时传回即可恢复 */
|
||||||
|
serializeHistory(): PersistedHistoryState;
|
||||||
|
destroy(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Services = Pick<
|
||||||
|
SdkContext,
|
||||||
|
'editor' | 'props' | 'history' | 'dataSource' | 'codeBlock' | 'dep' | 'storage' | 'events' | 'componentList'
|
||||||
|
>;
|
||||||
|
|
||||||
|
/** 把 options 里的登记表写入各 service,返回需要等待的异步任务(如属性表单配置) */
|
||||||
|
const registerOptions = (
|
||||||
|
{ editor, props, events, componentList, dataSource }: Services,
|
||||||
|
options: SdkOptions,
|
||||||
|
): Promise<unknown>[] => {
|
||||||
|
if (options.componentGroupList) componentList.setList(options.componentGroupList);
|
||||||
|
if (options.datasourceList) dataSource.set('datasourceTypeList', options.datasourceList);
|
||||||
|
|
||||||
|
props.setDisabledCodeBlock(options.disabledCodeBlock ?? false);
|
||||||
|
props.setDisabledDataSource(options.disabledDataSource ?? false);
|
||||||
|
|
||||||
|
const pending = [props.setPropsConfigs(options.propsConfigs ?? {}), props.setPropsValues(options.propsValues ?? {})];
|
||||||
|
|
||||||
|
const eventMethodList = Object.entries(options.eventMethodList ?? {});
|
||||||
|
events.setEvents(Object.fromEntries(eventMethodList.map(([type, item]) => [type, item.events])));
|
||||||
|
events.setMethods(Object.fromEntries(eventMethodList.map(([type, item]) => [type, item.methods])));
|
||||||
|
|
||||||
|
for (const [type, config] of Object.entries(options.datasourceConfigs ?? {})) {
|
||||||
|
dataSource.setFormConfig(type, config);
|
||||||
|
}
|
||||||
|
for (const [type, value] of Object.entries(options.datasourceValues ?? {})) {
|
||||||
|
dataSource.setFormValue(type, value);
|
||||||
|
}
|
||||||
|
for (const [type, item] of Object.entries(options.datasourceEventMethodList ?? {})) {
|
||||||
|
dataSource.setFormEvent(type, item.events);
|
||||||
|
dataSource.setFormMethod(type, item.methods);
|
||||||
|
}
|
||||||
|
|
||||||
|
editor.set('disabledMultiSelect', options.disabledMultiSelect ?? false);
|
||||||
|
editor.set('alwaysMultiSelect', options.alwaysMultiSelect ?? false);
|
||||||
|
editor.setConfig({
|
||||||
|
disabledPageFragment: options.disabledPageFragment ?? false,
|
||||||
|
canDropIn: options.canDropIn,
|
||||||
|
});
|
||||||
|
|
||||||
|
return pending;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建一个相互连线、互相独立的 DSL SDK 实例。
|
||||||
|
*
|
||||||
|
* @param options 见 {@link SdkOptions}
|
||||||
|
*/
|
||||||
|
export const createDslSdk = (options: SdkOptions = {}): DslSdk => {
|
||||||
|
const reactivity: Reactivity = {
|
||||||
|
// Vue 的 reactive 返回 Reactive<T>,与接口中的 T 类型不完全一致,这里做断言
|
||||||
|
|
||||||
|
reactive: reactive as Reactivity['reactive'],
|
||||||
|
|
||||||
|
shallowReactive: shallowReactive as Reactivity['shallowReactive'],
|
||||||
|
toRaw,
|
||||||
|
nextTick,
|
||||||
|
...options.reactivity,
|
||||||
|
};
|
||||||
|
const parseDSL = options.parseDSL ?? defaultParseDSL;
|
||||||
|
|
||||||
|
// service 之间互相访问用懒 getter,构造期互不依赖,从而解开 editor <-> props / history 的循环
|
||||||
|
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||||
|
const services = {} as { -readonly [K in keyof Services]: Services[K] };
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||||
|
const ctx = {
|
||||||
|
reactivity,
|
||||||
|
options,
|
||||||
|
validateMode: options.validateMode ?? 'strict',
|
||||||
|
parseDSL: (code: string) => parseDSL(code),
|
||||||
|
ready: Promise.resolve(),
|
||||||
|
} as SdkContext;
|
||||||
|
|
||||||
|
for (const name of SERVICE_NAMES) {
|
||||||
|
Object.defineProperty(ctx, name, { get: () => services[name], enumerable: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
services.storage = new WebStorage(ctx, options.storage);
|
||||||
|
services.history = new History(ctx);
|
||||||
|
services.props = new Props(ctx);
|
||||||
|
services.events = new Events(ctx);
|
||||||
|
services.componentList = new ComponentList(ctx);
|
||||||
|
services.dep = new Dep(ctx);
|
||||||
|
services.codeBlock = new CodeBlock(ctx);
|
||||||
|
services.dataSource = new DataSource(ctx);
|
||||||
|
services.editor = new Editor(ctx);
|
||||||
|
|
||||||
|
const { editor, history } = services;
|
||||||
|
|
||||||
|
const validate = createValidator(ctx);
|
||||||
|
ctx.validator = validate;
|
||||||
|
|
||||||
|
const pending = registerOptions(services, options);
|
||||||
|
|
||||||
|
let wiring: WiringHandle | null = null;
|
||||||
|
const unbind = () => {
|
||||||
|
wiring?.unbind();
|
||||||
|
wiring = null;
|
||||||
|
};
|
||||||
|
const bind = () => {
|
||||||
|
wiring ??= bindWiring(ctx);
|
||||||
|
return unbind;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (options.autoWiring ?? true) {
|
||||||
|
bind();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 先恢复历史再设置 root:恢复出的初始基线不会被 set root 重复创建
|
||||||
|
if (options.history) {
|
||||||
|
history.restore(options.history);
|
||||||
|
}
|
||||||
|
|
||||||
|
const setDsl = async (dsl: MApp | null) => {
|
||||||
|
editor.set('root', dsl ? cloneDeep(dsl) : null);
|
||||||
|
await whenWiringSettled(ctx);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (options.dsl) {
|
||||||
|
pending.push(setDsl(options.dsl));
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.ready = Promise.all(pending).then(() => undefined);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...services,
|
||||||
|
validate,
|
||||||
|
ctx,
|
||||||
|
get ready() {
|
||||||
|
return ctx.ready;
|
||||||
|
},
|
||||||
|
getDsl() {
|
||||||
|
const root = reactivity.toRaw(editor.get('root'));
|
||||||
|
return root && !isEmpty(root) ? cloneDeep(root) : null;
|
||||||
|
},
|
||||||
|
setDsl,
|
||||||
|
bindWiring: bind,
|
||||||
|
serializeHistory: () => history.serialize({ jsonSafe: true }),
|
||||||
|
destroy() {
|
||||||
|
unbind();
|
||||||
|
for (const service of Object.values(services)) {
|
||||||
|
service.destroy();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export { default as BaseService } from './services/BaseService';
|
||||||
|
export { default as CodeBlockService } from './services/CodeBlock';
|
||||||
|
export { default as ComponentListService } from './services/ComponentList';
|
||||||
|
export { default as DataSourceService } from './services/DataSource';
|
||||||
|
export { default as DepService } from './services/Dep';
|
||||||
|
export { default as EditorService } from './services/Editor';
|
||||||
|
export { default as EventsService } from './services/Events';
|
||||||
|
export { default as HistoryService } from './services/History';
|
||||||
|
export { default as PropsService } from './services/Props';
|
||||||
|
export { default as StorageService, MemoryStorage, Protocol } from './services/WebStorage';
|
||||||
|
export * from './type';
|
||||||
|
export * from './validate';
|
||||||
|
export * from './wiring';
|
||||||
@ -16,7 +16,6 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { reactive } from 'vue';
|
|
||||||
import { cloneDeep, get, keys, pick } from 'lodash-es';
|
import { cloneDeep, get, keys, pick } from 'lodash-es';
|
||||||
import type { Writable } from 'type-fest';
|
import type { Writable } from 'type-fest';
|
||||||
|
|
||||||
@ -25,9 +24,6 @@ import { Target, Watcher } from '@tmagic/core';
|
|||||||
import type { TableColumnConfig } from '@tmagic/form';
|
import type { TableColumnConfig } from '@tmagic/form';
|
||||||
import { getValueByKeyPath, setValueByKeyPath } from '@tmagic/utils';
|
import { getValueByKeyPath, setValueByKeyPath } from '@tmagic/utils';
|
||||||
|
|
||||||
import editorService from '@editor/services/editor';
|
|
||||||
import historyService from '@editor/services/history';
|
|
||||||
import storageService, { Protocol } from '@editor/services/storage';
|
|
||||||
import type {
|
import type {
|
||||||
AsyncHookPlugin,
|
AsyncHookPlugin,
|
||||||
CodeBlockStepValue,
|
CodeBlockStepValue,
|
||||||
@ -37,11 +33,13 @@ import type {
|
|||||||
HistoryOpOptionsWithChangeRecords,
|
HistoryOpOptionsWithChangeRecords,
|
||||||
} from '@editor/type';
|
} from '@editor/type';
|
||||||
import { CODE_DRAFT_STORAGE_KEY } from '@editor/type';
|
import { CODE_DRAFT_STORAGE_KEY } from '@editor/type';
|
||||||
import { getEditorConfig } from '@editor/utils/config';
|
|
||||||
import { COPY_CODE_STORAGE_KEY } from '@editor/utils/editor';
|
import { COPY_CODE_STORAGE_KEY } from '@editor/utils/editor';
|
||||||
import { createStackStep, describeRevertStep, getLastPushedHistoryIds } from '@editor/utils/history';
|
import { createStackStep, describeRevertStep, getLastPushedHistoryIds } from '@editor/utils/history';
|
||||||
|
|
||||||
|
import type { SdkContext } from '../type';
|
||||||
|
|
||||||
import BaseService from './BaseService';
|
import BaseService from './BaseService';
|
||||||
|
import { Protocol } from './WebStorage';
|
||||||
|
|
||||||
const canUsePluginMethods = {
|
const canUsePluginMethods = {
|
||||||
async: ['setCodeDslById', 'setEditStatus', 'setCombineIds', 'setUndeleteableList', 'deleteCodeDslByIds'] as const,
|
async: ['setCodeDslById', 'setEditStatus', 'setCombineIds', 'setUndeleteableList', 'deleteCodeDslByIds'] as const,
|
||||||
@ -51,13 +49,7 @@ const canUsePluginMethods = {
|
|||||||
type AsyncMethodName = Writable<(typeof canUsePluginMethods)['async']>;
|
type AsyncMethodName = Writable<(typeof canUsePluginMethods)['async']>;
|
||||||
|
|
||||||
class CodeBlock extends BaseService {
|
class CodeBlock extends BaseService {
|
||||||
private state = reactive<CodeState>({
|
private state: CodeState;
|
||||||
codeDsl: null,
|
|
||||||
editable: true,
|
|
||||||
combineIds: [],
|
|
||||||
undeletableList: [],
|
|
||||||
paramsColConfig: undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 最近一次写入历史栈的代码块历史记录 uuid(单条写入场景:新增 / 更新)。
|
* 最近一次写入历史栈的代码块历史记录 uuid(单条写入场景:新增 / 更新)。
|
||||||
@ -70,11 +62,18 @@ class CodeBlock extends BaseService {
|
|||||||
*/
|
*/
|
||||||
private lastDeletedHistoryIds: string[] = [];
|
private lastDeletedHistoryIds: string[] = [];
|
||||||
|
|
||||||
constructor() {
|
constructor(private ctx: SdkContext) {
|
||||||
super([
|
super([
|
||||||
...canUsePluginMethods.async.map((methodName) => ({ name: methodName, isAsync: true })),
|
...canUsePluginMethods.async.map((methodName) => ({ name: methodName, isAsync: true })),
|
||||||
...canUsePluginMethods.sync.map((methodName) => ({ name: methodName, isAsync: false })),
|
...canUsePluginMethods.sync.map((methodName) => ({ name: methodName, isAsync: false })),
|
||||||
]);
|
]);
|
||||||
|
this.state = ctx.reactivity.reactive<CodeState>({
|
||||||
|
codeDsl: null,
|
||||||
|
editable: true,
|
||||||
|
combineIds: [],
|
||||||
|
undeletableList: [],
|
||||||
|
paramsColConfig: undefined,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -170,9 +169,9 @@ class CodeBlock extends BaseService {
|
|||||||
const codeConfigProcessed = cloneDeep(codeConfig);
|
const codeConfigProcessed = cloneDeep(codeConfig);
|
||||||
if (codeConfigProcessed.content) {
|
if (codeConfigProcessed.content) {
|
||||||
// 在保存的时候转换代码内容
|
// 在保存的时候转换代码内容
|
||||||
const parseDSL = getEditorConfig('parseDSL');
|
// 没有注入 parseDSL(如 Node 默认)时保持源码字符串,不会执行代码,导出的 DSL 里仍是字符串
|
||||||
if (typeof codeConfigProcessed.content === 'string') {
|
if (typeof codeConfigProcessed.content === 'string' && this.ctx.options.parseDSL) {
|
||||||
codeConfigProcessed.content = parseDSL<(...args: any[]) => any>(codeConfigProcessed.content);
|
codeConfigProcessed.content = this.ctx.parseDSL(codeConfigProcessed.content) as (...args: any[]) => any;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -196,7 +195,7 @@ class CodeBlock extends BaseService {
|
|||||||
historyDescription,
|
historyDescription,
|
||||||
source: historySource,
|
source: historySource,
|
||||||
});
|
});
|
||||||
this.lastPushedHistoryId = (step ? historyService.push('codeBlock', step, id) : null)?.uuid ?? null;
|
this.lastPushedHistoryId = (step ? this.ctx.history.push('codeBlock', step, id) : null)?.uuid ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.emit('addOrUpdate', id, codeDsl[id]);
|
this.emit('addOrUpdate', id, codeDsl[id]);
|
||||||
@ -268,21 +267,21 @@ class CodeBlock extends BaseService {
|
|||||||
* 设置代码草稿
|
* 设置代码草稿
|
||||||
*/
|
*/
|
||||||
public setCodeDraft(codeId: Id, content: string): void {
|
public setCodeDraft(codeId: Id, content: string): void {
|
||||||
globalThis.localStorage.setItem(`${CODE_DRAFT_STORAGE_KEY}_${codeId}`, content);
|
this.ctx.storage.getStorage().setItem(`${CODE_DRAFT_STORAGE_KEY}_${codeId}`, content);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取代码草稿
|
* 获取代码草稿
|
||||||
*/
|
*/
|
||||||
public getCodeDraft(codeId: Id): string | null {
|
public getCodeDraft(codeId: Id): string | null {
|
||||||
return globalThis.localStorage.getItem(`${CODE_DRAFT_STORAGE_KEY}_${codeId}`);
|
return this.ctx.storage.getStorage().getItem(`${CODE_DRAFT_STORAGE_KEY}_${codeId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 删除代码草稿
|
* 删除代码草稿
|
||||||
*/
|
*/
|
||||||
public removeCodeDraft(codeId: Id): void {
|
public removeCodeDraft(codeId: Id): void {
|
||||||
globalThis.localStorage.removeItem(`${CODE_DRAFT_STORAGE_KEY}_${codeId}`);
|
this.ctx.storage.getStorage().removeItem(`${CODE_DRAFT_STORAGE_KEY}_${codeId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -314,7 +313,7 @@ class CodeBlock extends BaseService {
|
|||||||
historyDescription,
|
historyDescription,
|
||||||
source: historySource,
|
source: historySource,
|
||||||
});
|
});
|
||||||
const uuid = step ? historyService.push('codeBlock', step, id)?.uuid : undefined;
|
const uuid = step ? this.ctx.history.push('codeBlock', step, id)?.uuid : undefined;
|
||||||
if (uuid) this.lastDeletedHistoryIds.push(uuid);
|
if (uuid) this.lastDeletedHistoryIds.push(uuid);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -390,7 +389,7 @@ class CodeBlock extends BaseService {
|
|||||||
* @returns 撤销的 step;栈不存在或已无可撤销时返回 null
|
* @returns 撤销的 step;栈不存在或已无可撤销时返回 null
|
||||||
*/
|
*/
|
||||||
public async undo(id: Id): Promise<CodeBlockStepValue | null> {
|
public async undo(id: Id): Promise<CodeBlockStepValue | null> {
|
||||||
const step = historyService.undo('codeBlock', id);
|
const step = this.ctx.history.undo('codeBlock', id);
|
||||||
if (!step) return null;
|
if (!step) return null;
|
||||||
await this.applyHistoryStep(step, true);
|
await this.applyHistoryStep(step, true);
|
||||||
return step;
|
return step;
|
||||||
@ -402,7 +401,7 @@ class CodeBlock extends BaseService {
|
|||||||
* @returns 重做的 step;栈不存在或已无可重做时返回 null
|
* @returns 重做的 step;栈不存在或已无可重做时返回 null
|
||||||
*/
|
*/
|
||||||
public async redo(id: Id): Promise<CodeBlockStepValue | null> {
|
public async redo(id: Id): Promise<CodeBlockStepValue | null> {
|
||||||
const step = historyService.redo('codeBlock', id);
|
const step = this.ctx.history.redo('codeBlock', id);
|
||||||
if (!step) return null;
|
if (!step) return null;
|
||||||
await this.applyHistoryStep(step, false);
|
await this.applyHistoryStep(step, false);
|
||||||
return step;
|
return step;
|
||||||
@ -410,12 +409,12 @@ class CodeBlock extends BaseService {
|
|||||||
|
|
||||||
/** 是否可对指定代码块撤销。 */
|
/** 是否可对指定代码块撤销。 */
|
||||||
public canUndo(id: Id): boolean {
|
public canUndo(id: Id): boolean {
|
||||||
return historyService.canUndo('codeBlock', id);
|
return this.ctx.history.canUndo('codeBlock', id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 是否可对指定代码块重做。 */
|
/** 是否可对指定代码块重做。 */
|
||||||
public canRedo(id: Id): boolean {
|
public canRedo(id: Id): boolean {
|
||||||
return historyService.canRedo('codeBlock', id);
|
return this.ctx.history.canRedo('codeBlock', id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -426,7 +425,7 @@ class CodeBlock extends BaseService {
|
|||||||
* @returns 实际移动到的最终游标位置
|
* @returns 实际移动到的最终游标位置
|
||||||
*/
|
*/
|
||||||
public async goto(id: Id, targetCursor: number): Promise<number> {
|
public async goto(id: Id, targetCursor: number): Promise<number> {
|
||||||
let cursor = historyService.getCursor('codeBlock', id);
|
let cursor = this.ctx.history.getCursor('codeBlock', id);
|
||||||
const target = Math.max(0, targetCursor);
|
const target = Math.max(0, targetCursor);
|
||||||
while (cursor > target) {
|
while (cursor > target) {
|
||||||
const step = await this.undo(id);
|
const step = await this.undo(id);
|
||||||
@ -452,7 +451,7 @@ class CodeBlock extends BaseService {
|
|||||||
* @returns 反向后产生的新 step;目标不存在 / 未应用时返回 null
|
* @returns 反向后产生的新 step;目标不存在 / 未应用时返回 null
|
||||||
*/
|
*/
|
||||||
public async revert(id: Id, index: number): Promise<CodeBlockStepValue | null> {
|
public async revert(id: Id, index: number): Promise<CodeBlockStepValue | null> {
|
||||||
const list = historyService.getStepList('codeBlock', id);
|
const list = this.ctx.history.getStepList('codeBlock', id);
|
||||||
const entry = list[index];
|
const entry = list[index];
|
||||||
if (!entry?.applied) return null;
|
if (!entry?.applied) return null;
|
||||||
// 更新类步骤(前后 content 都存在)必须带 changeRecords 才支持回滚,否则只能整内容替换,会冲掉后续无关变更。
|
// 更新类步骤(前后 content 都存在)必须带 changeRecords 才支持回滚,否则只能整内容替换,会冲掉后续无关变更。
|
||||||
@ -473,7 +472,7 @@ class CodeBlock extends BaseService {
|
|||||||
public async revertById(uuids: string[]): Promise<(CodeBlockStepValue | null)[]> {
|
public async revertById(uuids: string[]): Promise<(CodeBlockStepValue | null)[]> {
|
||||||
const results: (CodeBlockStepValue | null)[] = [];
|
const results: (CodeBlockStepValue | null)[] = [];
|
||||||
for (const uuid of uuids) {
|
for (const uuid of uuids) {
|
||||||
const location = historyService.findStepLocationByUuid('codeBlock', uuid);
|
const location = this.ctx.history.findStepLocationByUuid('codeBlock', uuid);
|
||||||
results.push(location ? await this.revert(location.id, location.index) : null);
|
results.push(location ? await this.revert(location.id, location.index) : null);
|
||||||
}
|
}
|
||||||
return results;
|
return results;
|
||||||
@ -513,7 +512,7 @@ class CodeBlock extends BaseService {
|
|||||||
coperWatcher.collect(copyNodes, {}, true, collectorOptions.type);
|
coperWatcher.collect(copyNodes, {}, true, collectorOptions.type);
|
||||||
|
|
||||||
Object.keys(customTarget.deps).forEach((nodeId: Id) => {
|
Object.keys(customTarget.deps).forEach((nodeId: Id) => {
|
||||||
const node = editorService.getNodeById(nodeId);
|
const node = this.ctx.editor.getNodeById(nodeId);
|
||||||
if (!node) return;
|
if (!node) return;
|
||||||
customTarget!.deps[nodeId].keys.forEach((key) => {
|
customTarget!.deps[nodeId].keys.forEach((key) => {
|
||||||
const relateCodeId = get(node, key);
|
const relateCodeId = get(node, key);
|
||||||
@ -527,7 +526,7 @@ class CodeBlock extends BaseService {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
storageService.setItem(COPY_CODE_STORAGE_KEY, copyData, {
|
this.ctx.storage.setItem(COPY_CODE_STORAGE_KEY, copyData, {
|
||||||
protocol: Protocol.OBJECT,
|
protocol: Protocol.OBJECT,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -537,7 +536,7 @@ class CodeBlock extends BaseService {
|
|||||||
* @returns
|
* @returns
|
||||||
*/
|
*/
|
||||||
public paste() {
|
public paste() {
|
||||||
const codeDsl: CodeBlockDSL = storageService.getItem(COPY_CODE_STORAGE_KEY);
|
const codeDsl: CodeBlockDSL = this.ctx.storage.getItem(COPY_CODE_STORAGE_KEY);
|
||||||
Object.keys(codeDsl).forEach((codeId: Id) => {
|
Object.keys(codeDsl).forEach((codeId: Id) => {
|
||||||
// 不覆盖同样id的代码块
|
// 不覆盖同样id的代码块
|
||||||
this.setCodeDslByIdSync(codeId, codeDsl[codeId], false);
|
this.setCodeDslByIdSync(codeId, codeDsl[codeId], false);
|
||||||
@ -575,13 +574,16 @@ class CodeBlock extends BaseService {
|
|||||||
// 原本是新增 → revert 即删除
|
// 原本是新增 → revert 即删除
|
||||||
if (!oldSchema && newSchema) {
|
if (!oldSchema && newSchema) {
|
||||||
await this.deleteCodeDslByIds([id], { historyDescription, historySource: 'rollback' });
|
await this.deleteCodeDslByIds([id], { historyDescription, historySource: 'rollback' });
|
||||||
return historyService.getStepList('codeBlock', id).slice(-1)[0]?.step ?? null;
|
return this.ctx.history.getStepList('codeBlock', id).slice(-1)[0]?.step ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 原本是删除 → revert 即写回
|
// 原本是删除 → revert 即写回
|
||||||
if (oldSchema && !newSchema) {
|
if (oldSchema && !newSchema) {
|
||||||
this.setCodeDslByIdSync(id, cloneDeep(oldSchema), true, { historyDescription, historySource: 'rollback' });
|
this.setCodeDslByIdSync(id, cloneDeep(oldSchema), true, {
|
||||||
return historyService.getStepList('codeBlock', id).slice(-1)[0]?.step ?? null;
|
historyDescription,
|
||||||
|
historySource: 'rollback',
|
||||||
|
});
|
||||||
|
return this.ctx.history.getStepList('codeBlock', id).slice(-1)[0]?.step ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!oldSchema || !newSchema) return null;
|
if (!oldSchema || !newSchema) return null;
|
||||||
@ -605,11 +607,14 @@ class CodeBlock extends BaseService {
|
|||||||
historyDescription,
|
historyDescription,
|
||||||
historySource: 'rollback',
|
historySource: 'rollback',
|
||||||
});
|
});
|
||||||
return historyService.getStepList('codeBlock', id).slice(-1)[0]?.step ?? null;
|
return this.ctx.history.getStepList('codeBlock', id).slice(-1)[0]?.step ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.setCodeDslByIdSync(id, cloneDeep(oldSchema), true, { historyDescription, historySource: 'rollback' });
|
this.setCodeDslByIdSync(id, cloneDeep(oldSchema), true, {
|
||||||
return historyService.getStepList('codeBlock', id).slice(-1)[0]?.step ?? null;
|
historyDescription,
|
||||||
|
historySource: 'rollback',
|
||||||
|
});
|
||||||
|
return this.ctx.history.getStepList('codeBlock', id).slice(-1)[0]?.step ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -680,4 +685,4 @@ class CodeBlock extends BaseService {
|
|||||||
|
|
||||||
export type CodeBlockService = CodeBlock;
|
export type CodeBlockService = CodeBlock;
|
||||||
|
|
||||||
export default new CodeBlock();
|
export default CodeBlock;
|
||||||
@ -16,19 +16,20 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { shallowReactive } from 'vue';
|
|
||||||
|
|
||||||
import type { ComponentGroup, ComponentGroupState } from '@editor/type';
|
import type { ComponentGroup, ComponentGroupState } from '@editor/type';
|
||||||
|
|
||||||
|
import type { SdkContext } from '../type';
|
||||||
|
|
||||||
import BaseService from './BaseService';
|
import BaseService from './BaseService';
|
||||||
|
|
||||||
class ComponentList extends BaseService {
|
class ComponentList extends BaseService {
|
||||||
private state = shallowReactive<ComponentGroupState>({
|
private state: ComponentGroupState;
|
||||||
list: [],
|
|
||||||
});
|
|
||||||
|
|
||||||
constructor() {
|
constructor(ctx: SdkContext) {
|
||||||
super([]);
|
super([]);
|
||||||
|
this.state = ctx.reactivity.shallowReactive<ComponentGroupState>({
|
||||||
|
list: [],
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -55,4 +56,4 @@ class ComponentList extends BaseService {
|
|||||||
|
|
||||||
export type ComponentListService = ComponentList;
|
export type ComponentListService = ComponentList;
|
||||||
|
|
||||||
export default new ComponentList();
|
export default ComponentList;
|
||||||
@ -1,4 +1,3 @@
|
|||||||
import { reactive } from 'vue';
|
|
||||||
import { cloneDeep, get } from 'lodash-es';
|
import { cloneDeep, get } from 'lodash-es';
|
||||||
import type { Writable } from 'type-fest';
|
import type { Writable } from 'type-fest';
|
||||||
|
|
||||||
@ -7,9 +6,6 @@ import { Target, Watcher } from '@tmagic/core';
|
|||||||
import type { FormConfig } from '@tmagic/form';
|
import type { FormConfig } from '@tmagic/form';
|
||||||
import { getValueByKeyPath, guid, setValueByKeyPath, toLine } from '@tmagic/utils';
|
import { getValueByKeyPath, guid, setValueByKeyPath, toLine } from '@tmagic/utils';
|
||||||
|
|
||||||
import editorService from '@editor/services/editor';
|
|
||||||
import historyService from '@editor/services/history';
|
|
||||||
import storageService, { Protocol } from '@editor/services/storage';
|
|
||||||
import type {
|
import type {
|
||||||
DataSourceStepValue,
|
DataSourceStepValue,
|
||||||
DatasourceTypeOption,
|
DatasourceTypeOption,
|
||||||
@ -22,7 +18,10 @@ import { getFormConfig, getFormValue } from '@editor/utils/data-source';
|
|||||||
import { COPY_DS_STORAGE_KEY } from '@editor/utils/editor';
|
import { COPY_DS_STORAGE_KEY } from '@editor/utils/editor';
|
||||||
import { createStackStep, describeRevertStep, getLastPushedHistoryIds } from '@editor/utils/history';
|
import { createStackStep, describeRevertStep, getLastPushedHistoryIds } from '@editor/utils/history';
|
||||||
|
|
||||||
|
import type { SdkContext } from '../type';
|
||||||
|
|
||||||
import BaseService from './BaseService';
|
import BaseService from './BaseService';
|
||||||
|
import { Protocol } from './WebStorage';
|
||||||
|
|
||||||
interface State {
|
interface State {
|
||||||
datasourceTypeList: DatasourceTypeOption[];
|
datasourceTypeList: DatasourceTypeOption[];
|
||||||
@ -57,15 +56,7 @@ const canUsePluginMethods = {
|
|||||||
type SyncMethodName = Writable<(typeof canUsePluginMethods)['sync']>;
|
type SyncMethodName = Writable<(typeof canUsePluginMethods)['sync']>;
|
||||||
|
|
||||||
class DataSource extends BaseService {
|
class DataSource extends BaseService {
|
||||||
private state = reactive<State>({
|
private state: State;
|
||||||
datasourceTypeList: [],
|
|
||||||
dataSources: [],
|
|
||||||
editable: true,
|
|
||||||
configs: {},
|
|
||||||
values: {},
|
|
||||||
events: {},
|
|
||||||
methods: {},
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 最近一次写入历史栈的数据源历史记录 uuid。
|
* 最近一次写入历史栈的数据源历史记录 uuid。
|
||||||
@ -74,8 +65,17 @@ class DataSource extends BaseService {
|
|||||||
*/
|
*/
|
||||||
private lastPushedHistoryId: string | null = null;
|
private lastPushedHistoryId: string | null = null;
|
||||||
|
|
||||||
constructor() {
|
constructor(private ctx: SdkContext) {
|
||||||
super(canUsePluginMethods.sync.map((methodName) => ({ name: methodName, isAsync: false })));
|
super(canUsePluginMethods.sync.map((methodName) => ({ name: methodName, isAsync: false })));
|
||||||
|
this.state = ctx.reactivity.reactive<State>({
|
||||||
|
datasourceTypeList: [],
|
||||||
|
dataSources: [],
|
||||||
|
editable: true,
|
||||||
|
configs: {},
|
||||||
|
values: {},
|
||||||
|
events: {},
|
||||||
|
methods: {},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public set<K extends StateKey, T extends State[K]>(name: K, value: T) {
|
public set<K extends StateKey, T extends State[K]>(name: K, value: T) {
|
||||||
@ -129,6 +129,10 @@ class DataSource extends BaseService {
|
|||||||
config: DataSourceSchema,
|
config: DataSourceSchema,
|
||||||
{ doNotPushHistory = false, historyDescription, historySource }: HistoryOpOptions = {},
|
{ doNotPushHistory = false, historyDescription, historySource }: HistoryOpOptions = {},
|
||||||
) {
|
) {
|
||||||
|
// 数据源的新增 / 更新是同步方法,这里只能做同步的结构校验(类型是否可用);
|
||||||
|
// 属性表单校验见 `sdk.validate.dataSource(id)`
|
||||||
|
this.ctx.validator.guardDataSource(config, true);
|
||||||
|
|
||||||
const newConfig = {
|
const newConfig = {
|
||||||
...config,
|
...config,
|
||||||
id: config.id && !this.getDataSourceById(config.id) ? config.id : this.createId(),
|
id: config.id && !this.getDataSourceById(config.id) ? config.id : this.createId(),
|
||||||
@ -143,7 +147,7 @@ class DataSource extends BaseService {
|
|||||||
historyDescription,
|
historyDescription,
|
||||||
source: historySource,
|
source: historySource,
|
||||||
});
|
});
|
||||||
this.lastPushedHistoryId = (step ? historyService.push('dataSource', step, newConfig.id) : null)?.uuid ?? null;
|
this.lastPushedHistoryId = (step ? this.ctx.history.push('dataSource', step, newConfig.id) : null)?.uuid ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.emit('add', newConfig);
|
this.emit('add', newConfig);
|
||||||
@ -169,6 +173,8 @@ class DataSource extends BaseService {
|
|||||||
historySource,
|
historySource,
|
||||||
}: HistoryOpOptionsWithChangeRecords = {},
|
}: HistoryOpOptionsWithChangeRecords = {},
|
||||||
) {
|
) {
|
||||||
|
this.ctx.validator.guardDataSource(config, false);
|
||||||
|
|
||||||
const dataSources = this.get('dataSources');
|
const dataSources = this.get('dataSources');
|
||||||
|
|
||||||
const index = dataSources.findIndex((ds) => ds.id === config.id);
|
const index = dataSources.findIndex((ds) => ds.id === config.id);
|
||||||
@ -186,7 +192,7 @@ class DataSource extends BaseService {
|
|||||||
historyDescription,
|
historyDescription,
|
||||||
source: historySource,
|
source: historySource,
|
||||||
});
|
});
|
||||||
this.lastPushedHistoryId = (step ? historyService.push('dataSource', step, newConfig.id) : null)?.uuid ?? null;
|
this.lastPushedHistoryId = (step ? this.ctx.history.push('dataSource', step, newConfig.id) : null)?.uuid ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.emit('update', newConfig, {
|
this.emit('update', newConfig, {
|
||||||
@ -218,7 +224,7 @@ class DataSource extends BaseService {
|
|||||||
historyDescription,
|
historyDescription,
|
||||||
source: historySource,
|
source: historySource,
|
||||||
});
|
});
|
||||||
this.lastPushedHistoryId = (step ? historyService.push('dataSource', step, id) : null)?.uuid ?? null;
|
this.lastPushedHistoryId = (step ? this.ctx.history.push('dataSource', step, id) : null)?.uuid ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.emit('remove', id);
|
this.emit('remove', id);
|
||||||
@ -273,7 +279,7 @@ class DataSource extends BaseService {
|
|||||||
* @returns 撤销的 step;栈不存在或已无可撤销时返回 null
|
* @returns 撤销的 step;栈不存在或已无可撤销时返回 null
|
||||||
*/
|
*/
|
||||||
public undo(id: Id) {
|
public undo(id: Id) {
|
||||||
const step = historyService.undo('dataSource', id);
|
const step = this.ctx.history.undo('dataSource', id);
|
||||||
if (!step) return null;
|
if (!step) return null;
|
||||||
this.applyHistoryStep(step, true);
|
this.applyHistoryStep(step, true);
|
||||||
return step;
|
return step;
|
||||||
@ -285,7 +291,7 @@ class DataSource extends BaseService {
|
|||||||
* @returns 重做的 step;栈不存在或已无可重做时返回 null
|
* @returns 重做的 step;栈不存在或已无可重做时返回 null
|
||||||
*/
|
*/
|
||||||
public redo(id: Id) {
|
public redo(id: Id) {
|
||||||
const step = historyService.redo('dataSource', id);
|
const step = this.ctx.history.redo('dataSource', id);
|
||||||
if (!step) return null;
|
if (!step) return null;
|
||||||
this.applyHistoryStep(step, false);
|
this.applyHistoryStep(step, false);
|
||||||
return step;
|
return step;
|
||||||
@ -293,12 +299,12 @@ class DataSource extends BaseService {
|
|||||||
|
|
||||||
/** 是否可对指定数据源撤销。 */
|
/** 是否可对指定数据源撤销。 */
|
||||||
public canUndo(id: Id): boolean {
|
public canUndo(id: Id): boolean {
|
||||||
return historyService.canUndo('dataSource', id);
|
return this.ctx.history.canUndo('dataSource', id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 是否可对指定数据源重做。 */
|
/** 是否可对指定数据源重做。 */
|
||||||
public canRedo(id: Id): boolean {
|
public canRedo(id: Id): boolean {
|
||||||
return historyService.canRedo('dataSource', id);
|
return this.ctx.history.canRedo('dataSource', id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -309,7 +315,7 @@ class DataSource extends BaseService {
|
|||||||
* @returns 实际移动到的最终游标位置
|
* @returns 实际移动到的最终游标位置
|
||||||
*/
|
*/
|
||||||
public goto(id: Id, targetCursor: number): number {
|
public goto(id: Id, targetCursor: number): number {
|
||||||
let cursor = historyService.getCursor('dataSource', id);
|
let cursor = this.ctx.history.getCursor('dataSource', id);
|
||||||
const target = Math.max(0, targetCursor);
|
const target = Math.max(0, targetCursor);
|
||||||
while (cursor > target) {
|
while (cursor > target) {
|
||||||
if (!this.undo(id)) break;
|
if (!this.undo(id)) break;
|
||||||
@ -333,7 +339,7 @@ class DataSource extends BaseService {
|
|||||||
* @returns 反向后产生的新 step;目标不存在 / 未应用时返回 null
|
* @returns 反向后产生的新 step;目标不存在 / 未应用时返回 null
|
||||||
*/
|
*/
|
||||||
public revert(id: Id, index: number): DataSourceStepValue | null {
|
public revert(id: Id, index: number): DataSourceStepValue | null {
|
||||||
const list = historyService.getStepList('dataSource', id);
|
const list = this.ctx.history.getStepList('dataSource', id);
|
||||||
const entry = list[index];
|
const entry = list[index];
|
||||||
if (!entry?.applied) return null;
|
if (!entry?.applied) return null;
|
||||||
// 更新类步骤(前后 schema 都存在)必须带 changeRecords 才支持回滚,否则只能整 schema 替换,会冲掉后续无关变更。
|
// 更新类步骤(前后 schema 都存在)必须带 changeRecords 才支持回滚,否则只能整 schema 替换,会冲掉后续无关变更。
|
||||||
@ -352,7 +358,7 @@ class DataSource extends BaseService {
|
|||||||
*/
|
*/
|
||||||
public revertById(uuids: string[]): (DataSourceStepValue | null)[] {
|
public revertById(uuids: string[]): (DataSourceStepValue | null)[] {
|
||||||
return uuids.map((uuid) => {
|
return uuids.map((uuid) => {
|
||||||
const location = historyService.findStepLocationByUuid('dataSource', uuid);
|
const location = this.ctx.history.findStepLocationByUuid('dataSource', uuid);
|
||||||
if (!location) return null;
|
if (!location) return null;
|
||||||
return this.revert(location.id, location.index);
|
return this.revert(location.id, location.index);
|
||||||
});
|
});
|
||||||
@ -401,7 +407,7 @@ class DataSource extends BaseService {
|
|||||||
coperWatcher.collect(copyNodes, {}, true, collectorOptions.type);
|
coperWatcher.collect(copyNodes, {}, true, collectorOptions.type);
|
||||||
|
|
||||||
Object.keys(customTarget.deps).forEach((nodeId: Id) => {
|
Object.keys(customTarget.deps).forEach((nodeId: Id) => {
|
||||||
const node = editorService.getNodeById(nodeId);
|
const node = this.ctx.editor.getNodeById(nodeId);
|
||||||
if (!node) return;
|
if (!node) return;
|
||||||
customTarget!.deps[nodeId].keys.forEach((key) => {
|
customTarget!.deps[nodeId].keys.forEach((key) => {
|
||||||
const [relateDsId] = get(node, key);
|
const [relateDsId] = get(node, key);
|
||||||
@ -415,7 +421,7 @@ class DataSource extends BaseService {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
storageService.setItem(COPY_DS_STORAGE_KEY, copyData, {
|
this.ctx.storage.setItem(COPY_DS_STORAGE_KEY, copyData, {
|
||||||
protocol: Protocol.OBJECT,
|
protocol: Protocol.OBJECT,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -424,7 +430,7 @@ class DataSource extends BaseService {
|
|||||||
* @returns
|
* @returns
|
||||||
*/
|
*/
|
||||||
public paste() {
|
public paste() {
|
||||||
const dataSource: DataSourceSchema[] = storageService.getItem(COPY_DS_STORAGE_KEY);
|
const dataSource: DataSourceSchema[] = this.ctx.storage.getItem(COPY_DS_STORAGE_KEY);
|
||||||
dataSource.forEach((item: DataSourceSchema) => {
|
dataSource.forEach((item: DataSourceSchema) => {
|
||||||
// 不覆盖同样id的数据源
|
// 不覆盖同样id的数据源
|
||||||
if (!this.getDataSourceById(item.id)) {
|
if (!this.getDataSourceById(item.id)) {
|
||||||
@ -444,13 +450,13 @@ class DataSource extends BaseService {
|
|||||||
// 原本是新增 → revert 即删除
|
// 原本是新增 → revert 即删除
|
||||||
if (!oldSchema && newSchema) {
|
if (!oldSchema && newSchema) {
|
||||||
this.remove(`${id}`, { historyDescription, historySource: 'rollback' });
|
this.remove(`${id}`, { historyDescription, historySource: 'rollback' });
|
||||||
return historyService.getStepList('dataSource', id).slice(-1)[0]?.step ?? null;
|
return this.ctx.history.getStepList('dataSource', id).slice(-1)[0]?.step ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 原本是删除 → revert 即重新加回
|
// 原本是删除 → revert 即重新加回
|
||||||
if (oldSchema && !newSchema) {
|
if (oldSchema && !newSchema) {
|
||||||
this.add(cloneDeep(oldSchema), { historyDescription, historySource: 'rollback' });
|
this.add(cloneDeep(oldSchema), { historyDescription, historySource: 'rollback' });
|
||||||
return historyService.getStepList('dataSource', id).slice(-1)[0]?.step ?? null;
|
return this.ctx.history.getStepList('dataSource', id).slice(-1)[0]?.step ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!oldSchema || !newSchema) return null;
|
if (!oldSchema || !newSchema) return null;
|
||||||
@ -474,11 +480,11 @@ class DataSource extends BaseService {
|
|||||||
historyDescription,
|
historyDescription,
|
||||||
historySource: 'rollback',
|
historySource: 'rollback',
|
||||||
});
|
});
|
||||||
return historyService.getStepList('dataSource', id).slice(-1)[0]?.step ?? null;
|
return this.ctx.history.getStepList('dataSource', id).slice(-1)[0]?.step ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.update(cloneDeep(oldSchema), { historyDescription, historySource: 'rollback' });
|
this.update(cloneDeep(oldSchema), { historyDescription, historySource: 'rollback' });
|
||||||
return historyService.getStepList('dataSource', id).slice(-1)[0]?.step ?? null;
|
return this.ctx.history.getStepList('dataSource', id).slice(-1)[0]?.step ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -549,4 +555,4 @@ class DataSource extends BaseService {
|
|||||||
|
|
||||||
export type DataSourceService = DataSource;
|
export type DataSourceService = DataSource;
|
||||||
|
|
||||||
export default new DataSource();
|
export default DataSource;
|
||||||
@ -15,19 +15,28 @@
|
|||||||
* See the License for the specific language governing permissions and
|
* See the License for the specific language governing permissions and
|
||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
import { reactive, shallowReactive } from 'vue';
|
|
||||||
import { throttle } from 'lodash-es';
|
import { throttle } from 'lodash-es';
|
||||||
|
|
||||||
import type { DepData, DepExtendedData, Id, MApp, MNode, Target } from '@tmagic/core';
|
import type { DepData, DepExtendedData, Id, MApp, MNode, Target } from '@tmagic/core';
|
||||||
import { DepTargetType, traverseTarget, Watcher } from '@tmagic/core';
|
import { DepTargetType, traverseTarget, Watcher } from '@tmagic/core';
|
||||||
import { isPage } from '@tmagic/utils';
|
import { isPage } from '@tmagic/utils';
|
||||||
|
|
||||||
import { CollectWorkerClient } from '@editor/utils/dep/collect-worker-client';
|
|
||||||
import { IdleTask } from '@editor/utils/dep/idle-task';
|
import { IdleTask } from '@editor/utils/dep/idle-task';
|
||||||
import type { DepsData } from '@editor/utils/dep/worker';
|
import type { DepsData } from '@editor/utils/dep/worker';
|
||||||
|
|
||||||
|
import type { CollectWorkerLike, SdkContext } from '../type';
|
||||||
|
|
||||||
import BaseService from './BaseService';
|
import BaseService from './BaseService';
|
||||||
|
|
||||||
|
/** Node 下没有 worker 时使用:始终不可用,依赖收集退回主线程空闲队列 */
|
||||||
|
const noopCollectWorker: CollectWorkerLike = {
|
||||||
|
isSupported: false,
|
||||||
|
collectDsl: () => Promise.resolve(null),
|
||||||
|
collect: () => Promise.resolve(null),
|
||||||
|
terminate: () => {},
|
||||||
|
abort: () => {},
|
||||||
|
};
|
||||||
|
|
||||||
export interface DepEvents {
|
export interface DepEvents {
|
||||||
'add-target': [target: Target];
|
'add-target': [target: Target];
|
||||||
'remove-target': [id: string | number, type: string | DepTargetType];
|
'remove-target': [id: string | number, type: string | DepTargetType];
|
||||||
@ -59,16 +68,13 @@ interface CollectBatch {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class Dep extends BaseService {
|
class Dep extends BaseService {
|
||||||
private state = shallowReactive<State>({
|
private state: State;
|
||||||
collecting: false,
|
|
||||||
taskLength: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
private idleTask = new IdleTask<void>();
|
private idleTask = new IdleTask<void>();
|
||||||
|
|
||||||
private collectWorker = new CollectWorkerClient();
|
private collectWorker: CollectWorkerLike;
|
||||||
|
|
||||||
private watcher = new Watcher({ initialTargets: reactive({}) });
|
private watcher: Watcher;
|
||||||
|
|
||||||
private waitingWorker?: Promise<void>;
|
private waitingWorker?: Promise<void>;
|
||||||
|
|
||||||
@ -78,9 +84,16 @@ class Dep extends BaseService {
|
|||||||
|
|
||||||
private activeBatches = new Set<CollectBatch>();
|
private activeBatches = new Set<CollectBatch>();
|
||||||
|
|
||||||
constructor() {
|
constructor(private ctx: SdkContext) {
|
||||||
super();
|
super();
|
||||||
|
|
||||||
|
this.state = ctx.reactivity.shallowReactive<State>({
|
||||||
|
collecting: false,
|
||||||
|
taskLength: 0,
|
||||||
|
});
|
||||||
|
this.collectWorker = ctx.options.collectWorker ?? noopCollectWorker;
|
||||||
|
this.watcher = new Watcher({ initialTargets: ctx.reactivity.reactive({}) });
|
||||||
|
|
||||||
this.idleTask.on(
|
this.idleTask.on(
|
||||||
'update-task-length',
|
'update-task-length',
|
||||||
throttle(({ length }) => {
|
throttle(({ length }) => {
|
||||||
@ -137,8 +150,10 @@ class Dep extends BaseService {
|
|||||||
const targets = this.watcher.getCollectableTargets(type);
|
const targets = this.watcher.getCollectableTargets(type);
|
||||||
|
|
||||||
if (targets.length) {
|
if (targets.length) {
|
||||||
|
// 先统一删除所有节点的旧依赖,再统一收集。
|
||||||
|
// 如果逐节点“删除 + 收集”,共用 id 的多棵树(如复制出的页面片),
|
||||||
|
// 后一棵树的删除会清掉前一棵树刚写入的依赖。
|
||||||
for (const node of nodes) {
|
for (const node of nodes) {
|
||||||
// 先删除原有依赖,再批量收集
|
|
||||||
if (isPage(node)) {
|
if (isPage(node)) {
|
||||||
for (const target of targets) {
|
for (const target of targets) {
|
||||||
this.removePageDep(target, depExtendedData);
|
this.removePageDep(target, depExtendedData);
|
||||||
@ -146,6 +161,8 @@ class Dep extends BaseService {
|
|||||||
} else {
|
} else {
|
||||||
this.watcher.removeTargetsDep(targets, node);
|
this.watcher.removeTargetsDep(targets, node);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
for (const node of nodes) {
|
||||||
this.watcher.collectItems(node, targets, depExtendedData, deep);
|
this.watcher.collectItems(node, targets, depExtendedData, deep);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -229,7 +246,7 @@ class Dep extends BaseService {
|
|||||||
|
|
||||||
traverseTarget(this.watcher.getTargetsList(), (target) => {
|
traverseTarget(this.watcher.getTargetsList(), (target) => {
|
||||||
if (depsData[target.type]?.[target.id]) {
|
if (depsData[target.type]?.[target.id]) {
|
||||||
target.deps = reactive(depsData[target.type][target.id]);
|
target.deps = this.ctx.reactivity.reactive(depsData[target.type][target.id]);
|
||||||
|
|
||||||
if (target.type === DepTargetType.DATA_SOURCE && dsl.dataSourceDeps) {
|
if (target.type === DepTargetType.DATA_SOURCE && dsl.dataSourceDeps) {
|
||||||
dsl.dataSourceDeps[target.id] = target.deps;
|
dsl.dataSourceDeps[target.id] = target.deps;
|
||||||
@ -554,4 +571,4 @@ class Dep extends BaseService {
|
|||||||
|
|
||||||
export type DepService = Dep;
|
export type DepService = Dep;
|
||||||
|
|
||||||
export default new Dep();
|
export default Dep;
|
||||||
@ -16,13 +16,11 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { nextTick, reactive, toRaw } from 'vue';
|
|
||||||
import { cloneDeep, isEmpty, isEqual, isObject, mergeWith, uniq } from 'lodash-es';
|
import { cloneDeep, isEmpty, isEqual, isObject, mergeWith, uniq } from 'lodash-es';
|
||||||
|
|
||||||
import type { Id, MApp, MContainer, MNode, MPage, MPageFragment, TargetOptions } from '@tmagic/core';
|
import type { Id, MApp, MContainer, MNode, MPage, MPageFragment, TargetOptions } from '@tmagic/core';
|
||||||
import { NodeType } from '@tmagic/core';
|
import { NodeType } from '@tmagic/core';
|
||||||
import type { ChangeRecord } from '@tmagic/form';
|
import type { ChangeRecord } from '@tmagic/form';
|
||||||
import { isFixed } from '@tmagic/stage';
|
|
||||||
import {
|
import {
|
||||||
getNodeInfo,
|
getNodeInfo,
|
||||||
getNodePath,
|
getNodePath,
|
||||||
@ -35,14 +33,11 @@ import {
|
|||||||
traverseNode,
|
traverseNode,
|
||||||
} from '@tmagic/utils';
|
} from '@tmagic/utils';
|
||||||
|
|
||||||
import BaseService from '@editor/services//BaseService';
|
|
||||||
import propsService from '@editor/services//props';
|
|
||||||
import historyService from '@editor/services/history';
|
|
||||||
import storageService, { Protocol } from '@editor/services/storage';
|
|
||||||
import type {
|
import type {
|
||||||
AddMNode,
|
AddMNode,
|
||||||
AsyncHookPlugin,
|
AsyncHookPlugin,
|
||||||
AsyncMethodName,
|
AsyncMethodName,
|
||||||
|
CanDropInFunction,
|
||||||
DslOpOptions,
|
DslOpOptions,
|
||||||
DslOpWithHistoryIdsResult,
|
DslOpWithHistoryIdsResult,
|
||||||
EditorEvents,
|
EditorEvents,
|
||||||
@ -80,7 +75,13 @@ import {
|
|||||||
toggleFixedPosition,
|
toggleFixedPosition,
|
||||||
} from '@editor/utils/editor';
|
} from '@editor/utils/editor';
|
||||||
import { getLastPushedHistoryIds } from '@editor/utils/history';
|
import { getLastPushedHistoryIds } from '@editor/utils/history';
|
||||||
import { beforePaste, getAddParent } from '@editor/utils/operator';
|
|
||||||
|
import type { SdkContext } from '../type';
|
||||||
|
import { beforePaste, getAddParent } from '../utils/operator';
|
||||||
|
import { isFixed } from '../utils/position';
|
||||||
|
|
||||||
|
import BaseService from './BaseService';
|
||||||
|
import { Protocol } from './WebStorage';
|
||||||
|
|
||||||
type MoveItem = { node: MNode; parent: MContainer; pageForOp: { name: string; id: Id } | null };
|
type MoveItem = { node: MNode; parent: MContainer; pageForOp: { name: string; id: Id } | null };
|
||||||
|
|
||||||
@ -102,23 +103,13 @@ const buildUpdateDiff = (
|
|||||||
...(changeRecords?.length ? { changeRecords } : {}),
|
...(changeRecords?.length ? { changeRecords } : {}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
export interface EditorServiceConfig {
|
||||||
|
disabledPageFragment?: boolean;
|
||||||
|
canDropIn?: CanDropInFunction;
|
||||||
|
}
|
||||||
|
|
||||||
class Editor extends BaseService {
|
class Editor extends BaseService {
|
||||||
public state: StoreState = reactive({
|
public state: StoreState;
|
||||||
root: null,
|
|
||||||
page: null,
|
|
||||||
parent: null,
|
|
||||||
node: null,
|
|
||||||
nodes: [],
|
|
||||||
stage: null,
|
|
||||||
stageLoading: true,
|
|
||||||
highlightNode: null,
|
|
||||||
modifiedNodeIds: new Map(),
|
|
||||||
invalidNodeIds: new Map(),
|
|
||||||
pageLength: 0,
|
|
||||||
pageFragmentLength: 0,
|
|
||||||
disabledMultiSelect: false,
|
|
||||||
alwaysMultiSelect: false,
|
|
||||||
});
|
|
||||||
private selectionBeforeOp: Id[] | null = null;
|
private selectionBeforeOp: Id[] | null = null;
|
||||||
/**
|
/**
|
||||||
* 操作前的节点校验错误快照,与 selectionBeforeOp 同时在 captureSelectionBeforeOp 中捕获,
|
* 操作前的节点校验错误快照,与 selectionBeforeOp 同时在 captureSelectionBeforeOp 中捕获,
|
||||||
@ -137,12 +128,32 @@ class Editor extends BaseService {
|
|||||||
*/
|
*/
|
||||||
private lastAdded: { selectedId: Id; nodeId: Id } | null = null;
|
private lastAdded: { selectedId: Id; nodeId: Id } | null = null;
|
||||||
|
|
||||||
constructor() {
|
/** 与编辑器 props 中 `disabledPageFragment` / `canDropIn` 对应,供结构合法性校验使用 */
|
||||||
|
private config: EditorServiceConfig = {};
|
||||||
|
|
||||||
|
constructor(private ctx: SdkContext) {
|
||||||
super(
|
super(
|
||||||
canUsePluginMethods.async.map((methodName) => ({ name: methodName, isAsync: true })),
|
canUsePluginMethods.async.map((methodName) => ({ name: methodName, isAsync: true })),
|
||||||
// 需要注意循环依赖问题,如果函数间有相互调用的话,不能设置为串行调用
|
// 需要注意循环依赖问题,如果函数间有相互调用的话,不能设置为串行调用
|
||||||
['select', 'update', 'moveLayer'],
|
['select', 'update', 'moveLayer'],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
this.state = ctx.reactivity.reactive<StoreState>({
|
||||||
|
root: null,
|
||||||
|
page: null,
|
||||||
|
parent: null,
|
||||||
|
node: null,
|
||||||
|
nodes: [],
|
||||||
|
stage: null,
|
||||||
|
stageLoading: true,
|
||||||
|
highlightNode: null,
|
||||||
|
modifiedNodeIds: new Map(),
|
||||||
|
invalidNodeIds: new Map(),
|
||||||
|
pageLength: 0,
|
||||||
|
pageFragmentLength: 0,
|
||||||
|
disabledMultiSelect: false,
|
||||||
|
alwaysMultiSelect: false,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -185,8 +196,8 @@ class Editor extends BaseService {
|
|||||||
// 重复创建,set root 不额外产生记录,由恢复出的历史栈作为当前状态来源。
|
// 重复创建,set root 不额外产生记录,由恢复出的历史栈作为当前状态来源。
|
||||||
// 标记不进入撤销/重做栈,仅作为该页历史列表底部的初始基线展示。
|
// 标记不进入撤销/重做栈,仅作为该页历史列表底部的初始基线展示。
|
||||||
app.items?.forEach((pageNode) => {
|
app.items?.forEach((pageNode) => {
|
||||||
if (pageNode?.id !== undefined && !historyService.getMarker('page', pageNode.id)) {
|
if (pageNode?.id !== undefined && !this.ctx.history.getMarker('page', pageNode.id)) {
|
||||||
historyService.setMarker('page', pageNode.id, {
|
this.ctx.history.setMarker('page', pageNode.id, {
|
||||||
name: pageNode.name,
|
name: pageNode.name,
|
||||||
source: options.historySource,
|
source: options.historySource,
|
||||||
});
|
});
|
||||||
@ -224,7 +235,7 @@ class Editor extends BaseService {
|
|||||||
public getNodeInfo(id: Id, raw = true): EditorNodeInfo {
|
public getNodeInfo(id: Id, raw = true): EditorNodeInfo {
|
||||||
let root = this.get('root');
|
let root = this.get('root');
|
||||||
if (raw) {
|
if (raw) {
|
||||||
root = toRaw(root);
|
root = this.toRaw(root);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!root) {
|
if (!root) {
|
||||||
@ -340,11 +351,11 @@ class Editor extends BaseService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async selectNextNode(): Promise<MNode | null> | never {
|
public async selectNextNode(): Promise<MNode | null> | never {
|
||||||
const node = toRaw(this.get('node'));
|
const node = this.toRaw(this.get('node'));
|
||||||
|
|
||||||
if (!node || isPage(node) || node.type === NodeType.ROOT) return node;
|
if (!node || isPage(node) || node.type === NodeType.ROOT) return node;
|
||||||
|
|
||||||
const parent = toRaw(this.getParentById(node.id));
|
const parent = this.toRaw(this.getParentById(node.id));
|
||||||
|
|
||||||
if (!parent) return node;
|
if (!parent) return node;
|
||||||
|
|
||||||
@ -359,8 +370,8 @@ class Editor extends BaseService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async selectNextPage(): Promise<MNode> | never {
|
public async selectNextPage(): Promise<MNode> | never {
|
||||||
const root = toRaw(this.get('root'));
|
const root = this.toRaw(this.get('root'));
|
||||||
const page = toRaw(this.get('page'));
|
const page = this.toRaw(this.get('page'));
|
||||||
|
|
||||||
if (!page) throw new Error('page不能为空');
|
if (!page) throw new Error('page不能为空');
|
||||||
if (!root) throw new Error('root不能为空');
|
if (!root) throw new Error('root不能为空');
|
||||||
@ -414,6 +425,15 @@ class Editor extends BaseService {
|
|||||||
this.set('highlightNode', null);
|
this.set('highlightNode', null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 设置结构合法性校验用到的配置,只覆盖传入的字段 */
|
||||||
|
public setConfig(config: Partial<EditorServiceConfig>): void {
|
||||||
|
this.config = { ...this.config, ...config };
|
||||||
|
}
|
||||||
|
|
||||||
|
public getConfig(): Readonly<EditorServiceConfig> {
|
||||||
|
return this.config;
|
||||||
|
}
|
||||||
|
|
||||||
public async doAdd(node: MNode, parent: MContainer, _options: DslOpOptions = {}): Promise<MNode> {
|
public async doAdd(node: MNode, parent: MContainer, _options: DslOpOptions = {}): Promise<MNode> {
|
||||||
const root = this.get('root');
|
const root = this.get('root');
|
||||||
|
|
||||||
@ -439,7 +459,7 @@ class Editor extends BaseService {
|
|||||||
parent.items.splice(insertIndex, 0, node);
|
parent.items.splice(insertIndex, 0, node);
|
||||||
this.lastAdded = { selectedId: curNode.id, nodeId: node.id };
|
this.lastAdded = { selectedId: curNode.id, nodeId: node.id };
|
||||||
|
|
||||||
const layout = await this.getLayout(toRaw(parent), node as MNode);
|
const layout = await this.getLayout(this.toRaw(parent), node as MNode);
|
||||||
node.style = getInitPositionStyle(node.style, layout);
|
node.style = getInitPositionStyle(node.style, layout);
|
||||||
|
|
||||||
await stage?.add({
|
await stage?.add({
|
||||||
@ -493,7 +513,7 @@ class Editor extends BaseService {
|
|||||||
|
|
||||||
if (!type) throw new Error('组件类型不能为空');
|
if (!type) throw new Error('组件类型不能为空');
|
||||||
|
|
||||||
addNodes.push({ ...toRaw(await propsService.getPropsValue(type, config)) });
|
addNodes.push({ ...this.toRaw(await this.ctx.props.getPropsValue(type, config)) });
|
||||||
} else {
|
} else {
|
||||||
addNodes.push(...addNode);
|
addNodes.push(...addNode);
|
||||||
}
|
}
|
||||||
@ -501,13 +521,24 @@ class Editor extends BaseService {
|
|||||||
// 必须串行:每个节点的插入位置依赖上一个已插入的节点,并行时插入顺序会受
|
// 必须串行:每个节点的插入位置依赖上一个已插入的节点,并行时插入顺序会受
|
||||||
// doAdd 前置耗时(如异步 beforeDoAdd 插件钩子)影响而错乱
|
// doAdd 前置耗时(如异步 beforeDoAdd 插件钩子)影响而错乱
|
||||||
const newNodes: MNode[] = [];
|
const newNodes: MNode[] = [];
|
||||||
|
const targets: { node: MNode; parentNode: MContainer }[] = [];
|
||||||
for (const node of addNodes) {
|
for (const node of addNodes) {
|
||||||
const root = this.get('root');
|
const root = this.get('root');
|
||||||
const parentNode = isPageOrFragment(node) && root ? root : (parent ?? getAddParent(node));
|
const parentNode = isPageOrFragment(node) && root ? root : (parent ?? getAddParent(this.ctx, node));
|
||||||
if (!parentNode) throw new Error('未找到父元素');
|
if (!parentNode) throw new Error('未找到父元素');
|
||||||
|
targets.push({ node, parentNode });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 校验放在真正修改 DSL 之前,strict 模式下校验不通过则整批都不会被添加
|
||||||
|
const commitInvalid = await this.runGuard(() => this.ctx.validator.guardAdd(targets));
|
||||||
|
|
||||||
|
for (const { node, parentNode } of targets) {
|
||||||
newNodes.push(await this.doAdd(node, parentNode, options));
|
newNodes.push(await this.doAdd(node, parentNode, options));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// record 模式:在写入历史记录之前落库,使历史快照包含本次的错误状态
|
||||||
|
commitInvalid?.();
|
||||||
|
|
||||||
if (newNodes.length > 1) {
|
if (newNodes.length > 1) {
|
||||||
// 多选时只要任一新增节点位于非当前页面,触发的 multiSelect 就会引起页面切换
|
// 多选时只要任一新增节点位于非当前页面,触发的 multiSelect 就会引起页面切换
|
||||||
const wouldSwitchPage = newNodes.some((n) => this.isOnDifferentPage(n));
|
const wouldSwitchPage = newNodes.some((n) => this.isOnDifferentPage(n));
|
||||||
@ -544,7 +575,7 @@ class Editor extends BaseService {
|
|||||||
const p = this.getParentById(n.id, false) as MContainer;
|
const p = this.getParentById(n.id, false) as MContainer;
|
||||||
const idx = p ? getNodeIndex(n.id, p) : -1;
|
const idx = p ? getNodeIndex(n.id, p) : -1;
|
||||||
return {
|
return {
|
||||||
newSchema: cloneDeep(toRaw(n)),
|
newSchema: cloneDeep(this.toRaw(n)),
|
||||||
parentId,
|
parentId,
|
||||||
index: typeof idx === 'number' ? idx : -1,
|
index: typeof idx === 'number' ? idx : -1,
|
||||||
};
|
};
|
||||||
@ -569,7 +600,7 @@ class Editor extends BaseService {
|
|||||||
// 页面 / 页面片新增不入历史栈(见上方 isPageOrFragment 分支),这里合并补发一次结构变更通知
|
// 页面 / 页面片新增不入历史栈(见上方 isPageOrFragment 分支),这里合并补发一次结构变更通知
|
||||||
const addedPages = newNodes.filter((node) => isPageOrFragment(node)) as (MPage | MPageFragment)[];
|
const addedPages = newNodes.filter((node) => isPageOrFragment(node)) as (MPage | MPageFragment)[];
|
||||||
if (addedPages.length) {
|
if (addedPages.length) {
|
||||||
historyService.notifyPageStructureChange({ add: addedPages, remove: [] });
|
this.ctx.history.notifyPageStructureChange({ add: addedPages, remove: [] });
|
||||||
}
|
}
|
||||||
|
|
||||||
return Array.isArray(addNode) ? newNodes : newNodes[0];
|
return Array.isArray(addNode) ? newNodes : newNodes[0];
|
||||||
@ -626,7 +657,7 @@ class Editor extends BaseService {
|
|||||||
// nextTick 用于等 Stage 的 page watch 把新页面 id 同步给 runtime。
|
// nextTick 用于等 Stage 的 page watch 把新页面 id 同步给 runtime。
|
||||||
await this.select(rootItems[0]);
|
await this.select(rootItems[0]);
|
||||||
stage?.select(rootItems[0].id);
|
stage?.select(rootItems[0].id);
|
||||||
await nextTick();
|
await this.ctx.reactivity.nextTick();
|
||||||
stage?.remove(removeData);
|
stage?.remove(removeData);
|
||||||
} else {
|
} else {
|
||||||
// page 置空会让 Workspace 卸载 Stage 并销毁 renderer,删除通知必须在卸载前发出,
|
// page 置空会让 Workspace 卸载 Stage 并销毁 renderer,删除通知必须在卸载前发出,
|
||||||
@ -653,7 +684,7 @@ class Editor extends BaseService {
|
|||||||
|
|
||||||
if (!rootItems.length) {
|
if (!rootItems.length) {
|
||||||
this.resetModifiedNodeId();
|
this.resetModifiedNodeId();
|
||||||
historyService.reset();
|
this.ctx.history.reset();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -685,7 +716,7 @@ class Editor extends BaseService {
|
|||||||
}
|
}
|
||||||
const idx = getNodeIndex(curNode.id, parent);
|
const idx = getNodeIndex(curNode.id, parent);
|
||||||
removedItems.push({
|
removedItems.push({
|
||||||
oldSchema: cloneDeep(toRaw(curNode)),
|
oldSchema: cloneDeep(this.toRaw(curNode)),
|
||||||
parentId: parent.id,
|
parentId: parent.id,
|
||||||
index: typeof idx === 'number' ? idx : -1,
|
index: typeof idx === 'number' ? idx : -1,
|
||||||
});
|
});
|
||||||
@ -717,7 +748,7 @@ class Editor extends BaseService {
|
|||||||
// 页面 / 页面片删除不入历史栈(见上方 isPageOrFragment 分支),这里合并补发一次结构变更通知
|
// 页面 / 页面片删除不入历史栈(见上方 isPageOrFragment 分支),这里合并补发一次结构变更通知
|
||||||
const removedPages = nodes.filter((node) => isPageOrFragment(node)) as (MPage | MPageFragment)[];
|
const removedPages = nodes.filter((node) => isPageOrFragment(node)) as (MPage | MPageFragment)[];
|
||||||
if (removedPages.length) {
|
if (removedPages.length) {
|
||||||
historyService.notifyPageStructureChange({ add: [], remove: removedPages });
|
this.ctx.history.notifyPageStructureChange({ add: [], remove: removedPages });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -735,12 +766,12 @@ class Editor extends BaseService {
|
|||||||
|
|
||||||
if (!info.node) throw new Error(`获取不到id为${config.id}的节点`);
|
if (!info.node) throw new Error(`获取不到id为${config.id}的节点`);
|
||||||
|
|
||||||
const node = toRaw(info.node);
|
const node = this.toRaw(info.node);
|
||||||
|
|
||||||
// replace=true 时跳过 toggleFixedPosition / mergeWith / setChildrenLayout,直接用传入配置整节点替换
|
// replace=true 时跳过 toggleFixedPosition / mergeWith / setChildrenLayout,直接用传入配置整节点替换
|
||||||
let newConfig = replace
|
let newConfig = replace
|
||||||
? cloneDeep(toRaw(config))
|
? cloneDeep(this.toRaw(config))
|
||||||
: await toggleFixedPosition(toRaw(config), node, info.path, this.getLayout);
|
: await toggleFixedPosition(this.toRaw(config), node, info.path, this.getLayout);
|
||||||
|
|
||||||
if (!replace) {
|
if (!replace) {
|
||||||
newConfig = mergeWith(cloneDeep(node), newConfig, editorNodeMergeCustomizer);
|
newConfig = mergeWith(cloneDeep(node), newConfig, editorNodeMergeCustomizer);
|
||||||
@ -821,6 +852,8 @@ class Editor extends BaseService {
|
|||||||
|
|
||||||
const nodes = Array.isArray(config) ? config : [config];
|
const nodes = Array.isArray(config) ? config : [config];
|
||||||
|
|
||||||
|
const commitInvalid = await this.runGuard(() => this.ctx.validator.guardUpdate(nodes, { replace: data.replace }));
|
||||||
|
|
||||||
// 多节点必须使用 changeRecordList 为每个节点提供独立的记录;
|
// 多节点必须使用 changeRecordList 为每个节点提供独立的记录;
|
||||||
// 否则同一份 changeRecords 会被复用到每个节点上,nodeUpdateHandler / 历史回放都会按错误的 propPath 处理。
|
// 否则同一份 changeRecords 会被复用到每个节点上,nodeUpdateHandler / 历史回放都会按错误的 propPath 处理。
|
||||||
const updateData = await Promise.all(
|
const updateData = await Promise.all(
|
||||||
@ -832,6 +865,7 @@ class Editor extends BaseService {
|
|||||||
|
|
||||||
// 校验错误信息在 pushOpHistory 之前落库,保证历史快照包含本次变更对应的错误状态。
|
// 校验错误信息在 pushOpHistory 之前落库,保证历史快照包含本次变更对应的错误状态。
|
||||||
this.applyInvalidInfo(config, invalidInfo);
|
this.applyInvalidInfo(config, invalidInfo);
|
||||||
|
commitInvalid?.();
|
||||||
|
|
||||||
if (updateData[0].oldNode?.type !== NodeType.ROOT) {
|
if (updateData[0].oldNode?.type !== NodeType.ROOT) {
|
||||||
const curNodes = this.get('nodes');
|
const curNodes = this.get('nodes');
|
||||||
@ -891,7 +925,7 @@ class Editor extends BaseService {
|
|||||||
const node = this.get('node');
|
const node = this.get('node');
|
||||||
if (!node) throw new Error('当前节点为空');
|
if (!node) throw new Error('当前节点为空');
|
||||||
|
|
||||||
const parent = cloneDeep(toRaw(this.get('parent')));
|
const parent = cloneDeep(this.toRaw(this.get('parent')));
|
||||||
if (!parent) throw new Error('父节点为空');
|
if (!parent) throw new Error('父节点为空');
|
||||||
|
|
||||||
const index2 = parent.items.findIndex((node: MNode) => `${node.id}` === `${id2}`);
|
const index2 = parent.items.findIndex((node: MNode) => `${node.id}` === `${id2}`);
|
||||||
@ -919,7 +953,7 @@ class Editor extends BaseService {
|
|||||||
* @returns
|
* @returns
|
||||||
*/
|
*/
|
||||||
public copy(config: MNode | MNode[]): void {
|
public copy(config: MNode | MNode[]): void {
|
||||||
storageService.setItem(COPY_STORAGE_KEY, Array.isArray(config) ? config : [config], {
|
this.ctx.storage.setItem(COPY_STORAGE_KEY, Array.isArray(config) ? config : [config], {
|
||||||
protocol: Protocol.OBJECT,
|
protocol: Protocol.OBJECT,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -936,7 +970,7 @@ class Editor extends BaseService {
|
|||||||
collectRelatedNodes(copyNodes, collectorOptions, (id) => this.getNodeById(id));
|
collectRelatedNodes(copyNodes, collectorOptions, (id) => this.getNodeById(id));
|
||||||
}
|
}
|
||||||
|
|
||||||
storageService.setItem(COPY_STORAGE_KEY, copyNodes, {
|
this.ctx.storage.setItem(COPY_STORAGE_KEY, copyNodes, {
|
||||||
protocol: Protocol.OBJECT,
|
protocol: Protocol.OBJECT,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -962,7 +996,7 @@ class Editor extends BaseService {
|
|||||||
historySource,
|
historySource,
|
||||||
}: DslOpOptions = {},
|
}: DslOpOptions = {},
|
||||||
): Promise<MNode | MNode[] | void> {
|
): Promise<MNode | MNode[] | void> {
|
||||||
const config: MNode[] = storageService.getItem(COPY_STORAGE_KEY);
|
const config: MNode[] = this.ctx.storage.getItem(COPY_STORAGE_KEY);
|
||||||
if (!Array.isArray(config)) return;
|
if (!Array.isArray(config)) return;
|
||||||
|
|
||||||
const node = this.get('node');
|
const node = this.get('node');
|
||||||
@ -978,7 +1012,7 @@ class Editor extends BaseService {
|
|||||||
const pasteConfigs = await this.doPaste(config, position);
|
const pasteConfigs = await this.doPaste(config, position);
|
||||||
|
|
||||||
if (collectorOptions && typeof collectorOptions.isTarget === 'function') {
|
if (collectorOptions && typeof collectorOptions.isTarget === 'function') {
|
||||||
propsService.replaceRelateId(config, pasteConfigs, collectorOptions);
|
this.ctx.props.replaceRelateId(config, pasteConfigs, collectorOptions);
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.add(pasteConfigs, parent, {
|
return this.add(pasteConfigs, parent, {
|
||||||
@ -991,9 +1025,9 @@ class Editor extends BaseService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async doPaste(config: MNode[], position: PastePosition = {}): Promise<MNode[]> {
|
public async doPaste(config: MNode[], position: PastePosition = {}): Promise<MNode[]> {
|
||||||
propsService.clearRelateId();
|
this.ctx.props.clearRelateId();
|
||||||
const doc = this.get('stage')?.renderer?.contentWindow?.document;
|
const doc = this.get('stage')?.renderer?.contentWindow?.document;
|
||||||
const pasteConfigs = beforePaste(position, cloneDeep(config), doc);
|
const pasteConfigs = beforePaste(this.ctx, position, cloneDeep(config), doc);
|
||||||
return pasteConfigs;
|
return pasteConfigs;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1001,7 +1035,7 @@ class Editor extends BaseService {
|
|||||||
const parent = this.getParentById(config.id);
|
const parent = this.getParentById(config.id);
|
||||||
if (!parent) throw new Error('找不到父节点');
|
if (!parent) throw new Error('找不到父节点');
|
||||||
|
|
||||||
const node = cloneDeep(toRaw(config));
|
const node = cloneDeep(this.toRaw(config));
|
||||||
const layout = await this.getLayout(parent, node);
|
const layout = await this.getLayout(parent, node);
|
||||||
const doc = this.get('stage')?.renderer?.contentWindow?.document;
|
const doc = this.get('stage')?.renderer?.contentWindow?.document;
|
||||||
const newStyle = calcAlignCenterStyle(node, parent, layout, doc);
|
const newStyle = calcAlignCenterStyle(node, parent, layout, doc);
|
||||||
@ -1030,7 +1064,11 @@ class Editor extends BaseService {
|
|||||||
|
|
||||||
const newNodes = await Promise.all(nodes.map((node) => this.doAlignCenter(node)));
|
const newNodes = await Promise.all(nodes.map((node) => this.doAlignCenter(node)));
|
||||||
|
|
||||||
const newNode = await this.update(newNodes, { doNotPushHistory, historyDescription, historySource });
|
const newNode = await this.update(newNodes, {
|
||||||
|
doNotPushHistory,
|
||||||
|
historyDescription,
|
||||||
|
historySource,
|
||||||
|
});
|
||||||
|
|
||||||
if (!doNotSelect) {
|
if (!doNotSelect) {
|
||||||
if (newNodes.length > 1) {
|
if (newNodes.length > 1) {
|
||||||
@ -1075,7 +1113,7 @@ class Editor extends BaseService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const oldParent = cloneDeep(toRaw(parent));
|
const oldParent = cloneDeep(this.toRaw(parent));
|
||||||
|
|
||||||
brothers.splice(index, 1);
|
brothers.splice(index, 1);
|
||||||
brothers.splice(offsetIndex, 0, node);
|
brothers.splice(offsetIndex, 0, node);
|
||||||
@ -1083,7 +1121,7 @@ class Editor extends BaseService {
|
|||||||
const grandparent = this.getParentById(parent.id);
|
const grandparent = this.getParentById(parent.id);
|
||||||
|
|
||||||
this.get('stage')?.update({
|
this.get('stage')?.update({
|
||||||
config: cloneDeep(toRaw(parent)),
|
config: cloneDeep(this.toRaw(parent)),
|
||||||
parentId: grandparent?.id,
|
parentId: grandparent?.id,
|
||||||
root: cloneDeep(root),
|
root: cloneDeep(root),
|
||||||
});
|
});
|
||||||
@ -1095,7 +1133,7 @@ class Editor extends BaseService {
|
|||||||
'update',
|
'update',
|
||||||
|
|
||||||
{
|
{
|
||||||
diff: buildUpdateDiff([{ oldNode: oldParent, newNode: cloneDeep(toRaw(parent)) }]),
|
diff: buildUpdateDiff([{ oldNode: oldParent, newNode: cloneDeep(this.toRaw(parent)) }]),
|
||||||
pageData: { name: pageForOp?.name || '', id: pageForOp!.id },
|
pageData: { name: pageForOp?.name || '', id: pageForOp!.id },
|
||||||
historyDescription,
|
historyDescription,
|
||||||
source: historySource,
|
source: historySource,
|
||||||
@ -1156,12 +1194,20 @@ class Editor extends BaseService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const root = this.get('root');
|
const root = this.get('root');
|
||||||
const stage = this.get('stage');
|
|
||||||
|
|
||||||
if (!root || !stage) {
|
if (!root) {
|
||||||
throw new Error('root 或 stage为空');
|
throw new Error('root 为空');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 校验放在过滤之前:源节点不存在也要作为校验错误报出
|
||||||
|
await this.runGuard(() =>
|
||||||
|
this.ctx.validator.guardMove(
|
||||||
|
configs.map((item) => item.id),
|
||||||
|
target,
|
||||||
|
'stage-drag',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
// 收集 (节点, 源父) 信息,过滤掉异常节点(找不到父或源父等于目标本身)
|
// 收集 (节点, 源父) 信息,过滤掉异常节点(找不到父或源父等于目标本身)
|
||||||
const moves: MoveItem[] = [];
|
const moves: MoveItem[] = [];
|
||||||
for (const { id } of configs) {
|
for (const { id } of configs) {
|
||||||
@ -1176,10 +1222,10 @@ class Editor extends BaseService {
|
|||||||
|
|
||||||
// 记录所有涉及的源父容器(按 id 去重)+ 目标容器的前置快照;同一父容器只快照一次。
|
// 记录所有涉及的源父容器(按 id 去重)+ 目标容器的前置快照;同一父容器只快照一次。
|
||||||
const beforeSnapshots = new Map<Id, MNode>();
|
const beforeSnapshots = new Map<Id, MNode>();
|
||||||
beforeSnapshots.set(target.id, cloneDeep(toRaw(target)));
|
beforeSnapshots.set(target.id, cloneDeep(this.toRaw(target)));
|
||||||
for (const { parent } of moves) {
|
for (const { parent } of moves) {
|
||||||
if (!beforeSnapshots.has(parent.id)) {
|
if (!beforeSnapshots.has(parent.id)) {
|
||||||
beforeSnapshots.set(parent.id, cloneDeep(toRaw(parent)));
|
beforeSnapshots.set(parent.id, cloneDeep(this.toRaw(parent)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1198,7 +1244,7 @@ class Editor extends BaseService {
|
|||||||
// 整批只入栈一条历史:updatedItems 包含所有源父容器 + 目标容器的前后快照(撤销/重做最小依赖)。
|
// 整批只入栈一条历史:updatedItems 包含所有源父容器 + 目标容器的前后快照(撤销/重做最小依赖)。
|
||||||
const updatedItems = Array.from(beforeSnapshots.entries()).map(([id, oldNode]) => ({
|
const updatedItems = Array.from(beforeSnapshots.entries()).map(([id, oldNode]) => ({
|
||||||
oldNode,
|
oldNode,
|
||||||
newNode: cloneDeep(toRaw(this.getNodeById(id, false))) as MNode,
|
newNode: cloneDeep(this.toRaw(this.getNodeById(id, false))) as MNode,
|
||||||
}));
|
}));
|
||||||
const historyPage = moves[0].pageForOp ?? { name: '', id: target.id };
|
const historyPage = moves[0].pageForOp ?? { name: '', id: target.id };
|
||||||
this.pushOpHistory('update', {
|
this.pushOpHistory('update', {
|
||||||
@ -1226,15 +1272,23 @@ class Editor extends BaseService {
|
|||||||
|
|
||||||
const configs = Array.isArray(config) ? config : [config];
|
const configs = Array.isArray(config) ? config : [config];
|
||||||
|
|
||||||
|
await this.runGuard(() =>
|
||||||
|
this.ctx.validator.guardMove(
|
||||||
|
configs.map((item) => item.id),
|
||||||
|
targetParent,
|
||||||
|
'layer',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
const beforeSnapshots = new Map<string, MNode>();
|
const beforeSnapshots = new Map<string, MNode>();
|
||||||
for (const cfg of configs) {
|
for (const cfg of configs) {
|
||||||
const { parent } = this.getNodeInfo(cfg.id, false);
|
const { parent } = this.getNodeInfo(cfg.id, false);
|
||||||
if (parent && !beforeSnapshots.has(`${parent.id}`)) {
|
if (parent && !beforeSnapshots.has(`${parent.id}`)) {
|
||||||
beforeSnapshots.set(`${parent.id}`, cloneDeep(toRaw(parent)));
|
beforeSnapshots.set(`${parent.id}`, cloneDeep(this.toRaw(parent)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!beforeSnapshots.has(`${targetParent.id}`)) {
|
if (!beforeSnapshots.has(`${targetParent.id}`)) {
|
||||||
beforeSnapshots.set(`${targetParent.id}`, cloneDeep(toRaw(targetParent)));
|
beforeSnapshots.set(`${targetParent.id}`, cloneDeep(this.toRaw(targetParent)));
|
||||||
}
|
}
|
||||||
|
|
||||||
const newLayout = await this.getLayout(targetParent);
|
const newLayout = await this.getLayout(targetParent);
|
||||||
@ -1276,7 +1330,7 @@ class Editor extends BaseService {
|
|||||||
for (const oldNode of beforeSnapshots.values()) {
|
for (const oldNode of beforeSnapshots.values()) {
|
||||||
const newNode = this.getNodeById(oldNode.id, false);
|
const newNode = this.getNodeById(oldNode.id, false);
|
||||||
if (newNode) {
|
if (newNode) {
|
||||||
updatedItems.push({ oldNode, newNode: cloneDeep(toRaw(newNode)) });
|
updatedItems.push({ oldNode, newNode: cloneDeep(this.toRaw(newNode)) });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!doNotPushHistory) {
|
if (!doNotPushHistory) {
|
||||||
@ -1383,7 +1437,7 @@ class Editor extends BaseService {
|
|||||||
public async undo(): Promise<StepValue | null> {
|
public async undo(): Promise<StepValue | null> {
|
||||||
const pageId = this.get('page')?.id;
|
const pageId = this.get('page')?.id;
|
||||||
if (pageId === undefined) return null;
|
if (pageId === undefined) return null;
|
||||||
const value = historyService.undo('page', pageId);
|
const value = this.ctx.history.undo('page', pageId);
|
||||||
if (value) {
|
if (value) {
|
||||||
await this.applyHistoryOp(value, true);
|
await this.applyHistoryOp(value, true);
|
||||||
}
|
}
|
||||||
@ -1397,7 +1451,7 @@ class Editor extends BaseService {
|
|||||||
public async redo(): Promise<StepValue | null> {
|
public async redo(): Promise<StepValue | null> {
|
||||||
const pageId = this.get('page')?.id;
|
const pageId = this.get('page')?.id;
|
||||||
if (pageId === undefined) return null;
|
if (pageId === undefined) return null;
|
||||||
const value = historyService.redo('page', pageId);
|
const value = this.ctx.history.redo('page', pageId);
|
||||||
if (value) {
|
if (value) {
|
||||||
await this.applyHistoryOp(value, false);
|
await this.applyHistoryOp(value, false);
|
||||||
}
|
}
|
||||||
@ -1421,7 +1475,7 @@ class Editor extends BaseService {
|
|||||||
* @returns 反向后产生的新 step;目标不存在 / 未应用 / 反向失败时返回 null
|
* @returns 反向后产生的新 step;目标不存在 / 未应用 / 反向失败时返回 null
|
||||||
*/
|
*/
|
||||||
public async revertPageStep(index: number): Promise<StepValue | null> {
|
public async revertPageStep(index: number): Promise<StepValue | null> {
|
||||||
const list = historyService.getStepList('page', this.get('page')?.id);
|
const list = this.ctx.history.getStepList('page', this.get('page')?.id);
|
||||||
const entry = list[index];
|
const entry = list[index];
|
||||||
if (!entry?.applied) return null;
|
if (!entry?.applied) return null;
|
||||||
|
|
||||||
@ -1443,11 +1497,16 @@ class Editor extends BaseService {
|
|||||||
const captureRevert = (_pageId: Id, s: StepValue) => {
|
const captureRevert = (_pageId: Id, s: StepValue) => {
|
||||||
revertedStep = s;
|
revertedStep = s;
|
||||||
};
|
};
|
||||||
historyService.once('change', captureRevert);
|
this.ctx.history.once('change', captureRevert);
|
||||||
|
|
||||||
const historyDescription = `回滚 #${index + 1}: ${describeStepForRevert(step)}`;
|
const historyDescription = `回滚 #${index + 1}: ${describeStepForRevert(step)}`;
|
||||||
// revert 走 public add/remove/update,让操作以一条普通新 step 入栈;不要切换选区与页面,避免打断用户。
|
// revert 走 public add/remove/update,让操作以一条普通新 step 入栈;不要切换选区与页面,避免打断用户。
|
||||||
const opts = { doNotSelect: true, doNotSwitchPage: true, historyDescription, historySource: 'rollback' } as const;
|
const opts = {
|
||||||
|
doNotSelect: true,
|
||||||
|
doNotSwitchPage: true,
|
||||||
|
historyDescription,
|
||||||
|
historySource: 'rollback',
|
||||||
|
} as const;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
switch (step.opType) {
|
switch (step.opType) {
|
||||||
@ -1506,11 +1565,11 @@ class Editor extends BaseService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
historyService.off('change', captureRevert);
|
this.ctx.history.off('change', captureRevert);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 通知一次 history-change,让上层(如属性面板)按当前最新 DSL 刷新
|
// 通知一次 history-change,让上层(如属性面板)按当前最新 DSL 刷新
|
||||||
const page = toRaw(this.get('page'));
|
const page = this.toRaw(this.get('page'));
|
||||||
if (page) {
|
if (page) {
|
||||||
this.emit('history-change', page as MPage | MPageFragment);
|
this.emit('history-change', page as MPage | MPageFragment);
|
||||||
}
|
}
|
||||||
@ -1529,7 +1588,7 @@ class Editor extends BaseService {
|
|||||||
const results: (StepValue | null)[] = [];
|
const results: (StepValue | null)[] = [];
|
||||||
const pageId = this.get('page')?.id;
|
const pageId = this.get('page')?.id;
|
||||||
for (const uuid of uuids) {
|
for (const uuid of uuids) {
|
||||||
const location = historyService.findStepLocationByUuid('page', uuid, pageId);
|
const location = this.ctx.history.findStepLocationByUuid('page', uuid, pageId);
|
||||||
results.push(!location ? null : await this.revertPageStep(location.index));
|
results.push(!location ? null : await this.revertPageStep(location.index));
|
||||||
}
|
}
|
||||||
return results;
|
return results;
|
||||||
@ -1546,8 +1605,8 @@ class Editor extends BaseService {
|
|||||||
*/
|
*/
|
||||||
public async gotoPageStep(targetCursor: number): Promise<number> {
|
public async gotoPageStep(targetCursor: number): Promise<number> {
|
||||||
const pageId = this.get('page')?.id;
|
const pageId = this.get('page')?.id;
|
||||||
let cursor = historyService.getCursor('page', pageId);
|
let cursor = this.ctx.history.getCursor('page', pageId);
|
||||||
const { length } = historyService.getStepList('page', pageId);
|
const { length } = this.ctx.history.getStepList('page', pageId);
|
||||||
const target = Math.max(0, Math.min(targetCursor, length));
|
const target = Math.max(0, Math.min(targetCursor, length));
|
||||||
while (cursor > target) {
|
while (cursor > target) {
|
||||||
const step = await this.undo();
|
const step = await this.undo();
|
||||||
@ -1567,7 +1626,7 @@ class Editor extends BaseService {
|
|||||||
top: number,
|
top: number,
|
||||||
{ doNotPushHistory = false, historyDescription, historySource }: DslOpOptions = {},
|
{ doNotPushHistory = false, historyDescription, historySource }: DslOpOptions = {},
|
||||||
) {
|
) {
|
||||||
const node = toRaw(this.get('node'));
|
const node = this.toRaw(this.get('node'));
|
||||||
if (!node || isPage(node)) return;
|
if (!node || isPage(node)) return;
|
||||||
|
|
||||||
const newStyle = calcMoveStyle(node.style || {}, left, top);
|
const newStyle = calcMoveStyle(node.style || {}, left, top);
|
||||||
@ -1760,6 +1819,20 @@ class Editor extends BaseService {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行校验。校验不通过(strict 抛错)时操作不会继续,必须清掉 captureSelectionBeforeOp 留下的快照,
|
||||||
|
* 否则下一次成功的操作会沿用这份过期快照,写出错误的 selectedBefore / invalidNodeIdsBefore。
|
||||||
|
*/
|
||||||
|
private async runGuard<T>(guard: () => Promise<T>): Promise<T> {
|
||||||
|
try {
|
||||||
|
return await guard();
|
||||||
|
} catch (e) {
|
||||||
|
this.selectionBeforeOp = null;
|
||||||
|
this.invalidNodeIdsBeforeOp = null;
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private captureSelectionBeforeOp() {
|
private captureSelectionBeforeOp() {
|
||||||
if (this.selectionBeforeOp) return;
|
if (this.selectionBeforeOp) return;
|
||||||
this.selectionBeforeOp = this.get('nodes').map((n) => n.id);
|
this.selectionBeforeOp = this.get('nodes').map((n) => n.id);
|
||||||
@ -1794,15 +1867,22 @@ class Editor extends BaseService {
|
|||||||
this.pushPageDiffStep(
|
this.pushPageDiffStep(
|
||||||
'add',
|
'add',
|
||||||
nextPage,
|
nextPage,
|
||||||
{ newSchema: cloneDeep(toRaw(nextPage)), parentId: nextRoot.id, index: indexInItems(nextRoot, nextPage.id) },
|
{
|
||||||
|
newSchema: cloneDeep(this.toRaw(nextPage)),
|
||||||
|
parentId: nextRoot.id,
|
||||||
|
index: indexInItems(nextRoot, nextPage.id),
|
||||||
|
},
|
||||||
source,
|
source,
|
||||||
);
|
);
|
||||||
addedPages.push(nextPage);
|
addedPages.push(nextPage);
|
||||||
} else if (!isEqual(toRaw(prevPage), toRaw(nextPage))) {
|
} else if (!isEqual(this.toRaw(prevPage), this.toRaw(nextPage))) {
|
||||||
this.pushPageDiffStep(
|
this.pushPageDiffStep(
|
||||||
'update',
|
'update',
|
||||||
nextPage,
|
nextPage,
|
||||||
{ oldSchema: cloneDeep(toRaw(prevPage)), newSchema: cloneDeep(toRaw(nextPage)) },
|
{
|
||||||
|
oldSchema: cloneDeep(this.toRaw(prevPage)),
|
||||||
|
newSchema: cloneDeep(this.toRaw(nextPage)),
|
||||||
|
},
|
||||||
source,
|
source,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -1813,7 +1893,11 @@ class Editor extends BaseService {
|
|||||||
this.pushPageDiffStep(
|
this.pushPageDiffStep(
|
||||||
'remove',
|
'remove',
|
||||||
prevPage,
|
prevPage,
|
||||||
{ oldSchema: cloneDeep(toRaw(prevPage)), parentId: preRoot.id, index: indexInItems(preRoot, prevPage.id) },
|
{
|
||||||
|
oldSchema: cloneDeep(this.toRaw(prevPage)),
|
||||||
|
parentId: preRoot.id,
|
||||||
|
index: indexInItems(preRoot, prevPage.id),
|
||||||
|
},
|
||||||
source,
|
source,
|
||||||
);
|
);
|
||||||
removedPages.push(prevPage);
|
removedPages.push(prevPage);
|
||||||
@ -1821,7 +1905,7 @@ class Editor extends BaseService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (addedPages.length || removedPages.length) {
|
if (addedPages.length || removedPages.length) {
|
||||||
historyService.notifyPageStructureChange({ add: addedPages, remove: removedPages });
|
this.ctx.history.notifyPageStructureChange({ add: addedPages, remove: removedPages });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1852,11 +1936,11 @@ class Editor extends BaseService {
|
|||||||
};
|
};
|
||||||
if (source) step.source = source;
|
if (source) step.source = source;
|
||||||
|
|
||||||
const top = historyService.getCurrentPageStep(page.id);
|
const top = this.ctx.history.getCurrentPageStep(page.id);
|
||||||
if (top?.rootStep && top.source === source) {
|
if (top?.rootStep && top.source === source) {
|
||||||
historyService.replaceCurrentStep('page', step, page.id);
|
this.ctx.history.replaceCurrentStep('page', step, page.id);
|
||||||
} else {
|
} else {
|
||||||
historyService.push('page', step, page.id);
|
this.ctx.history.push('page', step, page.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1892,7 +1976,7 @@ class Editor extends BaseService {
|
|||||||
if (source) step.source = source;
|
if (source) step.source = source;
|
||||||
// 显式按 step.data.id 入栈:跨页操作(如 moveToContainer 从源页搬到目标页)
|
// 显式按 step.data.id 入栈:跨页操作(如 moveToContainer 从源页搬到目标页)
|
||||||
// 必须落到正确的页面栈,否则会把记录错误地推到当前活动页 / 操作发起页。
|
// 必须落到正确的页面栈,否则会把记录错误地推到当前活动页 / 操作发起页。
|
||||||
const pushed = historyService.push('page', step, pageData.id);
|
const pushed = this.ctx.history.push('page', step, pageData.id);
|
||||||
// push 返回 null 表示当前没有可写入的页面栈(未真正入栈),此时不应返回 uuid。
|
// push 返回 null 表示当前没有可写入的页面栈(未真正入栈),此时不应返回 uuid。
|
||||||
const historyId = pushed ? step.uuid : null;
|
const historyId = pushed ? step.uuid : null;
|
||||||
this.lastPushedHistoryId = historyId;
|
this.lastPushedHistoryId = historyId;
|
||||||
@ -2051,7 +2135,7 @@ class Editor extends BaseService {
|
|||||||
this.set('invalidNodeIds', new Map(invalidToRestore));
|
this.set('invalidNodeIds', new Map(invalidToRestore));
|
||||||
this.emit('invalid-node-change', this.get('invalidNodeIds'));
|
this.emit('invalid-node-change', this.get('invalidNodeIds'));
|
||||||
|
|
||||||
const page = toRaw(this.get('page'));
|
const page = this.toRaw(this.get('page'));
|
||||||
if (page) {
|
if (page) {
|
||||||
const selectIds = (reverse ? step.extra?.selectedBefore : step.extra?.selectedAfter) ?? [];
|
const selectIds = (reverse ? step.extra?.selectedBefore : step.extra?.selectedAfter) ?? [];
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@ -2089,8 +2173,13 @@ class Editor extends BaseService {
|
|||||||
doNotPushHistory: true,
|
doNotPushHistory: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 经由 reactivity 注入的 toRaw(默认是 Vue 的 toRaw) */
|
||||||
|
private toRaw<T>(value: T): T {
|
||||||
|
return this.ctx.reactivity.toRaw(value);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type EditorService = Editor;
|
export type EditorService = Editor;
|
||||||
|
|
||||||
export default new Editor();
|
export default Editor;
|
||||||
@ -16,7 +16,6 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { reactive } from 'vue';
|
|
||||||
import { cloneDeep } from 'lodash-es';
|
import { cloneDeep } from 'lodash-es';
|
||||||
import type { Writable } from 'type-fest';
|
import type { Writable } from 'type-fest';
|
||||||
|
|
||||||
@ -25,6 +24,8 @@ import { toLine } from '@tmagic/utils';
|
|||||||
|
|
||||||
import type { AsyncHookPlugin, SyncHookPlugin } from '@editor/type';
|
import type { AsyncHookPlugin, SyncHookPlugin } from '@editor/type';
|
||||||
|
|
||||||
|
import type { SdkContext } from '../type';
|
||||||
|
|
||||||
import BaseService from './BaseService';
|
import BaseService from './BaseService';
|
||||||
|
|
||||||
const canUsePluginMethods = {
|
const canUsePluginMethods = {
|
||||||
@ -35,15 +36,17 @@ const canUsePluginMethods = {
|
|||||||
type AsyncMethodName = Writable<(typeof canUsePluginMethods)['async']>;
|
type AsyncMethodName = Writable<(typeof canUsePluginMethods)['async']>;
|
||||||
type SyncMethodName = Writable<(typeof canUsePluginMethods)['sync']>;
|
type SyncMethodName = Writable<(typeof canUsePluginMethods)['sync']>;
|
||||||
|
|
||||||
const eventMap: Record<string, EventOption[]> = reactive({});
|
|
||||||
const methodMap: Record<string, EventOption[]> = reactive({});
|
|
||||||
|
|
||||||
class Events extends BaseService {
|
class Events extends BaseService {
|
||||||
constructor() {
|
private eventMap: Record<string, EventOption[]>;
|
||||||
|
private methodMap: Record<string, EventOption[]>;
|
||||||
|
|
||||||
|
constructor(ctx: SdkContext) {
|
||||||
super([
|
super([
|
||||||
...canUsePluginMethods.async.map((methodName) => ({ name: methodName, isAsync: true })),
|
...canUsePluginMethods.async.map((methodName) => ({ name: methodName, isAsync: true })),
|
||||||
...canUsePluginMethods.sync.map((methodName) => ({ name: methodName, isAsync: false })),
|
...canUsePluginMethods.sync.map((methodName) => ({ name: methodName, isAsync: false })),
|
||||||
]);
|
]);
|
||||||
|
this.eventMap = ctx.reactivity.reactive({});
|
||||||
|
this.methodMap = ctx.reactivity.reactive({});
|
||||||
}
|
}
|
||||||
|
|
||||||
public setEvents(events: Record<string, EventOption[]>) {
|
public setEvents(events: Record<string, EventOption[]>) {
|
||||||
@ -53,11 +56,11 @@ class Events extends BaseService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public setEvent(type: string, events: EventOption[]) {
|
public setEvent(type: string, events: EventOption[]) {
|
||||||
eventMap[toLine(type)] = [...events];
|
this.eventMap[toLine(type)] = [...events];
|
||||||
}
|
}
|
||||||
|
|
||||||
public getEvent(type: string, _data: { node?: MNode | null } = {}): EventOption[] {
|
public getEvent(type: string, _data: { node?: MNode | null } = {}): EventOption[] {
|
||||||
return cloneDeep(eventMap[toLine(type)]) || [];
|
return cloneDeep(this.eventMap[toLine(type)]) || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
public setMethods(methods: Record<string, EventOption[]>) {
|
public setMethods(methods: Record<string, EventOption[]>) {
|
||||||
@ -67,17 +70,17 @@ class Events extends BaseService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public setMethod(type: string, method: EventOption[]) {
|
public setMethod(type: string, method: EventOption[]) {
|
||||||
methodMap[toLine(type)] = [...method];
|
this.methodMap[toLine(type)] = [...method];
|
||||||
}
|
}
|
||||||
|
|
||||||
public getMethod(type: string, _data: { node?: MNode | null; targetId?: Id } = {}) {
|
public getMethod(type: string, _data: { node?: MNode | null; targetId?: Id } = {}) {
|
||||||
return cloneDeep(methodMap[toLine(type)]) || [];
|
return cloneDeep(this.methodMap[toLine(type)]) || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
public resetState() {
|
public resetState() {
|
||||||
// 原地清空而不是重新赋值,保留 reactive 代理身份,已建立的 computed / 渲染副作用才能收到更新
|
// 原地清空而不是重新赋值,保留 reactive 代理身份,已建立的 computed / 渲染副作用才能收到更新
|
||||||
Object.keys(eventMap).forEach((type) => delete eventMap[type]);
|
Object.keys(this.eventMap).forEach((type) => delete this.eventMap[type]);
|
||||||
Object.keys(methodMap).forEach((type) => delete methodMap[type]);
|
Object.keys(this.methodMap).forEach((type) => delete this.methodMap[type]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public destroy() {
|
public destroy() {
|
||||||
@ -93,4 +96,4 @@ class Events extends BaseService {
|
|||||||
|
|
||||||
export type EventsService = Events;
|
export type EventsService = Events;
|
||||||
|
|
||||||
export default new Events();
|
export default Events;
|
||||||
@ -16,7 +16,6 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { reactive } from 'vue';
|
|
||||||
import type { Writable } from 'type-fest';
|
import type { Writable } from 'type-fest';
|
||||||
|
|
||||||
import type { Id, MPage, MPageFragment } from '@tmagic/core';
|
import type { Id, MPage, MPageFragment } from '@tmagic/core';
|
||||||
@ -37,7 +36,6 @@ import type {
|
|||||||
StepValue,
|
StepValue,
|
||||||
SyncHookPlugin,
|
SyncHookPlugin,
|
||||||
} from '@editor/type';
|
} from '@editor/type';
|
||||||
import { getEditorConfig } from '@editor/utils/config';
|
|
||||||
import {
|
import {
|
||||||
deserializeStacks,
|
deserializeStacks,
|
||||||
getOrCreateStack,
|
getOrCreateStack,
|
||||||
@ -46,11 +44,11 @@ import {
|
|||||||
serializeStacks,
|
serializeStacks,
|
||||||
undoFloor,
|
undoFloor,
|
||||||
} from '@editor/utils/history';
|
} from '@editor/utils/history';
|
||||||
import { idbGet, idbSet } from '@editor/utils/indexed-db';
|
|
||||||
import { UndoRedo } from '@editor/utils/undo-redo';
|
import { UndoRedo } from '@editor/utils/undo-redo';
|
||||||
|
|
||||||
|
import type { SdkContext } from '../type';
|
||||||
|
|
||||||
import BaseService from './BaseService';
|
import BaseService from './BaseService';
|
||||||
import editorService from './editor';
|
|
||||||
|
|
||||||
const canUsePluginMethods = {
|
const canUsePluginMethods = {
|
||||||
sync: ['push', 'undo', 'redo'] as const,
|
sync: ['push', 'undo', 'redo'] as const,
|
||||||
@ -80,18 +78,57 @@ const DEFAULT_KEY: IDBValidKey = 'default';
|
|||||||
// 全部历史栈统一收敛到 steps 字段(见 HistorySteps)。
|
// 全部历史栈统一收敛到 steps 字段(见 HistorySteps)。
|
||||||
const PERSIST_VERSION = 3;
|
const PERSIST_VERSION = 3;
|
||||||
|
|
||||||
class History extends BaseService {
|
const EXTRA_MAP_KEYS = ['modifiedNodeIds', 'invalidNodeIdsBefore', 'invalidNodeIdsAfter'] as const;
|
||||||
public state = reactive<HistoryState>({
|
|
||||||
steps: {
|
|
||||||
page: {},
|
|
||||||
codeBlock: {},
|
|
||||||
dataSource: {},
|
|
||||||
},
|
|
||||||
stepNames: { ...DEFAULT_STEP_NAMES },
|
|
||||||
});
|
|
||||||
|
|
||||||
constructor() {
|
/**
|
||||||
|
* 转换各栈 step.extra 中的 Map 字段,返回新对象(不修改入参,避免污染运行中的历史状态)。
|
||||||
|
* - 导出:Map -> entries 数组,使快照可以直接 `JSON.stringify`
|
||||||
|
* - 还原:entries 数组 -> Map
|
||||||
|
*/
|
||||||
|
const convertExtraMaps = (
|
||||||
|
stacks: Record<string, any> | undefined,
|
||||||
|
restoring: boolean,
|
||||||
|
): Record<string, any> | undefined => {
|
||||||
|
if (!stacks) return stacks;
|
||||||
|
|
||||||
|
const convertValue = (value: any) => {
|
||||||
|
if (restoring) return Array.isArray(value) ? new Map(value) : value;
|
||||||
|
return value instanceof Map ? [...value.entries()] : value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const result: Record<string, any> = {};
|
||||||
|
Object.entries(stacks).forEach(([id, stack]: [string, any]) => {
|
||||||
|
result[id] = !stack?.elementList
|
||||||
|
? stack
|
||||||
|
: {
|
||||||
|
...stack,
|
||||||
|
elementList: stack.elementList.map((step: any) => {
|
||||||
|
if (!step?.extra) return step;
|
||||||
|
const extra = { ...step.extra };
|
||||||
|
// 导出与还原使用同一份 key 清单,保证往返对称
|
||||||
|
EXTRA_MAP_KEYS.forEach((key) => {
|
||||||
|
if (extra[key]) extra[key] = convertValue(extra[key]);
|
||||||
|
});
|
||||||
|
return { ...step, extra };
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
class History extends BaseService {
|
||||||
|
public state: HistoryState;
|
||||||
|
|
||||||
|
constructor(private ctx: SdkContext) {
|
||||||
super([...canUsePluginMethods.sync.map((methodName) => ({ name: methodName, isAsync: false }))]);
|
super([...canUsePluginMethods.sync.map((methodName) => ({ name: methodName, isAsync: false }))]);
|
||||||
|
this.state = ctx.reactivity.reactive<HistoryState>({
|
||||||
|
steps: {
|
||||||
|
page: {},
|
||||||
|
codeBlock: {},
|
||||||
|
dataSource: {},
|
||||||
|
},
|
||||||
|
stepNames: { ...DEFAULT_STEP_NAMES },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -148,7 +185,12 @@ class History extends BaseService {
|
|||||||
public setMarker(
|
public setMarker(
|
||||||
stepType: HistoryStepType,
|
stepType: HistoryStepType,
|
||||||
id: Id,
|
id: Id,
|
||||||
options: { name?: string; description?: string; source?: HistoryOpSource; extra?: Record<string, any> } = {},
|
options: {
|
||||||
|
name?: string;
|
||||||
|
description?: string;
|
||||||
|
source?: HistoryOpSource;
|
||||||
|
extra?: Record<string, any>;
|
||||||
|
} = {},
|
||||||
): StepValue | null {
|
): StepValue | null {
|
||||||
if (!this.isValidStackId(id)) return null;
|
if (!this.isValidStackId(id)) return null;
|
||||||
|
|
||||||
@ -385,21 +427,46 @@ class History extends BaseService {
|
|||||||
public async saveToIndexedDB(options: HistoryPersistOptions = {}): Promise<PersistedHistoryState> {
|
public async saveToIndexedDB(options: HistoryPersistOptions = {}): Promise<PersistedHistoryState> {
|
||||||
const { dbName = DEFAULT_DB_NAME, storeName = DEFAULT_STORE_NAME, key = DEFAULT_KEY, appId } = options;
|
const { dbName = DEFAULT_DB_NAME, storeName = DEFAULT_STORE_NAME, key = DEFAULT_KEY, appId } = options;
|
||||||
|
|
||||||
|
const persistence = this.getPersistence();
|
||||||
|
const snapshot = this.serialize();
|
||||||
|
|
||||||
|
await persistence.set(this.resolveDbName(dbName, appId), storeName, key, snapshot);
|
||||||
|
this.emit('save-to-indexed-db', snapshot);
|
||||||
|
return snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把当前内存中的全部历史栈导出成可持久化的快照(纯同步,不依赖 IndexedDB),
|
||||||
|
* 可用于无状态调用:下次通过 {@link restore} 或 `createDslSdk({ history })` 还原。
|
||||||
|
*/
|
||||||
|
public serialize({ jsonSafe = false }: { jsonSafe?: boolean } = {}): PersistedHistoryState {
|
||||||
// serializeStacks 会在序列化各栈时只把每条 step 的 diff(可能含函数)序列化成字符串,其余字段原样保留,
|
// serializeStacks 会在序列化各栈时只把每条 step 的 diff(可能含函数)序列化成字符串,其余字段原样保留,
|
||||||
// 因此整份快照可直接按对象写入 IndexedDB(结构化克隆),避免序列化整份快照的开销;读取时再用 parseDSL 还原 diff。
|
// 因此整份快照可直接按对象写入 IndexedDB(结构化克隆),避免序列化整份快照的开销;读取时再用 parseDSL 还原 diff。
|
||||||
const steps: PersistedHistoryState['steps'] = { page: {}, codeBlock: {}, dataSource: {} };
|
const steps: PersistedHistoryState['steps'] = { page: {}, codeBlock: {}, dataSource: {} };
|
||||||
Object.entries(this.state.steps).forEach(([stepType, bucket]) => {
|
Object.entries(this.state.steps).forEach(([stepType, bucket]) => {
|
||||||
steps[stepType] = serializeStacks(bucket);
|
steps[stepType] = serializeStacks(bucket);
|
||||||
|
// `extra` 里的校验错误记录是 Map,JSON 无法表示;jsonSafe 时转成 entries 数组,restore 时会还原
|
||||||
|
if (jsonSafe) steps[stepType] = convertExtraMaps(steps[stepType], false) as any;
|
||||||
});
|
});
|
||||||
|
|
||||||
const snapshot: PersistedHistoryState = {
|
return {
|
||||||
version: PERSIST_VERSION,
|
version: PERSIST_VERSION,
|
||||||
steps,
|
steps,
|
||||||
savedAt: Date.now(),
|
savedAt: Date.now(),
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
await idbSet(this.resolveDbName(dbName, appId), storeName, key, snapshot);
|
/**
|
||||||
this.emit('save-to-indexed-db', snapshot);
|
* 用 {@link serialize} 导出的快照重建全部撤销/重做栈,会整体覆盖当前内存中的历史状态。
|
||||||
|
*/
|
||||||
|
public restore(snapshot: PersistedHistoryState): PersistedHistoryState {
|
||||||
|
// 各 step 的 diff 以序列化字符串存储(含函数),由 deserializeStacks 逐条用 parseDSL 还原。
|
||||||
|
const steps: HistoryState['steps'] = { page: {}, codeBlock: {}, dataSource: {} };
|
||||||
|
Object.entries(snapshot.steps).forEach(([stepType, bucket]) => {
|
||||||
|
// 兼容 jsonSafe 导出的快照(Map 被转成了 entries 数组)
|
||||||
|
steps[stepType] = deserializeStacks(convertExtraMaps(bucket, true) as any, this.ctx.parseDSL);
|
||||||
|
});
|
||||||
|
this.state.steps = steps;
|
||||||
return snapshot;
|
return snapshot;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -414,17 +481,14 @@ class History extends BaseService {
|
|||||||
public async restoreFromIndexedDB(options: HistoryPersistOptions = {}): Promise<PersistedHistoryState | null> {
|
public async restoreFromIndexedDB(options: HistoryPersistOptions = {}): Promise<PersistedHistoryState | null> {
|
||||||
const { dbName = DEFAULT_DB_NAME, storeName = DEFAULT_STORE_NAME, key = DEFAULT_KEY, appId } = options;
|
const { dbName = DEFAULT_DB_NAME, storeName = DEFAULT_STORE_NAME, key = DEFAULT_KEY, appId } = options;
|
||||||
|
|
||||||
const snapshot = await idbGet<PersistedHistoryState>(this.resolveDbName(dbName, appId), storeName, key);
|
const snapshot = await this.getPersistence().get<PersistedHistoryState>(
|
||||||
|
this.resolveDbName(dbName, appId),
|
||||||
|
storeName,
|
||||||
|
key,
|
||||||
|
);
|
||||||
if (!snapshot) return null;
|
if (!snapshot) return null;
|
||||||
|
|
||||||
// 各 step 的 diff 以序列化字符串存储(含函数),由 deserializeStacks 逐条用 parseDSL 还原。
|
this.restore(snapshot);
|
||||||
const parseDSL = getEditorConfig('parseDSL');
|
|
||||||
|
|
||||||
const steps: HistoryState['steps'] = { page: {}, codeBlock: {}, dataSource: {} };
|
|
||||||
Object.entries(snapshot.steps).forEach(([stepType, bucket]) => {
|
|
||||||
steps[stepType] = deserializeStacks(bucket, parseDSL);
|
|
||||||
});
|
|
||||||
this.state.steps = steps;
|
|
||||||
// initial 基线作为各栈 index 0 的 step 随 steps 一并还原,无需单独恢复;活动页由 editorService 维护。
|
// initial 基线作为各栈 index 0 的 step 随 steps 一并还原,无需单独恢复;活动页由 editorService 维护。
|
||||||
this.emit('restore-from-indexed-db', snapshot);
|
this.emit('restore-from-indexed-db', snapshot);
|
||||||
return snapshot;
|
return snapshot;
|
||||||
@ -583,17 +647,25 @@ class History extends BaseService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getPersistence() {
|
||||||
|
const persistence = this.ctx.options.historyPersistence;
|
||||||
|
if (!persistence) {
|
||||||
|
throw new Error('未提供 historyPersistence,无法读写历史快照;请改用 serialize() / restore()');
|
||||||
|
}
|
||||||
|
return persistence;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 把基础 dbName 与当前 DSL(root app)的 id 拼成最终库名,实现不同应用历史隔离。
|
* 把基础 dbName 与当前 DSL(root app)的 id 拼成最终库名,实现不同应用历史隔离。
|
||||||
* 取不到 app id(如尚未加载 DSL)时退回基础 dbName。
|
* 取不到 app id(如尚未加载 DSL)时退回基础 dbName。
|
||||||
*/
|
*/
|
||||||
private resolveDbName(dbName: string, appId?: Id): string {
|
private resolveDbName(dbName: string, appId?: Id): string {
|
||||||
// 优先用显式传入的 appId(「先恢复再 set root」时 root 尚未就绪);否则回退到当前 root.id。
|
// 优先用显式传入的 appId(「先恢复再 set root」时 root 尚未就绪);否则回退到当前 root.id。
|
||||||
const resolvedAppId = appId ?? editorService.get('root')?.id;
|
const resolvedAppId = appId ?? this.ctx.editor.get('root')?.id;
|
||||||
return resolvedAppId ? `${dbName}-${resolvedAppId}` : dbName;
|
return resolvedAppId ? `${dbName}-${resolvedAppId}` : dbName;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type HistoryService = History;
|
export type HistoryService = History;
|
||||||
|
|
||||||
export default new History();
|
export default History;
|
||||||
@ -16,7 +16,6 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { reactive } from 'vue';
|
|
||||||
import { cloneDeep, mergeWith } from 'lodash-es';
|
import { cloneDeep, mergeWith } from 'lodash-es';
|
||||||
import type { Writable } from 'type-fest';
|
import type { Writable } from 'type-fest';
|
||||||
|
|
||||||
@ -25,7 +24,6 @@ import { Target, Watcher } from '@tmagic/core';
|
|||||||
import type { FormConfig } from '@tmagic/form';
|
import type { FormConfig } from '@tmagic/form';
|
||||||
import { getNodePath, getValueByKeyPath, guid, setValueByKeyPath, toLine } from '@tmagic/utils';
|
import { getNodePath, getValueByKeyPath, guid, setValueByKeyPath, toLine } from '@tmagic/utils';
|
||||||
|
|
||||||
import editorService from '@editor/services/editor';
|
|
||||||
import type {
|
import type {
|
||||||
AsyncHookPlugin,
|
AsyncHookPlugin,
|
||||||
PropsFormConfigFunction,
|
PropsFormConfigFunction,
|
||||||
@ -33,7 +31,9 @@ import type {
|
|||||||
PropsState,
|
PropsState,
|
||||||
SyncHookPlugin,
|
SyncHookPlugin,
|
||||||
} from '@editor/type';
|
} from '@editor/type';
|
||||||
import { fillConfig } from '@editor/utils/props';
|
|
||||||
|
import type { SdkContext } from '../type';
|
||||||
|
import { fillConfig } from '../utils/props';
|
||||||
|
|
||||||
import BaseService from './BaseService';
|
import BaseService from './BaseService';
|
||||||
|
|
||||||
@ -53,21 +53,22 @@ type AsyncMethodName = Writable<(typeof canUsePluginMethods)['async']>;
|
|||||||
type SyncMethodName = Writable<(typeof canUsePluginMethods)['sync']>;
|
type SyncMethodName = Writable<(typeof canUsePluginMethods)['sync']>;
|
||||||
|
|
||||||
class Props extends BaseService {
|
class Props extends BaseService {
|
||||||
private state = reactive<PropsState>({
|
private state: PropsState;
|
||||||
propsConfigMap: {},
|
|
||||||
propsValueMap: {},
|
|
||||||
relateIdMap: {},
|
|
||||||
/** 禁用数据源 */
|
|
||||||
disabledDataSource: false,
|
|
||||||
/** 禁用代码块 */
|
|
||||||
disabledCodeBlock: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
constructor() {
|
constructor(private ctx: SdkContext) {
|
||||||
super([
|
super([
|
||||||
...canUsePluginMethods.async.map((methodName) => ({ name: methodName, isAsync: true })),
|
...canUsePluginMethods.async.map((methodName) => ({ name: methodName, isAsync: true })),
|
||||||
...canUsePluginMethods.sync.map((methodName) => ({ name: methodName, isAsync: false })),
|
...canUsePluginMethods.sync.map((methodName) => ({ name: methodName, isAsync: false })),
|
||||||
]);
|
]);
|
||||||
|
this.state = ctx.reactivity.reactive<PropsState>({
|
||||||
|
propsConfigMap: {},
|
||||||
|
propsValueMap: {},
|
||||||
|
relateIdMap: {},
|
||||||
|
/** 禁用数据源 */
|
||||||
|
disabledDataSource: false,
|
||||||
|
/** 禁用代码块 */
|
||||||
|
disabledCodeBlock: false,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public setDisabledDataSource(disabled: boolean) {
|
public setDisabledDataSource(disabled: boolean) {
|
||||||
@ -86,10 +87,9 @@ class Props extends BaseService {
|
|||||||
return this.state.disabledCodeBlock;
|
return this.state.disabledCodeBlock;
|
||||||
}
|
}
|
||||||
|
|
||||||
public setPropsConfigs(configs: Record<string, FormConfig | PropsFormConfigFunction>) {
|
/** 批量设置组件属性表单配置,返回的 Promise 在全部配置处理完(含异步的)后 resolve */
|
||||||
Object.keys(configs).forEach((type: string) => {
|
public async setPropsConfigs(configs: Record<string, FormConfig | PropsFormConfigFunction>) {
|
||||||
this.setPropsConfig(toLine(type), configs[type]);
|
await Promise.all(Object.keys(configs).map((type: string) => this.setPropsConfig(toLine(type), configs[type])));
|
||||||
});
|
|
||||||
this.emit('props-configs-change');
|
this.emit('props-configs-change');
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -102,13 +102,15 @@ class Props extends BaseService {
|
|||||||
labelWidth,
|
labelWidth,
|
||||||
disabledDataSource: this.getDisabledDataSource(),
|
disabledDataSource: this.getDisabledDataSource(),
|
||||||
disabledCodeBlock: this.getDisabledCodeBlock(),
|
disabledCodeBlock: this.getDisabledCodeBlock(),
|
||||||
|
copyText: this.ctx.options.copyText,
|
||||||
|
notify: this.ctx.options.notify,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async setPropsConfig(type: string, config: FormConfig | PropsFormConfigFunction) {
|
public async setPropsConfig(type: string, config: FormConfig | PropsFormConfigFunction) {
|
||||||
let c: FormConfig;
|
let c: FormConfig;
|
||||||
if (typeof config === 'function') {
|
if (typeof config === 'function') {
|
||||||
c = config({ editorService });
|
c = config({ editorService: this.ctx.editor });
|
||||||
} else {
|
} else {
|
||||||
c = config;
|
c = config;
|
||||||
}
|
}
|
||||||
@ -134,9 +136,7 @@ class Props extends BaseService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public setPropsValues(values: Record<string, Partial<MNode> | PropsFormValueFunction>) {
|
public setPropsValues(values: Record<string, Partial<MNode> | PropsFormValueFunction>) {
|
||||||
Object.keys(values).forEach((type: string) => {
|
return Promise.all(Object.keys(values).map((type: string) => this.setPropsValue(toLine(type), values[type])));
|
||||||
this.setPropsValue(toLine(type), values[type]);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public getPropsValues(): Record<string, Partial<MNode>> {
|
public getPropsValues(): Record<string, Partial<MNode>> {
|
||||||
@ -151,7 +151,7 @@ class Props extends BaseService {
|
|||||||
public async setPropsValue(type: string, value: Partial<MNode> | PropsFormValueFunction) {
|
public async setPropsValue(type: string, value: Partial<MNode> | PropsFormValueFunction) {
|
||||||
let v = value;
|
let v = value;
|
||||||
if (typeof value === 'function') {
|
if (typeof value === 'function') {
|
||||||
v = value({ editorService });
|
v = value({ editorService: this.ctx.editor });
|
||||||
}
|
}
|
||||||
this.state.propsValueMap[toLine(type)] = v;
|
this.state.propsValueMap[toLine(type)] = v;
|
||||||
}
|
}
|
||||||
@ -206,7 +206,7 @@ class Props extends BaseService {
|
|||||||
*/
|
*/
|
||||||
/* eslint no-param-reassign: ["error", { "props": false }] */
|
/* eslint no-param-reassign: ["error", { "props": false }] */
|
||||||
public setNewItemId(config: MNode, force = true) {
|
public setNewItemId(config: MNode, force = true) {
|
||||||
if (force || editorService.getNodeById(config.id)) {
|
if (force || this.ctx.editor.getNodeById(config.id)) {
|
||||||
const newId = this.createId(config.type || 'component');
|
const newId = this.createId(config.type || 'component');
|
||||||
this.setRelateId(config.id, newId);
|
this.setRelateId(config.id, newId);
|
||||||
config.id = newId;
|
config.id = newId;
|
||||||
@ -325,4 +325,4 @@ class Props extends BaseService {
|
|||||||
|
|
||||||
export type PropsService = Props;
|
export type PropsService = Props;
|
||||||
|
|
||||||
export default new Props();
|
export default Props;
|
||||||
@ -2,7 +2,8 @@ import serialize from 'serialize-javascript';
|
|||||||
import type { Writable } from 'type-fest';
|
import type { Writable } from 'type-fest';
|
||||||
|
|
||||||
import type { SyncHookPlugin } from '@editor/type';
|
import type { SyncHookPlugin } from '@editor/type';
|
||||||
import { getEditorConfig } from '@editor/utils/config';
|
|
||||||
|
import type { SdkContext, StorageLike } from '../type';
|
||||||
|
|
||||||
import BaseService from './BaseService';
|
import BaseService from './BaseService';
|
||||||
|
|
||||||
@ -26,15 +27,48 @@ const canUsePluginMethods = {
|
|||||||
|
|
||||||
type SyncMethodName = Writable<(typeof canUsePluginMethods)['sync']>;
|
type SyncMethodName = Writable<(typeof canUsePluginMethods)['sync']>;
|
||||||
|
|
||||||
|
/** 默认的内存存储,Node 下没有 localStorage 时使用 */
|
||||||
|
export class MemoryStorage implements StorageLike {
|
||||||
|
private map = new Map<string, string>();
|
||||||
|
|
||||||
|
get length() {
|
||||||
|
return this.map.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
clear() {
|
||||||
|
this.map.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
getItem(key: string) {
|
||||||
|
return this.map.has(key) ? (this.map.get(key) as string) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
key(index: number) {
|
||||||
|
return Array.from(this.map.keys())[index] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
removeItem(key: string) {
|
||||||
|
this.map.delete(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
setItem(key: string, value: string) {
|
||||||
|
this.map.set(key, `${value}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 数据存储服务
|
* 数据存储服务
|
||||||
*/
|
*/
|
||||||
export class WebStorage extends BaseService {
|
export class WebStorage extends BaseService {
|
||||||
private storage: Storage = globalThis.localStorage;
|
private storage: StorageLike;
|
||||||
private namespace = 'tmagic';
|
private namespace = 'tmagic';
|
||||||
|
|
||||||
constructor() {
|
constructor(
|
||||||
|
private ctx: SdkContext,
|
||||||
|
storage?: StorageLike,
|
||||||
|
) {
|
||||||
super(canUsePluginMethods.sync.map((methodName) => ({ name: methodName, isAsync: false })));
|
super(canUsePluginMethods.sync.map((methodName) => ({ name: methodName, isAsync: false })));
|
||||||
|
this.storage = storage ?? new MemoryStorage();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -42,12 +76,12 @@ export class WebStorage extends BaseService {
|
|||||||
* const storageService = new StorageService();
|
* const storageService = new StorageService();
|
||||||
* storageService.usePlugin({
|
* storageService.usePlugin({
|
||||||
* // 替换存储对象为 sessionStorage
|
* // 替换存储对象为 sessionStorage
|
||||||
* async afterGetStorage(): Promise<Storage> {
|
* async afterGetStorage(): Promise<StorageLike> {
|
||||||
* return window.sessionStorage;
|
* return window.sessionStorage;
|
||||||
* },
|
* },
|
||||||
* });
|
* });
|
||||||
*/
|
*/
|
||||||
public getStorage(): Storage {
|
public getStorage(): StorageLike {
|
||||||
return this.storage;
|
return this.storage;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -76,7 +110,7 @@ export class WebStorage extends BaseService {
|
|||||||
|
|
||||||
switch (protocol) {
|
switch (protocol) {
|
||||||
case Protocol.OBJECT:
|
case Protocol.OBJECT:
|
||||||
return getEditorConfig('parseDSL')(`(${item})`);
|
return this.ctx.parseDSL(`(${item})`);
|
||||||
case Protocol.JSON:
|
case Protocol.JSON:
|
||||||
return JSON.parse(item);
|
return JSON.parse(item);
|
||||||
case Protocol.NUMBER:
|
case Protocol.NUMBER:
|
||||||
@ -153,4 +187,4 @@ export class WebStorage extends BaseService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type StorageService = WebStorage;
|
export type StorageService = WebStorage;
|
||||||
export default new WebStorage();
|
export default WebStorage;
|
||||||
191
packages/editor/src/sdk/type.ts
Normal file
191
packages/editor/src/sdk/type.ts
Normal file
@ -0,0 +1,191 @@
|
|||||||
|
/*
|
||||||
|
* Tencent is pleased to support the open source community by making TMagicEditor available.
|
||||||
|
*
|
||||||
|
* Copyright (C) 2025 Tencent. All rights reserved.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @fileoverview DSL SDK 的公共类型:响应式注入、适配器、上下文、校验结果。
|
||||||
|
*
|
||||||
|
* 这里只放类型,禁止引入运行时依赖(vue / stage / design 等)。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { DataSourceSchema, EventOption, Id, MApp, MNode } from '@tmagic/core';
|
||||||
|
import type { FormConfig } from '@tmagic/form';
|
||||||
|
|
||||||
|
import type {
|
||||||
|
CanDropInFunction,
|
||||||
|
ComponentGroup,
|
||||||
|
DatasourceTypeOption,
|
||||||
|
PropsFormConfigFunction,
|
||||||
|
PropsFormValueFunction,
|
||||||
|
} from '@editor/type';
|
||||||
|
import type { CollectWorkerPayload, CollectWorkerResult, DepsData } from '@editor/utils/dep/worker';
|
||||||
|
|
||||||
|
import type CodeBlock from './services/CodeBlock';
|
||||||
|
import type ComponentList from './services/ComponentList';
|
||||||
|
import type DataSource from './services/DataSource';
|
||||||
|
import type Dep from './services/Dep';
|
||||||
|
import type Editor from './services/Editor';
|
||||||
|
import type Events from './services/Events';
|
||||||
|
import type History from './services/History';
|
||||||
|
import type Props from './services/Props';
|
||||||
|
import type WebStorage from './services/WebStorage';
|
||||||
|
import type { Validator } from './validate';
|
||||||
|
|
||||||
|
// #region Reactivity
|
||||||
|
/**
|
||||||
|
* 响应式实现,默认直接使用 Vue 的 `reactive` / `shallowReactive` / `toRaw` / `nextTick`;
|
||||||
|
* 可通过 `SdkOptions.reactivity` 覆盖。
|
||||||
|
*/
|
||||||
|
export interface Reactivity {
|
||||||
|
reactive: <T extends object>(target: T) => T;
|
||||||
|
shallowReactive: <T extends object>(target: T) => T;
|
||||||
|
toRaw: <T>(value: T) => T;
|
||||||
|
nextTick: () => Promise<void>;
|
||||||
|
}
|
||||||
|
// #endregion Reactivity
|
||||||
|
|
||||||
|
// #region StorageLike
|
||||||
|
/** 存储适配器接口,`localStorage` 天然满足 */
|
||||||
|
export interface StorageLike {
|
||||||
|
readonly length: number;
|
||||||
|
clear(): void;
|
||||||
|
getItem(key: string): string | null;
|
||||||
|
key(index: number): string | null;
|
||||||
|
removeItem(key: string): void;
|
||||||
|
setItem(key: string, value: string): void;
|
||||||
|
}
|
||||||
|
// #endregion StorageLike
|
||||||
|
|
||||||
|
// #region HistoryPersistence
|
||||||
|
/** 历史快照的持久化适配器(浏览器端对应 IndexedDB)。不提供则 `saveToIndexedDB` / `restoreFromIndexedDB` 会 reject */
|
||||||
|
export interface HistoryPersistence {
|
||||||
|
get<T = unknown>(dbName: string, storeName: string, key: IDBValidKey): Promise<T | undefined>;
|
||||||
|
set(dbName: string, storeName: string, key: IDBValidKey, value: unknown): Promise<void>;
|
||||||
|
}
|
||||||
|
// #endregion HistoryPersistence
|
||||||
|
|
||||||
|
// #region CollectWorkerLike
|
||||||
|
/**
|
||||||
|
* 依赖收集 worker 适配器。浏览器端注入基于 Web Worker 的实现,Node 下不提供,
|
||||||
|
* 依赖收集会退回主线程的空闲队列(`IdleTask`,Node 下由 setTimeout 驱动)。
|
||||||
|
*/
|
||||||
|
export interface CollectWorkerLike {
|
||||||
|
readonly isSupported: boolean;
|
||||||
|
collectDsl(dsl: MApp): Promise<DepsData | null>;
|
||||||
|
collect(payload: CollectWorkerPayload): Promise<CollectWorkerResult | null>;
|
||||||
|
terminate(): void;
|
||||||
|
abort(): void;
|
||||||
|
}
|
||||||
|
// #endregion CollectWorkerLike
|
||||||
|
|
||||||
|
// #region ValidateMode
|
||||||
|
/**
|
||||||
|
* 校验模式
|
||||||
|
* - `strict`:修改前校验,不通过抛出 {@link DslValidationError},DSL 不被修改
|
||||||
|
* - `record`:只把校验结果写进 `invalidNodeIds`
|
||||||
|
* - `off`:不做额外校验
|
||||||
|
*/
|
||||||
|
export type ValidateMode = 'strict' | 'record' | 'off';
|
||||||
|
// #endregion ValidateMode
|
||||||
|
|
||||||
|
// #region ValidationError
|
||||||
|
export interface DslValidationIssue {
|
||||||
|
/** 出错对象类型 */
|
||||||
|
target: 'node' | 'data-source' | 'code-block' | 'dsl';
|
||||||
|
/** 出错对象 id */
|
||||||
|
id?: Id;
|
||||||
|
/** 出错属性路径,结构性错误时为空 */
|
||||||
|
path?: string;
|
||||||
|
/** 错误分类:structure 为结构合法性,props 为属性表单校验 */
|
||||||
|
kind: 'structure' | 'props';
|
||||||
|
message: string;
|
||||||
|
/** 可选的修正建议(如可选值列表) */
|
||||||
|
suggestion?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DslValidationResult {
|
||||||
|
valid: boolean;
|
||||||
|
errors: DslValidationIssue[];
|
||||||
|
}
|
||||||
|
// #endregion ValidationError
|
||||||
|
|
||||||
|
// #region SdkOptions
|
||||||
|
export interface SdkOptions {
|
||||||
|
/** 初始 DSL,会被深拷贝,不会修改传入的对象 */
|
||||||
|
dsl?: MApp | null;
|
||||||
|
/** 此前通过 `history.serialize()` 导出的历史快照 */
|
||||||
|
history?: import('@editor/type').PersistedHistoryState | null;
|
||||||
|
|
||||||
|
reactivity?: Partial<Reactivity>;
|
||||||
|
/** 存储适配器,默认内存实现 */
|
||||||
|
storage?: StorageLike;
|
||||||
|
/** 把序列化后的字符串还原成值(含函数),默认 `JSON.parse`;浏览器端传入 `eval` 实现 */
|
||||||
|
parseDSL?: (code: string) => any;
|
||||||
|
historyPersistence?: HistoryPersistence;
|
||||||
|
collectWorker?: CollectWorkerLike;
|
||||||
|
/** 提示信息,默认不做任何事 */
|
||||||
|
notify?: (type: 'success' | 'error' | 'warning' | 'info', message: string) => void;
|
||||||
|
/** 复制文本到剪贴板,默认不做任何事 */
|
||||||
|
copyText?: (text: string) => Promise<void> | void;
|
||||||
|
|
||||||
|
/** 校验模式,默认 `strict` */
|
||||||
|
validateMode?: ValidateMode;
|
||||||
|
/** 是否自动绑定依赖收集连线,默认 true;浏览器默认实例传 false,由 initService 挂载时绑定 */
|
||||||
|
autoWiring?: boolean;
|
||||||
|
|
||||||
|
// 登记表,与 EditorProps 同名项一一对应
|
||||||
|
componentGroupList?: ComponentGroup[];
|
||||||
|
propsConfigs?: Record<string, FormConfig | PropsFormConfigFunction>;
|
||||||
|
propsValues?: Record<string, Partial<MNode> | PropsFormValueFunction>;
|
||||||
|
eventMethodList?: Record<string, { events: EventOption[]; methods: EventOption[] }>;
|
||||||
|
datasourceList?: DatasourceTypeOption[];
|
||||||
|
datasourceConfigs?: Record<string, FormConfig>;
|
||||||
|
datasourceValues?: Record<string, Partial<DataSourceSchema>>;
|
||||||
|
datasourceEventMethodList?: Record<string, { events: EventOption[]; methods: EventOption[] }>;
|
||||||
|
disabledCodeBlock?: boolean;
|
||||||
|
disabledDataSource?: boolean;
|
||||||
|
disabledPageFragment?: boolean;
|
||||||
|
disabledMultiSelect?: boolean;
|
||||||
|
alwaysMultiSelect?: boolean;
|
||||||
|
canDropIn?: CanDropInFunction;
|
||||||
|
}
|
||||||
|
// #endregion SdkOptions
|
||||||
|
|
||||||
|
// #region SdkContext
|
||||||
|
/** service 之间互相访问的上下文,用懒 getter 解开循环依赖 */
|
||||||
|
export interface SdkContext {
|
||||||
|
readonly reactivity: Reactivity;
|
||||||
|
readonly storage: WebStorage;
|
||||||
|
readonly editor: Editor;
|
||||||
|
readonly props: Props;
|
||||||
|
readonly history: History;
|
||||||
|
readonly dataSource: DataSource;
|
||||||
|
readonly codeBlock: CodeBlock;
|
||||||
|
readonly dep: Dep;
|
||||||
|
readonly events: Events;
|
||||||
|
readonly componentList: ComponentList;
|
||||||
|
|
||||||
|
readonly options: Readonly<SdkOptions>;
|
||||||
|
/** 校验模式,可在运行时通过 `sdk.ctx.validateMode = ...` 切换 */
|
||||||
|
validateMode: ValidateMode;
|
||||||
|
/** 校验器,`createDslSdk` 之后可用 */
|
||||||
|
validator: Validator;
|
||||||
|
/** 登记表全部就绪后 resolve */
|
||||||
|
ready: Promise<void>;
|
||||||
|
parseDSL: (code: string) => any;
|
||||||
|
}
|
||||||
|
// #endregion SdkContext
|
||||||
196
packages/editor/src/sdk/utils/dep.ts
Normal file
196
packages/editor/src/sdk/utils/dep.ts
Normal file
@ -0,0 +1,196 @@
|
|||||||
|
/*
|
||||||
|
* Tencent is pleased to support the open source community by making TMagicEditor available.
|
||||||
|
*
|
||||||
|
* Copyright (C) 2025 Tencent. All rights reserved.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @fileoverview 依赖收集的纯规则:哪些变化需要重新收集、依赖目标怎么建、依赖信息怎么写回 root。
|
||||||
|
*
|
||||||
|
* SDK 的 `wiring`(同步收集)与浏览器端 `initService`(空闲队列收集 + 画布同步)共用,
|
||||||
|
* 两边只在「怎么收集」上不同,规则必须保持一致。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { CodeBlockDSL, DataSourceSchema, Id, MApp, MComponent, Target } from '@tmagic/core';
|
||||||
|
import {
|
||||||
|
createCodeBlockTarget,
|
||||||
|
createDataSourceCondTarget,
|
||||||
|
createDataSourceMethodTarget,
|
||||||
|
createDataSourceTarget,
|
||||||
|
DepTargetType,
|
||||||
|
NODE_CONDS_KEY,
|
||||||
|
NodeType,
|
||||||
|
} from '@tmagic/core';
|
||||||
|
import type { ChangeRecord } from '@tmagic/form';
|
||||||
|
import { isValueIncludeDataSource } from '@tmagic/utils';
|
||||||
|
|
||||||
|
import { isIncludeDataSource } from '@editor/utils/editor';
|
||||||
|
|
||||||
|
import type Dep from '../services/Dep';
|
||||||
|
import type { Reactivity } from '../type';
|
||||||
|
|
||||||
|
type DataSourceDepType = DepTargetType.DATA_SOURCE | DepTargetType.DATA_SOURCE_COND | DepTargetType.DATA_SOURCE_METHOD;
|
||||||
|
|
||||||
|
/** 数据源依赖类型 -> 写回 root 的字段 */
|
||||||
|
const DEPS_KEY = {
|
||||||
|
[DepTargetType.DATA_SOURCE]: 'dataSourceDeps',
|
||||||
|
[DepTargetType.DATA_SOURCE_COND]: 'dataSourceCondDeps',
|
||||||
|
[DepTargetType.DATA_SOURCE_METHOD]: 'dataSourceMethodDeps',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const DATA_SOURCE_DEP_TYPES: DataSourceDepType[] = [
|
||||||
|
DepTargetType.DATA_SOURCE,
|
||||||
|
DepTargetType.DATA_SOURCE_COND,
|
||||||
|
DepTargetType.DATA_SOURCE_METHOD,
|
||||||
|
];
|
||||||
|
|
||||||
|
const DATA_SOURCE_TARGET_CREATORS = {
|
||||||
|
[DepTargetType.DATA_SOURCE]: createDataSourceTarget,
|
||||||
|
[DepTargetType.DATA_SOURCE_COND]: createDataSourceCondTarget,
|
||||||
|
[DepTargetType.DATA_SOURCE_METHOD]: createDataSourceMethodTarget,
|
||||||
|
};
|
||||||
|
|
||||||
|
const isDataSourceDepType = (type: string): type is DataSourceDepType => type in DEPS_KEY;
|
||||||
|
|
||||||
|
type UpdatedItem = { newNode: MComponent; oldNode: MComponent; changeRecords?: ChangeRecord[] };
|
||||||
|
|
||||||
|
/** 显示条件(`NODE_CONDS_KEY`)相关的字段 */
|
||||||
|
const isCondsPath = (propPath: string) =>
|
||||||
|
new RegExp(`${NODE_CONDS_KEY}.(\\d)+.cond`).test(propPath) ||
|
||||||
|
new RegExp(`${NODE_CONDS_KEY}.(\\d)+.cond.(\\d)+.value`).test(propPath) ||
|
||||||
|
propPath === NODE_CONDS_KEY;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断一批已更新的节点哪些需要重新收集数据源依赖。
|
||||||
|
*
|
||||||
|
* - 根节点、数据源相关的字段变化、修改的 key 已经在收集到的依赖中:需要重新收集
|
||||||
|
* - 其余只需要收集代码块、数据源方法依赖
|
||||||
|
*/
|
||||||
|
export const classifyUpdatedNodes = (
|
||||||
|
ctx: { dep: Pick<Dep, 'getTargets'> },
|
||||||
|
data: UpdatedItem[],
|
||||||
|
): { needRecollectNodes: MComponent[]; normalNodes: MComponent[] } => {
|
||||||
|
const needRecollectNodes: MComponent[] = [];
|
||||||
|
const normalNodes: MComponent[] = [];
|
||||||
|
|
||||||
|
const needRecollect = ({ newNode, oldNode, changeRecords }: UpdatedItem) => {
|
||||||
|
if (!changeRecords?.length) return isIncludeDataSource(newNode, oldNode);
|
||||||
|
|
||||||
|
const dataSourceTargets = Object.values(ctx.dep.getTargets(DepTargetType.DATA_SOURCE) || {});
|
||||||
|
|
||||||
|
return changeRecords.some(({ propPath, value }) => {
|
||||||
|
if (!propPath || isCondsPath(propPath) || isValueIncludeDataSource(value)) return true;
|
||||||
|
|
||||||
|
// 修改的key在收集的依赖中,则需要触发重新收集
|
||||||
|
return dataSourceTargets.some((target) => target.deps[newNode.id]?.keys.includes(propPath));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const item of data) {
|
||||||
|
// 同一节点只归入其中一类
|
||||||
|
const isRecollect = item.newNode.type !== NodeType.ROOT && needRecollect(item);
|
||||||
|
(isRecollect ? needRecollectNodes : normalNodes).push(item.newNode);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { needRecollectNodes, normalNodes };
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 数据源变化规则:命中的 propPath 需要重建并重新收集哪些依赖 */
|
||||||
|
const DATA_SOURCE_RECOLLECT_RULES: { types: DataSourceDepType[]; test: (propPath: string) => boolean }[] = [
|
||||||
|
{
|
||||||
|
types: [DepTargetType.DATA_SOURCE, DepTargetType.DATA_SOURCE_COND],
|
||||||
|
test: (path) =>
|
||||||
|
path === 'fields' ||
|
||||||
|
/fields.(\d)+.name/.test(path) ||
|
||||||
|
/fields.(\d)+.defaultValue/.test(path) ||
|
||||||
|
/fields.(\d)+$/.test(path),
|
||||||
|
},
|
||||||
|
{ types: [DepTargetType.DATA_SOURCE], test: (path) => path === 'mocks' },
|
||||||
|
{
|
||||||
|
types: [DepTargetType.DATA_SOURCE_METHOD],
|
||||||
|
test: (path) => path === 'methods' || /methods.(\d)+.name/.test(path) || /methods.(\d)+$/.test(path),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 数据源更新后需要重建 / 重新收集的依赖类型;没有影响依赖的变化时返回 `null`。
|
||||||
|
* 按变更记录顺序,取第一条命中规则的记录。
|
||||||
|
*/
|
||||||
|
export const getDataSourceRecollectTypes = (changeRecords: ChangeRecord[]): DataSourceDepType[] | null => {
|
||||||
|
for (const { propPath } of changeRecords) {
|
||||||
|
if (!propPath) continue;
|
||||||
|
|
||||||
|
const rule = DATA_SOURCE_RECOLLECT_RULES.find(({ test }) => test(propPath));
|
||||||
|
if (rule) return rule.types;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 为数据源创建依赖目标(默认三类都建),`reactive` 用于使依赖结果在浏览器端可被订阅 */
|
||||||
|
export const addDataSourceTargets = (
|
||||||
|
dep: Pick<Dep, 'addTarget'>,
|
||||||
|
ds: DataSourceSchema,
|
||||||
|
reactive: Reactivity['reactive'],
|
||||||
|
types: DataSourceDepType[] = DATA_SOURCE_DEP_TYPES,
|
||||||
|
) => {
|
||||||
|
for (const type of types) {
|
||||||
|
dep.addTarget(DATA_SOURCE_TARGET_CREATORS[type](ds, reactive({})));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const removeDataSourceTargets = (
|
||||||
|
dep: Pick<Dep, 'removeTarget'>,
|
||||||
|
id: string,
|
||||||
|
types: DataSourceDepType[] = DATA_SOURCE_DEP_TYPES,
|
||||||
|
) => {
|
||||||
|
for (const type of types) {
|
||||||
|
dep.removeTarget(id, type);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** root 变化后重建所有依赖目标:代码块 + 数据源 */
|
||||||
|
export const resetDepTargets = (
|
||||||
|
dep: Pick<Dep, 'clearTargets' | 'addTarget'>,
|
||||||
|
codeBlocks: CodeBlockDSL,
|
||||||
|
dataSources: DataSourceSchema[],
|
||||||
|
reactive: Reactivity['reactive'],
|
||||||
|
) => {
|
||||||
|
dep.clearTargets();
|
||||||
|
|
||||||
|
for (const [id, code] of Object.entries(codeBlocks)) {
|
||||||
|
dep.addTarget(createCodeBlockTarget(id, code));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const ds of dataSources) {
|
||||||
|
addDataSourceTargets(dep, ds, reactive);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 依赖目标新增后,把依赖信息写回 root */
|
||||||
|
export const writeTargetDeps = (root: MApp | null | undefined, target: Target) => {
|
||||||
|
if (!root || !isDataSourceDepType(target.type)) return;
|
||||||
|
|
||||||
|
const key = DEPS_KEY[target.type];
|
||||||
|
root[key] = root[key] || {};
|
||||||
|
root[key]![target.id] = target.deps;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 依赖目标移除后,从 root 上清掉对应的依赖信息 */
|
||||||
|
export const removeTargetDeps = (root: MApp | null | undefined, id: Id, type: string) => {
|
||||||
|
if (!root || !isDataSourceDepType(type)) return;
|
||||||
|
|
||||||
|
delete root[DEPS_KEY[type]]?.[id];
|
||||||
|
};
|
||||||
114
packages/editor/src/sdk/utils/operator.ts
Normal file
114
packages/editor/src/sdk/utils/operator.ts
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
import { isEmpty } from 'lodash-es';
|
||||||
|
|
||||||
|
import type { Id, MContainer, MNode } from '@tmagic/core';
|
||||||
|
import { NodeType } from '@tmagic/core';
|
||||||
|
import { calcValueByFontsize, getElById, isPage, isPageOrFragment } from '@tmagic/utils';
|
||||||
|
|
||||||
|
import type { AddMNode, PastePosition } from '@editor/type';
|
||||||
|
import { generatePageNameByApp, getInitPositionStyle } from '@editor/utils/editor';
|
||||||
|
|
||||||
|
import type { SdkContext } from '../type';
|
||||||
|
|
||||||
|
type OperatorContext = Pick<SdkContext, 'editor' | 'props' | 'reactivity'>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 粘贴前置操作:返回分配了新id以及校准了坐标的配置
|
||||||
|
* @param position 粘贴的坐标
|
||||||
|
* @param config 待粘贴的元素配置(复制时保存的那份配置)
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
export const beforePaste = (
|
||||||
|
ctx: OperatorContext,
|
||||||
|
position: PastePosition,
|
||||||
|
config: MNode[],
|
||||||
|
doc?: Document,
|
||||||
|
): MNode[] => {
|
||||||
|
if (!config[0]?.style) return config;
|
||||||
|
const curNode = ctx.editor.get('node');
|
||||||
|
// 将数组中第一个元素的坐标作为参照点
|
||||||
|
const { left: referenceLeft, top: referenceTop } = config[0].style;
|
||||||
|
// 坐标校准后的粘贴数据
|
||||||
|
const pasteConfigs: MNode[] = config.map((configItem: MNode): MNode => {
|
||||||
|
// 解构 position 对象,从 position 删除 offsetX、offsetY字段
|
||||||
|
const { offsetX = 0, offsetY = 0, ...positionClone } = position;
|
||||||
|
let pastePosition = positionClone;
|
||||||
|
|
||||||
|
if (!isEmpty(pastePosition) && curNode?.items) {
|
||||||
|
// 如果没有传入粘贴坐标则可能为键盘操作,不再转换
|
||||||
|
// 如果粘贴时选中了容器,则将元素粘贴到容器内,坐标需要转换为相对于容器的坐标
|
||||||
|
pastePosition = getPositionInContainer(ctx, pastePosition, curNode.id, doc);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 将所有待粘贴元素坐标相对于多选第一个元素坐标重新计算,以保证多选粘贴后元素间距不变
|
||||||
|
if (pastePosition.left && configItem.style?.left) {
|
||||||
|
pastePosition.left = configItem.style.left - referenceLeft + pastePosition.left;
|
||||||
|
}
|
||||||
|
if (pastePosition.top && configItem.style?.top) {
|
||||||
|
pastePosition.top = configItem.style?.top - referenceTop + pastePosition.top;
|
||||||
|
}
|
||||||
|
const pasteConfig = ctx.props.setNewItemId(configItem, false);
|
||||||
|
|
||||||
|
if (pasteConfig.style) {
|
||||||
|
const { left, top } = pasteConfig.style;
|
||||||
|
// 判断能转换为数字时,做粘贴偏移量计算
|
||||||
|
if (typeof left === 'number' || (!!left && !isNaN(Number(left)))) {
|
||||||
|
pasteConfig.style.left = Number(left) + offsetX;
|
||||||
|
}
|
||||||
|
if (typeof top === 'number' || (!!top && !isNaN(Number(top)))) {
|
||||||
|
pasteConfig.style.top = Number(top) + offsetY;
|
||||||
|
}
|
||||||
|
|
||||||
|
pasteConfig.style = {
|
||||||
|
...pasteConfig.style,
|
||||||
|
...pastePosition,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const root = ctx.editor.get('root');
|
||||||
|
if (isPageOrFragment(pasteConfig) && root) {
|
||||||
|
pasteConfig.name = generatePageNameByApp(root, isPage(pasteConfig) ? NodeType.PAGE : NodeType.PAGE_FRAGMENT);
|
||||||
|
}
|
||||||
|
return pasteConfig as MNode;
|
||||||
|
});
|
||||||
|
return pasteConfigs;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将元素粘贴到容器内时,将相对于画布坐标转换为相对于容器的坐标
|
||||||
|
* @param position PastePosition 粘贴时相对于画布的坐标
|
||||||
|
* @param id 元素id
|
||||||
|
* @returns PastePosition 转换后的坐标
|
||||||
|
*/
|
||||||
|
export const getPositionInContainer = (ctx: OperatorContext, position: PastePosition = {}, id: Id, doc?: Document) => {
|
||||||
|
let { left = 0, top = 0 } = position;
|
||||||
|
const stageDoc = ctx.editor.get('stage')?.renderer?.contentWindow?.document;
|
||||||
|
const parentEl = stageDoc && getElById()(stageDoc, `${id}`);
|
||||||
|
const parentElRect = parentEl?.getBoundingClientRect();
|
||||||
|
left = left - calcValueByFontsize(doc, parentElRect?.left || 0);
|
||||||
|
top = top - calcValueByFontsize(doc, parentElRect?.top || 0);
|
||||||
|
return {
|
||||||
|
left,
|
||||||
|
top,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getAddParent = (ctx: OperatorContext, node: MNode) => {
|
||||||
|
const curNode = ctx.editor.get('node');
|
||||||
|
|
||||||
|
let parentNode;
|
||||||
|
if (isPage(node)) {
|
||||||
|
parentNode = ctx.editor.get('root');
|
||||||
|
} else if (curNode?.items) {
|
||||||
|
parentNode = curNode as MContainer;
|
||||||
|
} else if (curNode?.id) {
|
||||||
|
parentNode = ctx.editor.getParentById(curNode.id, false);
|
||||||
|
}
|
||||||
|
return parentNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getDefaultConfig = async (ctx: OperatorContext, addNode: AddMNode, parentNode: MContainer) => {
|
||||||
|
const { type, inputEvent, ...config } = addNode;
|
||||||
|
const layout = await ctx.editor.getLayout(ctx.reactivity.toRaw(parentNode), addNode as MNode);
|
||||||
|
const newNode = { ...ctx.reactivity.toRaw(await ctx.props.getPropsValue(type, config)) };
|
||||||
|
newNode.style = getInitPositionStyle(newNode.style, layout);
|
||||||
|
return newNode;
|
||||||
|
};
|
||||||
23
packages/editor/src/sdk/utils/position.ts
Normal file
23
packages/editor/src/sdk/utils/position.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
/*
|
||||||
|
* Tencent is pleased to support the open source community by making TMagicEditor available.
|
||||||
|
*
|
||||||
|
* Copyright (C) 2025 Tencent. All rights reserved.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否是 fixed 定位。与 `@tmagic/stage` 的同名函数等价,在这里单独实现,
|
||||||
|
* 避免 SDK 在 Node 下为了这一行逻辑引入整个画布依赖。
|
||||||
|
*/
|
||||||
|
export const isFixed = (style: { position?: string }): boolean => style.position === 'fixed';
|
||||||
368
packages/editor/src/sdk/utils/props.ts
Normal file
368
packages/editor/src/sdk/utils/props.ts
Normal file
@ -0,0 +1,368 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/naming-convention */
|
||||||
|
/*
|
||||||
|
* Tencent is pleased to support the open source community by making TMagicEditor available.
|
||||||
|
*
|
||||||
|
* Copyright (C) 2025 Tencent. All rights reserved.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
HookType,
|
||||||
|
NODE_CONDS_KEY,
|
||||||
|
NODE_CONDS_RESULT_KEY,
|
||||||
|
NODE_DISABLE_CODE_BLOCK_KEY,
|
||||||
|
NODE_DISABLE_DATA_SOURCE_KEY,
|
||||||
|
} from '@tmagic/core';
|
||||||
|
import type { ChildConfig, DisplayCondsConfig, FormConfig, TabConfig, TabPaneConfig } from '@tmagic/form';
|
||||||
|
|
||||||
|
export const arrayOptions = [
|
||||||
|
{ text: '包含', value: 'include' },
|
||||||
|
{ text: '不包含', value: 'not_include' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const eqOptions = [
|
||||||
|
{ text: '等于', value: '=' },
|
||||||
|
{ text: '不等于', value: '!=' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const numberOptions = [
|
||||||
|
{ text: '大于', value: '>' },
|
||||||
|
{ text: '大于等于', value: '>=' },
|
||||||
|
{ text: '小于', value: '<' },
|
||||||
|
{ text: '小于等于', value: '<=' },
|
||||||
|
{ text: '在范围内', value: 'between' },
|
||||||
|
{ text: '不在范围内', value: 'not_between' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const booleanOptions = [
|
||||||
|
{ text: '是', value: 'is' },
|
||||||
|
{ text: '不是', value: 'not' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** 按字段类型返回条件运算符选项(UI 与 typeMatch 校验共用) */
|
||||||
|
export const getCondOpOptionsByFieldType = (type: string) => {
|
||||||
|
if (type === 'array') {
|
||||||
|
return arrayOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'boolean' || type === 'null') {
|
||||||
|
return booleanOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'number') {
|
||||||
|
return [...eqOptions, ...numberOptions];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'string') {
|
||||||
|
return [...arrayOptions, ...eqOptions];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...arrayOptions, ...eqOptions, ...numberOptions];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const styleTabConfig: TabPaneConfig = {
|
||||||
|
title: '样式',
|
||||||
|
display: ({ services }: any) => !(services?.uiService?.get('showStylePanel') ?? true),
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
name: 'style',
|
||||||
|
labelWidth: '100px',
|
||||||
|
type: 'style-setter',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
names: [
|
||||||
|
'display',
|
||||||
|
'flexDirection',
|
||||||
|
'justifyContent',
|
||||||
|
'alignItems',
|
||||||
|
'flexWrap',
|
||||||
|
'marginTop',
|
||||||
|
'marginRight',
|
||||||
|
'marginBottom',
|
||||||
|
'marginLeft',
|
||||||
|
'paddingTop',
|
||||||
|
'paddingRight',
|
||||||
|
'paddingBottom',
|
||||||
|
'paddingLeft',
|
||||||
|
'width',
|
||||||
|
'height',
|
||||||
|
'overflow',
|
||||||
|
'fontSize',
|
||||||
|
'lineHeight',
|
||||||
|
'fontWeight',
|
||||||
|
'color',
|
||||||
|
'textAlign',
|
||||||
|
'backgroundColor',
|
||||||
|
'backgroundImage',
|
||||||
|
'backgroundSize',
|
||||||
|
'backgroundPosition',
|
||||||
|
'backgroundRepeat',
|
||||||
|
'position',
|
||||||
|
'zIndex',
|
||||||
|
'top',
|
||||||
|
'right',
|
||||||
|
'bottom',
|
||||||
|
'left',
|
||||||
|
'borderRadius',
|
||||||
|
'borderTopWidth',
|
||||||
|
'borderTopStyle',
|
||||||
|
'borderTopColor',
|
||||||
|
'borderRightColor',
|
||||||
|
'borderRightWidth',
|
||||||
|
'borderRightStyle',
|
||||||
|
'borderRightColor',
|
||||||
|
'borderBottomWidth',
|
||||||
|
'borderBottomStyle',
|
||||||
|
'borderBottomColor',
|
||||||
|
'borderLeftStyle',
|
||||||
|
'borderLeftWidth',
|
||||||
|
'borderLeftColor',
|
||||||
|
'borderWidth',
|
||||||
|
'borderStyle',
|
||||||
|
'borderColor',
|
||||||
|
'opacity',
|
||||||
|
],
|
||||||
|
} as unknown as ChildConfig,
|
||||||
|
{
|
||||||
|
name: 'transform',
|
||||||
|
defaultValue: () => ({}),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const eventTabConfig: TabPaneConfig = {
|
||||||
|
title: '事件',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
name: 'events',
|
||||||
|
src: 'component',
|
||||||
|
labelWidth: '100px',
|
||||||
|
type: 'event-select',
|
||||||
|
defaultValue: [],
|
||||||
|
rules: [{ typeMatch: true }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const advancedTabConfig: TabPaneConfig = {
|
||||||
|
title: '高级',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
name: NODE_DISABLE_CODE_BLOCK_KEY,
|
||||||
|
text: '禁用代码块',
|
||||||
|
type: 'switch',
|
||||||
|
defaultValue: false,
|
||||||
|
extra: '开启后,配置的代码块将不会被执行',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: NODE_DISABLE_DATA_SOURCE_KEY,
|
||||||
|
text: '禁用数据源',
|
||||||
|
type: 'switch',
|
||||||
|
defaultValue: false,
|
||||||
|
extra: '开启后,组件内配置的数据源相关配置将不会被编译,显隐条件将失效',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'created',
|
||||||
|
text: 'Created',
|
||||||
|
labelPosition: 'top',
|
||||||
|
type: 'code-select',
|
||||||
|
titleExtra: '组件初始化时执行',
|
||||||
|
rules: [
|
||||||
|
{ typeMatch: true, trigger: 'change' },
|
||||||
|
{
|
||||||
|
validator: ({ value, callback }: any) => {
|
||||||
|
if (value && value.hookType !== HookType.CODE) {
|
||||||
|
return callback('hookType 必须是 code');
|
||||||
|
}
|
||||||
|
callback();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'mounted',
|
||||||
|
text: 'Mounted',
|
||||||
|
labelPosition: 'top',
|
||||||
|
type: 'code-select',
|
||||||
|
titleExtra: '组件挂载到dom时执行',
|
||||||
|
rules: [
|
||||||
|
{ typeMatch: true, trigger: 'change' },
|
||||||
|
{
|
||||||
|
validator: ({ value, callback }: any) => {
|
||||||
|
if (value && value.hookType !== HookType.CODE) {
|
||||||
|
return callback('hookType 必须是 code');
|
||||||
|
}
|
||||||
|
callback();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'display',
|
||||||
|
text: 'Display',
|
||||||
|
titleExtra: '控制组件是否渲染,关系的代码块返回值为false时不渲染',
|
||||||
|
labelPosition: 'top',
|
||||||
|
type: 'code-select',
|
||||||
|
rules: [
|
||||||
|
{ typeMatch: true, trigger: 'change' },
|
||||||
|
{
|
||||||
|
validator: ({ value, callback }: any) => {
|
||||||
|
if (value && value.hookType !== HookType.CODE) {
|
||||||
|
return callback('hookType 必须是 code');
|
||||||
|
}
|
||||||
|
callback();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const displayTabConfig: TabPaneConfig<DisplayCondsConfig> = {
|
||||||
|
title: '显示条件',
|
||||||
|
display: (_state, { model }) => model.type !== 'page',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
name: NODE_CONDS_RESULT_KEY,
|
||||||
|
type: 'select',
|
||||||
|
text: '条件成立时',
|
||||||
|
defaultValue: false,
|
||||||
|
options: [
|
||||||
|
{ text: '显示', value: false },
|
||||||
|
{ text: '隐藏', value: true },
|
||||||
|
],
|
||||||
|
extra: (_state, { model }) =>
|
||||||
|
`条件成立时${model[NODE_CONDS_RESULT_KEY] ? '隐藏' : '显示'},不成立时${model[NODE_CONDS_RESULT_KEY] ? '显示' : '隐藏'};<br />同一条件组内的所有条件配置同时成立时表示该条件组成立,任意一个条件组成立时表示条件成立(条件组内为且的关系,条件组间为或的关系);<br />条件为空时表示成立;`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: '条件组配置',
|
||||||
|
static: true,
|
||||||
|
className: 'display-conds-title',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'display-conds',
|
||||||
|
name: NODE_CONDS_KEY,
|
||||||
|
titlePrefix: '条件组',
|
||||||
|
defaultValue: [],
|
||||||
|
rules: [{ typeMatch: true }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统一为组件属性表单加上事件、高级、样式配置
|
||||||
|
* @param config 组件属性配置
|
||||||
|
* @returns Object
|
||||||
|
*/
|
||||||
|
export interface FillConfigOptions {
|
||||||
|
labelWidth?: string;
|
||||||
|
disabledDataSource?: boolean;
|
||||||
|
disabledCodeBlock?: boolean;
|
||||||
|
/** 点击 ID 后面的复制按钮时调用,Node 下不提供则按钮不做任何事 */
|
||||||
|
copyText?: (text: string) => Promise<void> | void;
|
||||||
|
/** 复制结果提示 */
|
||||||
|
notify?: (type: 'success' | 'error', message: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const fillConfig = (
|
||||||
|
config: FormConfig = [],
|
||||||
|
{
|
||||||
|
labelWidth = '80px',
|
||||||
|
disabledDataSource = false,
|
||||||
|
disabledCodeBlock = false,
|
||||||
|
copyText,
|
||||||
|
notify,
|
||||||
|
}: FillConfigOptions = {},
|
||||||
|
): FormConfig => {
|
||||||
|
const propsConfig: FormConfig = [];
|
||||||
|
|
||||||
|
// 组件类型,必须要有
|
||||||
|
if (!config.find((item) => 'name' in item && item.name === 'type')) {
|
||||||
|
propsConfig.push({
|
||||||
|
text: 'type',
|
||||||
|
name: 'type',
|
||||||
|
type: 'hidden',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!config.find((item) => 'name' in item && item.name === 'id')) {
|
||||||
|
// 组件id,必须要有
|
||||||
|
propsConfig.push({
|
||||||
|
name: 'id',
|
||||||
|
text: 'ID',
|
||||||
|
type: 'text',
|
||||||
|
// 走纯文本渲染,避免出现 disabled 的灰底输入框;append 仍正常显示。
|
||||||
|
static: true,
|
||||||
|
|
||||||
|
append: {
|
||||||
|
type: 'icon',
|
||||||
|
text: 'https://vip.image.video.qpic.cn/vupload/20260615/36cf7e1781493669935.svg',
|
||||||
|
extra: '复制',
|
||||||
|
handler: (vm, { model }) => {
|
||||||
|
if (!copyText) return;
|
||||||
|
|
||||||
|
Promise.resolve(copyText(`${model.id}`))
|
||||||
|
.then(() => {
|
||||||
|
notify?.('success', '已复制');
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
notify?.('error', '复制失败');
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!config.find((item) => 'name' in item && item.name === 'name')) {
|
||||||
|
propsConfig.push({
|
||||||
|
name: 'name',
|
||||||
|
text: '组件名称',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const noCodeAdvancedTabItems = advancedTabConfig.items.filter(
|
||||||
|
(item) => 'type' in item && item.type !== 'code-select',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (noCodeAdvancedTabItems.length > 0 && disabledCodeBlock) {
|
||||||
|
advancedTabConfig.items = noCodeAdvancedTabItems;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tabConfig: TabConfig = {
|
||||||
|
type: 'tab',
|
||||||
|
labelWidth,
|
||||||
|
className: 'magic-right-panel-tabs-top',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
title: '属性',
|
||||||
|
items: [...propsConfig, ...config],
|
||||||
|
},
|
||||||
|
{ ...styleTabConfig },
|
||||||
|
{ ...eventTabConfig },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!disabledCodeBlock) {
|
||||||
|
tabConfig.items.push({ ...advancedTabConfig });
|
||||||
|
} else if (noCodeAdvancedTabItems.length > 0) {
|
||||||
|
tabConfig.items.push({ ...advancedTabConfig });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!disabledDataSource) {
|
||||||
|
tabConfig.items.push({ ...displayTabConfig });
|
||||||
|
}
|
||||||
|
|
||||||
|
return [tabConfig];
|
||||||
|
};
|
||||||
501
packages/editor/src/sdk/validate/index.ts
Normal file
501
packages/editor/src/sdk/validate/index.ts
Normal file
@ -0,0 +1,501 @@
|
|||||||
|
/*
|
||||||
|
* Tencent is pleased to support the open source community by making TMagicEditor available.
|
||||||
|
*
|
||||||
|
* Copyright (C) 2025 Tencent. All rights reserved.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @fileoverview DSL 校验:
|
||||||
|
*
|
||||||
|
* - 结构合法性:组件类型是否在组件列表中、父节点是否是容器、`canDropIn`、
|
||||||
|
* 页面片是否被禁用、id 是否重复、数据源类型是否可用;
|
||||||
|
* - 属性合法性:用 `@tmagic/form/headless` 的 `validateForm` 校验节点 / 数据源的表单配置。
|
||||||
|
*
|
||||||
|
* 模式见 {@link ValidateMode}:`strict` 抛 {@link DslValidationError},`record` 写入
|
||||||
|
* `editor.state.invalidNodeIds`,`off` 什么也不做。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { cloneDeep, mergeWith } from 'lodash-es';
|
||||||
|
|
||||||
|
import type { DataSourceSchema, Id, MContainer, MNode } from '@tmagic/core';
|
||||||
|
import { NodeType } from '@tmagic/core';
|
||||||
|
import type { FormConfig } from '@tmagic/form/headless';
|
||||||
|
import { builtInFields, registerBuiltInFields, validateForm } from '@tmagic/form/headless';
|
||||||
|
import { isPage, isPageFragment, isPageOrFragment, toLine, traverseNode } from '@tmagic/utils';
|
||||||
|
|
||||||
|
import type { CanDropInScene } from '@editor/type';
|
||||||
|
import { editorNodeMergeCustomizer } from '@editor/utils/editor';
|
||||||
|
|
||||||
|
import type { DslValidationIssue, DslValidationResult, SdkContext } from '../type';
|
||||||
|
|
||||||
|
/** 校验不通过时抛出,`errors` 里是全部问题,调用方(如 agent)可据此修正后重试 */
|
||||||
|
export class DslValidationError extends Error {
|
||||||
|
public readonly errors: DslValidationIssue[];
|
||||||
|
|
||||||
|
constructor(errors: DslValidationIssue[]) {
|
||||||
|
super(
|
||||||
|
`DSL 校验失败:${errors
|
||||||
|
.map((item) => `${item.id !== undefined ? `[${item.target}:${item.id}] ` : ''}${item.message}`)
|
||||||
|
.join(';')}`,
|
||||||
|
);
|
||||||
|
this.name = 'DslValidationError';
|
||||||
|
this.errors = errors;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 内置的、不需要出现在组件列表里也合法的节点类型 */
|
||||||
|
const BUILTIN_NODE_TYPES = new Set<string>([NodeType.ROOT, NodeType.PAGE, NodeType.PAGE_FRAGMENT, 'container']);
|
||||||
|
|
||||||
|
/** 内置的数据源类型 */
|
||||||
|
const BUILTIN_DATA_SOURCE_TYPES = new Set<string>(['base', 'http']);
|
||||||
|
|
||||||
|
const BR_REG = /<br\s*\/?>/gi;
|
||||||
|
|
||||||
|
/** `validateForm` 返回的是以 `<br>` 分隔的文案,拆成单条问题 */
|
||||||
|
const splitErrorText = (error: string): string[] =>
|
||||||
|
error
|
||||||
|
.split(BR_REG)
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `record` 模式下,校验结果要等到修改落进 DSL 之后再写入 `invalidNodeIds`(修改过程中会先清掉旧记录),
|
||||||
|
* 因此 guard 返回一个提交函数交给调用方在合适的时机调用;`strict` 抛错,`off` 返回空。
|
||||||
|
*/
|
||||||
|
export type GuardCommit = (() => void) | undefined;
|
||||||
|
|
||||||
|
export interface Validator {
|
||||||
|
/** 校验 DSL 中已有节点:结构(类型 / 父子关系)+ 属性表单 */
|
||||||
|
node(id: Id): Promise<DslValidationResult>;
|
||||||
|
/** 校验 DSL 中已有的数据源 */
|
||||||
|
dataSource(id: Id): Promise<DslValidationResult>;
|
||||||
|
/** 校验整份 DSL */
|
||||||
|
dsl(): Promise<DslValidationResult>;
|
||||||
|
|
||||||
|
/** 校验一个节点配置的属性表单(不要求它已经在 DSL 中) */
|
||||||
|
checkNodeProps(node: MNode): Promise<DslValidationIssue[]>;
|
||||||
|
/** 同步的结构校验:新增 */
|
||||||
|
checkAddStructure(node: MNode, parent: MContainer | null | undefined): DslValidationIssue[];
|
||||||
|
/** 同步的结构校验:移动 */
|
||||||
|
checkMoveStructure(sourceIds: Id[], target: MContainer, scene: CanDropInScene): DslValidationIssue[];
|
||||||
|
/** 同步的结构校验:数据源配置 */
|
||||||
|
checkDataSourceStructure(config: DataSourceSchema, isNew: boolean): DslValidationIssue[];
|
||||||
|
|
||||||
|
/** 供 service 在修改前调用:按 `validateMode` 抛出 / 记录 / 忽略。 */
|
||||||
|
guardAdd(items: { node: MNode; parentNode: MContainer }[]): Promise<GuardCommit>;
|
||||||
|
/** `replace` 与 `editor.update` 的同名选项一致:为 true 时整节点替换,否则与旧节点深合并 */
|
||||||
|
guardUpdate(nodes: MNode[], options?: { replace?: boolean }): Promise<GuardCommit>;
|
||||||
|
guardMove(sourceIds: Id[], target: MContainer, scene: CanDropInScene): Promise<void>;
|
||||||
|
guardDataSource(config: DataSourceSchema, isNew: boolean): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const toResult = (errors: DslValidationIssue[]): DslValidationResult => ({
|
||||||
|
valid: errors.length === 0,
|
||||||
|
errors,
|
||||||
|
});
|
||||||
|
|
||||||
|
const structureIssue = (
|
||||||
|
target: DslValidationIssue['target'],
|
||||||
|
id: Id | undefined,
|
||||||
|
message: string,
|
||||||
|
extra: Partial<DslValidationIssue> = {},
|
||||||
|
): DslValidationIssue => ({ target, id, kind: 'structure', message, ...extra });
|
||||||
|
|
||||||
|
const nodeIssue = (id: Id | undefined, message: string, extra?: Partial<DslValidationIssue>) =>
|
||||||
|
structureIssue('node', id, message, extra);
|
||||||
|
|
||||||
|
/** 节点及其所有子孙(先序) */
|
||||||
|
const flattenNode = (node: MNode): MNode[] => {
|
||||||
|
const nodes: MNode[] = [];
|
||||||
|
traverseNode(node, (item) => {
|
||||||
|
nodes.push(item);
|
||||||
|
});
|
||||||
|
return nodes;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isEmptyId = (id: Id | undefined) => id === undefined || id === null || id === '';
|
||||||
|
|
||||||
|
const DUPLICATE_ID_SUGGESTION = '请使用唯一的 id,或不传 id 由编辑器自动生成';
|
||||||
|
|
||||||
|
export const createValidator = (ctx: SdkContext): Validator => {
|
||||||
|
const mode = () => ctx.validateMode;
|
||||||
|
|
||||||
|
let builtInRegistered = false;
|
||||||
|
const ensureFields = () => {
|
||||||
|
if (builtInRegistered) return;
|
||||||
|
registerBuiltInFields(builtInFields);
|
||||||
|
builtInRegistered = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
let cachedFormContext: object | undefined;
|
||||||
|
const formContext = () =>
|
||||||
|
(cachedFormContext ??= {
|
||||||
|
services: {
|
||||||
|
editorService: ctx.editor,
|
||||||
|
propsService: ctx.props,
|
||||||
|
historyService: ctx.history,
|
||||||
|
dataSourceService: ctx.dataSource,
|
||||||
|
codeBlockService: ctx.codeBlock,
|
||||||
|
depService: ctx.dep,
|
||||||
|
storageService: ctx.storage,
|
||||||
|
eventsService: ctx.events,
|
||||||
|
componentListService: ctx.componentList,
|
||||||
|
},
|
||||||
|
stage: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const knownTypes = (): Set<string> | null => {
|
||||||
|
const set = new Set<string>();
|
||||||
|
for (const group of ctx.componentList.getList()) {
|
||||||
|
for (const item of group.items ?? []) {
|
||||||
|
set.add(toLine(item.type));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 没有登记组件列表时无从判断,不做类型校验
|
||||||
|
return set.size ? set : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isKnownType = (type: string, known: Set<string> | null) =>
|
||||||
|
BUILTIN_NODE_TYPES.has(type) || BUILTIN_NODE_TYPES.has(toLine(type)) || !known || known.has(toLine(type));
|
||||||
|
|
||||||
|
/** 节点类型合法性:在组件列表中、没有被禁用的页面片 */
|
||||||
|
const checkNodeType = (node: MNode, known = knownTypes()): DslValidationIssue[] => {
|
||||||
|
const issues: DslValidationIssue[] = [];
|
||||||
|
|
||||||
|
if (!isKnownType(node.type || '', known)) {
|
||||||
|
issues.push(
|
||||||
|
nodeIssue(node.id, `组件类型「${node.type}」不在组件列表中`, {
|
||||||
|
path: 'type',
|
||||||
|
suggestion: `可用类型:${[...(known ?? [])].join('、')}`,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPageFragment(node) && ctx.editor.getConfig().disabledPageFragment) {
|
||||||
|
issues.push(nodeIssue(node.id, '当前配置禁用了页面片(disabledPageFragment)', { path: 'type' }));
|
||||||
|
}
|
||||||
|
|
||||||
|
return issues;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 新增的节点 id 在这批节点内、或与当前 DSL 里已有节点重复 */
|
||||||
|
const findDuplicateIds = (nodes: MNode[]): DslValidationIssue[] => {
|
||||||
|
const issues: DslValidationIssue[] = [];
|
||||||
|
const seen = new Set<Id>();
|
||||||
|
|
||||||
|
for (const { id } of nodes.flatMap(flattenNode)) {
|
||||||
|
if (isEmptyId(id)) continue;
|
||||||
|
|
||||||
|
if (seen.has(id)) {
|
||||||
|
issues.push(nodeIssue(id, `节点 id「${id}」重复`, { suggestion: DUPLICATE_ID_SUGGESTION }));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
seen.add(id);
|
||||||
|
if (ctx.editor.getNodeById(id, false)) {
|
||||||
|
issues.push(nodeIssue(id, `节点 id「${id}」已存在于当前 DSL`, { suggestion: DUPLICATE_ID_SUGGESTION }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return issues;
|
||||||
|
};
|
||||||
|
|
||||||
|
const canDropIn = (sourceIds: Id[], target: MContainer, scene: CanDropInScene): DslValidationIssue | null => {
|
||||||
|
const fn = ctx.editor.getConfig().canDropIn;
|
||||||
|
if (!fn) return null;
|
||||||
|
|
||||||
|
const result = fn(sourceIds, target.id, scene);
|
||||||
|
if (result === false) {
|
||||||
|
return nodeIssue(target.id, `不允许放入容器「${target.id}」(canDropIn 返回 false)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof result !== 'string' && typeof result !== 'number') return null;
|
||||||
|
|
||||||
|
// 重定向:被重定向的目标必须存在,且同样是容器
|
||||||
|
const redirected = ctx.editor.getNodeById(result, false);
|
||||||
|
if (redirected && Array.isArray((redirected as MContainer).items)) return null;
|
||||||
|
|
||||||
|
return nodeIssue(target.id, `canDropIn 将容器「${target.id}」重定向到「${result}」,但该节点不存在或不是容器`);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 新增节点对父节点的要求 */
|
||||||
|
const checkAddParent = (node: MNode, parent: MContainer | null | undefined): DslValidationIssue | null => {
|
||||||
|
if (!parent) return nodeIssue(node.id, '未找到父节点');
|
||||||
|
if (!Array.isArray(parent.items)) return nodeIssue(node.id, `父节点「${parent.id}」不是容器(没有 items)`);
|
||||||
|
|
||||||
|
if (isPageOrFragment(node)) {
|
||||||
|
return parent.type === NodeType.ROOT ? null : nodeIssue(node.id, '页面 / 页面片只能添加到 app 下');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parent.type === NodeType.ROOT) return nodeIssue(node.id, 'app 下只能添加页面或页面片');
|
||||||
|
|
||||||
|
return canDropIn([], parent, 'stage-add');
|
||||||
|
};
|
||||||
|
|
||||||
|
const checkAddStructure: Validator['checkAddStructure'] = (node, parent) => {
|
||||||
|
const known = knownTypes();
|
||||||
|
|
||||||
|
const issues = flattenNode(node).flatMap((item) =>
|
||||||
|
item.type ? checkNodeType(item, known) : [nodeIssue(item.id, '组件类型不能为空')],
|
||||||
|
);
|
||||||
|
|
||||||
|
const parentIssue = checkAddParent(node, parent);
|
||||||
|
if (parentIssue) issues.push(parentIssue);
|
||||||
|
|
||||||
|
return issues;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 被移动的节点:必须存在,且不能移动到自己或子孙里 */
|
||||||
|
const checkMoveSource = (id: Id, target: MContainer): DslValidationIssue[] => {
|
||||||
|
const source = ctx.editor.getNodeById(id, false);
|
||||||
|
if (!source) return [nodeIssue(id, `要移动的节点「${id}」不存在`)];
|
||||||
|
if (isPageOrFragment(source)) return [];
|
||||||
|
|
||||||
|
return flattenNode(source).some((item) => item.id === target.id)
|
||||||
|
? [nodeIssue(id, `不能把节点「${id}」移动到它自己或子节点里`)]
|
||||||
|
: [];
|
||||||
|
};
|
||||||
|
|
||||||
|
const checkMoveStructure: Validator['checkMoveStructure'] = (sourceIds, target, scene) => {
|
||||||
|
if (!Array.isArray(target.items)) {
|
||||||
|
return [nodeIssue(target.id, `目标节点「${target.id}」不是容器(没有 items)`)];
|
||||||
|
}
|
||||||
|
|
||||||
|
const issues = sourceIds.flatMap((id) => checkMoveSource(id, target));
|
||||||
|
|
||||||
|
const targetIssue =
|
||||||
|
target.type === NodeType.ROOT
|
||||||
|
? nodeIssue(target.id, 'app 下只能放页面或页面片')
|
||||||
|
: canDropIn(sourceIds, target, scene);
|
||||||
|
if (targetIssue) issues.push(targetIssue);
|
||||||
|
|
||||||
|
return issues;
|
||||||
|
};
|
||||||
|
|
||||||
|
// add 在 id 冲突时会自动重新生成 id,不算错误,所以不区分是否新增
|
||||||
|
const checkDataSourceStructure: Validator['checkDataSourceStructure'] = (config) => {
|
||||||
|
const type = config.type || 'base';
|
||||||
|
|
||||||
|
const known = new Set<string>([
|
||||||
|
...BUILTIN_DATA_SOURCE_TYPES,
|
||||||
|
...ctx.dataSource.get('datasourceTypeList').map((item) => toLine(item.type)),
|
||||||
|
...Object.keys(ctx.dataSource.get('configs')),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (known.has(toLine(type))) return [];
|
||||||
|
|
||||||
|
return [
|
||||||
|
structureIssue('data-source', config.id, `数据源类型「${type}」不可用`, {
|
||||||
|
path: 'type',
|
||||||
|
suggestion: `可用类型:${[...known].join('、')}`,
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 用 `validateForm` 校验属性表单,配置不存在时不校验;校验本身出错也作为一条问题返回 */
|
||||||
|
const checkProps = async (
|
||||||
|
target: 'node' | 'data-source',
|
||||||
|
id: Id,
|
||||||
|
values: MNode | DataSourceSchema,
|
||||||
|
getConfig: () => Promise<FormConfig | undefined> | FormConfig | undefined,
|
||||||
|
options: { useFieldTextInError?: boolean } = {},
|
||||||
|
): Promise<DslValidationIssue[]> => {
|
||||||
|
const toIssues = (messages: string[]): DslValidationIssue[] =>
|
||||||
|
messages.map((message) => ({ target, id, kind: 'props', message }));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const config = await getConfig();
|
||||||
|
if (!config?.length) return [];
|
||||||
|
|
||||||
|
ensureFields();
|
||||||
|
|
||||||
|
const error = await validateForm({
|
||||||
|
config,
|
||||||
|
typeMatchValid: true,
|
||||||
|
initValues: values,
|
||||||
|
context: formContext() as any,
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
|
||||||
|
return toIssues(splitErrorText(error));
|
||||||
|
} catch (e: any) {
|
||||||
|
return toIssues([`属性校验异常:${e?.message ?? e}`]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const checkNodeProps: Validator['checkNodeProps'] = async (node) => {
|
||||||
|
if (!node.type || node.type === NodeType.ROOT) return [];
|
||||||
|
|
||||||
|
return checkProps('node', node.id, node, () => ctx.props.getPropsConfig(node.type!, { node }), {
|
||||||
|
useFieldTextInError: true,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const checkDataSourceProps = (config: DataSourceSchema) =>
|
||||||
|
checkProps('data-source', config.id, config, () => ctx.dataSource.getFormConfig(config.type || 'base'));
|
||||||
|
|
||||||
|
/** 已存在节点的结构校验:类型合法 */
|
||||||
|
const checkExistingNodeStructure = (node: MNode) => checkNodeType(node);
|
||||||
|
|
||||||
|
const validateNode = async (id: Id): Promise<DslValidationIssue[]> => {
|
||||||
|
const node = ctx.editor.getNodeById(id, false);
|
||||||
|
if (!node) return [nodeIssue(id, `节点「${id}」不存在`)];
|
||||||
|
|
||||||
|
const propsIssues = !isPage(node) || node.items ? await checkNodeProps(node) : [];
|
||||||
|
return [...checkExistingNodeStructure(node), ...propsIssues];
|
||||||
|
};
|
||||||
|
|
||||||
|
const validateDataSource = async (id: Id): Promise<DslValidationIssue[]> => {
|
||||||
|
const ds = ctx.dataSource.getDataSourceById(`${id}`);
|
||||||
|
if (!ds) return [structureIssue('data-source', id, `数据源「${id}」不存在`)];
|
||||||
|
|
||||||
|
return [...checkDataSourceStructure(ds, false), ...(await checkDataSourceProps(ds))];
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 按模式处理问题:strict 抛错;record 返回写入 invalidNodeIds 的提交函数 */
|
||||||
|
const handle = (issues: DslValidationIssue[], recordIds: Id[] = []): GuardCommit => {
|
||||||
|
if (mode() === 'strict') {
|
||||||
|
if (issues.length) throw new DslValidationError(issues);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode() !== 'record') return undefined;
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
for (const id of recordIds) {
|
||||||
|
ctx.editor.deleteInvalidNode(id, 'props');
|
||||||
|
}
|
||||||
|
|
||||||
|
const grouped = new Map<Id, string[]>();
|
||||||
|
for (const issue of issues) {
|
||||||
|
if (issue.target !== 'node' || issue.id === undefined) continue;
|
||||||
|
grouped.set(issue.id, [...(grouped.get(issue.id) ?? []), issue.message]);
|
||||||
|
}
|
||||||
|
for (const [id, messages] of grouped) {
|
||||||
|
ctx.editor.setInvalidNode(id, 'props', messages.join('<br>'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/** update 的单个节点:类型不可变,并校验 update 实际会写入的结果 */
|
||||||
|
const checkUpdatedNode = async (
|
||||||
|
node: MNode,
|
||||||
|
replace: boolean,
|
||||||
|
): Promise<{ id?: Id; issues: DslValidationIssue[] }> => {
|
||||||
|
const old = ctx.editor.getNodeById(node.id, false);
|
||||||
|
if (!old) return { issues: [nodeIssue(node.id, `节点「${node.id}」不存在`)] };
|
||||||
|
if (old.type === NodeType.ROOT) return { issues: [] };
|
||||||
|
|
||||||
|
const issues: DslValidationIssue[] = [];
|
||||||
|
|
||||||
|
if (node.type && node.type !== old.type) {
|
||||||
|
issues.push(nodeIssue(node.id, `不允许修改节点类型(${old.type} -> ${node.type})`, { path: 'type' }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 与 `Editor.doUpdate` 一致:replace 时整节点替换,否则深合并
|
||||||
|
const merged: MNode = replace
|
||||||
|
? cloneDeep(node)
|
||||||
|
: mergeWith(cloneDeep(ctx.reactivity.toRaw(old)), cloneDeep(node), editorNodeMergeCustomizer);
|
||||||
|
issues.push(...checkExistingNodeStructure(merged), ...(await checkNodeProps(merged)));
|
||||||
|
|
||||||
|
return { id: node.id, issues };
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
async node(id) {
|
||||||
|
return toResult(await validateNode(id));
|
||||||
|
},
|
||||||
|
|
||||||
|
async dataSource(id) {
|
||||||
|
return toResult(await validateDataSource(id));
|
||||||
|
},
|
||||||
|
|
||||||
|
async dsl() {
|
||||||
|
const root = ctx.editor.get('root');
|
||||||
|
if (!root) return toResult([structureIssue('dsl', undefined, 'DSL 为空')]);
|
||||||
|
|
||||||
|
const ids = flattenNode(root)
|
||||||
|
.filter((node) => node.type !== NodeType.ROOT)
|
||||||
|
.map((node) => node.id);
|
||||||
|
|
||||||
|
// 重复 id:同一个 DSL 内出现两次
|
||||||
|
const seen = new Set<Id>();
|
||||||
|
const issues = ids
|
||||||
|
.filter((id) => {
|
||||||
|
const duplicated = seen.has(id);
|
||||||
|
seen.add(id);
|
||||||
|
return duplicated;
|
||||||
|
})
|
||||||
|
.map((id) => nodeIssue(id, `节点 id「${id}」重复`, { suggestion: DUPLICATE_ID_SUGGESTION }));
|
||||||
|
|
||||||
|
for (const id of seen) {
|
||||||
|
issues.push(...(await validateNode(id)));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const ds of ctx.dataSource.get('dataSources')) {
|
||||||
|
issues.push(...(await validateDataSource(ds.id)));
|
||||||
|
}
|
||||||
|
|
||||||
|
return toResult(issues);
|
||||||
|
},
|
||||||
|
|
||||||
|
checkNodeProps,
|
||||||
|
checkAddStructure,
|
||||||
|
checkMoveStructure,
|
||||||
|
checkDataSourceStructure,
|
||||||
|
|
||||||
|
async guardAdd(items) {
|
||||||
|
if (mode() === 'off') return undefined;
|
||||||
|
|
||||||
|
const nodes = items.flatMap(({ node }) => flattenNode(node));
|
||||||
|
|
||||||
|
const issues = [
|
||||||
|
...findDuplicateIds(items.map(({ node }) => node)),
|
||||||
|
...items.flatMap(({ node, parentNode }) => checkAddStructure(node, parentNode)),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const node of nodes) {
|
||||||
|
issues.push(...(await checkNodeProps(node)));
|
||||||
|
}
|
||||||
|
|
||||||
|
return handle(
|
||||||
|
issues,
|
||||||
|
nodes.map((node) => node.id),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
async guardUpdate(nodes, { replace = false } = {}) {
|
||||||
|
if (mode() === 'off') return undefined;
|
||||||
|
|
||||||
|
const results = await Promise.all(nodes.map((node) => checkUpdatedNode(node, replace)));
|
||||||
|
|
||||||
|
return handle(
|
||||||
|
results.flatMap(({ issues }) => issues),
|
||||||
|
results.flatMap(({ id }) => (id === undefined ? [] : [id])),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
// 移动 / 数据源只有结构问题,没有可挂载到节点属性上的错误,因此只在 strict 模式下生效
|
||||||
|
async guardMove(sourceIds, target, scene) {
|
||||||
|
if (mode() !== 'strict') return;
|
||||||
|
handle(checkMoveStructure(sourceIds, target, scene));
|
||||||
|
},
|
||||||
|
|
||||||
|
guardDataSource(config, isNew) {
|
||||||
|
if (mode() !== 'strict') return;
|
||||||
|
handle(checkDataSourceStructure(config, isNew));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
211
packages/editor/src/sdk/wiring.ts
Normal file
211
packages/editor/src/sdk/wiring.ts
Normal file
@ -0,0 +1,211 @@
|
|||||||
|
/*
|
||||||
|
* Tencent is pleased to support the open source community by making TMagicEditor available.
|
||||||
|
*
|
||||||
|
* Copyright (C) 2025 Tencent. All rights reserved.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @fileoverview 依赖收集连线(只处理 DSL,不碰画布 / runtime)。
|
||||||
|
*
|
||||||
|
* 数据源、代码块的依赖信息(`dataSourceDeps` / `dataSourceCondDeps` / `dataSourceMethodDeps`)
|
||||||
|
* 会写回 DSL。这里把「节点变化 -> 重新收集 -> 写回 root」的链路连起来,使 Node 下导出的 DSL
|
||||||
|
* 依赖信息完整。浏览器端画布同步仍由 `initService` 负责,两边共用 `./utils/dep` 里的规则。
|
||||||
|
*
|
||||||
|
* 收集用的是同步的 `dep.collect`,不经过空闲队列,所以操作返回时依赖已经是最新的。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { CodeBlockContent, DataSourceSchema, Id, MApp, MComponent, MNode, Target } from '@tmagic/core';
|
||||||
|
import { createCodeBlockTarget, DepTargetType, NodeType } from '@tmagic/core';
|
||||||
|
import type { ChangeRecord } from '@tmagic/form';
|
||||||
|
import { getNodes, isPage } from '@tmagic/utils';
|
||||||
|
|
||||||
|
import {
|
||||||
|
addDataSourceTargets,
|
||||||
|
classifyUpdatedNodes,
|
||||||
|
getDataSourceRecollectTypes,
|
||||||
|
removeDataSourceTargets,
|
||||||
|
removeTargetDeps,
|
||||||
|
resetDepTargets,
|
||||||
|
writeTargetDeps,
|
||||||
|
} from './utils/dep';
|
||||||
|
import type { SdkContext } from './type';
|
||||||
|
|
||||||
|
export { classifyUpdatedNodes } from './utils/dep';
|
||||||
|
|
||||||
|
export interface WiringHandle {
|
||||||
|
unbind(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pendingSelects = new WeakMap<object, Promise<unknown>>();
|
||||||
|
|
||||||
|
/** root 变化后自动选中节点是异步的,需要时可以等它完成 */
|
||||||
|
export const whenWiringSettled = (ctx: SdkContext): Promise<unknown> => pendingSelects.get(ctx) ?? Promise.resolve();
|
||||||
|
|
||||||
|
export const bindWiring = (ctx: SdkContext): WiringHandle => {
|
||||||
|
const { editor, codeBlock, dataSource, dep, reactivity } = ctx;
|
||||||
|
|
||||||
|
const getPageIdByNode = (node: MComponent): Id | undefined => {
|
||||||
|
if (isPage(node)) return node.id;
|
||||||
|
return editor.getNodeInfo(node.id).page?.id;
|
||||||
|
};
|
||||||
|
|
||||||
|
const collect = (nodes: MComponent[], deep: boolean, type?: DepTargetType) => {
|
||||||
|
for (const node of nodes) {
|
||||||
|
if (node.type === NodeType.ROOT) continue;
|
||||||
|
dep.collect([node], { pageId: getPageIdByNode(node) }, deep, type);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** root 变化后选中原来的节点,没有则选中第一个页面(与浏览器端一致) */
|
||||||
|
const selectAfterRootChange = (root: MApp) => {
|
||||||
|
const nodeId = editor.get('node')?.id;
|
||||||
|
const node = nodeId ? editor.getNodeById(nodeId) : null;
|
||||||
|
|
||||||
|
let selecting: Promise<unknown> | undefined;
|
||||||
|
if (node && node !== root) {
|
||||||
|
selecting = editor.select(node.id);
|
||||||
|
} else if (root.items?.length) {
|
||||||
|
selecting = editor.select(root.items[0]);
|
||||||
|
} else if (root.id) {
|
||||||
|
editor.set('nodes', [root]);
|
||||||
|
editor.set('parent', null);
|
||||||
|
editor.set('page', null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selecting) {
|
||||||
|
pendingSelects.set(
|
||||||
|
ctx,
|
||||||
|
selecting.catch(() => {}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const rootChangeHandler = (root: MApp | null) => {
|
||||||
|
if (!root) return;
|
||||||
|
|
||||||
|
root.codeBlocks = root.codeBlocks || {};
|
||||||
|
root.dataSources = root.dataSources || [];
|
||||||
|
|
||||||
|
codeBlock.setCodeDsl(root.codeBlocks);
|
||||||
|
dataSource.set('dataSources', root.dataSources);
|
||||||
|
|
||||||
|
resetDepTargets(dep, root.codeBlocks, dataSource.get('dataSources'), reactivity.reactive);
|
||||||
|
|
||||||
|
if (Array.isArray(root.items)) {
|
||||||
|
dep.clearIdleTasks();
|
||||||
|
collect(root.items, true);
|
||||||
|
} else {
|
||||||
|
dep.clear();
|
||||||
|
delete root.dataSourceDeps;
|
||||||
|
delete root.dataSourceCondDeps;
|
||||||
|
}
|
||||||
|
|
||||||
|
selectAfterRootChange(root);
|
||||||
|
};
|
||||||
|
|
||||||
|
const nodeAddHandler = (nodes: MComponent[]) => collect(nodes, true);
|
||||||
|
|
||||||
|
const nodeUpdateHandler = (data: Parameters<typeof classifyUpdatedNodes>[1]) => {
|
||||||
|
const { needRecollectNodes, normalNodes } = classifyUpdatedNodes(ctx, data);
|
||||||
|
|
||||||
|
// 数据源引用发生变化的节点需要重新收集数据源依赖
|
||||||
|
collect(needRecollectNodes, true, DepTargetType.DATA_SOURCE);
|
||||||
|
collect(needRecollectNodes, true, DepTargetType.DATA_SOURCE_COND);
|
||||||
|
|
||||||
|
// 代码块、数据源方法依赖对所有被更新的节点都要收集:同一批里既改了数据源引用又改了方法时不能漏掉
|
||||||
|
const updatedNodes = [...needRecollectNodes, ...normalNodes];
|
||||||
|
collect(updatedNodes, true, DepTargetType.CODE_BLOCK);
|
||||||
|
collect(updatedNodes, true, DepTargetType.DATA_SOURCE_METHOD);
|
||||||
|
};
|
||||||
|
|
||||||
|
const nodeRemoveHandler = (nodes: MComponent[]) => dep.clear(nodes);
|
||||||
|
|
||||||
|
const historyChangeHandler = (page: MNode) => collect([page as MComponent], true);
|
||||||
|
|
||||||
|
const dataSourceAddHandler = (config: DataSourceSchema) => addDataSourceTargets(dep, config, reactivity.reactive);
|
||||||
|
|
||||||
|
const dataSourceUpdateHandler = (config: DataSourceSchema, { changeRecords }: { changeRecords: ChangeRecord[] }) => {
|
||||||
|
const types = getDataSourceRecollectTypes(changeRecords);
|
||||||
|
const items = editor.get('root')?.items;
|
||||||
|
if (!types || !Array.isArray(items)) return;
|
||||||
|
|
||||||
|
dep.clearIdleTasks();
|
||||||
|
removeDataSourceTargets(dep, config.id, types);
|
||||||
|
addDataSourceTargets(dep, config, reactivity.reactive, types);
|
||||||
|
|
||||||
|
for (const type of types) {
|
||||||
|
collect(items, true, type);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const dataSourceRemoveHandler = (id: string) => {
|
||||||
|
const root = editor.get('root');
|
||||||
|
if (!root) return;
|
||||||
|
|
||||||
|
const nodes = getNodes(Object.keys(root.dataSourceDeps?.[id] || {}), root.items) as MComponent[];
|
||||||
|
|
||||||
|
for (const type of [DepTargetType.DATA_SOURCE, DepTargetType.DATA_SOURCE_COND, DepTargetType.DATA_SOURCE_METHOD]) {
|
||||||
|
collect(nodes, false, type);
|
||||||
|
}
|
||||||
|
|
||||||
|
removeDataSourceTargets(dep, id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const codeBlockAddOrUpdateHandler = (id: Id, content: CodeBlockContent) => {
|
||||||
|
if (dep.hasTarget(id, DepTargetType.CODE_BLOCK)) {
|
||||||
|
dep.getTarget(id, DepTargetType.CODE_BLOCK)!.name = content.name;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
dep.addTarget(createCodeBlockTarget(id, content));
|
||||||
|
};
|
||||||
|
|
||||||
|
const codeBlockRemoveHandler = (id: Id) => dep.removeTarget(id, DepTargetType.CODE_BLOCK);
|
||||||
|
|
||||||
|
const targetAddHandler = (target: Target) => writeTargetDeps(editor.get('root'), target);
|
||||||
|
|
||||||
|
const targetRemoveHandler = (id: string | number, type: string) => removeTargetDeps(editor.get('root'), id, type);
|
||||||
|
|
||||||
|
const listeners: [{ on: Function; off: Function }, string, (...args: any[]) => void][] = [
|
||||||
|
[editor, 'history-change', historyChangeHandler],
|
||||||
|
[editor, 'root-change', rootChangeHandler],
|
||||||
|
[editor, 'add', nodeAddHandler],
|
||||||
|
[editor, 'remove', nodeRemoveHandler],
|
||||||
|
[editor, 'update', nodeUpdateHandler],
|
||||||
|
[dataSource, 'add', dataSourceAddHandler],
|
||||||
|
[dataSource, 'update', dataSourceUpdateHandler],
|
||||||
|
[dataSource, 'remove', dataSourceRemoveHandler],
|
||||||
|
[codeBlock, 'addOrUpdate', codeBlockAddOrUpdateHandler],
|
||||||
|
[codeBlock, 'remove', codeBlockRemoveHandler],
|
||||||
|
[dep, 'add-target', targetAddHandler],
|
||||||
|
[dep, 'remove-target', targetRemoveHandler],
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [service, event, handler] of listeners) {
|
||||||
|
service.on(event, handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 绑定时 DSL 已经设置过(如 `autoWiring: false` 之后再手动绑定):补做一次初始化与收集
|
||||||
|
const root = editor.get('root');
|
||||||
|
if (root) rootChangeHandler(root);
|
||||||
|
|
||||||
|
return {
|
||||||
|
unbind() {
|
||||||
|
for (const [service, event, handler] of listeners) {
|
||||||
|
service.off(event, handler);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
@ -2,10 +2,10 @@ import KeyController, { KeyControllerEvent } from 'keycon';
|
|||||||
|
|
||||||
import { isPageOrFragment } from '@tmagic/utils';
|
import { isPageOrFragment } from '@tmagic/utils';
|
||||||
|
|
||||||
|
import BaseService from '@editor/sdk/services/BaseService';
|
||||||
import { KeyBindingCacheItem, KeyBindingCommand, KeyBindingItem } from '@editor/type';
|
import { KeyBindingCacheItem, KeyBindingCommand, KeyBindingItem } from '@editor/type';
|
||||||
|
|
||||||
import BaseService from './BaseService';
|
import { editorService } from './sdk-instance';
|
||||||
import editorService from './editor';
|
|
||||||
import uiService from './ui';
|
import uiService from './ui';
|
||||||
|
|
||||||
class Keybinding extends BaseService {
|
class Keybinding extends BaseService {
|
||||||
|
|||||||
66
packages/editor/src/services/sdk-instance.ts
Normal file
66
packages/editor/src/services/sdk-instance.ts
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
/*
|
||||||
|
* Tencent is pleased to support the open source community by making TMagicEditor available.
|
||||||
|
*
|
||||||
|
* Copyright (C) 2025 Tencent. All rights reserved.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 浏览器端默认的 SDK 实例:`editorService` 等单例都从这里取。
|
||||||
|
*
|
||||||
|
* 与 Node 下 `createDslSdk` 创建的实例是同一套实现,区别只在注入的适配器:
|
||||||
|
* Vue 的响应式、localStorage、IndexedDB、Web Worker、剪贴板与 `tMagicMessage`。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createDslSdk } from '@editor/sdk';
|
||||||
|
import { getEditorConfig } from '@editor/utils/config';
|
||||||
|
import { CollectWorkerClient } from '@editor/utils/dep/collect-worker-client';
|
||||||
|
import { idbGet, idbSet } from '@editor/utils/indexed-db';
|
||||||
|
import { copyTextToClipboard, notifyByDesign } from '@editor/utils/props';
|
||||||
|
|
||||||
|
const sdk = createDslSdk({
|
||||||
|
storage: typeof globalThis.localStorage === 'undefined' ? undefined : globalThis.localStorage,
|
||||||
|
// 编辑器通过 `setEditorConfig` 注入,需在调用时读取
|
||||||
|
parseDSL: (code: string) => getEditorConfig('parseDSL')(code),
|
||||||
|
historyPersistence: { get: idbGet as any, set: idbSet },
|
||||||
|
collectWorker: new CollectWorkerClient(),
|
||||||
|
copyText: copyTextToClipboard,
|
||||||
|
notify: notifyByDesign,
|
||||||
|
// 浏览器端的校验由属性面板负责;依赖收集由 initService 挂载时连线
|
||||||
|
validateMode: 'off',
|
||||||
|
autoWiring: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const editorService = sdk.editor;
|
||||||
|
export const historyService = sdk.history;
|
||||||
|
export const propsService = sdk.props;
|
||||||
|
export const dataSourceService = sdk.dataSource;
|
||||||
|
export const codeBlockService = sdk.codeBlock;
|
||||||
|
export const depService = sdk.dep;
|
||||||
|
export const eventsService = sdk.events;
|
||||||
|
export const storageService = sdk.storage;
|
||||||
|
export const componentListService = sdk.componentList;
|
||||||
|
|
||||||
|
export { Protocol, WebStorage } from '@editor/sdk/services/WebStorage';
|
||||||
|
export type { CodeBlockService } from '@editor/sdk/services/CodeBlock';
|
||||||
|
export type { ComponentListService } from '@editor/sdk/services/ComponentList';
|
||||||
|
export type { DataSourceService } from '@editor/sdk/services/DataSource';
|
||||||
|
export type { DepEvents, DepService } from '@editor/sdk/services/Dep';
|
||||||
|
export type { EditorService } from '@editor/sdk/services/Editor';
|
||||||
|
export type { EventsService } from '@editor/sdk/services/Events';
|
||||||
|
export type { HistoryService } from '@editor/sdk/services/History';
|
||||||
|
export type { PropsService } from '@editor/sdk/services/Props';
|
||||||
|
export type { StorageService } from '@editor/sdk/services/WebStorage';
|
||||||
|
|
||||||
|
export default sdk;
|
||||||
@ -5,8 +5,8 @@ import StageCore from '@tmagic/stage';
|
|||||||
import { getIdFromEl } from '@tmagic/utils';
|
import { getIdFromEl } from '@tmagic/utils';
|
||||||
|
|
||||||
import { useStage } from '@editor/hooks/use-stage';
|
import { useStage } from '@editor/hooks/use-stage';
|
||||||
import BaseService from '@editor/services//BaseService';
|
import BaseService from '@editor/sdk/services/BaseService';
|
||||||
import editorService from '@editor/services//editor';
|
import { editorService } from '@editor/services/sdk-instance';
|
||||||
import type { StageOptions, StageOverlayState, SyncHookPlugin } from '@editor/type';
|
import type { StageOptions, StageOverlayState, SyncHookPlugin } from '@editor/type';
|
||||||
|
|
||||||
const canUsePluginMethods = {
|
const canUsePluginMethods = {
|
||||||
|
|||||||
@ -21,7 +21,8 @@ import type { Writable } from 'type-fest';
|
|||||||
|
|
||||||
import { convertToNumber } from '@tmagic/utils';
|
import { convertToNumber } from '@tmagic/utils';
|
||||||
|
|
||||||
import editorService from '@editor/services/editor';
|
import BaseService from '@editor/sdk/services/BaseService';
|
||||||
|
import { editorService } from '@editor/services/sdk-instance';
|
||||||
import type { AsyncHookPlugin, StageRect, UiState } from '@editor/type';
|
import type { AsyncHookPlugin, StageRect, UiState } from '@editor/type';
|
||||||
import {
|
import {
|
||||||
DEFAULT_LEFT_COLUMN_WIDTH,
|
DEFAULT_LEFT_COLUMN_WIDTH,
|
||||||
@ -33,8 +34,7 @@ import {
|
|||||||
RIGHT_COLUMN_WIDTH_STORAGE_KEY,
|
RIGHT_COLUMN_WIDTH_STORAGE_KEY,
|
||||||
} from '@editor/utils/const';
|
} from '@editor/utils/const';
|
||||||
|
|
||||||
import BaseService from './BaseService';
|
import { Protocol, storageService } from './sdk-instance';
|
||||||
import storageService, { Protocol } from './storage';
|
|
||||||
|
|
||||||
const state = shallowReactive<UiState>({
|
const state = shallowReactive<UiState>({
|
||||||
uiSelectMode: false,
|
uiSelectMode: false,
|
||||||
|
|||||||
@ -120,7 +120,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.m-editor-nav-menu-popover {
|
.tmagic-design-popper.m-editor-nav-menu-popover {
|
||||||
|
// 不固定宽度,按内容撑开,避免下拉框超出菜单;content-box 让 padding 不挤占内容宽度。
|
||||||
|
width: max-content;
|
||||||
|
min-width: 180px;
|
||||||
|
box-sizing: content-box;
|
||||||
|
|
||||||
.m-editor-nav-menu-overflow-list {
|
.m-editor-nav-menu-overflow-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@ -139,8 +144,30 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
&.divider {
|
&.divider {
|
||||||
padding: 0;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
align-self: stretch;
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
padding: 4px 12px;
|
||||||
cursor: default;
|
cursor: default;
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
|
// 更多菜单是纵向列表,分割线必须盖过组件默认的竖线样式
|
||||||
|
.tmagic-design-divider,
|
||||||
|
.el-divider,
|
||||||
|
.t-divider {
|
||||||
|
display: block;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
height: 0;
|
||||||
|
margin: 0;
|
||||||
|
border: 0;
|
||||||
|
border-top: 1px solid var(--el-border-color, #dcdfe6);
|
||||||
|
border-left: 0;
|
||||||
|
position: static;
|
||||||
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
|
|||||||
@ -45,17 +45,17 @@ import type {
|
|||||||
UpdateDragEl,
|
UpdateDragEl,
|
||||||
} from '@tmagic/stage';
|
} from '@tmagic/stage';
|
||||||
|
|
||||||
import type { CodeBlockService } from './services/codeBlock';
|
import type { CodeBlockService } from './sdk/services/CodeBlock';
|
||||||
import type { ComponentListService } from './services/componentList';
|
import type { ComponentListService } from './sdk/services/ComponentList';
|
||||||
import type { DataSourceService } from './services/dataSource';
|
import type { DataSourceService } from './sdk/services/DataSource';
|
||||||
import type { DepService } from './services/dep';
|
import type { DepService } from './sdk/services/Dep';
|
||||||
import type { EditorService } from './services/editor';
|
import type { EditorService } from './sdk/services/Editor';
|
||||||
import type { EventsService } from './services/events';
|
import type { EventsService } from './sdk/services/Events';
|
||||||
import type { HistoryService } from './services/history';
|
import type { HistoryService } from './sdk/services/History';
|
||||||
|
import type { PropsService } from './sdk/services/Props';
|
||||||
|
import type { StorageService } from './sdk/services/WebStorage';
|
||||||
import type { KeybindingService } from './services/keybinding';
|
import type { KeybindingService } from './services/keybinding';
|
||||||
import type { PropsService } from './services/props';
|
|
||||||
import type { StageOverlayService } from './services/stageOverlay';
|
import type { StageOverlayService } from './services/stageOverlay';
|
||||||
import type { StorageService } from './services/storage';
|
|
||||||
import type { UiService } from './services/ui';
|
import type { UiService } from './services/ui';
|
||||||
import type { SerializedUndoRedo, UndoRedo } from './utils/undo-redo';
|
import type { SerializedUndoRedo, UndoRedo } from './utils/undo-redo';
|
||||||
|
|
||||||
@ -221,6 +221,8 @@ export interface StageOptions {
|
|||||||
*/
|
*/
|
||||||
disabledFlashTip?: boolean;
|
disabledFlashTip?: boolean;
|
||||||
zoom?: number;
|
zoom?: number;
|
||||||
|
/** 画布 iframe 的 sandbox 属性值,不传 / false 表示不设置,见 @tmagic/stage 的 StageRenderConfig.iframeSandbox */
|
||||||
|
iframeSandbox?: string | false;
|
||||||
/** 画布双击前的钩子函数,返回 false 则阻止默认的双击行为 */
|
/** 画布双击前的钩子函数,返回 false 则阻止默认的双击行为 */
|
||||||
beforeDblclick?: (event: MouseEvent) => Promise<boolean | void> | boolean | void;
|
beforeDblclick?: (event: MouseEvent) => Promise<boolean | void> | boolean | void;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { defineFormConfig, type FormConfig } from '@tmagic/form';
|
import { defineFormConfig, type FormConfig } from '@tmagic/form/headless';
|
||||||
|
|
||||||
export default (): FormConfig =>
|
export default (): FormConfig =>
|
||||||
defineFormConfig([
|
defineFormConfig([
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { defineFormConfig } from '@tmagic/form';
|
import { defineFormConfig } from '@tmagic/form/headless';
|
||||||
|
|
||||||
export default defineFormConfig([
|
export default defineFormConfig([
|
||||||
{
|
{
|
||||||
|
|||||||
@ -1,16 +1,19 @@
|
|||||||
import serialize from 'serialize-javascript';
|
import serialize from 'serialize-javascript';
|
||||||
|
|
||||||
import type { Id, MApp } from '@tmagic/core';
|
import type { MApp } from '@tmagic/core';
|
||||||
|
|
||||||
import { error } from '../logger';
|
import { error } from '../logger';
|
||||||
|
|
||||||
import type { CollectWorkerPayload, CollectWorkerRequest, CollectWorkerResponse, DepsData } from './worker';
|
import type {
|
||||||
|
CollectWorkerPayload,
|
||||||
|
CollectWorkerRequest,
|
||||||
|
CollectWorkerResponse,
|
||||||
|
CollectWorkerResult,
|
||||||
|
DepsData,
|
||||||
|
} from './worker';
|
||||||
import CollectWorker from './worker.ts?worker&inline';
|
import CollectWorker from './worker.ts?worker&inline';
|
||||||
|
|
||||||
export interface CollectWorkerResult {
|
export type { CollectWorkerResult };
|
||||||
deps: DepsData;
|
|
||||||
nodeIds: Id[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CollectTask {
|
interface CollectTask {
|
||||||
id: number;
|
id: number;
|
||||||
|
|||||||
@ -19,6 +19,11 @@ import { error } from '../logger';
|
|||||||
|
|
||||||
export type DepsData = Record<string, Record<Id, DepData>>;
|
export type DepsData = Record<string, Record<Id, DepData>>;
|
||||||
|
|
||||||
|
export interface CollectWorkerResult {
|
||||||
|
deps: DepsData;
|
||||||
|
nodeIds: Id[];
|
||||||
|
}
|
||||||
|
|
||||||
/** 全量收集请求:worker 中根据 DSL 重建代码块/数据源 target */
|
/** 全量收集请求:worker 中根据 DSL 重建代码块/数据源 target */
|
||||||
export interface DslCollectRequest {
|
export interface DslCollectRequest {
|
||||||
/** 请求 id,用于把结果与调用方对应起来 */
|
/** 请求 id,用于把结果与调用方对应起来 */
|
||||||
|
|||||||
@ -23,7 +23,6 @@ import serialize from 'serialize-javascript';
|
|||||||
import type { Id, MApp, MContainer, MNode, MPage, MPageFragment, TargetOptions } from '@tmagic/core';
|
import type { Id, MApp, MContainer, MNode, MPage, MPageFragment, TargetOptions } from '@tmagic/core';
|
||||||
import { NODE_CONDS_KEY, NodeType, Target, Watcher } from '@tmagic/core';
|
import { NODE_CONDS_KEY, NodeType, Target, Watcher } from '@tmagic/core';
|
||||||
import type StageCore from '@tmagic/stage';
|
import type StageCore from '@tmagic/stage';
|
||||||
import { isFixed } from '@tmagic/stage';
|
|
||||||
import {
|
import {
|
||||||
calcValueByFontsize,
|
calcValueByFontsize,
|
||||||
getElById,
|
getElById,
|
||||||
@ -38,6 +37,8 @@ import {
|
|||||||
import type { EditorNodeInfo, StepValue } from '@editor/type';
|
import type { EditorNodeInfo, StepValue } from '@editor/type';
|
||||||
import { LayerOffset, Layout } from '@editor/type';
|
import { LayerOffset, Layout } from '@editor/type';
|
||||||
|
|
||||||
|
import { isFixed } from '../sdk/utils/position';
|
||||||
|
|
||||||
export const COPY_STORAGE_KEY = '$MagicEditorCopyData';
|
export const COPY_STORAGE_KEY = '$MagicEditorCopyData';
|
||||||
export const COPY_CODE_STORAGE_KEY = '$MagicEditorCopyCode';
|
export const COPY_CODE_STORAGE_KEY = '$MagicEditorCopyCode';
|
||||||
export const COPY_DS_STORAGE_KEY = '$MagicEditorCopyDataSource';
|
export const COPY_DS_STORAGE_KEY = '$MagicEditorCopyDataSource';
|
||||||
|
|||||||
@ -20,11 +20,16 @@ import type { EventOption, Id, MComponent, MContainer, MNode } from '@tmagic/cor
|
|||||||
import type { CascaderOption } from '@tmagic/form';
|
import type { CascaderOption } from '@tmagic/form';
|
||||||
import { DATA_SOURCE_FIELDS_CHANGE_EVENT_PREFIX, traverseNode } from '@tmagic/utils';
|
import { DATA_SOURCE_FIELDS_CHANGE_EVENT_PREFIX, traverseNode } from '@tmagic/utils';
|
||||||
|
|
||||||
import dataSourceService from '@editor/services/dataSource';
|
import { dataSourceService, editorService, eventsService } from '@editor/services/sdk-instance';
|
||||||
import editorService from '@editor/services/editor';
|
import type { Services } from '@editor/type';
|
||||||
import eventsService from '@editor/services/events';
|
|
||||||
import { getCascaderOptionsFromFields } from '@editor/utils/data-source';
|
import { getCascaderOptionsFromFields } from '@editor/utils/data-source';
|
||||||
|
|
||||||
|
/** 事件相关工具函数依赖的服务,可由表单上下文(`mForm.services`)注入 */
|
||||||
|
export type EventUtilServices = Pick<Services, 'editorService' | 'eventsService' | 'dataSourceService'>;
|
||||||
|
|
||||||
|
/** 未显式传入 services 时使用浏览器端默认实例 */
|
||||||
|
const getDefaultServices = (): EventUtilServices => ({ editorService, eventsService, dataSourceService });
|
||||||
|
|
||||||
export const EVENT_NAME_VALUE_SEPARATOR = '.';
|
export const EVENT_NAME_VALUE_SEPARATOR = '.';
|
||||||
|
|
||||||
export type EventNameSelectOption = { text: string; value: string };
|
export type EventNameSelectOption = { text: string; value: string };
|
||||||
@ -50,17 +55,21 @@ export const normalizeCompActionValue = (value: unknown): string => {
|
|||||||
export const getEventNameOptions = (
|
export const getEventNameOptions = (
|
||||||
src: string | undefined,
|
src: string | undefined,
|
||||||
formValue: Record<string, any> = {},
|
formValue: Record<string, any> = {},
|
||||||
|
services: EventUtilServices = getDefaultServices(),
|
||||||
): EventNameOption[] => {
|
): EventNameOption[] => {
|
||||||
if (!formValue.type) {
|
if (!formValue.type) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (src === 'component') {
|
if (src === 'component') {
|
||||||
const sourceNode = editorService.getNodeById(formValue.id) || (formValue as MNode);
|
const sourceNode = services.editorService.getNodeById(formValue.id) || (formValue as MNode);
|
||||||
let events: EventOption[] | CascaderOption[] = eventsService.getEvent(formValue.type, { node: sourceNode }) || [];
|
let events: EventOption[] | CascaderOption[] =
|
||||||
|
services.eventsService.getEvent(formValue.type, { node: sourceNode }) || [];
|
||||||
|
|
||||||
if (formValue.type === 'page-fragment-container' && formValue.pageFragmentId) {
|
if (formValue.type === 'page-fragment-container' && formValue.pageFragmentId) {
|
||||||
const pageFragment = editorService.get('root')?.items?.find((page) => page.id === formValue.pageFragmentId);
|
const pageFragment = services.editorService
|
||||||
|
.get('root')
|
||||||
|
?.items?.find((page) => page.id === formValue.pageFragmentId);
|
||||||
if (!pageFragment) {
|
if (!pageFragment) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@ -79,8 +88,8 @@ export const getEventNameOptions = (
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const node = editorService.getNodeById(current.id) || current;
|
const node = services.editorService.getNodeById(current.id) || current;
|
||||||
const nodeEvents = eventsService.getEvent(current.type, { node }) || [];
|
const nodeEvents = services.eventsService.getEvent(current.type, { node }) || [];
|
||||||
(events as CascaderOption[]).push({
|
(events as CascaderOption[]).push({
|
||||||
label: `${current.name}_${current.id}`,
|
label: `${current.name}_${current.id}`,
|
||||||
value: `${current.id}`,
|
value: `${current.id}`,
|
||||||
@ -99,8 +108,8 @@ export const getEventNameOptions = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (src === 'datasource') {
|
if (src === 'datasource') {
|
||||||
const events: EventNameOption[] = dataSourceService.getFormEvent(formValue.type) || [];
|
const events: EventNameOption[] = services.dataSourceService.getFormEvent(formValue.type) || [];
|
||||||
const dataSource = dataSourceService.getDataSourceById(formValue.id);
|
const dataSource = services.dataSourceService.getDataSourceById(formValue.id);
|
||||||
const fields = dataSource?.fields || [];
|
const fields = dataSource?.fields || [];
|
||||||
|
|
||||||
if (fields.length > 0) {
|
if (fields.length > 0) {
|
||||||
@ -157,6 +166,7 @@ export const collectEventNameOptionValues = (
|
|||||||
export const getEventNameAllowedValues = (
|
export const getEventNameAllowedValues = (
|
||||||
config: { src?: string; eventNameConfig?: { options?: unknown } } = {},
|
config: { src?: string; eventNameConfig?: { options?: unknown } } = {},
|
||||||
formValue: Record<string, any> = {},
|
formValue: Record<string, any> = {},
|
||||||
|
services: EventUtilServices = getDefaultServices(),
|
||||||
): Set<string> | null => {
|
): Set<string> | null => {
|
||||||
if (typeof config.eventNameConfig?.options !== 'undefined') {
|
if (typeof config.eventNameConfig?.options !== 'undefined') {
|
||||||
return null;
|
return null;
|
||||||
@ -166,26 +176,33 @@ export const getEventNameAllowedValues = (
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return collectEventNameOptionValues(getEventNameOptions(config.src, formValue), isEventNameCheckStrictly(config.src));
|
return collectEventNameOptionValues(
|
||||||
|
getEventNameOptions(config.src, formValue, services),
|
||||||
|
isEventNameCheckStrictly(config.src),
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 组装联动组件动作下拉/级联 options,与 EventSelect 默认 compActionConfig.options 逻辑一致。
|
* 组装联动组件动作下拉/级联 options,与 EventSelect 默认 compActionConfig.options 逻辑一致。
|
||||||
*/
|
*/
|
||||||
export const getCompActionOptions = (toId?: Id): EventNameOption[] => {
|
export const getCompActionOptions = (
|
||||||
|
toId?: Id,
|
||||||
|
services: EventUtilServices = getDefaultServices(),
|
||||||
|
): EventNameOption[] => {
|
||||||
if (typeof toId === 'undefined' || toId === null || toId === '') {
|
if (typeof toId === 'undefined' || toId === null || toId === '') {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const node = editorService.getNodeById(toId);
|
const node = services.editorService.getNodeById(toId);
|
||||||
if (!node?.type) {
|
if (!node?.type) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
let methods: EventOption[] | CascaderOption[] = eventsService.getMethod(node.type, { targetId: toId, node }) || [];
|
let methods: EventOption[] | CascaderOption[] =
|
||||||
|
services.eventsService.getMethod(node.type, { targetId: toId, node }) || [];
|
||||||
|
|
||||||
if (node.type === 'page-fragment-container' && node.pageFragmentId) {
|
if (node.type === 'page-fragment-container' && node.pageFragmentId) {
|
||||||
const pageFragment = editorService.get('root')?.items?.find((page) => page.id === node.pageFragmentId);
|
const pageFragment = services.editorService.get('root')?.items?.find((page) => page.id === node.pageFragmentId);
|
||||||
if (!pageFragment) {
|
if (!pageFragment) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@ -193,13 +210,13 @@ export const getCompActionOptions = (toId?: Id): EventNameOption[] => {
|
|||||||
methods = [];
|
methods = [];
|
||||||
(pageFragment.items || []).forEach((item: MComponent | MContainer) => {
|
(pageFragment.items || []).forEach((item: MComponent | MContainer) => {
|
||||||
traverseNode<MComponent | MContainer>(item, (current) => {
|
traverseNode<MComponent | MContainer>(item, (current) => {
|
||||||
const node = editorService.getNodeById(current.id) || current;
|
const node = services.editorService.getNodeById(current.id) || current;
|
||||||
|
|
||||||
if (!current.type) {
|
if (!current.type) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const nodeMethods = eventsService.getMethod(current.type, { targetId: current.id, node }) || [];
|
const nodeMethods = services.eventsService.getMethod(current.type, { targetId: current.id, node }) || [];
|
||||||
if (!nodeMethods.length) {
|
if (!nodeMethods.length) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -228,10 +245,11 @@ export const getCompActionOptions = (toId?: Id): EventNameOption[] => {
|
|||||||
export const getCompActionAllowedValues = (
|
export const getCompActionAllowedValues = (
|
||||||
config: { src?: string; compActionConfig?: { options?: unknown } } = {},
|
config: { src?: string; compActionConfig?: { options?: unknown } } = {},
|
||||||
model: { to?: Id } = {},
|
model: { to?: Id } = {},
|
||||||
|
services: EventUtilServices = getDefaultServices(),
|
||||||
): Set<string> | null => {
|
): Set<string> | null => {
|
||||||
if (typeof config.compActionConfig?.options !== 'undefined') {
|
if (typeof config.compActionConfig?.options !== 'undefined') {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return collectEventNameOptionValues(getCompActionOptions(model.to), isEventNameCheckStrictly(config.src));
|
return collectEventNameOptionValues(getCompActionOptions(model.to, services), isEventNameCheckStrictly(config.src));
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,14 +1,22 @@
|
|||||||
import { toRaw } from 'vue';
|
import { toRaw } from 'vue';
|
||||||
import { isEmpty } from 'lodash-es';
|
|
||||||
|
|
||||||
import type { Id, MContainer, MNode } from '@tmagic/core';
|
import type { Id, MContainer, MNode } from '@tmagic/core';
|
||||||
import { NodeType } from '@tmagic/core';
|
|
||||||
import { calcValueByFontsize, getElById, isPage, isPageOrFragment } from '@tmagic/utils';
|
|
||||||
|
|
||||||
import editorService from '@editor/services/editor';
|
import { editorService, propsService } from '@editor/services/sdk-instance';
|
||||||
import propsService from '@editor/services/props';
|
|
||||||
import type { AddMNode, PastePosition } from '@editor/type';
|
import type { AddMNode, PastePosition } from '@editor/type';
|
||||||
import { generatePageNameByApp, getInitPositionStyle } from '@editor/utils/editor';
|
|
||||||
|
import {
|
||||||
|
beforePaste as sdkBeforePaste,
|
||||||
|
getAddParent as sdkGetAddParent,
|
||||||
|
getDefaultConfig as sdkGetDefaultConfig,
|
||||||
|
getPositionInContainer as sdkGetPositionInContainer,
|
||||||
|
} from '../sdk/utils/operator';
|
||||||
|
|
||||||
|
const getContext = () => ({
|
||||||
|
editor: editorService,
|
||||||
|
props: propsService,
|
||||||
|
reactivity: { toRaw } as any,
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 粘贴前置操作:返回分配了新id以及校准了坐标的配置
|
* 粘贴前置操作:返回分配了新id以及校准了坐标的配置
|
||||||
@ -16,55 +24,8 @@ import { generatePageNameByApp, getInitPositionStyle } from '@editor/utils/edito
|
|||||||
* @param config 待粘贴的元素配置(复制时保存的那份配置)
|
* @param config 待粘贴的元素配置(复制时保存的那份配置)
|
||||||
* @returns
|
* @returns
|
||||||
*/
|
*/
|
||||||
export const beforePaste = (position: PastePosition, config: MNode[], doc?: Document): MNode[] => {
|
export const beforePaste = (position: PastePosition, config: MNode[], doc?: Document): MNode[] =>
|
||||||
if (!config[0]?.style) return config;
|
sdkBeforePaste(getContext(), position, config, doc);
|
||||||
const curNode = editorService.get('node');
|
|
||||||
// 将数组中第一个元素的坐标作为参照点
|
|
||||||
const { left: referenceLeft, top: referenceTop } = config[0].style;
|
|
||||||
// 坐标校准后的粘贴数据
|
|
||||||
const pasteConfigs: MNode[] = config.map((configItem: MNode): MNode => {
|
|
||||||
// 解构 position 对象,从 position 删除 offsetX、offsetY字段
|
|
||||||
const { offsetX = 0, offsetY = 0, ...positionClone } = position;
|
|
||||||
let pastePosition = positionClone;
|
|
||||||
|
|
||||||
if (!isEmpty(pastePosition) && curNode?.items) {
|
|
||||||
// 如果没有传入粘贴坐标则可能为键盘操作,不再转换
|
|
||||||
// 如果粘贴时选中了容器,则将元素粘贴到容器内,坐标需要转换为相对于容器的坐标
|
|
||||||
pastePosition = getPositionInContainer(pastePosition, curNode.id, doc);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 将所有待粘贴元素坐标相对于多选第一个元素坐标重新计算,以保证多选粘贴后元素间距不变
|
|
||||||
if (pastePosition.left && configItem.style?.left) {
|
|
||||||
pastePosition.left = configItem.style.left - referenceLeft + pastePosition.left;
|
|
||||||
}
|
|
||||||
if (pastePosition.top && configItem.style?.top) {
|
|
||||||
pastePosition.top = configItem.style?.top - referenceTop + pastePosition.top;
|
|
||||||
}
|
|
||||||
const pasteConfig = propsService.setNewItemId(configItem, false);
|
|
||||||
|
|
||||||
if (pasteConfig.style) {
|
|
||||||
const { left, top } = pasteConfig.style;
|
|
||||||
// 判断能转换为数字时,做粘贴偏移量计算
|
|
||||||
if (typeof left === 'number' || (!!left && !isNaN(Number(left)))) {
|
|
||||||
pasteConfig.style.left = Number(left) + offsetX;
|
|
||||||
}
|
|
||||||
if (typeof top === 'number' || (!!top && !isNaN(Number(top)))) {
|
|
||||||
pasteConfig.style.top = Number(top) + offsetY;
|
|
||||||
}
|
|
||||||
|
|
||||||
pasteConfig.style = {
|
|
||||||
...pasteConfig.style,
|
|
||||||
...pastePosition,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const root = editorService.get('root');
|
|
||||||
if (isPageOrFragment(pasteConfig) && root) {
|
|
||||||
pasteConfig.name = generatePageNameByApp(root, isPage(pasteConfig) ? NodeType.PAGE : NodeType.PAGE_FRAGMENT);
|
|
||||||
}
|
|
||||||
return pasteConfig as MNode;
|
|
||||||
});
|
|
||||||
return pasteConfigs;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 将元素粘贴到容器内时,将相对于画布坐标转换为相对于容器的坐标
|
* 将元素粘贴到容器内时,将相对于画布坐标转换为相对于容器的坐标
|
||||||
@ -72,37 +33,10 @@ export const beforePaste = (position: PastePosition, config: MNode[], doc?: Docu
|
|||||||
* @param id 元素id
|
* @param id 元素id
|
||||||
* @returns PastePosition 转换后的坐标
|
* @returns PastePosition 转换后的坐标
|
||||||
*/
|
*/
|
||||||
export const getPositionInContainer = (position: PastePosition = {}, id: Id, doc?: Document) => {
|
export const getPositionInContainer = (position: PastePosition = {}, id: Id, doc?: Document) =>
|
||||||
let { left = 0, top = 0 } = position;
|
sdkGetPositionInContainer(getContext(), position, id, doc);
|
||||||
const stageDoc = editorService.get('stage')?.renderer?.contentWindow?.document;
|
|
||||||
const parentEl = stageDoc && getElById()(stageDoc, `${id}`);
|
|
||||||
const parentElRect = parentEl?.getBoundingClientRect();
|
|
||||||
left = left - calcValueByFontsize(doc, parentElRect?.left || 0);
|
|
||||||
top = top - calcValueByFontsize(doc, parentElRect?.top || 0);
|
|
||||||
return {
|
|
||||||
left,
|
|
||||||
top,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getAddParent = (node: MNode) => {
|
export const getAddParent = (node: MNode) => sdkGetAddParent(getContext(), node);
|
||||||
const curNode = editorService.get('node');
|
|
||||||
|
|
||||||
let parentNode;
|
export const getDefaultConfig = (addNode: AddMNode, parentNode: MContainer) =>
|
||||||
if (isPage(node)) {
|
sdkGetDefaultConfig(getContext(), addNode, parentNode);
|
||||||
parentNode = editorService.get('root');
|
|
||||||
} else if (curNode?.items) {
|
|
||||||
parentNode = curNode as MContainer;
|
|
||||||
} else if (curNode?.id) {
|
|
||||||
parentNode = editorService.getParentById(curNode.id, false);
|
|
||||||
}
|
|
||||||
return parentNode;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getDefaultConfig = async (addNode: AddMNode, parentNode: MContainer) => {
|
|
||||||
const { type, inputEvent, ...config } = addNode;
|
|
||||||
const layout = await editorService.getLayout(toRaw(parentNode), addNode as MNode);
|
|
||||||
const newNode = { ...toRaw(await propsService.getPropsValue(type, config)) };
|
|
||||||
newNode.style = getInitPositionStyle(newNode.style, layout);
|
|
||||||
return newNode;
|
|
||||||
};
|
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
/* eslint-disable @typescript-eslint/naming-convention */
|
|
||||||
/*
|
/*
|
||||||
* Tencent is pleased to support the open source community by making TMagicEditor available.
|
* Tencent is pleased to support the open source community by making TMagicEditor available.
|
||||||
*
|
*
|
||||||
@ -16,341 +15,27 @@
|
|||||||
* See the License for the specific language governing permissions and
|
* See the License for the specific language governing permissions and
|
||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
import {
|
|
||||||
HookType,
|
|
||||||
NODE_CONDS_KEY,
|
|
||||||
NODE_CONDS_RESULT_KEY,
|
|
||||||
NODE_DISABLE_CODE_BLOCK_KEY,
|
|
||||||
NODE_DISABLE_DATA_SOURCE_KEY,
|
|
||||||
} from '@tmagic/core';
|
|
||||||
import { tMagicMessage } from '@tmagic/design';
|
import { tMagicMessage } from '@tmagic/design';
|
||||||
import type { ChildConfig, DisplayCondsConfig, FormConfig, TabConfig, TabPaneConfig } from '@tmagic/form';
|
import type { FormConfig } from '@tmagic/form';
|
||||||
|
|
||||||
export const arrayOptions = [
|
import type { FillConfigOptions } from '@editor/sdk/utils/props';
|
||||||
{ text: '包含', value: 'include' },
|
import { fillConfig as sdkFillConfig } from '@editor/sdk/utils/props';
|
||||||
{ text: '不包含', value: 'not_include' },
|
|
||||||
];
|
|
||||||
|
|
||||||
export const eqOptions = [
|
export * from '@editor/sdk/utils/props';
|
||||||
{ text: '等于', value: '=' },
|
|
||||||
{ text: '不等于', value: '!=' },
|
|
||||||
];
|
|
||||||
|
|
||||||
export const numberOptions = [
|
/** 浏览器端默认用 `navigator.clipboard` 复制,并用 `tMagicMessage` 提示 */
|
||||||
{ text: '大于', value: '>' },
|
export const copyTextToClipboard = (text: string): Promise<void> => navigator.clipboard.writeText(text);
|
||||||
{ text: '大于等于', value: '>=' },
|
|
||||||
{ text: '小于', value: '<' },
|
|
||||||
{ text: '小于等于', value: '<=' },
|
|
||||||
{ text: '在范围内', value: 'between' },
|
|
||||||
{ text: '不在范围内', value: 'not_between' },
|
|
||||||
];
|
|
||||||
|
|
||||||
export const booleanOptions = [
|
export const notifyByDesign = (type: 'success' | 'error' | 'warning' | 'info', message: string) => {
|
||||||
{ text: '是', value: 'is' },
|
const fn = (tMagicMessage as any)[type] ?? tMagicMessage;
|
||||||
{ text: '不是', value: 'not' },
|
fn(message);
|
||||||
];
|
|
||||||
|
|
||||||
/** 按字段类型返回条件运算符选项(UI 与 typeMatch 校验共用) */
|
|
||||||
export const getCondOpOptionsByFieldType = (type: string) => {
|
|
||||||
if (type === 'array') {
|
|
||||||
return arrayOptions;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type === 'boolean' || type === 'null') {
|
|
||||||
return booleanOptions;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type === 'number') {
|
|
||||||
return [...eqOptions, ...numberOptions];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type === 'string') {
|
|
||||||
return [...arrayOptions, ...eqOptions];
|
|
||||||
}
|
|
||||||
|
|
||||||
return [...arrayOptions, ...eqOptions, ...numberOptions];
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const styleTabConfig: TabPaneConfig = {
|
/** 与 SDK 的 `fillConfig` 一致,区别是默认接入浏览器的剪贴板与消息提示 */
|
||||||
title: '样式',
|
export const fillConfig = (config: FormConfig = [], options: FillConfigOptions = {}): FormConfig =>
|
||||||
display: ({ services }: any) => !(services?.uiService?.get('showStylePanel') ?? true),
|
sdkFillConfig(config, {
|
||||||
items: [
|
copyText: copyTextToClipboard,
|
||||||
{
|
notify: notifyByDesign,
|
||||||
name: 'style',
|
...options,
|
||||||
labelWidth: '100px',
|
});
|
||||||
type: 'style-setter',
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
names: [
|
|
||||||
'display',
|
|
||||||
'flexDirection',
|
|
||||||
'justifyContent',
|
|
||||||
'alignItems',
|
|
||||||
'flexWrap',
|
|
||||||
'marginTop',
|
|
||||||
'marginRight',
|
|
||||||
'marginBottom',
|
|
||||||
'marginLeft',
|
|
||||||
'paddingTop',
|
|
||||||
'paddingRight',
|
|
||||||
'paddingBottom',
|
|
||||||
'paddingLeft',
|
|
||||||
'width',
|
|
||||||
'height',
|
|
||||||
'overflow',
|
|
||||||
'fontSize',
|
|
||||||
'lineHeight',
|
|
||||||
'fontWeight',
|
|
||||||
'color',
|
|
||||||
'textAlign',
|
|
||||||
'backgroundColor',
|
|
||||||
'backgroundImage',
|
|
||||||
'backgroundSize',
|
|
||||||
'backgroundPosition',
|
|
||||||
'backgroundRepeat',
|
|
||||||
'position',
|
|
||||||
'zIndex',
|
|
||||||
'top',
|
|
||||||
'right',
|
|
||||||
'bottom',
|
|
||||||
'left',
|
|
||||||
'borderRadius',
|
|
||||||
'borderTopWidth',
|
|
||||||
'borderTopStyle',
|
|
||||||
'borderTopColor',
|
|
||||||
'borderRightColor',
|
|
||||||
'borderRightWidth',
|
|
||||||
'borderRightStyle',
|
|
||||||
'borderRightColor',
|
|
||||||
'borderBottomWidth',
|
|
||||||
'borderBottomStyle',
|
|
||||||
'borderBottomColor',
|
|
||||||
'borderLeftStyle',
|
|
||||||
'borderLeftWidth',
|
|
||||||
'borderLeftColor',
|
|
||||||
'borderWidth',
|
|
||||||
'borderStyle',
|
|
||||||
'borderColor',
|
|
||||||
'opacity',
|
|
||||||
],
|
|
||||||
} as unknown as ChildConfig,
|
|
||||||
{
|
|
||||||
name: 'transform',
|
|
||||||
defaultValue: () => ({}),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
export const eventTabConfig: TabPaneConfig = {
|
|
||||||
title: '事件',
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
name: 'events',
|
|
||||||
src: 'component',
|
|
||||||
labelWidth: '100px',
|
|
||||||
type: 'event-select',
|
|
||||||
defaultValue: [],
|
|
||||||
rules: [{ typeMatch: true }],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
export const advancedTabConfig: TabPaneConfig = {
|
|
||||||
title: '高级',
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
name: NODE_DISABLE_CODE_BLOCK_KEY,
|
|
||||||
text: '禁用代码块',
|
|
||||||
type: 'switch',
|
|
||||||
defaultValue: false,
|
|
||||||
extra: '开启后,配置的代码块将不会被执行',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: NODE_DISABLE_DATA_SOURCE_KEY,
|
|
||||||
text: '禁用数据源',
|
|
||||||
type: 'switch',
|
|
||||||
defaultValue: false,
|
|
||||||
extra: '开启后,组件内配置的数据源相关配置将不会被编译,显隐条件将失效',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'created',
|
|
||||||
text: 'Created',
|
|
||||||
labelPosition: 'top',
|
|
||||||
type: 'code-select',
|
|
||||||
titleExtra: '组件初始化时执行',
|
|
||||||
rules: [
|
|
||||||
{ typeMatch: true, trigger: 'change' },
|
|
||||||
{
|
|
||||||
validator: ({ value, callback }: any) => {
|
|
||||||
if (value && value.hookType !== HookType.CODE) {
|
|
||||||
return callback('hookType 必须是 code');
|
|
||||||
}
|
|
||||||
callback();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'mounted',
|
|
||||||
text: 'Mounted',
|
|
||||||
labelPosition: 'top',
|
|
||||||
type: 'code-select',
|
|
||||||
titleExtra: '组件挂载到dom时执行',
|
|
||||||
rules: [
|
|
||||||
{ typeMatch: true, trigger: 'change' },
|
|
||||||
{
|
|
||||||
validator: ({ value, callback }: any) => {
|
|
||||||
if (value && value.hookType !== HookType.CODE) {
|
|
||||||
return callback('hookType 必须是 code');
|
|
||||||
}
|
|
||||||
callback();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'display',
|
|
||||||
text: 'Display',
|
|
||||||
titleExtra: '控制组件是否渲染,关系的代码块返回值为false时不渲染',
|
|
||||||
labelPosition: 'top',
|
|
||||||
type: 'code-select',
|
|
||||||
rules: [
|
|
||||||
{ typeMatch: true, trigger: 'change' },
|
|
||||||
{
|
|
||||||
validator: ({ value, callback }: any) => {
|
|
||||||
if (value && value.hookType !== HookType.CODE) {
|
|
||||||
return callback('hookType 必须是 code');
|
|
||||||
}
|
|
||||||
callback();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
export const displayTabConfig: TabPaneConfig<DisplayCondsConfig> = {
|
|
||||||
title: '显示条件',
|
|
||||||
display: (_state, { model }) => model.type !== 'page',
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
name: NODE_CONDS_RESULT_KEY,
|
|
||||||
type: 'select',
|
|
||||||
text: '条件成立时',
|
|
||||||
defaultValue: false,
|
|
||||||
options: [
|
|
||||||
{ text: '显示', value: false },
|
|
||||||
{ text: '隐藏', value: true },
|
|
||||||
],
|
|
||||||
extra: (_state, { model }) =>
|
|
||||||
`条件成立时${model[NODE_CONDS_RESULT_KEY] ? '隐藏' : '显示'},不成立时${model[NODE_CONDS_RESULT_KEY] ? '显示' : '隐藏'};<br />同一条件组内的所有条件配置同时成立时表示该条件组成立,任意一个条件组成立时表示条件成立(条件组内为且的关系,条件组间为或的关系);<br />条件为空时表示成立;`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: '条件组配置',
|
|
||||||
static: true,
|
|
||||||
className: 'display-conds-title',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'display-conds',
|
|
||||||
name: NODE_CONDS_KEY,
|
|
||||||
titlePrefix: '条件组',
|
|
||||||
defaultValue: [],
|
|
||||||
rules: [{ typeMatch: true }],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 统一为组件属性表单加上事件、高级、样式配置
|
|
||||||
* @param config 组件属性配置
|
|
||||||
* @returns Object
|
|
||||||
*/
|
|
||||||
export const fillConfig = (
|
|
||||||
config: FormConfig = [],
|
|
||||||
{
|
|
||||||
labelWidth = '80px',
|
|
||||||
disabledDataSource = false,
|
|
||||||
disabledCodeBlock = false,
|
|
||||||
}: { labelWidth?: string; disabledDataSource?: boolean; disabledCodeBlock?: boolean } = {},
|
|
||||||
): FormConfig => {
|
|
||||||
const propsConfig: FormConfig = [];
|
|
||||||
|
|
||||||
// 组件类型,必须要有
|
|
||||||
if (!config.find((item) => 'name' in item && item.name === 'type')) {
|
|
||||||
propsConfig.push({
|
|
||||||
text: 'type',
|
|
||||||
name: 'type',
|
|
||||||
type: 'hidden',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!config.find((item) => 'name' in item && item.name === 'id')) {
|
|
||||||
// 组件id,必须要有
|
|
||||||
propsConfig.push({
|
|
||||||
name: 'id',
|
|
||||||
text: 'ID',
|
|
||||||
type: 'text',
|
|
||||||
// 走纯文本渲染,避免出现 disabled 的灰底输入框;append 仍正常显示。
|
|
||||||
static: true,
|
|
||||||
|
|
||||||
append: {
|
|
||||||
type: 'icon',
|
|
||||||
text: 'https://vip.image.video.qpic.cn/vupload/20260615/36cf7e1781493669935.svg',
|
|
||||||
extra: '复制',
|
|
||||||
handler: (vm, { model }) => {
|
|
||||||
navigator.clipboard
|
|
||||||
.writeText(`${model.id}`)
|
|
||||||
.then(() => {
|
|
||||||
tMagicMessage.success('已复制');
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
tMagicMessage.error('复制失败');
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!config.find((item) => 'name' in item && item.name === 'name')) {
|
|
||||||
propsConfig.push({
|
|
||||||
name: 'name',
|
|
||||||
text: '组件名称',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const noCodeAdvancedTabItems = advancedTabConfig.items.filter(
|
|
||||||
(item) => 'type' in item && item.type !== 'code-select',
|
|
||||||
);
|
|
||||||
|
|
||||||
if (noCodeAdvancedTabItems.length > 0 && disabledCodeBlock) {
|
|
||||||
advancedTabConfig.items = noCodeAdvancedTabItems;
|
|
||||||
}
|
|
||||||
|
|
||||||
const tabConfig: TabConfig = {
|
|
||||||
type: 'tab',
|
|
||||||
labelWidth,
|
|
||||||
className: 'magic-right-panel-tabs-top',
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
title: '属性',
|
|
||||||
items: [...propsConfig, ...config],
|
|
||||||
},
|
|
||||||
{ ...styleTabConfig },
|
|
||||||
{ ...eventTabConfig },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!disabledCodeBlock) {
|
|
||||||
tabConfig.items.push({ ...advancedTabConfig });
|
|
||||||
} else if (noCodeAdvancedTabItems.length > 0) {
|
|
||||||
tabConfig.items.push({ ...advancedTabConfig });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!disabledDataSource) {
|
|
||||||
tabConfig.items.push({ ...displayTabConfig });
|
|
||||||
}
|
|
||||||
|
|
||||||
return [tabConfig];
|
|
||||||
};
|
|
||||||
|
|||||||
@ -35,9 +35,7 @@ import {
|
|||||||
removeDataSourceFieldPrefix,
|
removeDataSourceFieldPrefix,
|
||||||
} from '@tmagic/utils';
|
} from '@tmagic/utils';
|
||||||
|
|
||||||
import codeBlockService from '@editor/services/codeBlock';
|
import { codeBlockService, dataSourceService, editorService } from '@editor/services/sdk-instance';
|
||||||
import dataSourceService from '@editor/services/dataSource';
|
|
||||||
import editorService from '@editor/services/editor';
|
|
||||||
import { getFieldType, resolveFieldByPath } from '@editor/utils/data-source';
|
import { getFieldType, resolveFieldByPath } from '@editor/utils/data-source';
|
||||||
import {
|
import {
|
||||||
arrayOptions,
|
arrayOptions,
|
||||||
|
|||||||
@ -25,35 +25,55 @@ vi.mock('@editor/initService', () => ({
|
|||||||
initServiceState: initServiceStateMock,
|
initServiceState: initServiceStateMock,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('@editor/services/codeBlock', () => ({ default: {} }));
|
vi.mock('@editor/services/sdk-instance', async (importOriginal) => {
|
||||||
vi.mock('@editor/services/componentList', () => ({ default: {} }));
|
const actual = await importOriginal<any>();
|
||||||
vi.mock('@editor/services/dataSource', () => ({ default: {} }));
|
const modCodeBlock = await (() => ({ default: {} }))();
|
||||||
vi.mock('@editor/services/dep', () => ({ default: {} }));
|
const modComponentList = await (() => ({ default: {} }))();
|
||||||
|
const modDataSource = await (() => ({ default: {} }))();
|
||||||
|
const modDep = await (() => ({ default: {} }))();
|
||||||
|
const modEditor = await (async () => {
|
||||||
|
const { shallowRef } = await import('vue');
|
||||||
|
const stage = shallowRef<any>(stageStub);
|
||||||
|
return {
|
||||||
|
default: {
|
||||||
|
get: vi.fn((key: string) => (key === 'stage' ? stage.value : undefined)),
|
||||||
|
__setStage: (value: any) => {
|
||||||
|
stage.value = value;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
const modEvents = await (() => ({ default: {} }))();
|
||||||
|
const modHistory = await (() => ({ default: {} }))();
|
||||||
|
const modProps = await (() => ({ default: {} }))();
|
||||||
|
const modStorage = await (() => ({ default: {}, Protocol: {} }))();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
codeBlockService: modCodeBlock.default,
|
||||||
|
componentListService: modComponentList.default,
|
||||||
|
dataSourceService: modDataSource.default,
|
||||||
|
depService: modDep.default,
|
||||||
|
editorService: modEditor.default,
|
||||||
|
eventsService: modEvents.default,
|
||||||
|
historyService: modHistory.default,
|
||||||
|
propsService: modProps.default,
|
||||||
|
storageService: modStorage.default,
|
||||||
|
Protocol: modStorage.Protocol,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
const { stageStub } = vi.hoisted(() => ({ stageStub: { name: 'stage-instance' } }));
|
const { stageStub } = vi.hoisted(() => ({ stageStub: { name: 'stage-instance' } }));
|
||||||
// 用响应式 ref 承载 stage,模拟真实 editorService 的 reactive state,
|
// 用响应式 ref 承载 stage,模拟真实 editorService 的 reactive state,
|
||||||
// 这样 formContext 的 computed 才会在 stage 变化时失效重算
|
// 这样 formContext 的 computed 才会在 stage 变化时失效重算
|
||||||
vi.mock('@editor/services/editor', async () => {
|
|
||||||
const { shallowRef } = await import('vue');
|
|
||||||
const stage = shallowRef<any>(stageStub);
|
|
||||||
return {
|
|
||||||
default: {
|
|
||||||
get: vi.fn((key: string) => (key === 'stage' ? stage.value : undefined)),
|
|
||||||
__setStage: (value: any) => {
|
|
||||||
stage.value = value;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
vi.mock('@editor/services/events', () => ({ default: {} }));
|
|
||||||
vi.mock('@editor/services/history', () => ({ default: {} }));
|
|
||||||
vi.mock('@editor/services/keybinding', () => ({
|
vi.mock('@editor/services/keybinding', () => ({
|
||||||
default: { register: vi.fn(), registerEl: vi.fn() },
|
default: { register: vi.fn(), registerEl: vi.fn() },
|
||||||
}));
|
}));
|
||||||
vi.mock('@editor/services/props', () => ({ default: {} }));
|
|
||||||
vi.mock('@editor/services/stageOverlay', () => ({
|
vi.mock('@editor/services/stageOverlay', () => ({
|
||||||
default: { set: vi.fn() },
|
default: { set: vi.fn() },
|
||||||
}));
|
}));
|
||||||
vi.mock('@editor/services/storage', () => ({ default: {}, Protocol: {} }));
|
|
||||||
vi.mock('@editor/services/ui', () => ({ default: {} }));
|
vi.mock('@editor/services/ui', () => ({ default: {} }));
|
||||||
vi.mock('@editor/utils/keybinding-config', () => ({ default: {}, KeyBindingContainerKey: { STAGE: 'stage' } }));
|
vi.mock('@editor/utils/keybinding-config', () => ({ default: {}, KeyBindingContainerKey: { STAGE: 'stage' } }));
|
||||||
|
|
||||||
@ -64,6 +84,15 @@ vi.mock('@editor/layouts/Framework.vue', () => ({
|
|||||||
setup(_p, { slots }) {
|
setup(_p, { slots }) {
|
||||||
return () =>
|
return () =>
|
||||||
h('div', { class: 'fake-framework' }, [
|
h('div', { class: 'fake-framework' }, [
|
||||||
|
slots['content-before']?.(),
|
||||||
|
slots['src-code']?.(),
|
||||||
|
slots.empty?.(),
|
||||||
|
slots['content-after']?.(),
|
||||||
|
slots['page-bar']?.(),
|
||||||
|
slots['page-bar-add-button']?.(),
|
||||||
|
slots['page-bar-title']?.({ page: { id: 'p' } }),
|
||||||
|
slots['page-bar-popover']?.({ page: { id: 'p' } }),
|
||||||
|
slots['page-list-popover']?.({ list: [] }),
|
||||||
slots.header?.(),
|
slots.header?.(),
|
||||||
slots.nav?.({ editorService: {} }),
|
slots.nav?.({ editorService: {} }),
|
||||||
slots.sidebar?.({ editorService: {} }),
|
slots.sidebar?.({ editorService: {} }),
|
||||||
@ -89,25 +118,52 @@ vi.mock('@editor/layouts/sidebar/Sidebar.vue', () => ({
|
|||||||
default: defineComponent({
|
default: defineComponent({
|
||||||
name: 'FakeSidebar',
|
name: 'FakeSidebar',
|
||||||
emits: ['layer-node-dblclick'],
|
emits: ['layer-node-dblclick'],
|
||||||
setup(_p, { emit }) {
|
setup(_p, { emit, slots }) {
|
||||||
return () =>
|
return () =>
|
||||||
h('div', {
|
h(
|
||||||
class: 'fake-sidebar',
|
'div',
|
||||||
onClick: () => emit('layer-node-dblclick', new MouseEvent('dblclick'), { id: 'a' }),
|
{
|
||||||
});
|
class: 'fake-sidebar',
|
||||||
|
onClick: () => emit('layer-node-dblclick', new MouseEvent('dblclick'), { id: 'a' }),
|
||||||
|
},
|
||||||
|
[
|
||||||
|
slots['layer-panel-header']?.(),
|
||||||
|
slots['layer-node-content']?.({ data: { id: 'a' } }),
|
||||||
|
slots['layer-node-label']?.({ data: { id: 'a' } }),
|
||||||
|
slots['layer-node-tool']?.({ data: { id: 'a' } }),
|
||||||
|
slots['component-list-panel-header']?.(),
|
||||||
|
slots['component-list']?.({ componentGroupList: [] }),
|
||||||
|
slots['component-list-item']?.({ component: { type: 'x' } }),
|
||||||
|
slots['code-block-panel-header']?.(),
|
||||||
|
slots['code-block-panel-tool']?.({ id: 'c', data: {} }),
|
||||||
|
slots['code-block-panel-search']?.(),
|
||||||
|
slots['data-source-panel-tool']?.({ data: {} }),
|
||||||
|
slots['data-source-panel-search']?.(),
|
||||||
|
],
|
||||||
|
);
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('@editor/layouts/workspace/Workspace.vue', () => ({
|
vi.mock('@editor/layouts/workspace/Workspace.vue', () => ({
|
||||||
default: defineComponent({ name: 'FakeWorkspace', setup: () => () => h('div', { class: 'fake-workspace' }) }),
|
default: defineComponent({
|
||||||
|
name: 'FakeWorkspace',
|
||||||
|
setup:
|
||||||
|
(_p, { slots }) =>
|
||||||
|
() =>
|
||||||
|
h('div', { class: 'fake-workspace' }, [
|
||||||
|
slots['stage-top']?.(),
|
||||||
|
slots.stage?.(),
|
||||||
|
slots['workspace-content']?.(),
|
||||||
|
]),
|
||||||
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('@editor/layouts/props-panel/PropsPanel.vue', () => ({
|
vi.mock('@editor/layouts/props-panel/PropsPanel.vue', () => ({
|
||||||
default: defineComponent({
|
default: defineComponent({
|
||||||
name: 'PropsPanel',
|
name: 'PropsPanel',
|
||||||
emits: ['mounted', 'unmounted', 'submit-error', 'form-error'],
|
emits: ['mounted', 'unmounted', 'submit-error', 'form-error'],
|
||||||
setup(_p, { emit }) {
|
setup(_p, { emit, slots }) {
|
||||||
injectedFormContext = inject(FORM_CONTEXT_KEY, undefined);
|
injectedFormContext = inject(FORM_CONTEXT_KEY, undefined);
|
||||||
injectedEnablePropsFormValidate = inject(
|
injectedEnablePropsFormValidate = inject(
|
||||||
ENABLE_PROPS_FORM_VALIDATE,
|
ENABLE_PROPS_FORM_VALIDATE,
|
||||||
@ -115,6 +171,7 @@ vi.mock('@editor/layouts/props-panel/PropsPanel.vue', () => ({
|
|||||||
);
|
);
|
||||||
return () =>
|
return () =>
|
||||||
h('div', { class: 'fake-props-panel' }, [
|
h('div', { class: 'fake-props-panel' }, [
|
||||||
|
slots['props-panel-header']?.(),
|
||||||
h('button', { class: 'mounted-btn', onClick: () => emit('mounted', { proxy: true }) }),
|
h('button', { class: 'mounted-btn', onClick: () => emit('mounted', { proxy: true }) }),
|
||||||
h('button', { class: 'unmounted-btn', onClick: () => emit('unmounted') }),
|
h('button', { class: 'unmounted-btn', onClick: () => emit('unmounted') }),
|
||||||
h('button', { class: 'submit-err', onClick: () => emit('submit-error', new Error('e')) }),
|
h('button', { class: 'submit-err', onClick: () => emit('submit-error', new Error('e')) }),
|
||||||
@ -241,16 +298,16 @@ describe('Editor', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('stage 走 computed 读时求值,切换画布后能读到新实例', async () => {
|
test('stage 走 computed 读时求值,切换画布后能读到新实例', async () => {
|
||||||
const editorServiceMod = (await import('@editor/services/editor')) as any;
|
const editorServiceMod = (await import('@editor/services/sdk-instance')) as any;
|
||||||
mount(Editor, { props: {} as any });
|
mount(Editor, { props: {} as any });
|
||||||
await nextTick();
|
await nextTick();
|
||||||
|
|
||||||
expect((injectedFormContext!.value as any).stage).toBe(stageStub);
|
expect((injectedFormContext!.value as any).stage).toBe(stageStub);
|
||||||
|
|
||||||
const nextStage = { name: 'next-stage' };
|
const nextStage = { name: 'next-stage' };
|
||||||
editorServiceMod.default.__setStage(nextStage);
|
editorServiceMod.editorService.__setStage(nextStage);
|
||||||
expect((injectedFormContext!.value as any).stage).toBe(nextStage);
|
expect((injectedFormContext!.value as any).stage).toBe(nextStage);
|
||||||
editorServiceMod.default.__setStage(stageStub);
|
editorServiceMod.editorService.__setStage(stageStub);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('ENABLE_PROPS_FORM_VALIDATE 默认注入 false', async () => {
|
test('ENABLE_PROPS_FORM_VALIDATE 默认注入 false', async () => {
|
||||||
@ -276,4 +333,44 @@ describe('Editor', () => {
|
|||||||
expect((wrapper.vm as any).editorService).toBeDefined();
|
expect((wrapper.vm as any).editorService).toBeDefined();
|
||||||
expect((wrapper.vm as any).propsService).toBeDefined();
|
expect((wrapper.vm as any).propsService).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('所有插槽透传到子布局', () => {
|
||||||
|
const names = [
|
||||||
|
'content-before',
|
||||||
|
'src-code',
|
||||||
|
'empty',
|
||||||
|
'content-after',
|
||||||
|
'page-bar',
|
||||||
|
'page-bar-add-button',
|
||||||
|
'page-bar-title',
|
||||||
|
'page-bar-popover',
|
||||||
|
'page-list-popover',
|
||||||
|
'layer-panel-header',
|
||||||
|
'layer-node-content',
|
||||||
|
'layer-node-label',
|
||||||
|
'layer-node-tool',
|
||||||
|
'component-list-panel-header',
|
||||||
|
'component-list',
|
||||||
|
'component-list-item',
|
||||||
|
'code-block-panel-header',
|
||||||
|
'code-block-panel-tool',
|
||||||
|
'code-block-panel-search',
|
||||||
|
'data-source-panel-tool',
|
||||||
|
'data-source-panel-search',
|
||||||
|
'stage-top',
|
||||||
|
'stage',
|
||||||
|
'workspace-content',
|
||||||
|
'props-panel-header',
|
||||||
|
];
|
||||||
|
const slots = Object.fromEntries(names.map((n) => [n, `<i class="slot-${n}"></i>`]));
|
||||||
|
const wrapper = mount(Editor, { props: {} as any, slots });
|
||||||
|
for (const n of names) {
|
||||||
|
expect(wrapper.find(`.slot-${n}`).exists(), n).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('传入 isLargeStageContainer 与 theme', () => {
|
||||||
|
const wrapper = mount(Editor, { props: { isLargeStageContainer: true, theme: 'dark' } as any });
|
||||||
|
expect(wrapper.exists()).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -10,7 +10,7 @@ import { mount } from '@vue/test-utils';
|
|||||||
import CodeSelect from '@editor/fields/CodeSelect.vue';
|
import CodeSelect from '@editor/fields/CodeSelect.vue';
|
||||||
|
|
||||||
// 表单配置由 fields/configs/codeSelect.ts 产出(组件与无渲染校验的嵌套配置共用),
|
// 表单配置由 fields/configs/codeSelect.ts 产出(组件与无渲染校验的嵌套配置共用),
|
||||||
// 那里直接用服务单例,因此这里 mock 服务模块本身
|
// 那里通过 mForm.services 取服务,因此这里构造一个带 services 的 mForm
|
||||||
const { dataSourceService, codeBlockService } = vi.hoisted(() => ({
|
const { dataSourceService, codeBlockService } = vi.hoisted(() => ({
|
||||||
dataSourceService: {
|
dataSourceService: {
|
||||||
get: vi.fn(() => true),
|
get: vi.fn(() => true),
|
||||||
@ -22,8 +22,7 @@ const { dataSourceService, codeBlockService } = vi.hoisted(() => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('@editor/services/dataSource', () => ({ default: dataSourceService }));
|
const mForm = { services: { dataSourceService, codeBlockService } } as any;
|
||||||
vi.mock('@editor/services/codeBlock', () => ({ default: codeBlockService }));
|
|
||||||
|
|
||||||
vi.mock('@tmagic/form', async (importOriginal) => {
|
vi.mock('@tmagic/form', async (importOriginal) => {
|
||||||
const actual = await importOriginal<any>();
|
const actual = await importOriginal<any>();
|
||||||
@ -73,7 +72,7 @@ describe('CodeSelect', () => {
|
|||||||
const wrapper = mount(CodeSelect, { props: baseProps() as any });
|
const wrapper = mount(CodeSelect, { props: baseProps() as any });
|
||||||
const container = wrapper.findComponent({ name: 'MContainer' });
|
const container = wrapper.findComponent({ name: 'MContainer' });
|
||||||
const config = container.props('config') as any;
|
const config = container.props('config') as any;
|
||||||
const title = config.title(undefined, { model: { codeType: 'code', codeId: 'c1' }, index: 0 });
|
const title = config.title(mForm, { model: { codeType: 'code', codeId: 'c1' }, index: 0 });
|
||||||
expect(title).toBe('code-name');
|
expect(title).toBe('code-name');
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -81,7 +80,7 @@ describe('CodeSelect', () => {
|
|||||||
const wrapper = mount(CodeSelect, { props: baseProps() as any });
|
const wrapper = mount(CodeSelect, { props: baseProps() as any });
|
||||||
const container = wrapper.findComponent({ name: 'MContainer' });
|
const container = wrapper.findComponent({ name: 'MContainer' });
|
||||||
const config = container.props('config') as any;
|
const config = container.props('config') as any;
|
||||||
const title = config.title(undefined, {
|
const title = config.title(mForm, {
|
||||||
model: { codeType: 'data-source-method', codeId: ['ds1', 'doFetch'] },
|
model: { codeType: 'data-source-method', codeId: ['ds1', 'doFetch'] },
|
||||||
index: 0,
|
index: 0,
|
||||||
});
|
});
|
||||||
@ -92,7 +91,7 @@ describe('CodeSelect', () => {
|
|||||||
const wrapper = mount(CodeSelect, { props: baseProps() as any });
|
const wrapper = mount(CodeSelect, { props: baseProps() as any });
|
||||||
const container = wrapper.findComponent({ name: 'MContainer' });
|
const container = wrapper.findComponent({ name: 'MContainer' });
|
||||||
const config = container.props('config') as any;
|
const config = container.props('config') as any;
|
||||||
const title = config.title(undefined, {
|
const title = config.title(mForm, {
|
||||||
model: { codeType: 'data-source-method', codeId: ['ds1'] },
|
model: { codeType: 'data-source-method', codeId: ['ds1'] },
|
||||||
index: 5,
|
index: 5,
|
||||||
});
|
});
|
||||||
@ -104,7 +103,7 @@ describe('CodeSelect', () => {
|
|||||||
const wrapper = mount(CodeSelect, { props: baseProps() as any });
|
const wrapper = mount(CodeSelect, { props: baseProps() as any });
|
||||||
const container = wrapper.findComponent({ name: 'MContainer' });
|
const container = wrapper.findComponent({ name: 'MContainer' });
|
||||||
const config = container.props('config') as any;
|
const config = container.props('config') as any;
|
||||||
const title = config.title(undefined, { model: { codeType: 'code', codeId: 'unknown' }, index: 0 });
|
const title = config.title(mForm, { model: { codeType: 'code', codeId: 'unknown' }, index: 0 });
|
||||||
expect(title).toBe('unknown');
|
expect(title).toBe('unknown');
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -165,8 +164,8 @@ describe('CodeSelect', () => {
|
|||||||
const wrapper = mount(CodeSelect, { props: baseProps() as any });
|
const wrapper = mount(CodeSelect, { props: baseProps() as any });
|
||||||
const container = wrapper.findComponent({ name: 'MContainer' });
|
const container = wrapper.findComponent({ name: 'MContainer' });
|
||||||
const config = container.props('config') as any;
|
const config = container.props('config') as any;
|
||||||
expect(config.items[1].notEditable()).toBe(true);
|
expect(config.items[1].notEditable(mForm)).toBe(true);
|
||||||
expect(config.items[2].notEditable()).toBe(true);
|
expect(config.items[2].notEditable(mForm)).toBe(true);
|
||||||
codeBlockService.getEditStatus.mockReturnValue(true);
|
codeBlockService.getEditStatus.mockReturnValue(true);
|
||||||
dataSourceService.get.mockReturnValue(true);
|
dataSourceService.get.mockReturnValue(true);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -15,7 +15,7 @@ const { dataSourceService } = vi.hoisted(() => ({
|
|||||||
dataSourceService: { getDataSourceById: vi.fn() },
|
dataSourceService: { getDataSourceById: vi.fn() },
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('@editor/services/dataSource', () => ({ default: dataSourceService }));
|
const mForm = { services: { dataSourceService } } as any;
|
||||||
|
|
||||||
const { fieldTypeMock } = vi.hoisted(() => ({
|
const { fieldTypeMock } = vi.hoisted(() => ({
|
||||||
fieldTypeMock: vi.fn((_ds: any, names: string[]) => {
|
fieldTypeMock: vi.fn((_ds: any, names: string[]) => {
|
||||||
@ -111,7 +111,7 @@ describe('DisplayConds', () => {
|
|||||||
});
|
});
|
||||||
const item = capturedConfig.items[0].items[0];
|
const item = capturedConfig.items[0].items[0];
|
||||||
expect(item.type).toBe('cascader');
|
expect(item.type).toBe('cascader');
|
||||||
expect(item.options()).toEqual([{ label: 'f1', value: 'f1' }]);
|
expect(item.options(mForm)).toEqual([{ label: 'f1', value: 'f1' }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('parentFields 为空时使用 data-source-field-select', () => {
|
test('parentFields 为空时使用 data-source-field-select', () => {
|
||||||
@ -160,10 +160,10 @@ describe('DisplayConds', () => {
|
|||||||
props: { config: { titlePrefix: 't', parentFields: ['ds1'] }, model: {}, name: 'conds' } as any,
|
props: { config: { titlePrefix: 't', parentFields: ['ds1'] }, model: {}, name: 'conds' } as any,
|
||||||
});
|
});
|
||||||
const valueItem = capturedConfig.items[0].items[2];
|
const valueItem = capturedConfig.items[0].items[2];
|
||||||
expect(valueItem.type(undefined, { model: { field: ['numField'] } })).toBe('number');
|
expect(valueItem.type(mForm, { model: { field: ['numField'] } })).toBe('number');
|
||||||
expect(valueItem.type(undefined, { model: { field: ['boolField'] } })).toBe('select');
|
expect(valueItem.type(mForm, { model: { field: ['boolField'] } })).toBe('select');
|
||||||
expect(valueItem.type(undefined, { model: { field: ['nullField'] } })).toBe('display');
|
expect(valueItem.type(mForm, { model: { field: ['nullField'] } })).toBe('display');
|
||||||
expect(valueItem.type(undefined, { model: { field: ['anyField'] } })).toBe('text');
|
expect(valueItem.type(mForm, { model: { field: ['anyField'] } })).toBe('text');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('value display 函数', () => {
|
test('value display 函数', () => {
|
||||||
@ -218,7 +218,7 @@ describe('DisplayConds', () => {
|
|||||||
} as any,
|
} as any,
|
||||||
});
|
});
|
||||||
const item = capturedConfig.items[0].items[0];
|
const item = capturedConfig.items[0].items[0];
|
||||||
expect(item.options()).toEqual([]);
|
expect(item.options(mForm)).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('isCompare 但无 lastValues 时不进入对比', () => {
|
test('isCompare 但无 lastValues 时不进入对比', () => {
|
||||||
|
|||||||
@ -37,11 +37,9 @@ vi.mock('@editor/hooks/use-services', () => ({
|
|||||||
useServices: () => ({ editorService, dataSourceService, eventsService, codeBlockService, propsService }),
|
useServices: () => ({ editorService, dataSourceService, eventsService, codeBlockService, propsService }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('@editor/services/editor', () => ({ default: editorService }));
|
const mForm = {
|
||||||
vi.mock('@editor/services/dataSource', () => ({ default: dataSourceService }));
|
services: { editorService, dataSourceService, eventsService, codeBlockService, propsService },
|
||||||
vi.mock('@editor/services/events', () => ({ default: eventsService }));
|
} as any;
|
||||||
vi.mock('@editor/services/codeBlock', () => ({ default: codeBlockService }));
|
|
||||||
vi.mock('@editor/services/props', () => ({ default: propsService }));
|
|
||||||
|
|
||||||
vi.mock('@editor/utils/data-source', async () => {
|
vi.mock('@editor/utils/data-source', async () => {
|
||||||
const actual = await vi.importActual<any>('@editor/utils/data-source');
|
const actual = await vi.importActual<any>('@editor/utils/data-source');
|
||||||
@ -188,8 +186,8 @@ describe('EventSelect', () => {
|
|||||||
model: { events: [{ name: 'a', actions: [] }] },
|
model: { events: [{ name: 'a', actions: [] }] },
|
||||||
});
|
});
|
||||||
const cfg = eventNameCfg();
|
const cfg = eventNameCfg();
|
||||||
expect(cfg.type(undefined, { formValue: { type: 'btn' } })).toBe('select');
|
expect(cfg.type(mForm, { formValue: { type: 'btn' } })).toBe('select');
|
||||||
const opts = cfg.options(undefined, { formValue: { type: 'btn' } });
|
const opts = cfg.options(mForm, { formValue: { type: 'btn' } });
|
||||||
expect(Array.isArray(opts)).toBe(true);
|
expect(Array.isArray(opts)).toBe(true);
|
||||||
expect(opts[0]).toMatchObject({ text: 'click', value: 'click' });
|
expect(opts[0]).toMatchObject({ text: 'click', value: 'click' });
|
||||||
});
|
});
|
||||||
@ -202,16 +200,16 @@ describe('EventSelect', () => {
|
|||||||
const [rule] = cfg.rules;
|
const [rule] = cfg.rules;
|
||||||
|
|
||||||
const okCb = vi.fn();
|
const okCb = vi.fn();
|
||||||
rule.validator({ value: 'click', callback: okCb }, { formValue: { type: 'btn' } });
|
rule.validator({ value: 'click', callback: okCb }, { formValue: { type: 'btn' } }, mForm);
|
||||||
expect(okCb).toHaveBeenCalledWith();
|
expect(okCb).toHaveBeenCalledWith();
|
||||||
|
|
||||||
const errCb = vi.fn();
|
const errCb = vi.fn();
|
||||||
rule.validator({ value: 'unknown', callback: errCb }, { formValue: { type: 'btn' } });
|
rule.validator({ value: 'unknown', callback: errCb }, { formValue: { type: 'btn' } }, mForm);
|
||||||
expect(errCb).toHaveBeenCalledWith('事件名(unknown)不存在');
|
expect(errCb).toHaveBeenCalledWith('事件名(unknown)不存在');
|
||||||
|
|
||||||
// 空值不做枚举校验
|
// 空值不做枚举校验
|
||||||
const emptyCb = vi.fn();
|
const emptyCb = vi.fn();
|
||||||
rule.validator({ value: '', callback: emptyCb }, { formValue: { type: 'btn' } });
|
rule.validator({ value: '', callback: emptyCb }, { formValue: { type: 'btn' } }, mForm);
|
||||||
expect(emptyCb).toHaveBeenCalledWith();
|
expect(emptyCb).toHaveBeenCalledWith();
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -224,7 +222,7 @@ describe('EventSelect', () => {
|
|||||||
const [rule] = cfg.rules;
|
const [rule] = cfg.rules;
|
||||||
|
|
||||||
const cb = vi.fn();
|
const cb = vi.fn();
|
||||||
rule.validator({ value: 'whatever', callback: cb }, { formValue: { type: 'btn' } });
|
rule.validator({ value: 'whatever', callback: cb }, { formValue: { type: 'btn' } }, mForm);
|
||||||
expect(cb).toHaveBeenCalledWith();
|
expect(cb).toHaveBeenCalledWith();
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -234,10 +232,8 @@ describe('EventSelect', () => {
|
|||||||
model: { events: [{ name: 'a', actions: [] }] },
|
model: { events: [{ name: 'a', actions: [] }] },
|
||||||
});
|
});
|
||||||
const cfg = eventNameCfg();
|
const cfg = eventNameCfg();
|
||||||
expect(cfg.type(undefined, { formValue: { type: 'page-fragment-container', pageFragmentId: 'pf1' } })).toBe(
|
expect(cfg.type(mForm, { formValue: { type: 'page-fragment-container', pageFragmentId: 'pf1' } })).toBe('cascader');
|
||||||
'cascader',
|
const opts = cfg.options(mForm, { formValue: { type: 'page-fragment-container', pageFragmentId: 'pf1' } });
|
||||||
);
|
|
||||||
const opts = cfg.options(undefined, { formValue: { type: 'page-fragment-container', pageFragmentId: 'pf1' } });
|
|
||||||
expect(Array.isArray(opts)).toBe(true);
|
expect(Array.isArray(opts)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -248,7 +244,7 @@ describe('EventSelect', () => {
|
|||||||
model: { events: [{ name: 'a', actions: [] }] },
|
model: { events: [{ name: 'a', actions: [] }] },
|
||||||
});
|
});
|
||||||
const cfg = eventNameCfg();
|
const cfg = eventNameCfg();
|
||||||
const opts = cfg.options(undefined, { formValue: { type: 'ds', id: 'd1' } });
|
const opts = cfg.options(mForm, { formValue: { type: 'ds', id: 'd1' } });
|
||||||
expect(opts).toEqual([{ label: '数据变化', value: 'ds_change_', children: [] }]);
|
expect(opts).toEqual([{ label: '数据变化', value: 'ds_change_', children: [] }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -259,10 +255,34 @@ describe('EventSelect', () => {
|
|||||||
model: { events: [{ name: 'a', actions: [] }] },
|
model: { events: [{ name: 'a', actions: [] }] },
|
||||||
});
|
});
|
||||||
const cfg = eventNameCfg();
|
const cfg = eventNameCfg();
|
||||||
const opts = cfg.options(undefined, { formValue: { type: 'ds', id: 'd1' } });
|
const opts = cfg.options(mForm, { formValue: { type: 'ds', id: 'd1' } });
|
||||||
expect(opts).toEqual([]);
|
expect(opts).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('表单上下文没有 services 时安全降级,字段不抛错', () => {
|
||||||
|
mountEvent({
|
||||||
|
model: { events: [{ name: 'a', actions: [] }] },
|
||||||
|
});
|
||||||
|
const nameCfg = eventNameCfg();
|
||||||
|
// 没有 services 时选项为空,回调不会抛错
|
||||||
|
expect(nameCfg.options(undefined, { formValue: { type: 'btn' } })).toEqual([]);
|
||||||
|
const cb = vi.fn();
|
||||||
|
nameCfg.rules[0].validator({ value: 'x', callback: cb }, { formValue: { type: 'btn' } }, undefined);
|
||||||
|
expect(cb).toHaveBeenCalledWith();
|
||||||
|
|
||||||
|
const [actionType, , compAction, codeAction, dsAction] = actionsCfg().items;
|
||||||
|
// 没有 services 时只保留组件联动
|
||||||
|
expect(actionType.options(undefined).map((o: any) => o.value)).toEqual(['comp']);
|
||||||
|
expect(compAction.type(undefined, { model: { to: '1' } })).toBe('select');
|
||||||
|
expect(compAction.options(undefined, { model: { to: '1' } })).toEqual([]);
|
||||||
|
const cb2 = vi.fn();
|
||||||
|
compAction.rules[0].validator({ value: 'x', callback: cb2 }, { model: { to: '1' } }, undefined);
|
||||||
|
expect(cb2).toHaveBeenCalledWith();
|
||||||
|
// 没有 services 时代码块 / 数据源联动只读
|
||||||
|
expect(codeAction.notEditable(undefined)).toBe(true);
|
||||||
|
expect(dsAction.notEditable(undefined)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
test('actionTypeConfig 含 组件/代码/数据源', () => {
|
test('actionTypeConfig 含 组件/代码/数据源', () => {
|
||||||
propsService.getDisabledCodeBlock.mockReturnValue(false);
|
propsService.getDisabledCodeBlock.mockReturnValue(false);
|
||||||
propsService.getDisabledDataSource.mockReturnValue(false);
|
propsService.getDisabledDataSource.mockReturnValue(false);
|
||||||
@ -270,7 +290,7 @@ describe('EventSelect', () => {
|
|||||||
model: { events: [{ name: 'a', actions: [] }] },
|
model: { events: [{ name: 'a', actions: [] }] },
|
||||||
});
|
});
|
||||||
const actionType = actionsCfg().items[0];
|
const actionType = actionsCfg().items[0];
|
||||||
const opts = typeof actionType.options === 'function' ? actionType.options() : actionType.options;
|
const opts = typeof actionType.options === 'function' ? actionType.options(mForm) : actionType.options;
|
||||||
expect(opts.map((o: any) => o.value).sort()).toEqual(['code', 'comp', 'data-source'].sort());
|
expect(opts.map((o: any) => o.value).sort()).toEqual(['code', 'comp', 'data-source'].sort());
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -281,7 +301,7 @@ describe('EventSelect', () => {
|
|||||||
model: { events: [{ name: 'a', actions: [] }] },
|
model: { events: [{ name: 'a', actions: [] }] },
|
||||||
});
|
});
|
||||||
const actionType = actionsCfg().items[0];
|
const actionType = actionsCfg().items[0];
|
||||||
const opts = typeof actionType.options === 'function' ? actionType.options() : actionType.options;
|
const opts = typeof actionType.options === 'function' ? actionType.options(mForm) : actionType.options;
|
||||||
expect(opts.map((o: any) => o.value)).toEqual(['comp']);
|
expect(opts.map((o: any) => o.value)).toEqual(['comp']);
|
||||||
propsService.getDisabledCodeBlock.mockReturnValue(false);
|
propsService.getDisabledCodeBlock.mockReturnValue(false);
|
||||||
propsService.getDisabledDataSource.mockReturnValue(false);
|
propsService.getDisabledDataSource.mockReturnValue(false);
|
||||||
@ -304,8 +324,8 @@ describe('EventSelect', () => {
|
|||||||
model: { events: [{ name: 'a', actions: [] }] },
|
model: { events: [{ name: 'a', actions: [] }] },
|
||||||
});
|
});
|
||||||
const compAction = actionsCfg().items[2];
|
const compAction = actionsCfg().items[2];
|
||||||
expect(compAction.type(undefined, { model: { to: '1' } })).toBe('select');
|
expect(compAction.type(mForm, { model: { to: '1' } })).toBe('select');
|
||||||
expect(Array.isArray(compAction.options(undefined, { model: { to: '1' } }))).toBe(true);
|
expect(Array.isArray(compAction.options(mForm, { model: { to: '1' } }))).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('compActionConfig type cascader 当 page-fragment-container', () => {
|
test('compActionConfig type cascader 当 page-fragment-container', () => {
|
||||||
@ -315,8 +335,8 @@ describe('EventSelect', () => {
|
|||||||
model: { events: [{ name: 'a', actions: [] }] },
|
model: { events: [{ name: 'a', actions: [] }] },
|
||||||
});
|
});
|
||||||
const compAction = actionsCfg().items[2];
|
const compAction = actionsCfg().items[2];
|
||||||
expect(compAction.type(undefined, { model: { to: '1' } })).toBe('cascader');
|
expect(compAction.type(mForm, { model: { to: '1' } })).toBe('cascader');
|
||||||
const opts = compAction.options(undefined, { model: { to: '1' } });
|
const opts = compAction.options(mForm, { model: { to: '1' } });
|
||||||
expect(Array.isArray(opts)).toBe(true);
|
expect(Array.isArray(opts)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -326,7 +346,7 @@ describe('EventSelect', () => {
|
|||||||
model: { events: [{ name: 'a', actions: [] }] },
|
model: { events: [{ name: 'a', actions: [] }] },
|
||||||
});
|
});
|
||||||
const compAction = actionsCfg().items[2];
|
const compAction = actionsCfg().items[2];
|
||||||
expect(compAction.options(undefined, { model: { to: 'unknown' } })).toEqual([]);
|
expect(compAction.options(mForm, { model: { to: 'unknown' } })).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('compActionConfig.rules 仅校验动作名是否在可选项中', () => {
|
test('compActionConfig.rules 仅校验动作名是否在可选项中', () => {
|
||||||
@ -339,15 +359,15 @@ describe('EventSelect', () => {
|
|||||||
const [rule] = compAction.rules;
|
const [rule] = compAction.rules;
|
||||||
|
|
||||||
const okCb = vi.fn();
|
const okCb = vi.fn();
|
||||||
rule.validator({ value: 'open', callback: okCb }, { model: { to: '1' } });
|
rule.validator({ value: 'open', callback: okCb }, { model: { to: '1' } }, mForm);
|
||||||
expect(okCb).toHaveBeenCalledWith();
|
expect(okCb).toHaveBeenCalledWith();
|
||||||
|
|
||||||
const errCb = vi.fn();
|
const errCb = vi.fn();
|
||||||
rule.validator({ value: 'unknown', callback: errCb }, { model: { to: '1' } });
|
rule.validator({ value: 'unknown', callback: errCb }, { model: { to: '1' } }, mForm);
|
||||||
expect(errCb).toHaveBeenCalledWith('动作名(unknown)不存在');
|
expect(errCb).toHaveBeenCalledWith('动作名(unknown)不存在');
|
||||||
|
|
||||||
const emptyCb = vi.fn();
|
const emptyCb = vi.fn();
|
||||||
rule.validator({ value: '', callback: emptyCb }, { model: { to: '1' } });
|
rule.validator({ value: '', callback: emptyCb }, { model: { to: '1' } }, mForm);
|
||||||
expect(emptyCb).toHaveBeenCalledWith();
|
expect(emptyCb).toHaveBeenCalledWith();
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -365,7 +385,7 @@ describe('EventSelect', () => {
|
|||||||
const [rule] = compAction.rules;
|
const [rule] = compAction.rules;
|
||||||
|
|
||||||
const cb = vi.fn();
|
const cb = vi.fn();
|
||||||
rule.validator({ value: 'whatever', callback: cb }, { model: { to: '1' } });
|
rule.validator({ value: 'whatever', callback: cb }, { model: { to: '1' } }, mForm);
|
||||||
expect(cb).toHaveBeenCalledWith();
|
expect(cb).toHaveBeenCalledWith();
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -380,11 +400,11 @@ describe('EventSelect', () => {
|
|||||||
const [rule] = compAction.rules;
|
const [rule] = compAction.rules;
|
||||||
|
|
||||||
const okCb = vi.fn();
|
const okCb = vi.fn();
|
||||||
rule.validator({ value: ['c1', 'open'], callback: okCb }, { model: { to: '1' } });
|
rule.validator({ value: ['c1', 'open'], callback: okCb }, { model: { to: '1' } }, mForm);
|
||||||
expect(okCb).toHaveBeenCalledWith();
|
expect(okCb).toHaveBeenCalledWith();
|
||||||
|
|
||||||
const errCb = vi.fn();
|
const errCb = vi.fn();
|
||||||
rule.validator({ value: ['c1', 'missing'], callback: errCb }, { model: { to: '1' } });
|
rule.validator({ value: ['c1', 'missing'], callback: errCb }, { model: { to: '1' } }, mForm);
|
||||||
expect(errCb).toHaveBeenCalledWith('动作名(c1.missing)不存在');
|
expect(errCb).toHaveBeenCalledWith('动作名(c1.missing)不存在');
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -395,7 +415,7 @@ describe('EventSelect', () => {
|
|||||||
});
|
});
|
||||||
const codeAction = actionsCfg().items[3];
|
const codeAction = actionsCfg().items[3];
|
||||||
expect(codeAction.display(undefined, { model: { actionType: 'code' } })).toBe(true);
|
expect(codeAction.display(undefined, { model: { actionType: 'code' } })).toBe(true);
|
||||||
expect(codeAction.notEditable()).toBe(true);
|
expect(codeAction.notEditable(mForm)).toBe(true);
|
||||||
codeBlockService.getEditStatus.mockReturnValue(true);
|
codeBlockService.getEditStatus.mockReturnValue(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -406,7 +426,7 @@ describe('EventSelect', () => {
|
|||||||
});
|
});
|
||||||
const dsAction = actionsCfg().items[4];
|
const dsAction = actionsCfg().items[4];
|
||||||
expect(dsAction.display(undefined, { model: { actionType: 'data-source' } })).toBe(true);
|
expect(dsAction.display(undefined, { model: { actionType: 'data-source' } })).toBe(true);
|
||||||
expect(dsAction.notEditable()).toBe(true);
|
expect(dsAction.notEditable(mForm)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('table 配置中 method options', () => {
|
test('table 配置中 method options', () => {
|
||||||
@ -418,10 +438,10 @@ describe('EventSelect', () => {
|
|||||||
});
|
});
|
||||||
const tableCfg = wrapper.findComponent({ name: 'MTable' }).props('config') as any;
|
const tableCfg = wrapper.findComponent({ name: 'MTable' }).props('config') as any;
|
||||||
const methodCol = tableCfg.items.find((it: any) => it.name === 'method');
|
const methodCol = tableCfg.items.find((it: any) => it.name === 'method');
|
||||||
const opts = methodCol.options(undefined, { model: { to: '1' } });
|
const opts = methodCol.options(mForm, { model: { to: '1' } });
|
||||||
expect(opts).toEqual([{ text: 'open', value: 'open' }]);
|
expect(opts).toEqual([{ text: 'open', value: 'open' }]);
|
||||||
editorService.getNodeById.mockReturnValue(null);
|
editorService.getNodeById.mockReturnValue(null);
|
||||||
expect(methodCol.options(undefined, { model: { to: '1' } })).toEqual([]);
|
expect(methodCol.options(mForm, { model: { to: '1' } })).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('对比模式', () => {
|
describe('对比模式', () => {
|
||||||
|
|||||||
@ -6,7 +6,7 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||||
|
|
||||||
import { useStage } from '@editor/hooks/use-stage';
|
import { useStage } from '@editor/hooks/use-stage';
|
||||||
import editorService from '@editor/services/editor';
|
import { editorService } from '@editor/services/sdk-instance';
|
||||||
import uiService from '@editor/services/ui';
|
import uiService from '@editor/services/ui';
|
||||||
|
|
||||||
const { stageInstance, StageCoreCtor, getIdFromElFn } = vi.hoisted(() => {
|
const { stageInstance, StageCoreCtor, getIdFromElFn } = vi.hoisted(() => {
|
||||||
@ -56,22 +56,29 @@ const editorState: Record<string, any> = {
|
|||||||
alwaysMultiSelect: false,
|
alwaysMultiSelect: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
vi.mock('@editor/services/editor', () => ({
|
vi.mock('@editor/services/sdk-instance', async (importOriginal) => {
|
||||||
default: {
|
const actual = await importOriginal<any>();
|
||||||
get: (k: string) => editorState[k],
|
const modEditor = await (() => ({
|
||||||
set: vi.fn((k: string, v: any) => {
|
default: {
|
||||||
editorState[k] = v;
|
get: (k: string) => editorState[k],
|
||||||
}),
|
set: vi.fn((k: string, v: any) => {
|
||||||
select: vi.fn(),
|
editorState[k] = v;
|
||||||
multiSelect: vi.fn(),
|
}),
|
||||||
highlight: vi.fn(),
|
select: vi.fn(),
|
||||||
moveToContainer: vi.fn(),
|
multiSelect: vi.fn(),
|
||||||
update: vi.fn(),
|
highlight: vi.fn(),
|
||||||
sort: vi.fn(),
|
moveToContainer: vi.fn(),
|
||||||
remove: vi.fn(),
|
update: vi.fn(),
|
||||||
getNodeById: vi.fn((id: string) => ({ id })),
|
sort: vi.fn(),
|
||||||
},
|
remove: vi.fn(),
|
||||||
}));
|
getNodeById: vi.fn((id: string) => ({ id })),
|
||||||
|
},
|
||||||
|
}))();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
editorService: modEditor.default,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
const uiState: Record<string, any> = { zoom: 1, uiSelectMode: false };
|
const uiState: Record<string, any> = { zoom: 1, uiSelectMode: false };
|
||||||
vi.mock('@editor/services/ui', () => ({
|
vi.mock('@editor/services/ui', () => ({
|
||||||
|
|||||||
@ -35,6 +35,7 @@ const mkServices = () => {
|
|||||||
const editorService: any = {
|
const editorService: any = {
|
||||||
...mkSvc('editor'),
|
...mkSvc('editor'),
|
||||||
state: {} as any,
|
state: {} as any,
|
||||||
|
setConfig: vi.fn(),
|
||||||
set: vi.fn((k: string, v: any) => (editorService.state[k] = v)),
|
set: vi.fn((k: string, v: any) => (editorService.state[k] = v)),
|
||||||
get: vi.fn((k: string) => editorService.state[k]),
|
get: vi.fn((k: string) => editorService.state[k]),
|
||||||
select: vi.fn(),
|
select: vi.fn(),
|
||||||
|
|||||||
@ -76,8 +76,14 @@ vi.mock('@editor/layouts/page-bar/PageBar.vue', () => ({
|
|||||||
default: defineComponent({
|
default: defineComponent({
|
||||||
name: 'PageBar',
|
name: 'PageBar',
|
||||||
props: ['disabledPageFragment'],
|
props: ['disabledPageFragment'],
|
||||||
setup() {
|
setup(_p, { slots }) {
|
||||||
return () => h('div', { class: 'fake-page-bar' });
|
return () =>
|
||||||
|
h('div', { class: 'fake-page-bar' }, [
|
||||||
|
slots['page-bar-add-button']?.(),
|
||||||
|
slots['page-bar-title']?.({ page: { id: 'p1' } }),
|
||||||
|
slots['page-bar-popover']?.({ page: { id: 'p1' } }),
|
||||||
|
slots['page-list-popover']?.({ list: [] }),
|
||||||
|
]);
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
@ -154,4 +160,24 @@ describe('Framework', () => {
|
|||||||
expect(uiService.set).toHaveBeenCalledWith('columnWidth', { left: 100, right: 200, center: 600 });
|
expect(uiService.set).toHaveBeenCalledWith('columnWidth', { left: 100, right: 200, center: 600 });
|
||||||
expect(storageService.setItem).toHaveBeenCalled();
|
expect(storageService.setItem).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('PageBar 插槽透传', () => {
|
||||||
|
const wrapper = mount(Framework, {
|
||||||
|
props: { disabledPageFragment: false } as any,
|
||||||
|
slots: {
|
||||||
|
'page-bar-add-button': '<i class="s-add"></i>',
|
||||||
|
'page-bar-title': '<i class="s-title"></i>',
|
||||||
|
'page-bar-popover': '<i class="s-popover"></i>',
|
||||||
|
'page-list-popover': '<i class="s-list"></i>',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
for (const c of ['.s-add', '.s-title', '.s-popover', '.s-list']) {
|
||||||
|
expect(wrapper.find(c).exists()).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('卸载时断开 ResizeObserver', () => {
|
||||||
|
const wrapper = mount(Framework, { props: { disabledPageFragment: false } as any });
|
||||||
|
expect(() => wrapper.unmount()).not.toThrow();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -21,7 +21,8 @@ vi.mock('@editor/components/ToolButton.vue', () => ({
|
|||||||
'button',
|
'button',
|
||||||
{
|
{
|
||||||
ref: rootEl,
|
ref: rootEl,
|
||||||
class: ['tool-btn', (props.data as any)?.className],
|
class: ['tool-btn', (props.data as any)?.className, (props.data as any)?.type],
|
||||||
|
'data-direction': (props.data as any)?.direction || '',
|
||||||
},
|
},
|
||||||
(props.data as any)?.text || '',
|
(props.data as any)?.text || '',
|
||||||
);
|
);
|
||||||
@ -119,6 +120,15 @@ describe('NavMenuColumn', () => {
|
|||||||
expect(moreWrapper.classes()).toContain('m-editor-nav-menu-more-wrapper-hidden');
|
expect(moreWrapper.classes()).toContain('m-editor-nav-menu-more-wrapper-hidden');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('未指定 popoverWidth 时不给更多菜单固定宽度', () => {
|
||||||
|
const wrapper = mount(NavMenuColumn as any, {
|
||||||
|
props: { columnKey: 'left', items: [{ type: 'button', text: 'A' }] },
|
||||||
|
});
|
||||||
|
const popover = wrapper.findComponent({ name: 'TMagicPopover' });
|
||||||
|
expect(popover.exists()).toBe(true);
|
||||||
|
expect(popover.props('width')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
test('点击 more 引用元素切换 popover 显示状态', async () => {
|
test('点击 more 引用元素切换 popover 显示状态', async () => {
|
||||||
const wrapper = mount(NavMenuColumn as any, {
|
const wrapper = mount(NavMenuColumn as any, {
|
||||||
props: { columnKey: 'left', items: [{ type: 'button', text: 'A' }] },
|
props: { columnKey: 'left', items: [{ type: 'button', text: 'A' }] },
|
||||||
@ -344,6 +354,127 @@ describe('NavMenuColumn', () => {
|
|||||||
wrapper.unmount();
|
wrapper.unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('更多菜单中的分割线改为横向,工具栏中的分割线保持竖向', async () => {
|
||||||
|
const items = [
|
||||||
|
{ type: 'button', text: 'A' },
|
||||||
|
{ type: 'button', text: 'B' },
|
||||||
|
{ type: 'divider', className: 'sep' },
|
||||||
|
{ type: 'button', text: 'C' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const wrapper = mount(NavMenuColumn as any, {
|
||||||
|
props: { columnKey: 'right', items, width: 80 },
|
||||||
|
attachTo: document.body,
|
||||||
|
});
|
||||||
|
|
||||||
|
const columnEl = wrapper.find('.m-editor-nav-menu-column').element as HTMLElement;
|
||||||
|
Object.defineProperty(columnEl, 'clientWidth', { configurable: true, get: () => 80 });
|
||||||
|
|
||||||
|
const moreEl = wrapper.find('.m-editor-nav-menu-more-wrapper').element as HTMLElement;
|
||||||
|
moreEl.getBoundingClientRect = () => ({
|
||||||
|
width: 30,
|
||||||
|
height: 0,
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
toJSON: () => ({}),
|
||||||
|
});
|
||||||
|
|
||||||
|
wrapper.findAll('.tool-btn').forEach((b) => {
|
||||||
|
const el = b.element as HTMLElement;
|
||||||
|
el.getBoundingClientRect = () => ({
|
||||||
|
width: 40,
|
||||||
|
height: 0,
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
toJSON: () => ({}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
roCallbacks.forEach((cb) => cb());
|
||||||
|
await flushRaf();
|
||||||
|
await flushRaf();
|
||||||
|
|
||||||
|
const overflowDividers = wrapper.findAll('.m-editor-nav-menu-overflow-list .sep');
|
||||||
|
expect(overflowDividers.length).toBeGreaterThan(0);
|
||||||
|
overflowDividers.forEach((divider) => {
|
||||||
|
expect(divider.attributes('data-direction')).toBe('horizontal');
|
||||||
|
});
|
||||||
|
|
||||||
|
wrapper.findAll('.m-editor-nav-menu-column > .sep').forEach((divider) => {
|
||||||
|
expect(divider.attributes('data-direction')).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
wrapper.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('更多菜单首尾的分割线会被去掉,中间的分割线保留', async () => {
|
||||||
|
const items = [
|
||||||
|
{ type: 'button', text: 'A' },
|
||||||
|
{ type: 'divider', className: 'lead' },
|
||||||
|
{ type: 'button', text: 'B' },
|
||||||
|
{ type: 'divider', className: 'mid' },
|
||||||
|
{ type: 'button', text: 'C' },
|
||||||
|
{ type: 'divider', className: 'trail' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const wrapper = mount(NavMenuColumn as any, {
|
||||||
|
props: { columnKey: 'right', items, width: 80 },
|
||||||
|
attachTo: document.body,
|
||||||
|
});
|
||||||
|
|
||||||
|
const columnEl = wrapper.find('.m-editor-nav-menu-column').element as HTMLElement;
|
||||||
|
Object.defineProperty(columnEl, 'clientWidth', { configurable: true, get: () => 80 });
|
||||||
|
|
||||||
|
const moreEl = wrapper.find('.m-editor-nav-menu-more-wrapper').element as HTMLElement;
|
||||||
|
moreEl.getBoundingClientRect = () => ({
|
||||||
|
width: 30,
|
||||||
|
height: 0,
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
toJSON: () => ({}),
|
||||||
|
});
|
||||||
|
|
||||||
|
wrapper.findAll('.tool-btn').forEach((b) => {
|
||||||
|
const el = b.element as HTMLElement;
|
||||||
|
el.getBoundingClientRect = () => ({
|
||||||
|
width: 40,
|
||||||
|
height: 0,
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
toJSON: () => ({}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
roCallbacks.forEach((cb) => cb());
|
||||||
|
await flushRaf();
|
||||||
|
await flushRaf();
|
||||||
|
|
||||||
|
const overflow = wrapper.find('.m-editor-nav-menu-overflow-list');
|
||||||
|
expect(overflow.find('.lead').exists()).toBe(false);
|
||||||
|
expect(overflow.find('.trail').exists()).toBe(false);
|
||||||
|
expect(
|
||||||
|
overflow.findAll('.tool-btn').map((button) => button.text() || button.classes().find((name) => name === 'mid')),
|
||||||
|
).toEqual(['B', 'mid', 'C']);
|
||||||
|
|
||||||
|
wrapper.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
test('卸载时清理 ResizeObserver', () => {
|
test('卸载时清理 ResizeObserver', () => {
|
||||||
const wrapper = mount(NavMenuColumn as any, {
|
const wrapper = mount(NavMenuColumn as any, {
|
||||||
props: { columnKey: 'left', items: [{ type: 'button', text: 'A' }] },
|
props: { columnKey: 'left', items: [{ type: 'button', text: 'A' }] },
|
||||||
|
|||||||
@ -7,7 +7,7 @@ import { afterEach, describe, expect, test, vi } from 'vitest';
|
|||||||
import { defineComponent, h, nextTick } from 'vue';
|
import { defineComponent, h, nextTick } from 'vue';
|
||||||
import { mount } from '@vue/test-utils';
|
import { mount } from '@vue/test-utils';
|
||||||
|
|
||||||
import historyService from '@editor/services/history';
|
import { historyService } from '@editor/services/sdk-instance';
|
||||||
import { createStackStep } from '@editor/utils/history';
|
import { createStackStep } from '@editor/utils/history';
|
||||||
|
|
||||||
// pushCodeBlock / pushDataSource 已合入统一的 push(stepType, step, id);用等价小工具沿用既有用例。
|
// pushCodeBlock / pushDataSource 已合入统一的 push(stepType, step, id);用等价小工具沿用既有用例。
|
||||||
|
|||||||
@ -18,7 +18,7 @@ import {
|
|||||||
opLabel,
|
opLabel,
|
||||||
} from '@editor/layouts/history-list/composables';
|
} from '@editor/layouts/history-list/composables';
|
||||||
import { useHistoryList } from '@editor/layouts/history-list/useHistoryList';
|
import { useHistoryList } from '@editor/layouts/history-list/useHistoryList';
|
||||||
import historyService from '@editor/services/history';
|
import { historyService } from '@editor/services/sdk-instance';
|
||||||
import type { HistoryGroup, HistoryStepEntry, StepValue } from '@editor/type';
|
import type { HistoryGroup, HistoryStepEntry, StepValue } from '@editor/type';
|
||||||
import { createStackStep } from '@editor/utils/history';
|
import { createStackStep } from '@editor/utils/history';
|
||||||
|
|
||||||
|
|||||||
@ -21,10 +21,9 @@ import { mount } from '@vue/test-utils';
|
|||||||
import { NodeType } from '@tmagic/core';
|
import { NodeType } from '@tmagic/core';
|
||||||
|
|
||||||
import Stage from '@editor/layouts/workspace/viewer/Stage.vue';
|
import Stage from '@editor/layouts/workspace/viewer/Stage.vue';
|
||||||
import editorService from '@editor/services/editor';
|
|
||||||
import keybindingService from '@editor/services/keybinding';
|
import keybindingService from '@editor/services/keybinding';
|
||||||
|
import { editorService, storageService } from '@editor/services/sdk-instance';
|
||||||
import stageOverlayService from '@editor/services/stageOverlay';
|
import stageOverlayService from '@editor/services/stageOverlay';
|
||||||
import storageService from '@editor/services/storage';
|
|
||||||
import uiService from '@editor/services/ui';
|
import uiService from '@editor/services/ui';
|
||||||
|
|
||||||
globalThis.ResizeObserver =
|
globalThis.ResizeObserver =
|
||||||
|
|||||||
173
packages/editor/tests/unit/sdk/dep-rules.spec.ts
Normal file
173
packages/editor/tests/unit/sdk/dep-rules.spec.ts
Normal file
@ -0,0 +1,173 @@
|
|||||||
|
// @vitest-environment node
|
||||||
|
import { describe, expect, test, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { DepTargetType } from '@tmagic/core';
|
||||||
|
|
||||||
|
import { createDslSdk } from '@editor/sdk';
|
||||||
|
import {
|
||||||
|
addDataSourceTargets,
|
||||||
|
getDataSourceRecollectTypes,
|
||||||
|
removeDataSourceTargets,
|
||||||
|
removeTargetDeps,
|
||||||
|
resetDepTargets,
|
||||||
|
writeTargetDeps,
|
||||||
|
} from '@editor/sdk/utils/dep';
|
||||||
|
|
||||||
|
import { createDsl, createSdk } from './helpers';
|
||||||
|
|
||||||
|
const record = (propPath?: string) => ({ propPath, value: undefined }) as any;
|
||||||
|
|
||||||
|
describe('getDataSourceRecollectTypes', () => {
|
||||||
|
test('字段变化:重建数据源与条件依赖', () => {
|
||||||
|
for (const path of ['fields', 'fields.0.name', 'fields.1.defaultValue', 'fields.2']) {
|
||||||
|
expect(getDataSourceRecollectTypes([record(path)])).toEqual([
|
||||||
|
DepTargetType.DATA_SOURCE,
|
||||||
|
DepTargetType.DATA_SOURCE_COND,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mocks 变化:只重建数据源依赖', () => {
|
||||||
|
expect(getDataSourceRecollectTypes([record('mocks')])).toEqual([DepTargetType.DATA_SOURCE]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('方法变化:只重建方法依赖', () => {
|
||||||
|
for (const path of ['methods', 'methods.0.name', 'methods.3']) {
|
||||||
|
expect(getDataSourceRecollectTypes([record(path)])).toEqual([DepTargetType.DATA_SOURCE_METHOD]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('取第一条命中规则的记录,忽略没有 propPath 的记录', () => {
|
||||||
|
expect(
|
||||||
|
getDataSourceRecollectTypes([record(undefined), record('title'), record('methods'), record('fields')]),
|
||||||
|
).toEqual([DepTargetType.DATA_SOURCE_METHOD]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('不影响依赖的变化返回 null', () => {
|
||||||
|
expect(getDataSourceRecollectTypes([record('title'), record(undefined)])).toBeNull();
|
||||||
|
expect(getDataSourceRecollectTypes([])).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('依赖目标与 root 写回', () => {
|
||||||
|
const ds = { id: 'ds_1', type: 'base', fields: [], methods: [] } as any;
|
||||||
|
|
||||||
|
test('addDataSourceTargets / removeDataSourceTargets 默认处理三类,可指定类型', () => {
|
||||||
|
const dep = { addTarget: vi.fn(), removeTarget: vi.fn() };
|
||||||
|
|
||||||
|
addDataSourceTargets(dep as any, ds, (v) => v);
|
||||||
|
expect(dep.addTarget.mock.calls.map(([target]) => target.type)).toEqual([
|
||||||
|
DepTargetType.DATA_SOURCE,
|
||||||
|
DepTargetType.DATA_SOURCE_COND,
|
||||||
|
DepTargetType.DATA_SOURCE_METHOD,
|
||||||
|
]);
|
||||||
|
|
||||||
|
dep.addTarget.mockClear();
|
||||||
|
addDataSourceTargets(dep as any, ds, (v) => v, [DepTargetType.DATA_SOURCE_METHOD]);
|
||||||
|
expect(dep.addTarget).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
removeDataSourceTargets(dep as any, 'ds_1');
|
||||||
|
expect(dep.removeTarget).toHaveBeenCalledTimes(3);
|
||||||
|
removeDataSourceTargets(dep as any, 'ds_1', [DepTargetType.DATA_SOURCE]);
|
||||||
|
expect(dep.removeTarget).toHaveBeenLastCalledWith('ds_1', DepTargetType.DATA_SOURCE);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resetDepTargets 先清空再建代码块与数据源目标', () => {
|
||||||
|
const dep = { clearTargets: vi.fn(), addTarget: vi.fn() };
|
||||||
|
|
||||||
|
resetDepTargets(dep as any, { code_1: { name: 'c', content: '', params: [] } } as any, [ds], (v) => v);
|
||||||
|
|
||||||
|
expect(dep.clearTargets).toHaveBeenCalledTimes(1);
|
||||||
|
expect(dep.addTarget.mock.calls.map(([target]) => target.type)).toEqual([
|
||||||
|
DepTargetType.CODE_BLOCK,
|
||||||
|
DepTargetType.DATA_SOURCE,
|
||||||
|
DepTargetType.DATA_SOURCE_COND,
|
||||||
|
DepTargetType.DATA_SOURCE_METHOD,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('writeTargetDeps / removeTargetDeps 只处理数据源三类依赖', () => {
|
||||||
|
const root: any = {};
|
||||||
|
const deps = { n1: { name: 'n1', keys: ['text'] } };
|
||||||
|
|
||||||
|
writeTargetDeps(root, { id: 'ds_1', type: DepTargetType.DATA_SOURCE, deps } as any);
|
||||||
|
writeTargetDeps(root, { id: 'ds_1', type: DepTargetType.DATA_SOURCE_COND, deps } as any);
|
||||||
|
writeTargetDeps(root, { id: 'ds_1', type: DepTargetType.DATA_SOURCE_METHOD, deps } as any);
|
||||||
|
writeTargetDeps(root, { id: 'code_1', type: DepTargetType.CODE_BLOCK, deps } as any);
|
||||||
|
writeTargetDeps(null, { id: 'ds_1', type: DepTargetType.DATA_SOURCE, deps } as any);
|
||||||
|
|
||||||
|
expect(root.dataSourceDeps.ds_1).toBe(deps);
|
||||||
|
expect(root.dataSourceCondDeps.ds_1).toBe(deps);
|
||||||
|
expect(root.dataSourceMethodDeps.ds_1).toBe(deps);
|
||||||
|
expect(Object.keys(root)).toHaveLength(3);
|
||||||
|
|
||||||
|
removeTargetDeps(root, 'ds_1', DepTargetType.DATA_SOURCE);
|
||||||
|
removeTargetDeps(root, 'ds_1', DepTargetType.CODE_BLOCK);
|
||||||
|
removeTargetDeps(root, 'ds_1', 'unknown');
|
||||||
|
removeTargetDeps(undefined, 'ds_1', DepTargetType.DATA_SOURCE);
|
||||||
|
removeTargetDeps({} as any, 'ds_1', DepTargetType.DATA_SOURCE);
|
||||||
|
|
||||||
|
expect(root.dataSourceDeps.ds_1).toBeUndefined();
|
||||||
|
expect(root.dataSourceCondDeps.ds_1).toBe(deps);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('延迟绑定连线', () => {
|
||||||
|
test('autoWiring 为 false 时,之后调用 bindWiring 会补收集已有 DSL 的依赖', async () => {
|
||||||
|
const sdk = createSdk({ autoWiring: false });
|
||||||
|
await sdk.ready;
|
||||||
|
expect(sdk.getDsl()!.dataSourceDeps).toBeUndefined();
|
||||||
|
|
||||||
|
const unbind = sdk.bindWiring();
|
||||||
|
await sdk.ready;
|
||||||
|
|
||||||
|
expect(sdk.getDsl()!.dataSourceDeps!.ds_1.text_1).toBeDefined();
|
||||||
|
|
||||||
|
const node = (await sdk.editor.add({ type: 'text', text: '${ds_1.name}' } as any)) as any;
|
||||||
|
expect(sdk.getDsl()!.dataSourceDeps!.ds_1[node.id]).toBeDefined();
|
||||||
|
|
||||||
|
unbind();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('重复 bindWiring 返回同一个解绑函数且不会重复绑定', async () => {
|
||||||
|
const sdk = createDslSdk({ dsl: createDsl(), autoWiring: false });
|
||||||
|
await sdk.ready;
|
||||||
|
|
||||||
|
const first = sdk.bindWiring();
|
||||||
|
const second = sdk.bindWiring();
|
||||||
|
expect(second).toBe(first);
|
||||||
|
|
||||||
|
first();
|
||||||
|
expect(sdk.editor.listenerCount('add')).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('登记表', () => {
|
||||||
|
test('setPropsConfigs / setPropsValues 返回的 Promise 在异步配置处理完后 resolve', async () => {
|
||||||
|
const sdk = createDslSdk({});
|
||||||
|
|
||||||
|
await sdk.props.setPropsConfigs({ Text: [{ name: 'text', text: '文本' }] });
|
||||||
|
await sdk.props.setPropsValues({ Text: { text: 'x' } });
|
||||||
|
|
||||||
|
expect(sdk.props.getPropsConfigs()).toHaveProperty('text');
|
||||||
|
expect(sdk.props.getPropsValues()).toHaveProperty('text', { text: 'x' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('setPropsConfigs 在配置全部写入后才 emit props-configs-change', async () => {
|
||||||
|
const sdk = createDslSdk({});
|
||||||
|
let hasTextAtEmit: boolean | undefined;
|
||||||
|
sdk.props.on('props-configs-change', () => {
|
||||||
|
hasTextAtEmit = sdk.props.hasPropsConfig('Text');
|
||||||
|
});
|
||||||
|
|
||||||
|
await sdk.props.setPropsConfigs({ Text: [{ name: 'text', text: '文本' }] });
|
||||||
|
|
||||||
|
expect(hasTextAtEmit).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('没有传 eventMethodList 时 events 保持为空', () => {
|
||||||
|
const sdk = createDslSdk({});
|
||||||
|
expect(sdk.events.getEvent('text')).toEqual([]);
|
||||||
|
expect(sdk.events.getMethod('text')).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
40
packages/editor/tests/unit/sdk/helpers/index.ts
Normal file
40
packages/editor/tests/unit/sdk/helpers/index.ts
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import type { MApp } from '@tmagic/core';
|
||||||
|
|
||||||
|
import type { SdkOptions } from '@editor/sdk';
|
||||||
|
import { createDslSdk } from '@editor/sdk';
|
||||||
|
|
||||||
|
export const createDsl = (): MApp =>
|
||||||
|
({
|
||||||
|
id: 'app_1',
|
||||||
|
type: 'app',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: 'page_1',
|
||||||
|
type: 'page',
|
||||||
|
name: 'page',
|
||||||
|
items: [
|
||||||
|
{ id: 'text_1', type: 'text', text: '${ds_1.name}' },
|
||||||
|
{ id: 'container_1', type: 'container', items: [{ id: 'button_1', type: 'button', text: 'btn' }] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
dataSources: [{ id: 'ds_1', type: 'base', title: 'ds', fields: [{ name: 'name' }], methods: [], events: [] }],
|
||||||
|
codeBlocks: { code_1: { name: 'code', content: '() => {}', params: [] } },
|
||||||
|
}) as any;
|
||||||
|
|
||||||
|
export const COMPONENT_GROUP_LIST = [
|
||||||
|
{
|
||||||
|
title: '基础',
|
||||||
|
items: [
|
||||||
|
{ text: '文本', type: 'text' },
|
||||||
|
{ text: '按钮', type: 'button' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const createSdk = (options: SdkOptions = {}) =>
|
||||||
|
createDslSdk({
|
||||||
|
dsl: createDsl(),
|
||||||
|
componentGroupList: COMPONENT_GROUP_LIST,
|
||||||
|
...options,
|
||||||
|
});
|
||||||
213
packages/editor/tests/unit/sdk/index.spec.ts
Normal file
213
packages/editor/tests/unit/sdk/index.spec.ts
Normal file
@ -0,0 +1,213 @@
|
|||||||
|
// @vitest-environment node
|
||||||
|
import { describe, expect, test, vi } from 'vitest';
|
||||||
|
import { isReactive, reactive, toRaw } from 'vue';
|
||||||
|
|
||||||
|
import { createDslSdk, DslValidationError, MemoryStorage } from '@editor/sdk';
|
||||||
|
|
||||||
|
import { createDsl, createSdk } from './helpers';
|
||||||
|
|
||||||
|
describe('createDslSdk', () => {
|
||||||
|
test('不传任何参数也能创建,Node 下默认 validateMode 为 strict', async () => {
|
||||||
|
const sdk = createDslSdk();
|
||||||
|
await sdk.ready;
|
||||||
|
|
||||||
|
expect(sdk.getDsl()).toBeNull();
|
||||||
|
expect(sdk.ctx.validateMode).toBe('strict');
|
||||||
|
expect(sdk.editor.get('root')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('传入的 DSL 会被深拷贝,不会被修改', async () => {
|
||||||
|
const dsl = createDsl();
|
||||||
|
const snapshot = JSON.stringify(dsl);
|
||||||
|
const sdk = createSdk({ dsl });
|
||||||
|
|
||||||
|
await sdk.editor.update({ id: 'text_1', text: 'changed' } as any);
|
||||||
|
|
||||||
|
expect(JSON.stringify(dsl)).toBe(snapshot);
|
||||||
|
expect(sdk.getDsl()!.items[0].items![0].text).toBe('changed');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getDsl 返回的是深拷贝', () => {
|
||||||
|
const sdk = createSdk();
|
||||||
|
const dsl = sdk.getDsl()!;
|
||||||
|
dsl.items.length = 0;
|
||||||
|
|
||||||
|
expect(sdk.getDsl()!.items).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('创建后自动选中第一个页面', () => {
|
||||||
|
const sdk = createSdk();
|
||||||
|
expect(sdk.editor.get('page')?.id).toBe('page_1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('多个实例互相独立', async () => {
|
||||||
|
const a = createSdk();
|
||||||
|
const b = createSdk();
|
||||||
|
|
||||||
|
await a.editor.remove({ id: 'text_1' } as any);
|
||||||
|
|
||||||
|
expect(a.editor.getNodeById('text_1')).toBeFalsy();
|
||||||
|
expect(b.editor.getNodeById('text_1')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('add / update / remove / undo / redo', async () => {
|
||||||
|
const sdk = createSdk();
|
||||||
|
|
||||||
|
const added = (await sdk.editor.add({ type: 'text', text: 'new' } as any)) as any;
|
||||||
|
expect(sdk.editor.getNodeById(added.id)).toBeTruthy();
|
||||||
|
|
||||||
|
await sdk.editor.update({ id: added.id, text: 'updated' } as any);
|
||||||
|
expect((sdk.editor.getNodeById(added.id) as any).text).toBe('updated');
|
||||||
|
|
||||||
|
await sdk.editor.undo();
|
||||||
|
expect((sdk.editor.getNodeById(added.id) as any).text).toBe('new');
|
||||||
|
|
||||||
|
await sdk.editor.redo();
|
||||||
|
expect((sdk.editor.getNodeById(added.id) as any).text).toBe('updated');
|
||||||
|
|
||||||
|
await sdk.editor.remove(sdk.editor.getNodeById(added.id)!);
|
||||||
|
expect(sdk.editor.getNodeById(added.id)).toBeFalsy();
|
||||||
|
|
||||||
|
await sdk.editor.undo();
|
||||||
|
expect(sdk.editor.getNodeById(added.id)).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('moveToContainer 把节点移动到容器中', async () => {
|
||||||
|
const sdk = createSdk();
|
||||||
|
|
||||||
|
await sdk.editor.moveToContainer({ id: 'text_1' } as any, 'container_1');
|
||||||
|
|
||||||
|
const container = sdk.editor.getNodeById('container_1') as any;
|
||||||
|
expect(container.items.map((item: any) => item.id)).toContain('text_1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('setDsl 整体替换并写入新的 root', async () => {
|
||||||
|
const sdk = createSdk();
|
||||||
|
const next = createDsl();
|
||||||
|
next.items[0].id = 'page_2';
|
||||||
|
|
||||||
|
await sdk.setDsl(next);
|
||||||
|
expect(sdk.getDsl()!.items[0].id).toBe('page_2');
|
||||||
|
expect(sdk.editor.get('page')?.id).toBe('page_2');
|
||||||
|
|
||||||
|
await sdk.setDsl(null);
|
||||||
|
expect(sdk.getDsl()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('历史快照可以导出后在新实例中恢复(无状态使用)', async () => {
|
||||||
|
const first = createSdk();
|
||||||
|
await first.editor.update({ id: 'text_1', text: 'step1' } as any);
|
||||||
|
await first.editor.update({ id: 'text_1', text: 'step2' } as any);
|
||||||
|
|
||||||
|
const dsl = first.getDsl()!;
|
||||||
|
const history = JSON.parse(JSON.stringify(first.serializeHistory()));
|
||||||
|
|
||||||
|
const second = createSdk({ dsl, history });
|
||||||
|
expect((second.editor.getNodeById('text_1') as any).text).toBe('step2');
|
||||||
|
|
||||||
|
await second.editor.undo();
|
||||||
|
expect((second.editor.getNodeById('text_1') as any).text).toBe('step1');
|
||||||
|
|
||||||
|
await second.editor.undo();
|
||||||
|
expect((second.editor.getNodeById('text_1') as any).text).toBe('${ds_1.name}');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('登记表:属性配置 / 属性默认值 / 事件 / 数据源配置', async () => {
|
||||||
|
const sdk = createSdk({
|
||||||
|
propsConfigs: { text: [{ name: 'text', text: '文本' }] },
|
||||||
|
propsValues: { text: { text: '默认文案' } },
|
||||||
|
eventMethodList: {
|
||||||
|
text: { events: [{ label: 'click', value: 'click' }], methods: [{ label: 'm', value: 'm' }] },
|
||||||
|
},
|
||||||
|
datasourceList: [{ type: 'custom', text: '自定义' }],
|
||||||
|
datasourceConfigs: { custom: [{ name: 'a', text: 'a' }] },
|
||||||
|
datasourceValues: { custom: { title: 'custom' } as any },
|
||||||
|
datasourceEventMethodList: {
|
||||||
|
custom: { events: [{ label: 'e', value: 'e' }], methods: [{ label: 'm', value: 'm' }] },
|
||||||
|
},
|
||||||
|
disabledCodeBlock: true,
|
||||||
|
disabledDataSource: true,
|
||||||
|
disabledMultiSelect: true,
|
||||||
|
alwaysMultiSelect: true,
|
||||||
|
disabledPageFragment: true,
|
||||||
|
});
|
||||||
|
await sdk.ready;
|
||||||
|
|
||||||
|
expect(JSON.stringify(await sdk.props.getPropsConfig('text'))).toContain('"text":"文本"');
|
||||||
|
expect(((await sdk.props.getPropsValue('text')) as any).text).toBe('默认文案');
|
||||||
|
expect(sdk.events.getEvent('text')).toHaveLength(1);
|
||||||
|
expect(sdk.events.getMethod('text')).toHaveLength(1);
|
||||||
|
expect(sdk.dataSource.get('datasourceTypeList')).toHaveLength(1);
|
||||||
|
expect(sdk.dataSource.getFormConfig('custom')).toContainEqual({ name: 'a', text: 'a' });
|
||||||
|
expect(sdk.dataSource.getFormEvent('custom')).toHaveLength(1);
|
||||||
|
expect(sdk.dataSource.getFormMethod('custom')).toHaveLength(1);
|
||||||
|
expect(sdk.props.getDisabledCodeBlock()).toBe(true);
|
||||||
|
expect(sdk.props.getDisabledDataSource()).toBe(true);
|
||||||
|
expect(sdk.editor.get('disabledMultiSelect')).toBe(true);
|
||||||
|
expect(sdk.editor.get('alwaysMultiSelect')).toBe(true);
|
||||||
|
expect(sdk.editor.getConfig().disabledPageFragment).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('默认使用 Vue 的响应式实现', () => {
|
||||||
|
const sdk = createSdk();
|
||||||
|
expect(sdk.ctx.reactivity.reactive).toBe(reactive);
|
||||||
|
expect(sdk.ctx.reactivity.toRaw).toBe(toRaw);
|
||||||
|
expect(isReactive(sdk.ctx.reactivity.reactive({ a: 1 }))).toBe(true);
|
||||||
|
expect(toRaw(sdk.ctx.reactivity.reactive(sdk.ctx.reactivity.reactive({ a: 1 })))).toEqual({ a: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('注入 reactivity 与 storage', async () => {
|
||||||
|
const reactiveSpy = vi.fn(<T>(v: T) => v);
|
||||||
|
const storage = new MemoryStorage();
|
||||||
|
const sdk = createSdk({ reactivity: { reactive: reactiveSpy as any }, storage });
|
||||||
|
|
||||||
|
expect(reactiveSpy).toHaveBeenCalled();
|
||||||
|
|
||||||
|
await sdk.editor.copy({ id: 'text_1', type: 'text' } as any);
|
||||||
|
expect(storage.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseDSL 可注入,用于还原带函数的存储内容', () => {
|
||||||
|
// eslint-disable-next-line no-eval
|
||||||
|
const parseDSL = vi.fn((code: string) => eval(code));
|
||||||
|
const sdk = createSdk({ parseDSL });
|
||||||
|
|
||||||
|
sdk.storage.setItem('fn', () => 1, { protocol: 'object' as any });
|
||||||
|
const value = sdk.storage.getItem('fn', { protocol: 'object' as any });
|
||||||
|
|
||||||
|
expect(parseDSL).toHaveBeenCalled();
|
||||||
|
expect(typeof value).toBe('function');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('autoWiring 为 false 时不收集依赖,bindWiring 之后再绑定', async () => {
|
||||||
|
const sdk = createSdk({ autoWiring: false });
|
||||||
|
expect(sdk.getDsl()!.dataSourceDeps).toBeUndefined();
|
||||||
|
|
||||||
|
const unbind = sdk.bindWiring();
|
||||||
|
await sdk.setDsl(createDsl());
|
||||||
|
expect(sdk.getDsl()!.dataSourceDeps!.ds_1.text_1).toBeDefined();
|
||||||
|
|
||||||
|
// 重复调用不会重复绑定
|
||||||
|
sdk.bindWiring();
|
||||||
|
unbind();
|
||||||
|
await sdk.editor.update({ id: 'text_1', text: 'plain' } as any);
|
||||||
|
expect(sdk.getDsl()!.dataSourceDeps!.ds_1.text_1).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('strict 模式下校验失败抛出 DslValidationError 且不修改 DSL', async () => {
|
||||||
|
const sdk = createSdk();
|
||||||
|
const before = JSON.stringify(sdk.getDsl());
|
||||||
|
|
||||||
|
const error = await sdk.editor.add({ type: 'unknown-type' } as any).catch((e) => e);
|
||||||
|
|
||||||
|
expect(error).toBeInstanceOf(DslValidationError);
|
||||||
|
expect(error.errors[0].kind).toBe('structure');
|
||||||
|
expect(JSON.stringify(sdk.getDsl())).toBe(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('destroy 后可以重复调用', () => {
|
||||||
|
const sdk = createSdk();
|
||||||
|
sdk.destroy();
|
||||||
|
expect(() => sdk.destroy()).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
163
packages/editor/tests/unit/sdk/review-regressions.spec.ts
Normal file
163
packages/editor/tests/unit/sdk/review-regressions.spec.ts
Normal file
@ -0,0 +1,163 @@
|
|||||||
|
// @vitest-environment node
|
||||||
|
import { describe, expect, test } from 'vitest';
|
||||||
|
|
||||||
|
import { createDslSdk, defaultParseDSL, DslValidationError } from '@editor/sdk';
|
||||||
|
|
||||||
|
import { createDsl, createSdk } from './helpers';
|
||||||
|
|
||||||
|
const styleConfig = [
|
||||||
|
{
|
||||||
|
name: 'style',
|
||||||
|
items: [{ name: 'width', text: '宽', rules: [{ required: true }] }, { name: 'color' }],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const createStyledSdk = (validateMode: 'strict' | 'record' = 'strict') => {
|
||||||
|
const dsl = createDsl() as any;
|
||||||
|
dsl.items[0].items[0].style = { width: 100 };
|
||||||
|
return createSdk({ dsl, validateMode, propsConfigs: { text: styleConfig } as any });
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('strict 校验与 update 的合并语义一致', () => {
|
||||||
|
test('深合并的局部更新不会因为缺少已有的必填字段被拒绝', async () => {
|
||||||
|
const sdk = createStyledSdk();
|
||||||
|
await sdk.ready;
|
||||||
|
|
||||||
|
await expect(sdk.editor.update({ id: 'text_1', style: { color: 'red' } } as any)).resolves.toBeTruthy();
|
||||||
|
expect((sdk.editor.getNodeById('text_1') as any).style).toEqual({ width: 100, color: 'red' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('replace 时按传入配置整体校验:丢掉必填字段会被拒绝', async () => {
|
||||||
|
const sdk = createStyledSdk();
|
||||||
|
await sdk.ready;
|
||||||
|
|
||||||
|
const error = await sdk.editor
|
||||||
|
.update({ id: 'text_1', type: 'text', style: { color: 'red' } } as any, { replace: true })
|
||||||
|
.catch((e) => e);
|
||||||
|
|
||||||
|
expect(error).toBeInstanceOf(DslValidationError);
|
||||||
|
expect((sdk.editor.getNodeById('text_1') as any).style).toEqual({ width: 100 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('校验失败不污染后续历史记录', () => {
|
||||||
|
test('被拒绝的 moveToContainer 不会留下过期快照', async () => {
|
||||||
|
const sdk = createSdk();
|
||||||
|
await sdk.ready;
|
||||||
|
|
||||||
|
await sdk.editor.select('text_1');
|
||||||
|
await expect(sdk.editor.moveToContainer({ id: 'button_1' } as any, 'text_1')).rejects.toBeInstanceOf(
|
||||||
|
DslValidationError,
|
||||||
|
);
|
||||||
|
|
||||||
|
await sdk.editor.select('button_1');
|
||||||
|
await sdk.editor.update({ id: 'button_1', text: 'changed' } as any);
|
||||||
|
|
||||||
|
const steps = sdk.history.getStepList('page', sdk.editor.get('page')!.id);
|
||||||
|
expect((steps[steps.length - 1].step as any).extra.selectedBefore).toEqual(['button_1']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('被拒绝的 update 之后,下一条历史记录的选区快照来自它自己', async () => {
|
||||||
|
const sdk = createSdk({
|
||||||
|
propsConfigs: { text: [{ name: 'text', rules: [{ required: true }] }] } as any,
|
||||||
|
});
|
||||||
|
await sdk.ready;
|
||||||
|
|
||||||
|
await sdk.editor.select('text_1');
|
||||||
|
await expect(sdk.editor.update({ id: 'text_1', text: '' } as any)).rejects.toBeInstanceOf(DslValidationError);
|
||||||
|
|
||||||
|
// 换一个选区后再做一次成功的操作
|
||||||
|
await sdk.editor.select('button_1');
|
||||||
|
await sdk.editor.update({ id: 'button_1', text: 'changed' } as any);
|
||||||
|
|
||||||
|
const pageId = sdk.editor.get('page')!.id;
|
||||||
|
const steps = sdk.history.getStepList('page', pageId);
|
||||||
|
const last = steps[steps.length - 1].step as any;
|
||||||
|
expect(last.extra.selectedBefore).toEqual(['button_1']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('被拒绝的 add 同样不会留下过期快照', async () => {
|
||||||
|
const sdk = createSdk();
|
||||||
|
await sdk.ready;
|
||||||
|
|
||||||
|
await sdk.editor.select('text_1');
|
||||||
|
await expect(sdk.editor.add({ type: 'not-exist' } as any)).rejects.toBeInstanceOf(DslValidationError);
|
||||||
|
|
||||||
|
await sdk.editor.select('button_1');
|
||||||
|
await sdk.editor.update({ id: 'button_1', text: 'changed' } as any);
|
||||||
|
|
||||||
|
const steps = sdk.history.getStepList('page', sdk.editor.get('page')!.id);
|
||||||
|
expect((steps[steps.length - 1].step as any).extra.selectedBefore).toEqual(['button_1']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('依赖收集:同一批更新里数据源引用与方法一起变化', () => {
|
||||||
|
test('dataSourceMethodDeps 不会丢失', async () => {
|
||||||
|
const dsl = createDsl() as any;
|
||||||
|
dsl.dataSources[0].methods = [{ name: 'refresh', content: '() => {}', params: [] }];
|
||||||
|
const sdk = createSdk({ dsl, validateMode: 'off' });
|
||||||
|
await sdk.ready;
|
||||||
|
|
||||||
|
await sdk.editor.update({
|
||||||
|
id: 'text_1',
|
||||||
|
text: '${ds_1.name}x',
|
||||||
|
action: ['ds_1', 'refresh'],
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const out = sdk.getDsl() as any;
|
||||||
|
expect(out.dataSourceDeps.ds_1.text_1).toBeTruthy();
|
||||||
|
expect(out.dataSourceMethodDeps.ds_1.text_1).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('一个节点有多条未命中的 changeRecord 时只归类一次', async () => {
|
||||||
|
const { classifyUpdatedNodes } = await import('@editor/sdk');
|
||||||
|
const node: any = { id: 'n1', type: 'text' };
|
||||||
|
const result = classifyUpdatedNodes({ dep: { getTargets: () => ({}) } } as any, [
|
||||||
|
{
|
||||||
|
newNode: node,
|
||||||
|
oldNode: node,
|
||||||
|
changeRecords: [
|
||||||
|
{ propPath: 'a', value: 1 },
|
||||||
|
{ propPath: 'b', value: 2 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(result.normalNodes).toHaveLength(1);
|
||||||
|
expect(result.needRecollectNodes).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('历史快照还原', () => {
|
||||||
|
test('默认 parseDSL 无法解析函数时给出明确的错误', () => {
|
||||||
|
expect(() => defaultParseDSL('({"a":() => 1})')).toThrow('默认的 parseDSL 只能解析 JSON');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('传入自己的 parseDSL 后带函数的历史可以往返', async () => {
|
||||||
|
// eslint-disable-next-line no-new-func
|
||||||
|
const parseDSL = (code: string) => new Function(`return ${code}`)();
|
||||||
|
const sdk = createSdk({ parseDSL, validateMode: 'off' });
|
||||||
|
await sdk.ready;
|
||||||
|
await sdk.editor.update({ id: 'text_1', onClick: () => 1 } as any);
|
||||||
|
|
||||||
|
const snapshot = JSON.parse(JSON.stringify(sdk.serializeHistory()));
|
||||||
|
expect(() => createDslSdk({ dsl: sdk.getDsl(), history: snapshot, parseDSL, validateMode: 'off' })).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('extra 里只有已知的 Map 字段会被转换,其他字段原样保留', async () => {
|
||||||
|
const sdk = createSdk({ validateMode: 'off' });
|
||||||
|
await sdk.ready;
|
||||||
|
await sdk.editor.update({ id: 'text_1', text: 'a' } as any);
|
||||||
|
|
||||||
|
const snapshot = sdk.history.serialize({ jsonSafe: true });
|
||||||
|
const list = Object.values(snapshot.steps.page)[0].elementList as any[];
|
||||||
|
const step = list.find((item) => item.extra);
|
||||||
|
step.extra.custom = new Map([['k', 'v']]);
|
||||||
|
|
||||||
|
const restored = createSdk({ dsl: sdk.getDsl(), history: snapshot, validateMode: 'off' });
|
||||||
|
const restoredStep = (Object.values(restored.history.serialize().steps.page)[0].elementList as any[]).find(
|
||||||
|
(item) => item.extra,
|
||||||
|
);
|
||||||
|
expect(restoredStep.extra.modifiedNodeIds).toBeInstanceOf(Map);
|
||||||
|
expect(restoredStep.extra.custom).toBeInstanceOf(Map);
|
||||||
|
});
|
||||||
|
});
|
||||||
463
packages/editor/tests/unit/sdk/services-extra.spec.ts
Normal file
463
packages/editor/tests/unit/sdk/services-extra.spec.ts
Normal file
@ -0,0 +1,463 @@
|
|||||||
|
// @vitest-environment node
|
||||||
|
import { describe, expect, test } from 'vitest';
|
||||||
|
|
||||||
|
import type { MApp } from '@tmagic/core';
|
||||||
|
import { HookCodeType, HookType } from '@tmagic/core';
|
||||||
|
|
||||||
|
import { LayerOffset } from '@editor/type';
|
||||||
|
|
||||||
|
import { createDsl, createSdk } from './helpers';
|
||||||
|
|
||||||
|
/** 每个节点都带 style,便于测试位置相关的操作 */
|
||||||
|
const createStyledDsl = (): MApp =>
|
||||||
|
({
|
||||||
|
id: 'app_1',
|
||||||
|
type: 'app',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: 'page_1',
|
||||||
|
type: 'page',
|
||||||
|
name: 'page',
|
||||||
|
style: { width: 375, height: 800 },
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: 'text_1',
|
||||||
|
type: 'text',
|
||||||
|
text: 'a',
|
||||||
|
style: { position: 'absolute', left: 10, top: 10, width: 100, height: 20 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'text_2',
|
||||||
|
type: 'text',
|
||||||
|
text: 'b',
|
||||||
|
style: { position: 'absolute', left: 20, top: 40, width: 100, height: 20 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'text_3',
|
||||||
|
type: 'text',
|
||||||
|
text: 'c',
|
||||||
|
style: { position: 'absolute', left: 30, top: 70, width: 100, height: 20 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'container_1',
|
||||||
|
type: 'container',
|
||||||
|
style: { position: 'absolute', left: 0, top: 100, width: 300, height: 300 },
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: 'button_1',
|
||||||
|
type: 'button',
|
||||||
|
style: { position: 'absolute', left: 5, top: 5, width: 50, height: 20 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ id: 'page_2', type: 'page', name: 'page2', items: [{ id: 'text_9', type: 'text', text: 'z' }] },
|
||||||
|
],
|
||||||
|
dataSources: [{ id: 'ds_1', type: 'base', title: 'ds', fields: [{ name: 'name' }], methods: [], events: [] }],
|
||||||
|
codeBlocks: { code_1: { name: 'code', content: '() => {}', params: [] } },
|
||||||
|
}) as any;
|
||||||
|
|
||||||
|
const createStyled = () => createSdk({ dsl: createStyledDsl() });
|
||||||
|
|
||||||
|
describe('editor:选择与高亮', () => {
|
||||||
|
test('selectNextNode / selectNextPage', async () => {
|
||||||
|
const sdk = createStyled();
|
||||||
|
|
||||||
|
await sdk.editor.select('text_1');
|
||||||
|
expect((await sdk.editor.selectNextNode())?.id).toBe('text_2');
|
||||||
|
|
||||||
|
await sdk.editor.select('text_3');
|
||||||
|
expect((await sdk.editor.selectNextNode())?.id).toBeTruthy();
|
||||||
|
|
||||||
|
await sdk.editor.select('page_1');
|
||||||
|
expect((await sdk.editor.selectNextNode())?.id).toBe('page_1');
|
||||||
|
|
||||||
|
expect((await sdk.editor.selectNextPage()).id).toBe('page_2');
|
||||||
|
expect((await sdk.editor.selectNextPage()).id).toBe('page_1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('selectNextPage 没有页面 / root 时报错', async () => {
|
||||||
|
const sdk = createSdk({ dsl: null });
|
||||||
|
await expect(sdk.editor.selectNextPage()).rejects.toThrow('page不能为空');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('highlight / multiSelect', async () => {
|
||||||
|
const sdk = createStyled();
|
||||||
|
|
||||||
|
sdk.editor.highlight('text_1');
|
||||||
|
expect(sdk.editor.get('highlightNode')?.id).toBe('text_1');
|
||||||
|
sdk.editor.highlight('text_1');
|
||||||
|
expect(sdk.editor.get('highlightNode')?.id).toBe('text_1');
|
||||||
|
|
||||||
|
sdk.editor.multiSelect(['text_1', 'text_2', 'text_2', 'not-exist']);
|
||||||
|
expect(sdk.editor.get('nodes').map((node) => node.id)).toEqual(['text_1', 'text_2']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('selectRoot', () => {
|
||||||
|
const sdk = createStyled();
|
||||||
|
sdk.editor.selectRoot();
|
||||||
|
expect(sdk.editor.get('node')?.id).toBe('app_1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('editor:位置与层级', () => {
|
||||||
|
test('alignCenter(单个 / 多个)', async () => {
|
||||||
|
const sdk = createStyled();
|
||||||
|
|
||||||
|
const one = (await sdk.editor.alignCenter(sdk.editor.getNodeById('text_1')!)) as any;
|
||||||
|
expect(one).toBeTruthy();
|
||||||
|
|
||||||
|
const many = await sdk.editor.alignCenter([sdk.editor.getNodeById('text_2')!, sdk.editor.getNodeById('text_3')!], {
|
||||||
|
doNotPushHistory: true,
|
||||||
|
});
|
||||||
|
expect(Array.isArray(many)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('move 改变选中节点的位置', async () => {
|
||||||
|
const sdk = createStyled();
|
||||||
|
await sdk.editor.select('text_1');
|
||||||
|
await sdk.editor.move(5, 6);
|
||||||
|
|
||||||
|
const { style } = sdk.editor.getNodeById('text_1') as any;
|
||||||
|
expect(style.left).toBe(15);
|
||||||
|
expect(style.top).toBe(16);
|
||||||
|
|
||||||
|
// 页面不可移动
|
||||||
|
await sdk.editor.select('page_1');
|
||||||
|
await sdk.editor.move(1, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('moveLayer', async () => {
|
||||||
|
const sdk = createStyled();
|
||||||
|
await sdk.editor.select('text_1');
|
||||||
|
|
||||||
|
await sdk.editor.moveLayer(1);
|
||||||
|
await sdk.editor.moveLayer(LayerOffset.BOTTOM);
|
||||||
|
await sdk.editor.moveLayer(LayerOffset.TOP);
|
||||||
|
|
||||||
|
const ids = (sdk.editor.getNodeById('page_1') as any).items.map((item: any) => item.id);
|
||||||
|
expect(ids).toContain('text_1');
|
||||||
|
|
||||||
|
const withId = await sdk.editor.moveLayerAndGetHistoryId(-1);
|
||||||
|
expect(withId.historyIds.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('moveToContainer / dragTo,含 *AndGetHistoryId', async () => {
|
||||||
|
const sdk = createStyled();
|
||||||
|
|
||||||
|
await sdk.editor.moveToContainer(
|
||||||
|
[sdk.editor.getNodeById('text_1')!, sdk.editor.getNodeById('text_2')!],
|
||||||
|
'container_1',
|
||||||
|
{
|
||||||
|
doNotSelect: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const container = sdk.editor.getNodeById('container_1') as any;
|
||||||
|
expect(container.items.map((item: any) => item.id)).toEqual(expect.arrayContaining(['text_1', 'text_2']));
|
||||||
|
|
||||||
|
const moved = await sdk.editor.moveToContainerAndGetHistoryId(sdk.editor.getNodeById('text_3')!, 'container_1');
|
||||||
|
expect(moved.historyIds.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// 同容器内排序
|
||||||
|
await sdk.editor.dragTo(sdk.editor.getNodeById('text_3')!, container, 0);
|
||||||
|
expect(container.items[0].id).toBe('text_3');
|
||||||
|
|
||||||
|
// 跨容器
|
||||||
|
const page = sdk.editor.getNodeById('page_1') as any;
|
||||||
|
const dragged = await sdk.editor.dragToAndGetHistoryId(sdk.editor.getNodeById('button_1')!, page, 0);
|
||||||
|
expect(dragged.historyIds.length).toBeGreaterThan(0);
|
||||||
|
expect(page.items[0].id).toBe('button_1');
|
||||||
|
|
||||||
|
// 目标不是容器时什么也不做
|
||||||
|
expect(
|
||||||
|
await sdk.editor.dragTo(sdk.editor.getNodeById('text_1')!, sdk.editor.getNodeById('text_2') as any, 0),
|
||||||
|
).toBeUndefined();
|
||||||
|
|
||||||
|
// 不能移动页面
|
||||||
|
await expect(sdk.editor.moveToContainer(sdk.editor.getNodeById('page_2')!, 'container_1')).rejects.toThrow();
|
||||||
|
await expect(sdk.editor.moveToContainer(sdk.editor.getNodeById('text_1')!, 'missing')).rejects.toThrow(
|
||||||
|
'目标容器不存在',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('editor:历史', () => {
|
||||||
|
test('gotoPageStep / revertPageStep / revertPageStepById', async () => {
|
||||||
|
const sdk = createStyled();
|
||||||
|
|
||||||
|
await sdk.editor.update({ id: 'text_1', text: 'a1' } as any, {
|
||||||
|
changeRecords: [{ propPath: 'text', value: 'a1' }],
|
||||||
|
});
|
||||||
|
await sdk.editor.update({ id: 'text_2', text: 'b1' } as any, {
|
||||||
|
changeRecords: [{ propPath: 'text', value: 'b1' }],
|
||||||
|
});
|
||||||
|
const added = (await sdk.editor.add({ type: 'text', text: 'new' } as any)) as any;
|
||||||
|
await sdk.editor.remove(sdk.editor.getNodeById('text_3')!);
|
||||||
|
|
||||||
|
// 回滚第一次更新,不影响后面的更新
|
||||||
|
const reverted = await sdk.editor.revertPageStep(1);
|
||||||
|
expect(reverted).toBeTruthy();
|
||||||
|
expect((sdk.editor.getNodeById('text_1') as any).text).toBe('a');
|
||||||
|
expect((sdk.editor.getNodeById('text_2') as any).text).toBe('b1');
|
||||||
|
|
||||||
|
// 回滚新增 / 删除
|
||||||
|
const listLength = sdk.history.getStepList('page', 'page_1').length;
|
||||||
|
expect(await sdk.editor.revertPageStep(3)).toBeTruthy();
|
||||||
|
expect(sdk.editor.getNodeById(added.id)).toBeFalsy();
|
||||||
|
expect(sdk.history.getStepList('page', 'page_1').length).toBeGreaterThan(listLength - 1);
|
||||||
|
|
||||||
|
// 初始基线不可回滚 / 越界
|
||||||
|
expect(await sdk.editor.revertPageStep(0)).toBeNull();
|
||||||
|
expect(await sdk.editor.revertPageStep(99)).toBeNull();
|
||||||
|
|
||||||
|
const { step } = sdk.history.getStepList('page', 'page_1')[2];
|
||||||
|
const byId = await sdk.editor.revertPageStepById([step.uuid!, 'not-found']);
|
||||||
|
expect(byId[1]).toBeNull();
|
||||||
|
|
||||||
|
const cursor = await sdk.editor.gotoPageStep(0);
|
||||||
|
expect(cursor).toBeLessThanOrEqual(1);
|
||||||
|
expect((sdk.editor.getNodeById('text_2') as any).text).toBe('b');
|
||||||
|
|
||||||
|
const end = await sdk.editor.gotoPageStep(99);
|
||||||
|
expect(end).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('没有选中页面时 undo / redo 返回 null', async () => {
|
||||||
|
const sdk = createSdk({ dsl: null });
|
||||||
|
expect(await sdk.editor.undo()).toBeNull();
|
||||||
|
expect(await sdk.editor.redo()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('撤销新增 / 删除 / 更新各类历史', async () => {
|
||||||
|
const sdk = createStyled();
|
||||||
|
|
||||||
|
await sdk.editor.add({ type: 'text', text: 'n1' } as any);
|
||||||
|
await sdk.editor.undo();
|
||||||
|
await sdk.editor.redo();
|
||||||
|
await sdk.editor.undo();
|
||||||
|
|
||||||
|
await sdk.editor.remove(sdk.editor.getNodeById('text_1')!);
|
||||||
|
await sdk.editor.undo();
|
||||||
|
await sdk.editor.redo();
|
||||||
|
await sdk.editor.undo();
|
||||||
|
|
||||||
|
await sdk.editor.update({ id: 'text_2', text: 'u' } as any);
|
||||||
|
await sdk.editor.undo();
|
||||||
|
await sdk.editor.redo();
|
||||||
|
|
||||||
|
expect((sdk.editor.getNodeById('text_2') as any).text).toBe('u');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('editor:copyWithRelated / paste', () => {
|
||||||
|
test('copyWithRelated 把关联节点一起复制', async () => {
|
||||||
|
const dsl = createStyledDsl();
|
||||||
|
(dsl.items[0].items![0] as any).relate = 'text_2';
|
||||||
|
const sdk = createSdk({ dsl });
|
||||||
|
|
||||||
|
sdk.editor.copyWithRelated(sdk.editor.getNodeById('text_1')!, {
|
||||||
|
type: 'relate' as any,
|
||||||
|
isTarget: (key: string | number, value: any) => key === 'relate' && !!value,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const copied = sdk.storage.getItem('tmagic-editor-copy' as any) ?? null;
|
||||||
|
expect(copied === null || Array.isArray(copied) || typeof copied === 'object').toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('粘贴选中节点的副本时放进它的父节点', async () => {
|
||||||
|
const sdk = createStyled();
|
||||||
|
await sdk.editor.select('text_1');
|
||||||
|
await sdk.editor.copy(sdk.editor.getNodeById('text_1')!);
|
||||||
|
|
||||||
|
const pasted = (await sdk.editor.paste({}, undefined, { doNotSelect: true })) as any;
|
||||||
|
expect(sdk.editor.getParentById(Array.isArray(pasted) ? pasted[0].id : pasted.id)?.id).toBe('page_1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('props:关联 id', () => {
|
||||||
|
test('setNewItemId 记录映射,replaceRelateId 替换关联 id', () => {
|
||||||
|
const sdk = createSdk();
|
||||||
|
const origin = [
|
||||||
|
{ id: 'a', type: 'text', relate: 'b' },
|
||||||
|
{ id: 'b', type: 'text', items: [{ id: 'c', type: 'text', relate: 'a' }] },
|
||||||
|
] as any[];
|
||||||
|
|
||||||
|
const copied = JSON.parse(JSON.stringify(origin));
|
||||||
|
sdk.props.clearRelateId();
|
||||||
|
copied.forEach((config: any) => {
|
||||||
|
sdk.props.setNewItemId(config);
|
||||||
|
config.items?.forEach((item: any) => sdk.props.setNewItemId(item));
|
||||||
|
});
|
||||||
|
|
||||||
|
sdk.props.replaceRelateId(origin, copied, {
|
||||||
|
type: 'relate' as any,
|
||||||
|
isTarget: (key: string | number, value: any) => key === 'relate' && !!value,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
expect(copied[0].relate).toBe(copied[1].id);
|
||||||
|
|
||||||
|
// 没有映射时什么也不做
|
||||||
|
sdk.props.clearRelateId();
|
||||||
|
sdk.props.replaceRelateId(origin, copied, { type: 'relate' as any, isTarget: () => false } as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('area 类型使用 button 的配置', async () => {
|
||||||
|
const sdk = createSdk({ propsValues: { button: { text: 'btn' } } });
|
||||||
|
await sdk.ready;
|
||||||
|
const value = (await sdk.props.getPropsValue('area')) as any;
|
||||||
|
expect(value.className).toBe('action-area');
|
||||||
|
expect(await sdk.props.getPropsConfig('area')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('props 的 usePlugin', async () => {
|
||||||
|
const sdk = createSdk();
|
||||||
|
sdk.props.usePlugin({ afterGetPropsConfig: async () => [{ name: 'plugin' }] } as any);
|
||||||
|
sdk.codeBlock.usePlugin({} as any);
|
||||||
|
sdk.dataSource.usePlugin({} as any);
|
||||||
|
sdk.editor.usePlugin({} as any);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('codeBlock', () => {
|
||||||
|
test('goto / revert / revertById / 历史', async () => {
|
||||||
|
const sdk = createStyled();
|
||||||
|
const { codeBlock } = sdk;
|
||||||
|
|
||||||
|
await codeBlock.setCodeDslById('code_2', { name: 'c2', content: '() => 1', params: [] });
|
||||||
|
await codeBlock.setCodeDslById(
|
||||||
|
'code_2',
|
||||||
|
{ name: 'c2-new', content: '() => 2', params: [] },
|
||||||
|
{
|
||||||
|
changeRecords: [{ propPath: 'name', value: 'c2-new' }],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await codeBlock.setCodeDslById(
|
||||||
|
'code_2',
|
||||||
|
{ name: 'c2-3', content: '() => 3', params: [] },
|
||||||
|
{
|
||||||
|
changeRecords: [{ propPath: '', value: {} }],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await codeBlock.deleteCodeDslByIds(['code_2']);
|
||||||
|
|
||||||
|
// 回滚删除 -> 写回
|
||||||
|
const list = sdk.history.getStepList('codeBlock', 'code_2');
|
||||||
|
expect(await codeBlock.revert('code_2', list.length - 1)).toBeTruthy();
|
||||||
|
expect(codeBlock.getCodeContentById('code_2')).toBeTruthy();
|
||||||
|
|
||||||
|
// 局部回滚更新
|
||||||
|
expect(await codeBlock.revert('code_2', 1)).toBeTruthy();
|
||||||
|
// 回滚带空 propPath 的更新 -> 整体替换
|
||||||
|
expect(await codeBlock.revert('code_2', 2)).toBeTruthy();
|
||||||
|
// 回滚新增 -> 删除
|
||||||
|
expect(await codeBlock.revert('code_2', 0)).toBeTruthy();
|
||||||
|
expect(codeBlock.getCodeContentById('code_2')).toBeFalsy();
|
||||||
|
|
||||||
|
// 更新类但没有 changeRecords 的不可回滚;越界为 null
|
||||||
|
expect(await codeBlock.revert('code_2', 99)).toBeNull();
|
||||||
|
|
||||||
|
const { step } = sdk.history.getStepList('codeBlock', 'code_2')[0];
|
||||||
|
expect((await codeBlock.revertById([step.uuid!, 'missing']))[1]).toBeNull();
|
||||||
|
|
||||||
|
// goto
|
||||||
|
const total = sdk.history.getStepList('codeBlock', 'code_2').length;
|
||||||
|
expect(await codeBlock.goto('code_2', 1)).toBe(1);
|
||||||
|
expect(await codeBlock.goto('code_2', total)).toBe(total);
|
||||||
|
expect(await codeBlock.goto('code_2', -1)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('undo / redo 单个代码块', async () => {
|
||||||
|
const sdk = createStyled();
|
||||||
|
await sdk.codeBlock.setCodeDslById(
|
||||||
|
'code_1',
|
||||||
|
{ name: 'changed' },
|
||||||
|
{ changeRecords: [{ propPath: 'name', value: 'changed' }] },
|
||||||
|
);
|
||||||
|
expect(await sdk.codeBlock.undo('code_1')).toBeTruthy();
|
||||||
|
expect(sdk.codeBlock.getCodeContentById('code_1')?.name).toBe('code');
|
||||||
|
expect(await sdk.codeBlock.redo('code_1')).toBeTruthy();
|
||||||
|
expect(sdk.codeBlock.getCodeContentById('code_1')?.name).toBe('changed');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('copyWithRelated 复制节点关联的代码块', async () => {
|
||||||
|
const dsl = createStyledDsl();
|
||||||
|
(dsl.items[0].items![0] as any)[HookType.HOOK_TYPE] = HookType.CODE;
|
||||||
|
(dsl.items[0].items![0] as any).hooks = [
|
||||||
|
{ hookType: HookType.CODE, hookData: [{ codeType: HookCodeType.CODE, codeId: 'code_1' }] },
|
||||||
|
];
|
||||||
|
const sdk = createSdk({ dsl });
|
||||||
|
|
||||||
|
sdk.codeBlock.copyWithRelated(sdk.editor.getNodeById('text_1')!, {
|
||||||
|
type: 'code-block' as any,
|
||||||
|
isTarget: (key: string | number, value: any) => key === 'codeId' && !!value,
|
||||||
|
} as any);
|
||||||
|
sdk.codeBlock.copyWithRelated(sdk.editor.getNodeById('text_1')!);
|
||||||
|
|
||||||
|
// 不存在的节点 / 没有收集器时不报错
|
||||||
|
sdk.codeBlock.copyWithRelated([], { type: 'code-block' as any, isTarget: () => true } as any);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('dataSource', () => {
|
||||||
|
test('goto / revert / revertById / undo / redo', () => {
|
||||||
|
const sdk = createStyled();
|
||||||
|
const { dataSource } = sdk;
|
||||||
|
|
||||||
|
dataSource.add({ id: 'ds_2', type: 'base', title: 'two', fields: [], methods: [], events: [] } as any);
|
||||||
|
dataSource.update({ id: 'ds_2', type: 'base', title: 'three', fields: [], methods: [], events: [] } as any, {
|
||||||
|
changeRecords: [{ propPath: 'title', value: 'three' }],
|
||||||
|
});
|
||||||
|
dataSource.update({ id: 'ds_2', type: 'base', title: 'four', fields: [], methods: [], events: [] } as any, {
|
||||||
|
changeRecords: [{ propPath: '', value: {} }],
|
||||||
|
});
|
||||||
|
dataSource.update({ id: 'ds_2', type: 'base', title: 'five', fields: [], methods: [], events: [] } as any);
|
||||||
|
dataSource.remove('ds_2');
|
||||||
|
|
||||||
|
const total = sdk.history.getStepList('dataSource', 'ds_2').length;
|
||||||
|
|
||||||
|
// 回滚删除 -> 写回
|
||||||
|
expect(dataSource.revert('ds_2', total - 1)).toBeTruthy();
|
||||||
|
expect(dataSource.getDataSourceById('ds_2')).toBeTruthy();
|
||||||
|
|
||||||
|
// 局部回滚 / 整体回滚
|
||||||
|
expect(dataSource.revert('ds_2', 1)).toBeTruthy();
|
||||||
|
expect(dataSource.revert('ds_2', 2)).toBeTruthy();
|
||||||
|
// 没有 changeRecords 的更新不可回滚
|
||||||
|
expect(dataSource.revert('ds_2', 3)).toBeNull();
|
||||||
|
expect(dataSource.revert('ds_2', 99)).toBeNull();
|
||||||
|
// 回滚新增 -> 删除
|
||||||
|
expect(dataSource.revert('ds_2', 0)).toBeTruthy();
|
||||||
|
expect(dataSource.getDataSourceById('ds_2')).toBeFalsy();
|
||||||
|
|
||||||
|
const { step } = sdk.history.getStepList('dataSource', 'ds_2')[0];
|
||||||
|
expect(dataSource.revertById([step.uuid!, 'missing'])[1]).toBeNull();
|
||||||
|
|
||||||
|
const { length } = sdk.history.getStepList('dataSource', 'ds_2');
|
||||||
|
expect(dataSource.goto('ds_2', 1)).toBe(1);
|
||||||
|
expect(dataSource.goto('ds_2', length)).toBe(length);
|
||||||
|
expect(dataSource.goto('ds_2', -1)).toBe(0);
|
||||||
|
|
||||||
|
expect(dataSource.undo('ds_2')).toBeNull();
|
||||||
|
expect(dataSource.redo('ds_2')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('copyWithRelated 复制节点关联的数据源', () => {
|
||||||
|
const sdk = createStyled();
|
||||||
|
(sdk.editor.getNodeById('text_1') as any).dsField = ['ds_1', 'name'];
|
||||||
|
|
||||||
|
sdk.dataSource.copyWithRelated(sdk.editor.getNodeById('text_1')!, {
|
||||||
|
type: 'data-source' as any,
|
||||||
|
isTarget: (key: string | number, value: any) => key === 'dsField' && Array.isArray(value),
|
||||||
|
} as any);
|
||||||
|
sdk.dataSource.copyWithRelated(sdk.editor.getNodeById('text_1')!);
|
||||||
|
sdk.dataSource.copyWithRelated([], { type: 'data-source' as any, isTarget: () => true } as any);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DSL 兜底', () => {
|
||||||
|
test('createDsl 夹具本身是合法的', async () => {
|
||||||
|
const sdk = createSdk({ dsl: createDsl() });
|
||||||
|
expect((await sdk.validate.dsl()).valid).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
173
packages/editor/tests/unit/sdk/storage-history.spec.ts
Normal file
173
packages/editor/tests/unit/sdk/storage-history.spec.ts
Normal file
@ -0,0 +1,173 @@
|
|||||||
|
// @vitest-environment node
|
||||||
|
import { describe, expect, test, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { createDslSdk, defaultParseDSL, MemoryStorage, Protocol } from '@editor/sdk';
|
||||||
|
import { isFixed } from '@editor/sdk/utils/position';
|
||||||
|
|
||||||
|
import { createSdk } from './helpers';
|
||||||
|
|
||||||
|
describe('MemoryStorage', () => {
|
||||||
|
test('实现 Storage 接口', () => {
|
||||||
|
const storage = new MemoryStorage();
|
||||||
|
|
||||||
|
expect(storage.length).toBe(0);
|
||||||
|
expect(storage.getItem('a')).toBeNull();
|
||||||
|
|
||||||
|
storage.setItem('a', '1');
|
||||||
|
storage.setItem('b', '2');
|
||||||
|
expect(storage.length).toBe(2);
|
||||||
|
expect(storage.getItem('a')).toBe('1');
|
||||||
|
expect(storage.key(1)).toBe('b');
|
||||||
|
expect(storage.key(5)).toBeNull();
|
||||||
|
|
||||||
|
storage.removeItem('a');
|
||||||
|
expect(storage.getItem('a')).toBeNull();
|
||||||
|
|
||||||
|
storage.clear();
|
||||||
|
expect(storage.length).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('defaultParseDSL', () => {
|
||||||
|
test('解析被括号包起来的 JSON,不执行代码', () => {
|
||||||
|
expect(defaultParseDSL('({"a":1})')).toEqual({ a: 1 });
|
||||||
|
expect(defaultParseDSL('{"a":1}')).toEqual({ a: 1 });
|
||||||
|
expect(() => defaultParseDSL('(() => {})')).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('storage service', () => {
|
||||||
|
test('各协议读写', () => {
|
||||||
|
const sdk = createSdk();
|
||||||
|
const { storage } = sdk;
|
||||||
|
|
||||||
|
storage.setItem('str', 'hello');
|
||||||
|
expect(storage.getItem('str')).toBe('hello');
|
||||||
|
|
||||||
|
storage.setItem('num', 12, { protocol: Protocol.NUMBER });
|
||||||
|
expect(storage.getItem('num', { protocol: Protocol.NUMBER })).toBe(12);
|
||||||
|
|
||||||
|
storage.setItem('bool', true, { protocol: Protocol.BOOLEAN });
|
||||||
|
expect(storage.getItem('bool', { protocol: Protocol.BOOLEAN })).toBe(true);
|
||||||
|
|
||||||
|
storage.setItem('obj', { a: 1 }, { protocol: Protocol.OBJECT });
|
||||||
|
expect(storage.getItem('obj', { protocol: Protocol.OBJECT })).toEqual({ a: 1 });
|
||||||
|
|
||||||
|
expect(storage.getItem('none')).toBeNull();
|
||||||
|
|
||||||
|
storage.removeItem('str');
|
||||||
|
expect(storage.getItem('str')).toBeNull();
|
||||||
|
|
||||||
|
storage.clear();
|
||||||
|
expect(storage.getItem('num', { protocol: Protocol.NUMBER })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('注入自定义 storage', () => {
|
||||||
|
const custom = new MemoryStorage();
|
||||||
|
const sdk = createSdk({ storage: custom });
|
||||||
|
|
||||||
|
sdk.storage.setItem('k', 'v');
|
||||||
|
expect(custom.getItem('tmagic:k')).toBeTruthy();
|
||||||
|
expect(sdk.storage.getStorage()).toBe(custom);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('复制 / 粘贴', () => {
|
||||||
|
test('copy 之后 paste 会生成新节点', async () => {
|
||||||
|
const sdk = createSdk();
|
||||||
|
await sdk.editor.update({ id: 'button_1', style: { position: 'absolute', left: 10, top: 20 } } as any);
|
||||||
|
const node = sdk.editor.getNodeById('button_1')!;
|
||||||
|
await sdk.editor.select('button_1');
|
||||||
|
|
||||||
|
await sdk.editor.copy(node);
|
||||||
|
const result = (await sdk.editor.paste({ left: 10, top: 10 })) as any;
|
||||||
|
const pasted = Array.isArray(result) ? result[0] : result;
|
||||||
|
|
||||||
|
expect(pasted).toBeTruthy();
|
||||||
|
expect(pasted.id).not.toBe('button_1');
|
||||||
|
expect(sdk.editor.getNodeById(pasted.id)).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('没有复制内容时 paste 什么也不做', async () => {
|
||||||
|
const sdk = createSdk();
|
||||||
|
expect(await sdk.editor.paste()).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('历史记录持久化', () => {
|
||||||
|
const createPersistence = () => {
|
||||||
|
const db = new Map<string, unknown>();
|
||||||
|
return {
|
||||||
|
db,
|
||||||
|
get: vi.fn(async (dbName: string, storeName: string, key: IDBValidKey) =>
|
||||||
|
db.get(`${dbName}/${storeName}/${String(key)}`),
|
||||||
|
),
|
||||||
|
set: vi.fn(async (dbName: string, storeName: string, key: IDBValidKey, value: unknown) => {
|
||||||
|
db.set(`${dbName}/${storeName}/${String(key)}`, value);
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
test('没有提供 historyPersistence 时读写会失败,并提示改用 serialize / restore', async () => {
|
||||||
|
const sdk = createSdk();
|
||||||
|
|
||||||
|
await expect(sdk.history.saveToIndexedDB()).rejects.toThrow('historyPersistence');
|
||||||
|
await expect(sdk.history.restoreFromIndexedDB()).rejects.toThrow('historyPersistence');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('saveToIndexedDB / restoreFromIndexedDB 走注入的适配器', async () => {
|
||||||
|
const persistence = createPersistence();
|
||||||
|
const sdk = createSdk({ historyPersistence: persistence });
|
||||||
|
await sdk.editor.update({ id: 'text_1', text: 'changed' } as any);
|
||||||
|
|
||||||
|
await sdk.history.saveToIndexedDB();
|
||||||
|
expect(persistence.set).toHaveBeenCalled();
|
||||||
|
// 库名带上应用 id,使不同应用的历史互相隔离
|
||||||
|
expect(persistence.set.mock.calls[0][0]).toContain('app_1');
|
||||||
|
|
||||||
|
const other = createSdk({ dsl: sdk.getDsl(), historyPersistence: persistence });
|
||||||
|
await other.history.restoreFromIndexedDB();
|
||||||
|
await other.editor.undo();
|
||||||
|
expect((other.editor.getNodeById('text_1') as any).text).toBe('${ds_1.name}');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('serialize 默认保留 Map;jsonSafe 导出的快照可以 JSON 往返', async () => {
|
||||||
|
const sdk = createSdk();
|
||||||
|
await sdk.editor.update({ id: 'text_1', text: 'a' } as any);
|
||||||
|
|
||||||
|
const raw = sdk.history.serialize();
|
||||||
|
const jsonSafe = sdk.history.serialize({ jsonSafe: true });
|
||||||
|
const step = Object.values(raw.steps.page)[0].elementList.find((item: any) => item.extra);
|
||||||
|
|
||||||
|
expect(step.extra.modifiedNodeIds).toBeInstanceOf(Map);
|
||||||
|
// 导出 jsonSafe 不会污染运行中的历史
|
||||||
|
expect(
|
||||||
|
Object.values(sdk.history.serialize().steps.page)[0].elementList.find((item: any) => item.extra).extra
|
||||||
|
.modifiedNodeIds,
|
||||||
|
).toBeInstanceOf(Map);
|
||||||
|
|
||||||
|
const copy = JSON.parse(JSON.stringify(jsonSafe));
|
||||||
|
const restored = createDslSdk({ dsl: sdk.getDsl(), history: copy });
|
||||||
|
expect(restored.history.serialize().steps.page).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('代码块 / 数据源的历史同样可以导出恢复', async () => {
|
||||||
|
const sdk = createSdk();
|
||||||
|
sdk.dataSource.add({ id: 'ds_2', type: 'base', title: 'two', fields: [], methods: [], events: [] } as any);
|
||||||
|
await sdk.codeBlock.setCodeDslById('code_2', { name: 'two', content: '() => {}', params: [] });
|
||||||
|
|
||||||
|
const snapshot = JSON.parse(JSON.stringify(sdk.serializeHistory()));
|
||||||
|
const restored = createSdk({ dsl: sdk.getDsl(), history: snapshot });
|
||||||
|
|
||||||
|
expect(Object.keys(restored.history.serialize().steps.dataSource)).toContain('ds_2');
|
||||||
|
expect(Object.keys(restored.history.serialize().steps.codeBlock)).toContain('code_2');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isFixed', () => {
|
||||||
|
test('是否 fixed 定位', () => {
|
||||||
|
expect(isFixed({ position: 'fixed' })).toBe(true);
|
||||||
|
expect(isFixed({ position: 'absolute' })).toBe(false);
|
||||||
|
expect(isFixed({})).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
305
packages/editor/tests/unit/sdk/validate.spec.ts
Normal file
305
packages/editor/tests/unit/sdk/validate.spec.ts
Normal file
@ -0,0 +1,305 @@
|
|||||||
|
// @vitest-environment node
|
||||||
|
import { describe, expect, test } from 'vitest';
|
||||||
|
|
||||||
|
import { DslValidationError } from '@editor/sdk';
|
||||||
|
|
||||||
|
import { createDsl, createSdk } from './helpers';
|
||||||
|
|
||||||
|
const textConfig = [{ name: 'text', text: '文本', rules: [{ required: true, message: '必填' }] }];
|
||||||
|
|
||||||
|
describe('validate: 结构', () => {
|
||||||
|
test('组件类型不在组件列表中', async () => {
|
||||||
|
const sdk = createSdk();
|
||||||
|
const error = await sdk.editor.add({ type: 'not-exist' } as any).catch((e) => e);
|
||||||
|
|
||||||
|
expect(error).toBeInstanceOf(DslValidationError);
|
||||||
|
expect(error.errors[0]).toMatchObject({ kind: 'structure', path: 'type' });
|
||||||
|
expect(error.errors[0].suggestion).toContain('text');
|
||||||
|
expect(error.message).toContain('not-exist');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('内置类型不需要出现在组件列表里;组件列表为空时不校验类型', async () => {
|
||||||
|
const sdk = createSdk({ componentGroupList: [] });
|
||||||
|
await expect(sdk.editor.add({ type: 'anything' } as any)).resolves.toBeTruthy();
|
||||||
|
|
||||||
|
const sdk2 = createSdk();
|
||||||
|
await expect(sdk2.editor.add({ type: 'container' } as any)).resolves.toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('类型比较忽略大小写写法差异(驼峰与下划线)', async () => {
|
||||||
|
const sdk = createSdk({
|
||||||
|
componentGroupList: [{ title: 'g', items: [{ text: 'a', type: 'page-fragment-container' }] }],
|
||||||
|
});
|
||||||
|
await expect(sdk.editor.add({ type: 'pageFragmentContainer' } as any)).resolves.toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('缺少 type', async () => {
|
||||||
|
const sdk = createSdk();
|
||||||
|
await expect(sdk.editor.add({} as any)).rejects.toThrow('组件类型不能为空');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('父节点不是容器', async () => {
|
||||||
|
const sdk = createSdk();
|
||||||
|
const error = await sdk.editor
|
||||||
|
.add({ type: 'text' } as any, sdk.editor.getNodeById('text_1') as any)
|
||||||
|
.catch((e) => e);
|
||||||
|
|
||||||
|
expect(error).toBeInstanceOf(DslValidationError);
|
||||||
|
expect(error.errors[0].message).toContain('不是容器');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('app 下只能添加页面', async () => {
|
||||||
|
const sdk = createSdk();
|
||||||
|
const error = await sdk.editor.add({ type: 'text' } as any, sdk.editor.get('root') as any).catch((e) => e);
|
||||||
|
|
||||||
|
expect(error).toBeInstanceOf(DslValidationError);
|
||||||
|
expect(error.errors[0].message).toContain('app 下只能添加页面');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('可以添加页面(页面总是添加到 app 下)', async () => {
|
||||||
|
const sdk = createSdk();
|
||||||
|
await expect(sdk.editor.add({ type: 'page', name: 'p2' } as any)).resolves.toBeTruthy();
|
||||||
|
expect(sdk.getDsl()!.items).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('checkAddStructure:页面 / 页面片只能放在 app 下,没有父节点', () => {
|
||||||
|
const sdk = createSdk();
|
||||||
|
const container = sdk.editor.getNodeById('container_1') as any;
|
||||||
|
|
||||||
|
expect(sdk.validate.checkAddStructure({ id: 'p', type: 'page' } as any, container)[0].message).toContain(
|
||||||
|
'只能添加到 app',
|
||||||
|
);
|
||||||
|
expect(sdk.validate.checkAddStructure({ id: 't', type: 'text' } as any, null)[0].message).toContain('未找到父节点');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('id 已存在 / 批量里 id 重复', async () => {
|
||||||
|
const sdk = createSdk();
|
||||||
|
|
||||||
|
const exists = await sdk.editor.add([{ id: 'text_1', type: 'text' }] as any).catch((e) => e);
|
||||||
|
expect(exists.errors[0].message).toContain('已存在');
|
||||||
|
|
||||||
|
const dup = await sdk.editor
|
||||||
|
.add([
|
||||||
|
{ id: 'new_1', type: 'text' },
|
||||||
|
{ id: 'new_1', type: 'text' },
|
||||||
|
] as any)
|
||||||
|
.catch((e) => e);
|
||||||
|
expect(dup.errors[0].message).toContain('重复');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('disabledPageFragment', async () => {
|
||||||
|
const sdk = createSdk({ disabledPageFragment: true });
|
||||||
|
const error = await sdk.editor.add({ type: 'page-fragment' } as any).catch((e) => e);
|
||||||
|
|
||||||
|
expect(error).toBeInstanceOf(DslValidationError);
|
||||||
|
expect(error.errors[0].message).toContain('disabledPageFragment');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('canDropIn 返回 false 时阻止添加与移动', async () => {
|
||||||
|
const calls: any[] = [];
|
||||||
|
const sdk = createSdk({
|
||||||
|
canDropIn: (...args) => {
|
||||||
|
calls.push(args);
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const container = sdk.editor.getNodeById('container_1') as any;
|
||||||
|
const add = await sdk.editor.add({ type: 'text' } as any, container).catch((e) => e);
|
||||||
|
expect(add).toBeInstanceOf(DslValidationError);
|
||||||
|
expect(calls[0]).toEqual([[], 'container_1', 'stage-add']);
|
||||||
|
|
||||||
|
const move = await sdk.editor.moveToContainer({ id: 'text_1' } as any, 'container_1').catch((e) => e);
|
||||||
|
expect(move).toBeInstanceOf(DslValidationError);
|
||||||
|
expect(calls.at(-1)).toEqual([['text_1'], 'container_1', 'stage-drag']);
|
||||||
|
|
||||||
|
const drag = await sdk.editor.dragTo({ id: 'text_1' } as any, container, 0).catch((e) => e);
|
||||||
|
expect(drag).toBeInstanceOf(DslValidationError);
|
||||||
|
expect(calls.at(-1)[2]).toBe('layer');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('canDropIn 重定向到不存在的节点 / 非容器时报错,重定向到容器时放行', async () => {
|
||||||
|
const sdk = createSdk({ canDropIn: () => 'nothing' });
|
||||||
|
const container = sdk.editor.getNodeById('container_1') as any;
|
||||||
|
const bad = await sdk.editor.add({ type: 'text' } as any, container).catch((e) => e);
|
||||||
|
expect(bad.errors[0].message).toContain('重定向');
|
||||||
|
|
||||||
|
const ok = createSdk({ canDropIn: () => 'container_1' });
|
||||||
|
await expect(
|
||||||
|
ok.editor.add({ type: 'text' } as any, ok.editor.getNodeById('container_1') as any),
|
||||||
|
).resolves.toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('移动:目标不是容器 / 移到自己里面 / 源节点不存在', async () => {
|
||||||
|
const sdk = createSdk();
|
||||||
|
|
||||||
|
const notContainer = await sdk.editor.moveToContainer({ id: 'button_1' } as any, 'text_1').catch((e) => e);
|
||||||
|
expect(notContainer.errors[0].message).toContain('不是容器');
|
||||||
|
|
||||||
|
const inside = await sdk.editor.moveToContainer({ id: 'container_1' } as any, 'container_1').catch((e) => e);
|
||||||
|
expect(inside.errors[0].message).toContain('自己');
|
||||||
|
|
||||||
|
const missing = await sdk.editor.moveToContainer({ id: 'nope' } as any, 'container_1').catch((e) => e);
|
||||||
|
expect(missing.errors[0].message).toContain('不存在');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('update:节点不存在 / 修改类型', async () => {
|
||||||
|
const sdk = createSdk();
|
||||||
|
|
||||||
|
const missing = await sdk.editor.update({ id: 'nope', text: 'x' } as any).catch((e) => e);
|
||||||
|
expect(missing).toBeInstanceOf(DslValidationError);
|
||||||
|
|
||||||
|
const typeChange = await sdk.editor.update({ id: 'text_1', type: 'button' } as any).catch((e) => e);
|
||||||
|
expect(typeChange.errors[0].message).toContain('不允许修改节点类型');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('update 根节点不做校验', async () => {
|
||||||
|
const sdk = createSdk();
|
||||||
|
await expect(sdk.editor.update({ id: 'app_1', name: 'renamed' } as any)).resolves.toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('数据源:类型不可用时 add / update 抛错', () => {
|
||||||
|
const sdk = createSdk({ datasourceList: [{ type: 'custom', text: '自定义' }] });
|
||||||
|
|
||||||
|
expect(() => sdk.dataSource.add({ id: 'ds_x', type: 'unknown' } as any)).toThrow(DslValidationError);
|
||||||
|
expect(() => sdk.dataSource.update({ id: 'ds_1', type: 'unknown' } as any)).toThrow(DslValidationError);
|
||||||
|
expect(() =>
|
||||||
|
sdk.dataSource.add({ id: 'ds_y', type: 'custom', fields: [], methods: [], events: [] } as any),
|
||||||
|
).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('validate: 属性', () => {
|
||||||
|
test('add 时校验属性表单', async () => {
|
||||||
|
const sdk = createSdk({ propsConfigs: { text: textConfig }, propsValues: { text: { text: '' } } });
|
||||||
|
await sdk.ready;
|
||||||
|
|
||||||
|
const error = await sdk.editor.add({ type: 'text' } as any).catch((e) => e);
|
||||||
|
expect(error).toBeInstanceOf(DslValidationError);
|
||||||
|
expect(error.errors[0]).toMatchObject({ kind: 'props', target: 'node' });
|
||||||
|
expect(error.errors[0].message).toContain('必填');
|
||||||
|
|
||||||
|
await expect(sdk.editor.add({ type: 'text', text: 'ok' } as any)).resolves.toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('update 时校验合并之后的属性', async () => {
|
||||||
|
const sdk = createSdk({ propsConfigs: { text: textConfig } });
|
||||||
|
await sdk.ready;
|
||||||
|
|
||||||
|
const error = await sdk.editor.update({ id: 'text_1', text: '' } as any).catch((e) => e);
|
||||||
|
expect(error).toBeInstanceOf(DslValidationError);
|
||||||
|
expect((sdk.editor.getNodeById('text_1') as any).text).toBe('${ds_1.name}');
|
||||||
|
|
||||||
|
await expect(sdk.editor.update({ id: 'text_1', text: 'good' } as any)).resolves.toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('取属性配置时抛错,作为校验异常上报', async () => {
|
||||||
|
const sdk = createSdk();
|
||||||
|
sdk.props.usePlugin({
|
||||||
|
beforeGetPropsConfig: () => {
|
||||||
|
throw new Error('boom');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await sdk.validate.node('text_1');
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
expect(result.errors[0].message).toContain('boom');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('checkNodeProps:没有配置 / root 不校验', async () => {
|
||||||
|
const sdk = createSdk();
|
||||||
|
expect(await sdk.validate.checkNodeProps({ id: 'a', type: 'unknown-without-config' } as any)).toEqual([]);
|
||||||
|
expect(await sdk.validate.checkNodeProps({ id: 'app_1', type: 'app' } as any)).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('validate: 查询接口', () => {
|
||||||
|
test('node()', async () => {
|
||||||
|
const sdk = createSdk({ propsConfigs: { text: textConfig } });
|
||||||
|
await sdk.ready;
|
||||||
|
|
||||||
|
expect((await sdk.validate.node('button_1')).valid).toBe(true);
|
||||||
|
expect(await sdk.validate.node('nope')).toMatchObject({ valid: false });
|
||||||
|
|
||||||
|
sdk.ctx.validateMode = 'off';
|
||||||
|
await sdk.editor.update({ id: 'text_1', text: '' } as any);
|
||||||
|
|
||||||
|
const result = await sdk.validate.node('text_1');
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
expect(result.errors[0].kind).toBe('props');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('node() 能发现已经在 DSL 中的非法类型 / 被禁用的页面片', async () => {
|
||||||
|
const dsl = createDsl();
|
||||||
|
dsl.items[0].items!.push({ id: 'weird', type: 'weird-type' } as any);
|
||||||
|
dsl.items.push({ id: 'frag_1', type: 'page-fragment', items: [] } as any);
|
||||||
|
|
||||||
|
const sdk = createSdk({ dsl, disabledPageFragment: true });
|
||||||
|
|
||||||
|
expect((await sdk.validate.node('weird')).errors[0].message).toContain('weird-type');
|
||||||
|
expect((await sdk.validate.node('frag_1')).errors[0].message).toContain('disabledPageFragment');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dataSource()', async () => {
|
||||||
|
const dsl = createDsl();
|
||||||
|
dsl.dataSources![0].title = '';
|
||||||
|
const sdk = createSdk({ dsl });
|
||||||
|
|
||||||
|
expect((await sdk.validate.dataSource('nope')).valid).toBe(false);
|
||||||
|
|
||||||
|
const result = await sdk.validate.dataSource('ds_1');
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
expect(result.errors[0]).toMatchObject({ target: 'data-source', kind: 'props' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dsl():汇总整份 DSL 的问题', async () => {
|
||||||
|
const dsl = createDsl();
|
||||||
|
dsl.items[0].items!.push({ id: 'text_1', type: 'text' } as any, { id: 'text_2', type: 'text', text: '' } as any);
|
||||||
|
|
||||||
|
const sdk = createSdk({ dsl, propsConfigs: { text: textConfig } });
|
||||||
|
await sdk.ready;
|
||||||
|
|
||||||
|
const result = await sdk.validate.dsl();
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
expect(result.errors.some((item) => item.message.includes('重复'))).toBe(true);
|
||||||
|
expect(result.errors.some((item) => item.kind === 'props')).toBe(true);
|
||||||
|
|
||||||
|
expect((await createSdk({ dsl: null }).validate.dsl()).errors[0].target).toBe('dsl');
|
||||||
|
expect((await createSdk().validate.dsl()).valid).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('validateMode', () => {
|
||||||
|
test('off:不校验', async () => {
|
||||||
|
const sdk = createSdk({ validateMode: 'off' });
|
||||||
|
await expect(sdk.editor.add({ type: 'not-exist' } as any)).resolves.toBeTruthy();
|
||||||
|
expect(() => sdk.dataSource.add({ id: 'ds_z', type: 'unknown' } as any)).not.toThrow();
|
||||||
|
await expect(sdk.editor.moveToContainer({ id: 'text_1' } as any, 'container_1')).resolves.toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('record:不抛错,错误写进 invalidNodeIds', async () => {
|
||||||
|
const sdk = createSdk({ validateMode: 'record', propsConfigs: { text: textConfig } });
|
||||||
|
await sdk.ready;
|
||||||
|
|
||||||
|
await sdk.editor.update({ id: 'text_1', text: '' } as any);
|
||||||
|
expect(sdk.editor.getInvalidNodeInfo('text_1')?.props).toContain('必填');
|
||||||
|
|
||||||
|
await sdk.editor.update({ id: 'text_1', text: 'fixed' } as any);
|
||||||
|
expect(sdk.editor.getInvalidNodeInfo('text_1')).toBeUndefined();
|
||||||
|
|
||||||
|
// 结构问题同样只记录
|
||||||
|
const added = (await sdk.editor.add({ type: 'text' } as any)) as any;
|
||||||
|
expect(added).toBeTruthy();
|
||||||
|
expect(sdk.editor.getInvalidNodeInfo(added.id)?.props).toContain('必填');
|
||||||
|
|
||||||
|
// 数据源 / 移动只在 strict 模式下校验
|
||||||
|
expect(() => sdk.dataSource.add({ id: 'ds_z', type: 'unknown' } as any)).not.toThrow();
|
||||||
|
await expect(sdk.editor.moveToContainer({ id: 'text_1' } as any, 'container_1')).resolves.toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('运行时切换 validateMode', async () => {
|
||||||
|
const sdk = createSdk();
|
||||||
|
sdk.ctx.validateMode = 'off';
|
||||||
|
await expect(sdk.editor.add({ type: 'not-exist' } as any)).resolves.toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
206
packages/editor/tests/unit/sdk/wiring.spec.ts
Normal file
206
packages/editor/tests/unit/sdk/wiring.spec.ts
Normal file
@ -0,0 +1,206 @@
|
|||||||
|
// @vitest-environment node
|
||||||
|
import { describe, expect, test } from 'vitest';
|
||||||
|
|
||||||
|
import { DepTargetType, NODE_CONDS_KEY } from '@tmagic/core';
|
||||||
|
|
||||||
|
import { classifyUpdatedNodes } from '@editor/sdk';
|
||||||
|
|
||||||
|
import { createDsl, createSdk } from './helpers';
|
||||||
|
|
||||||
|
describe('依赖收集连线', () => {
|
||||||
|
test('创建时收集数据源依赖并写回 DSL', () => {
|
||||||
|
const sdk = createSdk();
|
||||||
|
const dsl = sdk.getDsl()!;
|
||||||
|
|
||||||
|
expect(dsl.dataSourceDeps!.ds_1.text_1).toMatchObject({ keys: ['text'] });
|
||||||
|
expect(dsl.dataSourceMethodDeps).toHaveProperty('ds_1');
|
||||||
|
expect(dsl.dataSourceCondDeps).toHaveProperty('ds_1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('没有 items 的 root 会清空依赖', async () => {
|
||||||
|
const sdk = createSdk();
|
||||||
|
await sdk.setDsl({ id: 'app_2', type: 'app', dataSourceDeps: { x: {} } } as any);
|
||||||
|
|
||||||
|
expect(sdk.getDsl()!.dataSourceDeps).toBeUndefined();
|
||||||
|
expect(sdk.editor.get('nodes')[0].id).toBe('app_2');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('root 变化后保持原来的选中节点', async () => {
|
||||||
|
const sdk = createSdk();
|
||||||
|
await sdk.editor.select('button_1');
|
||||||
|
await sdk.setDsl(createDsl());
|
||||||
|
|
||||||
|
expect(sdk.editor.get('node')?.id).toBe('button_1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('add:新增节点引用数据源时收集依赖', async () => {
|
||||||
|
const sdk = createSdk();
|
||||||
|
const node = (await sdk.editor.add({ type: 'text', text: '${ds_1.name}' } as any)) as any;
|
||||||
|
|
||||||
|
expect(sdk.getDsl()!.dataSourceDeps!.ds_1[node.id]).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('update:修改绑定数据源的字段触发重新收集;改成普通文本后依赖被移除', async () => {
|
||||||
|
const sdk = createSdk();
|
||||||
|
|
||||||
|
await sdk.editor.update({ id: 'button_1', text: '${ds_1.name}' } as any, {
|
||||||
|
changeRecords: [{ propPath: 'text', value: '${ds_1.name}' }],
|
||||||
|
});
|
||||||
|
expect(sdk.getDsl()!.dataSourceDeps!.ds_1.button_1).toBeDefined();
|
||||||
|
|
||||||
|
await sdk.editor.update({ id: 'button_1', text: 'plain' } as any, {
|
||||||
|
changeRecords: [{ propPath: 'text', value: 'plain' }],
|
||||||
|
});
|
||||||
|
expect(sdk.getDsl()!.dataSourceDeps!.ds_1.button_1).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('update:没有 changeRecords 时按新旧节点是否包含数据源判断', async () => {
|
||||||
|
const sdk = createSdk();
|
||||||
|
|
||||||
|
await sdk.editor.update({ id: 'button_1', text: '${ds_1.name}' } as any);
|
||||||
|
expect(sdk.getDsl()!.dataSourceDeps!.ds_1.button_1).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('update:普通字段变化只收集代码块 / 数据源方法依赖', async () => {
|
||||||
|
const sdk = createSdk();
|
||||||
|
await sdk.editor.update({ id: 'button_1', text: 'new' } as any, {
|
||||||
|
changeRecords: [{ propPath: 'text', value: 'new' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(sdk.getDsl()!.dataSourceDeps!.ds_1.text_1).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('update root 不触发收集', async () => {
|
||||||
|
const sdk = createSdk();
|
||||||
|
await sdk.editor.update({ id: 'app_1', name: 'x' } as any);
|
||||||
|
|
||||||
|
expect(sdk.getDsl()!.dataSourceDeps!.ds_1.text_1).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('remove:删除节点时清理依赖', async () => {
|
||||||
|
const sdk = createSdk();
|
||||||
|
await sdk.editor.remove({ id: 'text_1' } as any);
|
||||||
|
|
||||||
|
expect(sdk.getDsl()!.dataSourceDeps!.ds_1.text_1).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('undo / redo 后依赖保持一致', async () => {
|
||||||
|
const sdk = createSdk();
|
||||||
|
await sdk.editor.remove({ id: 'text_1' } as any);
|
||||||
|
await sdk.editor.undo();
|
||||||
|
|
||||||
|
expect(sdk.getDsl()!.dataSourceDeps!.ds_1.text_1).toBeDefined();
|
||||||
|
|
||||||
|
await sdk.editor.redo();
|
||||||
|
expect(sdk.getDsl()!.dataSourceDeps!.ds_1.text_1).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('数据源:新增后引用它的节点生效;修改字段 / mock / 方法会重新收集;删除时清理', async () => {
|
||||||
|
const sdk = createSdk();
|
||||||
|
|
||||||
|
sdk.dataSource.add({
|
||||||
|
id: 'ds_2',
|
||||||
|
type: 'base',
|
||||||
|
title: 'ds2',
|
||||||
|
fields: [{ name: 'a' }],
|
||||||
|
methods: [],
|
||||||
|
events: [],
|
||||||
|
} as any);
|
||||||
|
expect(sdk.dep.getTarget('ds_2', DepTargetType.DATA_SOURCE)).toBeTruthy();
|
||||||
|
|
||||||
|
await sdk.editor.update({ id: 'button_1', text: '${ds_2.a}' } as any);
|
||||||
|
expect(sdk.getDsl()!.dataSourceDeps!.ds_2.button_1).toBeDefined();
|
||||||
|
|
||||||
|
// 改字段
|
||||||
|
sdk.dataSource.update(
|
||||||
|
{ id: 'ds_2', type: 'base', title: 'ds2', fields: [{ name: 'b' }], methods: [], events: [] } as any,
|
||||||
|
{ changeRecords: [{ propPath: 'fields', value: [{ name: 'b' }] }] },
|
||||||
|
);
|
||||||
|
expect(sdk.dep.getTarget('ds_2', DepTargetType.DATA_SOURCE)).toBeTruthy();
|
||||||
|
|
||||||
|
// 改 mock
|
||||||
|
sdk.dataSource.update(
|
||||||
|
{ id: 'ds_2', type: 'base', title: 'ds2', fields: [{ name: 'b' }], methods: [], events: [], mocks: [] } as any,
|
||||||
|
{ changeRecords: [{ propPath: 'mocks', value: [] }] },
|
||||||
|
);
|
||||||
|
expect(sdk.dep.getTarget('ds_2', DepTargetType.DATA_SOURCE)).toBeTruthy();
|
||||||
|
|
||||||
|
// 改方法
|
||||||
|
sdk.dataSource.update(
|
||||||
|
{ id: 'ds_2', type: 'base', title: 'ds2', fields: [{ name: 'b' }], methods: [], events: [] } as any,
|
||||||
|
{ changeRecords: [{ propPath: 'methods', value: [] }] },
|
||||||
|
);
|
||||||
|
expect(sdk.dep.getTarget('ds_2', DepTargetType.DATA_SOURCE_METHOD)).toBeTruthy();
|
||||||
|
|
||||||
|
// 与依赖无关的字段
|
||||||
|
sdk.dataSource.update(
|
||||||
|
{ id: 'ds_2', type: 'base', title: 'renamed', fields: [{ name: 'b' }], methods: [], events: [] } as any,
|
||||||
|
{
|
||||||
|
changeRecords: [
|
||||||
|
{ propPath: 'title', value: 'renamed' },
|
||||||
|
{ propPath: '', value: '' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
sdk.dataSource.remove('ds_2');
|
||||||
|
expect(sdk.dep.getTarget('ds_2', DepTargetType.DATA_SOURCE)).toBeFalsy();
|
||||||
|
expect(sdk.getDsl()!.dataSourceDeps!.ds_2).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('代码块:新增 / 更新 / 删除对应的依赖目标', async () => {
|
||||||
|
const sdk = createSdk();
|
||||||
|
|
||||||
|
expect(sdk.dep.hasTarget('code_1', DepTargetType.CODE_BLOCK)).toBe(true);
|
||||||
|
|
||||||
|
await sdk.codeBlock.setCodeDslById('code_2', { name: 'two', content: '() => {}', params: [] });
|
||||||
|
expect(sdk.dep.hasTarget('code_2', DepTargetType.CODE_BLOCK)).toBe(true);
|
||||||
|
|
||||||
|
await sdk.codeBlock.setCodeDslById('code_2', { name: 'renamed', content: '() => {}', params: [] });
|
||||||
|
expect(sdk.dep.getTarget('code_2', DepTargetType.CODE_BLOCK)?.name).toBe('renamed');
|
||||||
|
|
||||||
|
await sdk.codeBlock.deleteCodeDslByIds(['code_2']);
|
||||||
|
expect(sdk.dep.hasTarget('code_2', DepTargetType.CODE_BLOCK)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unbind 之后不再响应', async () => {
|
||||||
|
const sdk = createSdk();
|
||||||
|
const unbind = sdk.bindWiring();
|
||||||
|
unbind();
|
||||||
|
|
||||||
|
await sdk.editor.add({ type: 'text', text: '${ds_1.name}' } as any);
|
||||||
|
// 创建时已自动绑定,这里 unbind 的是 bindWiring 返回的同一个句柄
|
||||||
|
expect(Object.keys(sdk.getDsl()!.dataSourceDeps!.ds_1)).toContain('text_1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('classifyUpdatedNodes', () => {
|
||||||
|
const sdk = createSdk();
|
||||||
|
const make = (newNode: any, oldNode: any, changeRecords?: any[]) => ({ newNode, oldNode, changeRecords });
|
||||||
|
|
||||||
|
test('根节点归为普通节点', () => {
|
||||||
|
const { normalNodes } = classifyUpdatedNodes(sdk.ctx, [make({ id: 'a', type: 'app' }, { id: 'a', type: 'app' })]);
|
||||||
|
expect(normalNodes).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('没有 propPath、显示条件、绑定数据源的值都需要重新收集', () => {
|
||||||
|
const node = { id: 'x', type: 'text' };
|
||||||
|
const { needRecollectNodes } = classifyUpdatedNodes(sdk.ctx, [
|
||||||
|
make(node, node, [{ propPath: '', value: 1 }]),
|
||||||
|
make(node, node, [{ propPath: NODE_CONDS_KEY, value: 1 }]),
|
||||||
|
make(node, node, [{ propPath: `${NODE_CONDS_KEY}.0.cond`, value: 1 }]),
|
||||||
|
make(node, node, [{ propPath: 'a', value: '${ds_1.a}' }]),
|
||||||
|
]);
|
||||||
|
expect(needRecollectNodes).toHaveLength(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('修改的 key 已在依赖里时需要重新收集', () => {
|
||||||
|
const { needRecollectNodes, normalNodes } = classifyUpdatedNodes(sdk.ctx, [
|
||||||
|
make({ id: 'text_1', type: 'text' }, { id: 'text_1', type: 'text' }, [{ propPath: 'text', value: 'a' }]),
|
||||||
|
make({ id: 'text_1', type: 'text' }, { id: 'text_1', type: 'text' }, [{ propPath: 'other', value: 'a' }]),
|
||||||
|
make({ id: 'zzz', type: 'text' }, { id: 'zzz', type: 'text' }, [{ propPath: 'text', value: 'a' }]),
|
||||||
|
]);
|
||||||
|
expect(needRecollectNodes).toHaveLength(1);
|
||||||
|
expect(normalNodes).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -5,7 +5,7 @@
|
|||||||
*/
|
*/
|
||||||
import { describe, expect, test, vi } from 'vitest';
|
import { describe, expect, test, vi } from 'vitest';
|
||||||
|
|
||||||
import BaseService from '@editor/services/BaseService';
|
import BaseService from '@editor/sdk/services/BaseService';
|
||||||
|
|
||||||
class SyncService extends BaseService {
|
class SyncService extends BaseService {
|
||||||
public count = 0;
|
public count = 0;
|
||||||
|
|||||||
@ -5,18 +5,23 @@
|
|||||||
*/
|
*/
|
||||||
import { afterEach, beforeAll, beforeEach, describe, expect, test, vi } from 'vitest';
|
import { afterEach, beforeAll, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||||
|
|
||||||
import codeBlockService from '@editor/services/codeBlock';
|
import { codeBlockService, historyService, Protocol, storageService } from '@editor/services/sdk-instance';
|
||||||
import historyService from '@editor/services/history';
|
|
||||||
import storageService, { Protocol } from '@editor/services/storage';
|
|
||||||
import { CODE_DRAFT_STORAGE_KEY } from '@editor/type';
|
import { CODE_DRAFT_STORAGE_KEY } from '@editor/type';
|
||||||
import { setEditorConfig } from '@editor/utils/config';
|
import { setEditorConfig } from '@editor/utils/config';
|
||||||
import { COPY_CODE_STORAGE_KEY } from '@editor/utils/editor';
|
import { COPY_CODE_STORAGE_KEY } from '@editor/utils/editor';
|
||||||
|
|
||||||
vi.mock('@editor/services/editor', () => ({
|
vi.mock('@editor/services/sdk-instance', async (importOriginal) => {
|
||||||
default: {
|
const actual = await importOriginal<any>();
|
||||||
getNodeById: vi.fn((id: string) => ({ id, code: 'code1' })),
|
const modEditor = await (() => ({
|
||||||
},
|
default: {
|
||||||
}));
|
getNodeById: vi.fn((id: string) => ({ id, code: 'code1' })),
|
||||||
|
},
|
||||||
|
}))();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
editorService: modEditor.default,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
setEditorConfig({
|
setEditorConfig({
|
||||||
|
|||||||
@ -5,7 +5,7 @@
|
|||||||
*/
|
*/
|
||||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||||
|
|
||||||
import componentList from '@editor/services/componentList';
|
import { componentListService as componentList } from '@editor/services/sdk-instance';
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
componentList.resetState();
|
componentList.resetState();
|
||||||
|
|||||||
@ -5,17 +5,27 @@
|
|||||||
*/
|
*/
|
||||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||||
|
|
||||||
import dataSource from '@editor/services/dataSource';
|
import {
|
||||||
import historyService from '@editor/services/history';
|
dataSourceService as dataSource,
|
||||||
import storageService, { Protocol } from '@editor/services/storage';
|
historyService,
|
||||||
|
Protocol,
|
||||||
|
storageService,
|
||||||
|
} from '@editor/services/sdk-instance';
|
||||||
import { setEditorConfig } from '@editor/utils/config';
|
import { setEditorConfig } from '@editor/utils/config';
|
||||||
import { COPY_DS_STORAGE_KEY } from '@editor/utils/editor';
|
import { COPY_DS_STORAGE_KEY } from '@editor/utils/editor';
|
||||||
|
|
||||||
vi.mock('@editor/services/editor', () => ({
|
vi.mock('@editor/services/sdk-instance', async (importOriginal) => {
|
||||||
default: {
|
const actual = await importOriginal<any>();
|
||||||
getNodeById: vi.fn((id: string) => ({ id, dsBinding: ['ds1'] })),
|
const modEditor = await (() => ({
|
||||||
},
|
default: {
|
||||||
}));
|
getNodeById: vi.fn((id: string) => ({ id, dsBinding: ['ds1'] })),
|
||||||
|
},
|
||||||
|
}))();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
editorService: modEditor.default,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
setEditorConfig({
|
setEditorConfig({
|
||||||
// eslint-disable-next-line no-new-func
|
// eslint-disable-next-line no-new-func
|
||||||
|
|||||||
@ -8,7 +8,7 @@ import { reactive } from 'vue';
|
|||||||
|
|
||||||
import { createDataSourceTarget, DepTargetType, Target } from '@tmagic/core';
|
import { createDataSourceTarget, DepTargetType, Target } from '@tmagic/core';
|
||||||
|
|
||||||
import depService from '@editor/services/dep';
|
import { depService } from '@editor/services/sdk-instance';
|
||||||
|
|
||||||
// 全量收集({ id, dsl })与增量收集({ id, payload })共用一个常驻 worker
|
// 全量收集({ id, dsl })与增量收集({ id, payload })共用一个常驻 worker
|
||||||
vi.mock('@editor/utils/dep/worker.ts?worker&inline', () => ({
|
vi.mock('@editor/utils/dep/worker.ts?worker&inline', () => ({
|
||||||
@ -227,6 +227,49 @@ describe('Dep service', () => {
|
|||||||
expect(() => depService.collect([{ id: 'p1', type: 'page', items: [] }] as any, { pageId: 'p1' })).not.toThrow();
|
expect(() => depService.collect([{ id: 'p1', type: 'page', items: [] }] as any, { pageId: 'p1' })).not.toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const makeTextTarget = (id: string, text: string) =>
|
||||||
|
new Target({
|
||||||
|
id,
|
||||||
|
type: DepTargetType.DEFAULT,
|
||||||
|
isTarget: (key: string | number, value: any) => key === 'text' && value === text,
|
||||||
|
});
|
||||||
|
|
||||||
|
const makeSharedIdFragments = () => [
|
||||||
|
{ id: 'fragment_a', type: 'container', items: [{ id: 'shared', type: 'text', text: 'a' }] },
|
||||||
|
{ id: 'fragment_b', type: 'container', items: [{ id: 'shared', type: 'text', text: 'b' }] },
|
||||||
|
];
|
||||||
|
|
||||||
|
test('collect 多个根节点共用子节点 id 时不会互相清掉依赖', () => {
|
||||||
|
const targetA = makeTextTarget('ds_a', 'a');
|
||||||
|
const targetB = makeTextTarget('ds_b', 'b');
|
||||||
|
depService.addTarget(targetA);
|
||||||
|
depService.addTarget(targetB);
|
||||||
|
|
||||||
|
depService.collect(makeSharedIdFragments() as any, {}, true);
|
||||||
|
|
||||||
|
expect(targetA.deps.shared?.keys).toEqual(['text']);
|
||||||
|
expect(targetB.deps.shared?.keys).toEqual(['text']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('collect 多个 page 节点同 pageId 时不会互相清掉依赖', () => {
|
||||||
|
const target = makeTextTarget('ds_a', 'a');
|
||||||
|
depService.addTarget(target);
|
||||||
|
target.deps.stale = { name: 'stale', keys: ['text'], data: { pageId: 'p' } };
|
||||||
|
|
||||||
|
depService.collect(
|
||||||
|
[
|
||||||
|
{ id: 'page_1', type: 'page', text: 'a', items: [] },
|
||||||
|
{ id: 'page_2', type: 'page', items: [{ id: 'child', type: 'text', text: 'a' }] },
|
||||||
|
] as any,
|
||||||
|
{ pageId: 'p' },
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(target.deps.stale).toBeUndefined();
|
||||||
|
expect(target.deps.page_1?.keys).toEqual(['text']);
|
||||||
|
expect(target.deps.child?.keys).toEqual(['text']);
|
||||||
|
});
|
||||||
|
|
||||||
test('collectNode 支持 page 与普通节点两条路径', () => {
|
test('collectNode 支持 page 与普通节点两条路径', () => {
|
||||||
const target = makeTarget('node-target');
|
const target = makeTarget('node-target');
|
||||||
depService.addTarget(target);
|
depService.addTarget(target);
|
||||||
|
|||||||
@ -4,8 +4,7 @@ import { cloneDeep } from 'lodash-es';
|
|||||||
import type { MApp, MContainer } from '@tmagic/core';
|
import type { MApp, MContainer } from '@tmagic/core';
|
||||||
import { NodeType } from '@tmagic/core';
|
import { NodeType } from '@tmagic/core';
|
||||||
|
|
||||||
import editorService from '@editor/services/editor';
|
import { editorService, historyService } from '@editor/services/sdk-instance';
|
||||||
import historyService from '@editor/services/history';
|
|
||||||
import { setEditorConfig } from '@editor/utils';
|
import { setEditorConfig } from '@editor/utils';
|
||||||
|
|
||||||
setEditorConfig({
|
setEditorConfig({
|
||||||
|
|||||||
@ -23,9 +23,7 @@ import type { MApp, MContainer, MNode } from '@tmagic/core';
|
|||||||
import { NodeType } from '@tmagic/core';
|
import { NodeType } from '@tmagic/core';
|
||||||
import { getNodePath } from '@tmagic/utils';
|
import { getNodePath } from '@tmagic/utils';
|
||||||
|
|
||||||
import editorService from '@editor/services/editor';
|
import { editorService, historyService, storageService } from '@editor/services/sdk-instance';
|
||||||
import historyService from '@editor/services/history';
|
|
||||||
import storageService from '@editor/services/storage';
|
|
||||||
import type { EditorChangeEvent } from '@editor/type';
|
import type { EditorChangeEvent } from '@editor/type';
|
||||||
import { COPY_STORAGE_KEY, setEditorConfig } from '@editor/utils';
|
import { COPY_STORAGE_KEY, setEditorConfig } from '@editor/utils';
|
||||||
|
|
||||||
|
|||||||
@ -19,7 +19,7 @@
|
|||||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
|
|
||||||
import events from '@editor/services/events';
|
import { eventsService as events } from '@editor/services/sdk-instance';
|
||||||
|
|
||||||
describe('events', () => {
|
describe('events', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
|||||||
@ -17,7 +17,7 @@
|
|||||||
*/
|
*/
|
||||||
import { afterEach, beforeAll, beforeEach, describe, expect, test, vi } from 'vitest';
|
import { afterEach, beforeAll, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||||
|
|
||||||
import history from '@editor/services/history';
|
import { historyService as history } from '@editor/services/sdk-instance';
|
||||||
import { setEditorConfig } from '@editor/utils/config';
|
import { setEditorConfig } from '@editor/utils/config';
|
||||||
import { createStackStep } from '@editor/utils/history';
|
import { createStackStep } from '@editor/utils/history';
|
||||||
import * as indexedDb from '@editor/utils/indexed-db';
|
import * as indexedDb from '@editor/utils/indexed-db';
|
||||||
|
|||||||
@ -5,7 +5,7 @@
|
|||||||
*/
|
*/
|
||||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||||
|
|
||||||
import history from '@editor/services/history';
|
import { historyService as history } from '@editor/services/sdk-instance';
|
||||||
import { createStackStep } from '@editor/utils/history';
|
import { createStackStep } from '@editor/utils/history';
|
||||||
|
|
||||||
// pushCodeBlock / pushDataSource 已合入统一的 push(stepType, step, id)。
|
// pushCodeBlock / pushDataSource 已合入统一的 push(stepType, step, id)。
|
||||||
|
|||||||
@ -25,7 +25,14 @@ const uiService = vi.hoisted(() => ({
|
|||||||
calcZoom: vi.fn(async () => 1.2),
|
calcZoom: vi.fn(async () => 1.2),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('@editor/services/editor', () => ({ default: editorService }));
|
vi.mock('@editor/services/sdk-instance', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<any>();
|
||||||
|
const modEditor = await (() => ({ default: editorService }))();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
editorService: modEditor.default,
|
||||||
|
};
|
||||||
|
});
|
||||||
vi.mock('@editor/services/ui', () => ({ default: uiService }));
|
vi.mock('@editor/services/ui', () => ({ default: uiService }));
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { describe, expect, test } from 'vitest';
|
|||||||
|
|
||||||
import { NodeType } from '@tmagic/core';
|
import { NodeType } from '@tmagic/core';
|
||||||
|
|
||||||
import props from '@editor/services/props';
|
import { propsService as props } from '@editor/services/sdk-instance';
|
||||||
|
|
||||||
test('createId', async () => {
|
test('createId', async () => {
|
||||||
const id = await props.createId('text');
|
const id = await props.createId('text');
|
||||||
|
|||||||
@ -28,26 +28,33 @@ vi.mock('@editor/hooks/use-stage', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
const editorEvents: Record<string, ((..._args: any[]) => any)[]> = {};
|
const editorEvents: Record<string, ((..._args: any[]) => any)[]> = {};
|
||||||
vi.mock('@editor/services/editor', () => ({
|
vi.mock('@editor/services/sdk-instance', async (importOriginal) => {
|
||||||
default: {
|
const actual = await importOriginal<any>();
|
||||||
on: vi.fn((evt: string, fn: any) => {
|
const modEditor = await (() => ({
|
||||||
editorEvents[evt] ||= [];
|
default: {
|
||||||
editorEvents[evt].push(fn);
|
on: vi.fn((evt: string, fn: any) => {
|
||||||
}),
|
editorEvents[evt] ||= [];
|
||||||
off: vi.fn(),
|
editorEvents[evt].push(fn);
|
||||||
get: vi.fn((k: string) => {
|
}),
|
||||||
if (k === 'nodes') return [{ id: 'n1' }];
|
off: vi.fn(),
|
||||||
if (k === 'stage') {
|
get: vi.fn((k: string) => {
|
||||||
return {
|
if (k === 'nodes') return [{ id: 'n1' }];
|
||||||
renderer: {
|
if (k === 'stage') {
|
||||||
getDocument: () => ({ documentElement: document.createElement('html') }),
|
return {
|
||||||
},
|
renderer: {
|
||||||
};
|
getDocument: () => ({ documentElement: document.createElement('html') }),
|
||||||
}
|
},
|
||||||
return null;
|
};
|
||||||
}),
|
}
|
||||||
},
|
return null;
|
||||||
}));
|
}),
|
||||||
|
},
|
||||||
|
}))();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
editorService: modEditor.default,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
vi.mock('@tmagic/utils', async () => {
|
vi.mock('@tmagic/utils', async () => {
|
||||||
const actual = await vi.importActual<any>('@tmagic/utils');
|
const actual = await vi.importActual<any>('@tmagic/utils');
|
||||||
@ -192,8 +199,8 @@ describe('stageOverlay', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('updateSelectStatus 多节点调用 multiSelect', async () => {
|
test('updateSelectStatus 多节点调用 multiSelect', async () => {
|
||||||
const editorMod = (await import('@editor/services/editor')) as any;
|
const editorMod = (await import('@editor/services/sdk-instance')) as any;
|
||||||
editorMod.default.get.mockImplementation((k: string) => {
|
editorMod.editorService.get.mockImplementation((k: string) => {
|
||||||
if (k === 'nodes') return [{ id: 'n1' }, { id: 'n2' }];
|
if (k === 'nodes') return [{ id: 'n1' }, { id: 'n2' }];
|
||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
|
|||||||
@ -5,7 +5,7 @@
|
|||||||
*/
|
*/
|
||||||
import { afterEach, beforeEach, describe, expect, test } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, test } from 'vitest';
|
||||||
|
|
||||||
import storage, { Protocol } from '@editor/services/storage';
|
import { Protocol, storageService as storage } from '@editor/services/sdk-instance';
|
||||||
import { setEditorConfig } from '@editor/utils/config';
|
import { setEditorConfig } from '@editor/utils/config';
|
||||||
|
|
||||||
describe('storage service', () => {
|
describe('storage service', () => {
|
||||||
|
|||||||
@ -29,9 +29,14 @@ const { editorService, dataSourceService, eventsService } = vi.hoisted(() => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('@editor/services/editor', () => ({ default: editorService }));
|
const services = { editorService, dataSourceService, eventsService } as any;
|
||||||
vi.mock('@editor/services/dataSource', () => ({ default: dataSourceService }));
|
|
||||||
vi.mock('@editor/services/events', () => ({ default: eventsService }));
|
// 不传 services 时使用默认实例(保持历史签名可用)
|
||||||
|
vi.mock('@editor/services/sdk-instance', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<any>();
|
||||||
|
return { ...actual, editorService, dataSourceService, eventsService };
|
||||||
|
});
|
||||||
|
|
||||||
vi.mock('@editor/utils/data-source', async () => {
|
vi.mock('@editor/utils/data-source', async () => {
|
||||||
const actual = await vi.importActual<any>('@editor/utils/data-source');
|
const actual = await vi.importActual<any>('@editor/utils/data-source');
|
||||||
return { ...actual, getCascaderOptionsFromFields: vi.fn(() => [{ label: 'f1', value: 'f1' }]) };
|
return { ...actual, getCascaderOptionsFromFields: vi.fn(() => [{ label: 'f1', value: 'f1' }]) };
|
||||||
@ -54,14 +59,14 @@ describe('event utils', () => {
|
|||||||
test('getCompActionOptions 普通组件返回 select options', () => {
|
test('getCompActionOptions 普通组件返回 select options', () => {
|
||||||
const node = { type: 'btn', id: '1' };
|
const node = { type: 'btn', id: '1' };
|
||||||
editorService.getNodeById.mockReturnValue(node);
|
editorService.getNodeById.mockReturnValue(node);
|
||||||
expect(getCompActionOptions('1')).toEqual([{ text: 'open', value: 'open' }]);
|
expect(getCompActionOptions('1', services)).toEqual([{ text: 'open', value: 'open' }]);
|
||||||
expect(eventsService.getMethod).toHaveBeenCalledWith('btn', { targetId: '1', node });
|
expect(eventsService.getMethod).toHaveBeenCalledWith('btn', { targetId: '1', node });
|
||||||
});
|
});
|
||||||
|
|
||||||
test('getCompActionOptions 无节点返回空', () => {
|
test('getCompActionOptions 无节点返回空', () => {
|
||||||
editorService.getNodeById.mockReturnValue(null);
|
editorService.getNodeById.mockReturnValue(null);
|
||||||
expect(getCompActionOptions('missing')).toEqual([]);
|
expect(getCompActionOptions('missing', services)).toEqual([]);
|
||||||
expect(getCompActionOptions(undefined)).toEqual([]);
|
expect(getCompActionOptions(undefined, services)).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('getCompActionOptions 页面片容器返回 cascader options', () => {
|
test('getCompActionOptions 页面片容器返回 cascader options', () => {
|
||||||
@ -69,7 +74,7 @@ describe('event utils', () => {
|
|||||||
const child = { id: 'c1', type: 'btn', name: 'b' };
|
const child = { id: 'c1', type: 'btn', name: 'b' };
|
||||||
editorService.getNodeById.mockImplementation((id: string) => (id === '1' ? container : null));
|
editorService.getNodeById.mockImplementation((id: string) => (id === '1' ? container : null));
|
||||||
editorService.get.mockReturnValue({ items: [{ id: 'pf1', items: [child] }] });
|
editorService.get.mockReturnValue({ items: [{ id: 'pf1', items: [child] }] });
|
||||||
expect(getCompActionOptions('1')).toEqual([
|
expect(getCompActionOptions('1', services)).toEqual([
|
||||||
{
|
{
|
||||||
label: 'b_c1',
|
label: 'b_c1',
|
||||||
value: 'c1',
|
value: 'c1',
|
||||||
@ -81,25 +86,25 @@ describe('event utils', () => {
|
|||||||
|
|
||||||
test('getCompActionAllowedValues 枚举合法动作', () => {
|
test('getCompActionAllowedValues 枚举合法动作', () => {
|
||||||
editorService.getNodeById.mockReturnValue({ type: 'btn', id: '1' });
|
editorService.getNodeById.mockReturnValue({ type: 'btn', id: '1' });
|
||||||
expect(getCompActionAllowedValues({ src: 'component' }, { to: '1' })).toEqual(new Set(['open']));
|
expect(getCompActionAllowedValues({ src: 'component' }, { to: '1' }, services)).toEqual(new Set(['open']));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('getCompActionAllowedValues 自定义 options 返回 null', () => {
|
test('getCompActionAllowedValues 自定义 options 返回 null', () => {
|
||||||
expect(
|
expect(
|
||||||
getCompActionAllowedValues({ src: 'component', compActionConfig: { options: () => [] } }, { to: '1' }),
|
getCompActionAllowedValues({ src: 'component', compActionConfig: { options: () => [] } }, { to: '1' }, services),
|
||||||
).toBeNull();
|
).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('getCompActionAllowedValues 页面片路径用 . 拼接', () => {
|
test('getCompActionAllowedValues 页面片路径用 . 拼接', () => {
|
||||||
editorService.getNodeById.mockReturnValue({ type: 'page-fragment-container', id: '1', pageFragmentId: 'pf1' });
|
editorService.getNodeById.mockReturnValue({ type: 'page-fragment-container', id: '1', pageFragmentId: 'pf1' });
|
||||||
editorService.get.mockReturnValue({ items: [{ id: 'pf1', items: [{ id: 'c1', type: 'btn', name: 'b' }] }] });
|
editorService.get.mockReturnValue({ items: [{ id: 'pf1', items: [{ id: 'c1', type: 'btn', name: 'b' }] }] });
|
||||||
expect(getCompActionAllowedValues({ src: 'component' }, { to: '1' })).toEqual(new Set(['c1.open']));
|
expect(getCompActionAllowedValues({ src: 'component' }, { to: '1' }, services)).toEqual(new Set(['c1.open']));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('getEventNameAllowedValues / collectEventNameOptionValues', () => {
|
test('getEventNameAllowedValues / collectEventNameOptionValues', () => {
|
||||||
expect(getEventNameAllowedValues({ src: 'component' }, { type: 'btn' })).toEqual(new Set(['click']));
|
expect(getEventNameAllowedValues({ src: 'component' }, { type: 'btn' }, services)).toEqual(new Set(['click']));
|
||||||
expect(
|
expect(
|
||||||
getEventNameAllowedValues({ src: 'component', eventNameConfig: { options: [] } }, { type: 'btn' }),
|
getEventNameAllowedValues({ src: 'component', eventNameConfig: { options: [] } }, { type: 'btn' }, services),
|
||||||
).toBeNull();
|
).toBeNull();
|
||||||
expect(collectEventNameOptionValues([{ value: 'a', children: [{ value: 'b' }] }], false)).toEqual(new Set(['a.b']));
|
expect(collectEventNameOptionValues([{ value: 'a', children: [{ value: 'b' }] }], false)).toEqual(new Set(['a.b']));
|
||||||
});
|
});
|
||||||
@ -112,7 +117,7 @@ describe('event utils', () => {
|
|||||||
items: [{ id: 'pf1', name: '页面片', items: [child] }],
|
items: [{ id: 'pf1', name: '页面片', items: [child] }],
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(getEventNameOptions('component', formValue)).toEqual([
|
expect(getEventNameOptions('component', formValue, services)).toEqual([
|
||||||
{
|
{
|
||||||
label: '页面片',
|
label: '页面片',
|
||||||
value: 'pf1',
|
value: 'pf1',
|
||||||
@ -129,7 +134,18 @@ describe('event utils', () => {
|
|||||||
|
|
||||||
test('getEventNameOptions datasource 追加数据变化', () => {
|
test('getEventNameOptions datasource 追加数据变化', () => {
|
||||||
dataSourceService.getDataSourceById.mockReturnValue({ fields: [{ name: 'f1' }] });
|
dataSourceService.getDataSourceById.mockReturnValue({ fields: [{ name: 'f1' }] });
|
||||||
const opts = getEventNameOptions('datasource', { type: 'ds', id: 'd1' });
|
const opts = getEventNameOptions('datasource', { type: 'ds', id: 'd1' }, services);
|
||||||
expect(opts.some((item: any) => item.value === 'ds-field-changed' || item.label === '数据变化')).toBe(true);
|
expect(opts.some((item: any) => item.value === 'ds-field-changed' || item.label === '数据变化')).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('不传 services 时使用默认 service 实例(历史签名)', () => {
|
||||||
|
editorService.getNodeById.mockReturnValue({ id: '1', type: 'btn' });
|
||||||
|
|
||||||
|
expect(getEventNameOptions('component', { type: 'btn', id: '1' })).toEqual([{ text: 'click', value: 'click' }]);
|
||||||
|
expect(getEventNameAllowedValues({ src: 'component' }, { type: 'btn' })).toEqual(new Set(['click']));
|
||||||
|
expect(getEventNameAllowedValues()).toBeNull();
|
||||||
|
expect(getCompActionOptions('1')).toEqual([{ text: 'open', value: 'open' }]);
|
||||||
|
expect(getCompActionAllowedValues({ src: 'component' }, { to: '1' })).toEqual(new Set(['open']));
|
||||||
|
expect(getCompActionAllowedValues()).toEqual(new Set());
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -7,8 +7,7 @@ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
|||||||
|
|
||||||
import { NodeType } from '@tmagic/core';
|
import { NodeType } from '@tmagic/core';
|
||||||
|
|
||||||
import editorService from '@editor/services/editor';
|
import { editorService, propsService } from '@editor/services/sdk-instance';
|
||||||
import propsService from '@editor/services/props';
|
|
||||||
import { beforePaste, getAddParent, getDefaultConfig, getPositionInContainer } from '@editor/utils/operator';
|
import { beforePaste, getAddParent, getDefaultConfig, getPositionInContainer } from '@editor/utils/operator';
|
||||||
|
|
||||||
const { editorState } = vi.hoisted(() => {
|
const { editorState } = vi.hoisted(() => {
|
||||||
@ -16,20 +15,27 @@ const { editorState } = vi.hoisted(() => {
|
|||||||
return { editorState: state };
|
return { editorState: state };
|
||||||
});
|
});
|
||||||
|
|
||||||
vi.mock('@editor/services/editor', () => ({
|
vi.mock('@editor/services/sdk-instance', async (importOriginal) => {
|
||||||
default: {
|
const actual = await importOriginal<any>();
|
||||||
get: (k: string) => editorState[k],
|
const modEditor = await (() => ({
|
||||||
getParentById: vi.fn((id: string) => ({ id: `parent-of-${id}`, items: [] })),
|
default: {
|
||||||
getLayout: vi.fn(async () => 'absolute'),
|
get: (k: string) => editorState[k],
|
||||||
},
|
getParentById: vi.fn((id: string) => ({ id: `parent-of-${id}`, items: [] })),
|
||||||
}));
|
getLayout: vi.fn(async () => 'absolute'),
|
||||||
|
},
|
||||||
vi.mock('@editor/services/props', () => ({
|
}))();
|
||||||
default: {
|
const modProps = await (() => ({
|
||||||
setNewItemId: vi.fn((node: any) => ({ ...node, id: `new-${node.id}` })),
|
default: {
|
||||||
getPropsValue: vi.fn(async (type: string, cfg: any) => ({ type, ...cfg })),
|
setNewItemId: vi.fn((node: any) => ({ ...node, id: `new-${node.id}` })),
|
||||||
},
|
getPropsValue: vi.fn(async (type: string, cfg: any) => ({ type, ...cfg })),
|
||||||
}));
|
},
|
||||||
|
}))();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
editorService: modEditor.default,
|
||||||
|
propsService: modProps.default,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
vi.mock('@tmagic/utils', async () => {
|
vi.mock('@tmagic/utils', async () => {
|
||||||
const actual = (await vi.importActual('@tmagic/utils')) as Record<string, unknown>;
|
const actual = (await vi.importActual('@tmagic/utils')) as Record<string, unknown>;
|
||||||
|
|||||||
@ -33,37 +33,44 @@ const componentEventsState = vi.hoisted((): { value: Record<string, { label: str
|
|||||||
const rootState = vi.hoisted(() => ({ value: null as any }));
|
const rootState = vi.hoisted(() => ({ value: null as any }));
|
||||||
const nodesState = vi.hoisted((): { value: Record<string, any> } => ({ value: {} }));
|
const nodesState = vi.hoisted((): { value: Record<string, any> } => ({ value: {} }));
|
||||||
|
|
||||||
vi.mock('@editor/services/codeBlock', () => ({
|
vi.mock('@editor/services/sdk-instance', async (importOriginal) => {
|
||||||
default: {
|
const actual = await importOriginal<any>();
|
||||||
getCodeDsl: () => codeDslState.value,
|
const modCodeBlock = await (() => ({
|
||||||
},
|
default: {
|
||||||
}));
|
getCodeDsl: () => codeDslState.value,
|
||||||
|
|
||||||
vi.mock('@editor/services/dataSource', () => ({
|
|
||||||
default: {
|
|
||||||
get: (key: string) => {
|
|
||||||
if (key === 'dataSources') return dataSourcesState.value;
|
|
||||||
if (key === 'events') return {};
|
|
||||||
return undefined;
|
|
||||||
},
|
},
|
||||||
getFormMethod: () => formMethodsState.value,
|
}))();
|
||||||
getFormEvent: () => formEventsState.value,
|
const modDataSource = await (() => ({
|
||||||
getDataSourceById: (id: string) => dataSourcesState.value.find((ds) => `${ds.id}` === `${id}`),
|
default: {
|
||||||
},
|
get: (key: string) => {
|
||||||
}));
|
if (key === 'dataSources') return dataSourcesState.value;
|
||||||
|
if (key === 'events') return {};
|
||||||
vi.mock('@editor/services/editor', () => ({
|
return undefined;
|
||||||
default: {
|
},
|
||||||
get: (key: string) => (key === 'root' ? rootState.value : undefined),
|
getFormMethod: () => formMethodsState.value,
|
||||||
getNodeById: (id: string | number) => nodesState.value[`${id}`] || null,
|
getFormEvent: () => formEventsState.value,
|
||||||
},
|
getDataSourceById: (id: string) => dataSourcesState.value.find((ds) => `${ds.id}` === `${id}`),
|
||||||
}));
|
},
|
||||||
|
}))();
|
||||||
vi.mock('@editor/services/events', () => ({
|
const modEditor = await (() => ({
|
||||||
default: {
|
default: {
|
||||||
getEvent: (type: string) => componentEventsState.value[type] || [],
|
get: (key: string) => (key === 'root' ? rootState.value : undefined),
|
||||||
},
|
getNodeById: (id: string | number) => nodesState.value[`${id}`] || null,
|
||||||
}));
|
},
|
||||||
|
}))();
|
||||||
|
const modEvents = await (() => ({
|
||||||
|
default: {
|
||||||
|
getEvent: (type: string) => componentEventsState.value[type] || [],
|
||||||
|
},
|
||||||
|
}))();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
codeBlockService: modCodeBlock.default,
|
||||||
|
dataSourceService: modDataSource.default,
|
||||||
|
editorService: modEditor.default,
|
||||||
|
eventsService: modEvents.default,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
const ctx = (config: Record<string, any> = {}, model: Record<string, any> = {}, message?: string) => ({
|
const ctx = (config: Record<string, any> = {}, model: Record<string, any> = {}, message?: string) => ({
|
||||||
fieldType: config.type || '',
|
fieldType: config.type || '',
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"version": "1.8.2",
|
"version": "1.8.3",
|
||||||
"name": "@tmagic/element-plus-adapter",
|
"name": "@tmagic/element-plus-adapter",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"version": "1.8.2",
|
"version": "1.8.3",
|
||||||
"name": "@tmagic/form-schema",
|
"name": "@tmagic/form-schema",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
|
|||||||
@ -832,6 +832,47 @@ export interface AddButtonConfig {
|
|||||||
}
|
}
|
||||||
// #endregion AddButtonConfig
|
// #endregion AddButtonConfig
|
||||||
|
|
||||||
|
// #region ActionButtonConfig
|
||||||
|
/**
|
||||||
|
* 行操作按钮。与 `addButtonConfig` 相同:`text` 是文案,`props` 透传给按钮。
|
||||||
|
* 不支持吸底。未配置 `text` 时使用内置中文。
|
||||||
|
*/
|
||||||
|
export type ActionButtonConfig = Omit<AddButtonConfig, 'sticky'>;
|
||||||
|
// #endregion ActionButtonConfig
|
||||||
|
|
||||||
|
// #region CustomActionButtonContext
|
||||||
|
/** 自定义行操作按钮回调收到的上下文 */
|
||||||
|
export interface CustomActionButtonContext {
|
||||||
|
/** 当前行数据 */
|
||||||
|
model: any;
|
||||||
|
/** 当前行在整个列表中的下标(table 分页时为全局下标) */
|
||||||
|
index: number;
|
||||||
|
/** 整个列表数据 */
|
||||||
|
groupModel: any[];
|
||||||
|
prop?: string;
|
||||||
|
config: any;
|
||||||
|
formValue: any;
|
||||||
|
}
|
||||||
|
// #endregion CustomActionButtonContext
|
||||||
|
|
||||||
|
// #region CustomActionButtonConfig
|
||||||
|
/** table / group-list 行操作中的自定义按钮,渲染在内置按钮之后 */
|
||||||
|
export interface CustomActionButtonConfig {
|
||||||
|
/** 按钮文案;table 非 flat 模式下仅作为 title 提示(有 icon 时) */
|
||||||
|
text?: string;
|
||||||
|
/** 透传给按钮的属性 */
|
||||||
|
props?: Record<string, any>;
|
||||||
|
/** 按钮图标组件 */
|
||||||
|
icon?: any;
|
||||||
|
/** 是否显示,默认显示 */
|
||||||
|
display?: boolean | ((mForm: FormState | undefined, ctx: CustomActionButtonContext) => boolean);
|
||||||
|
/** 是否禁用,表单禁用时始终禁用 */
|
||||||
|
disabled?: boolean | ((mForm: FormState | undefined, ctx: CustomActionButtonContext) => boolean);
|
||||||
|
/** 点击回调 */
|
||||||
|
handler: (mForm: FormState | undefined, ctx: CustomActionButtonContext) => void;
|
||||||
|
}
|
||||||
|
// #endregion CustomActionButtonConfig
|
||||||
|
|
||||||
// #region GroupListHeaderConfig
|
// #region GroupListHeaderConfig
|
||||||
/** group-list 卡片标题;仅 sticky 开启时吸顶 */
|
/** group-list 卡片标题;仅 sticky 开启时吸顶 */
|
||||||
export interface GroupListHeaderConfig {
|
export interface GroupListHeaderConfig {
|
||||||
@ -862,6 +903,18 @@ export interface TableGroupListCommonConfig extends FormItem {
|
|||||||
/** 新增后滚动到最后一项(group-list 形态,默认关闭) */
|
/** 新增后滚动到最后一项(group-list 形态,默认关闭) */
|
||||||
scrollLastItemIntoView?: boolean;
|
scrollLastItemIntoView?: boolean;
|
||||||
addButtonConfig?: AddButtonConfig;
|
addButtonConfig?: AddButtonConfig;
|
||||||
|
/** 删除按钮,text 默认「删除」 */
|
||||||
|
deleteButtonConfig?: ActionButtonConfig;
|
||||||
|
/** 复制按钮,text 默认「复制」 */
|
||||||
|
copyButtonConfig?: ActionButtonConfig;
|
||||||
|
/** 上移按钮,text 默认「上移」。仅 group-list 形态 */
|
||||||
|
moveUpButtonConfig?: ActionButtonConfig;
|
||||||
|
/** 下移按钮,text 默认「下移」。仅 group-list 形态 */
|
||||||
|
moveDownButtonConfig?: ActionButtonConfig;
|
||||||
|
/** 移动至按钮,text 默认「移动至」。仅 group-list 形态 */
|
||||||
|
moveToButtonConfig?: ActionButtonConfig;
|
||||||
|
/** 自定义行操作按钮,渲染在内置按钮之后 */
|
||||||
|
actionButtons?: CustomActionButtonConfig[];
|
||||||
/** group-list 形态的标题吸顶(默认关闭);table 形态忽略 */
|
/** group-list 形态的标题吸顶(默认关闭);table 形态忽略 */
|
||||||
header?: GroupListHeaderConfig;
|
header?: GroupListHeaderConfig;
|
||||||
}
|
}
|
||||||
@ -898,6 +951,11 @@ export interface TableConfig<T = never> extends TableGroupListCommonConfig {
|
|||||||
showIndex?: boolean;
|
showIndex?: boolean;
|
||||||
/** 操作栏宽度 */
|
/** 操作栏宽度 */
|
||||||
operateColWidth?: number | string;
|
operateColWidth?: number | string;
|
||||||
|
/**
|
||||||
|
* 操作列。`text` 是列标题,默认「操作」。
|
||||||
|
* `props` 透传给列(如 width、align、fixed、minWidth),可覆盖 operateColWidth / fixed。
|
||||||
|
*/
|
||||||
|
operateColConfig?: ActionButtonConfig;
|
||||||
pagination?: boolean;
|
pagination?: boolean;
|
||||||
/** 表格标题前缀 ,新增xxx,暂无xxx数据,表明表格内容是什么 */
|
/** 表格标题前缀 ,新增xxx,暂无xxx数据,表明表格内容是什么 */
|
||||||
titlePrefix?: string;
|
titlePrefix?: string;
|
||||||
@ -919,6 +977,8 @@ export interface TableConfig<T = never> extends TableGroupListCommonConfig {
|
|||||||
dropActionButtonIcon?: any;
|
dropActionButtonIcon?: any;
|
||||||
copyActionButtonIcon?: any;
|
copyActionButtonIcon?: any;
|
||||||
deleteActionButtonIcon?: any;
|
deleteActionButtonIcon?: any;
|
||||||
|
/** 拖动排序按钮,text 作为提示,默认「拖动排序」 */
|
||||||
|
dropSortButtonConfig?: ActionButtonConfig;
|
||||||
/** 是否显示全屏按钮 */
|
/** 是否显示全屏按钮 */
|
||||||
enableFullscreen?: boolean;
|
enableFullscreen?: boolean;
|
||||||
fixed?: boolean | 'left' | 'right';
|
fixed?: boolean | 'left' | 'right';
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user