Compare commits

..

No commits in common. "master" and "v1.8.2" have entirely different histories.

146 changed files with 2210 additions and 7602 deletions

View File

@ -1,14 +1,3 @@
## [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)

View File

@ -228,10 +228,6 @@ export default defineConfig({
text: 'storageService',
link: '/api/editor/storageServiceMethods.md',
},
{
text: 'DSL SDK(Node)',
link: '/api/editor/sdk.md',
},
]
},
{

View File

@ -503,32 +503,6 @@ const renderFunction = async (stage) => {
</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
- **详情:**

View File

@ -1,154 +0,0 @@
# 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];
},
});
```

View File

@ -29,7 +29,7 @@
| `display` / `hidden` | 不校验 |
| `number` | `number`(非 NaN) |
| `number-range` | 长度为 2 的数字数组 |
| `date` / `datetime` / `time` | 按 `valueFormat` 校验;`x` / `X` / `timestamp` 期望 `number`(`x`、`timestamp` 为毫秒,`X` 为秒)。日期组件会把选择器写出的秒字符串收成 number。其余格式按 [Day.js format](https://day.js.org/docs/en/display/format) 严格解析字符串 |
| `date` / `datetime` / `time` | 按 `valueFormat` 校验;`x` / `timestamp` 期望 `number`,其余按 [Day.js format](https://day.js.org/docs/en/display/format) 严格解析字符串 |
| `daterange` / `timerange` | 无 `names` 时为长度为 2 的数组,元素按 `valueFormat` 校验;有 `names` 时跳过 |
| `switch` / `checkbox` | 值必须是解析后的 `activeValue` / `inactiveValue` 之一(显式配置优先;未配置且 `filter === 'number'` 时为 `1`/`0`;否则为 `true`/`false`) |
| `select` | 单选:值 ∈ options;`multiple`:数组且每项 ∈ options;`allowCreate` / `remote`:只校验基础形态,不做 options 枚举 |

View File

@ -1,5 +1,5 @@
{
"version": "1.8.3",
"version": "1.8.2",
"name": "tmagic",
"private": true,
"type": "module",
@ -41,12 +41,12 @@
"url": "https://github.com/Tencent/tmagic-editor.git"
},
"devDependencies": {
"@commitlint/cli": "^21.2.4",
"@commitlint/config-conventional": "^21.2.4",
"@commitlint/cli": "^21.2.3",
"@commitlint/config-conventional": "^21.2.3",
"@tmagic/eslint-config": "workspace:*",
"@types/node": "26.6.5",
"@vitejs/plugin-vue": "^6.0.10",
"@vitest/coverage-v8": "^5.0.5",
"@types/node": "26.6.2",
"@vitejs/plugin-vue": "^6.0.9",
"@vitest/coverage-v8": "^5.0.1",
"@vue/compiler-sfc": "catalog:",
"c8": "^12.0.0",
"commitizen": "^4.3.2",
@ -55,30 +55,30 @@
"cz-conventional-changelog": "^3.3.0",
"element-plus": "catalog:",
"enquirer": "^2.4.1",
"eslint": "^10.13.0",
"execa": "^10.2.0",
"happy-dom": "^21.0.3",
"eslint": "^10.11.0",
"execa": "^10.0.1",
"happy-dom": "^20.14.5",
"highlight.js": "^11.12.0",
"husky": "^9.1.7",
"jsdom": "^30.1.3",
"lint-staged": "^17.6.0",
"jsdom": "^30.1.0",
"lint-staged": "^17.5.1",
"minimist": "^1.2.8",
"picocolors": "^1.1.1",
"prettier": "^3.9.10",
"recast": "^0.24.0",
"prettier": "^3.9.8",
"recast": "^0.23.20",
"rimraf": "^6.1.3",
"rolldown": "^1.2.14",
"rolldown-plugin-dts": "^0.28.8",
"sass-embedded": "^1.105.2",
"rolldown": "^1.2.9",
"rolldown-plugin-dts": "^0.28.6",
"sass-embedded": "^1.104.1",
"semver": "^7.8.5",
"serialize-javascript": "^7.1.2",
"serialize-javascript": "^7.1.1",
"shx": "^0.4.0",
"typescript": "catalog:",
"vite": "catalog:",
"vitepress": "2.0.0-alpha.20",
"vitest": "^5.0.5",
"vue": "^3.5.44",
"vue-tsc": "^3.3.13"
"vitest": "^5.0.1",
"vue": "^3.5.43",
"vue-tsc": "^3.3.11"
},
"config": {
"commitizen": {

View File

@ -1,5 +1,5 @@
{
"version": "1.8.3",
"version": "1.8.2",
"name": "@tmagic/cli",
"type": "module",
"main": "lib/index.js",

View File

@ -1,5 +1,5 @@
{
"version": "1.8.3",
"version": "1.8.2",
"name": "@tmagic/core",
"type": "module",
"sideEffects": false,

View File

@ -1,5 +1,5 @@
{
"version": "1.8.3",
"version": "1.8.2",
"name": "@tmagic/data-source",
"type": "module",
"sideEffects": false,

View File

@ -1,5 +1,5 @@
{
"version": "1.8.3",
"version": "1.8.2",
"name": "@tmagic/dep",
"type": "module",
"sideEffects": false,

View File

@ -133,13 +133,9 @@ export default class Watcher {
return;
}
// 先清掉本次节点上的旧依赖,再统一收集。
// 复制出的页面片会共用节点 id。如果在收集每一棵树之前立刻删除,
// 后一棵树会按相同 id 把前一棵树刚写入的依赖清掉。
// 整棵树只遍历一次、在每个属性上检查所有 target,把结构遍历开销从 ×targets 降到 ×1(详见 collectItems)
for (const node of nodes) {
this.removeTargetsDep(targets, node);
}
for (const node of nodes) {
this.collectItems(node, targets, depExtendedData, deep);
}
}

View File

@ -249,86 +249,4 @@ describe('Watcher', () => {
expect(target1?.deps?.node_1).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');
});
});

View File

@ -1,5 +1,5 @@
{
"version": "1.8.3",
"version": "1.8.2",
"name": "@tmagic/design",
"type": "module",
"sideEffects": [

View File

@ -1,5 +1,5 @@
{
"version": "1.8.3",
"version": "1.8.2",
"name": "@tmagic/editor",
"type": "module",
"sideEffects": [
@ -22,11 +22,6 @@
"import": "./dist/es/headless.js",
"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": {
"import": "./dist/style.css",
"require": "./dist/style.css"

View File

@ -148,19 +148,17 @@ import FormPanel from './layouts/props-panel/FormPanel.vue';
import PropsPanel from './layouts/props-panel/PropsPanel.vue';
import Sidebar from './layouts/sidebar/Sidebar.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 {
codeBlockService,
componentListService,
dataSourceService,
depService,
editorService,
eventsService,
historyService,
propsService,
storageService,
} from './services/sdk-instance';
import propsService from './services/props';
import stageOverlayService from './services/stageOverlay';
import storageService from './services/storage';
import uiService from './services/ui';
import keybindingConfig from './utils/keybinding-config';
import { defaultEditorProps, EditorProps, ENABLE_PROPS_FORM_VALIDATE } from './editorProps';
@ -224,7 +222,6 @@ const stageOptions: StageOptions = {
containerHighlightAddOnly: props.containerHighlightAddOnly,
disabledDragStart: props.disabledDragStart,
renderType: props.renderType,
iframeSandbox: props.iframeSandbox,
guidesOptions: props.guidesOptions,
disabledMultiSelect: props.disabledMultiSelect,
alwaysMultiSelect: props.alwaysMultiSelect,

View File

@ -62,16 +62,6 @@ export interface EditorProps {
runtimeUrl?: string;
/** 是用iframe渲染还是直接渲染 */
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;
/** 组件的属性配置表单的dsl */

View File

@ -19,7 +19,10 @@
import { isEmpty } from 'lodash-es';
import { HookCodeType, HookType } from '@tmagic/core';
import type { CodeSelectConfig, FormState, FormValue, GroupListConfig } from '@tmagic/form/headless';
import type { CodeSelectConfig, FormValue, GroupListConfig } from '@tmagic/form/headless';
import codeBlockService from '@editor/services/codeBlock';
import dataSourceService from '@editor/services/dataSource';
import { stickyAddButton } from './stickyAddButton';
@ -39,21 +42,21 @@ export const createCodeSelectConfig = (config: CodeSelectConfig): GroupListConfi
codeId: '',
}),
...stickyAddButton(`添加${config.text || ''}`),
title: (mForm: FormState | undefined, { model, index }: any) => {
title: (_mForm: any, { model, index }: any) => {
if (model.codeType === HookCodeType.DATA_SOURCE_METHOD) {
if (Array.isArray(model.codeId)) {
if (model.codeId.length < 2) {
return index;
}
const ds = mForm?.services?.dataSourceService.getDataSourceById(model.codeId[0]);
const ds = dataSourceService.getDataSourceById(model.codeId[0]);
return `${ds?.title} / ${model.codeId[1]}`;
}
return Array.isArray(model.codeId) ? model.codeId.join('/') : index;
}
const codeContent = mForm?.services?.codeBlockService.getCodeContentById(model.codeId);
const codeContent = codeBlockService.getCodeContentById(model.codeId);
if (codeContent) {
return codeContent.name;
@ -89,7 +92,7 @@ export const createCodeSelectConfig = (config: CodeSelectConfig): GroupListConfi
rules: [{ typeMatch: true, trigger: 'change' }],
display: (_mForm: any, { model }: any) => model.codeType !== HookCodeType.DATA_SOURCE_METHOD,
notEditable: (mForm: FormState | undefined) => !mForm?.services?.codeBlockService.getEditStatus(),
notEditable: () => !codeBlockService.getEditStatus(),
},
{
type: 'data-source-method-select',
@ -97,7 +100,7 @@ export const createCodeSelectConfig = (config: CodeSelectConfig): GroupListConfi
text: '数据源字段',
rules: [{ typeMatch: true, trigger: 'change' }],
display: (_mForm: any, { model }: any) => model.codeType === HookCodeType.DATA_SOURCE_METHOD,
notEditable: (mForm: FormState | undefined) => !mForm?.services?.dataSourceService.get('editable'),
notEditable: () => !dataSourceService.get('editable'),
},
],
} as any as GroupListConfig;

View File

@ -19,6 +19,7 @@
import type { DisplayCondsConfig, FormState, GroupListConfig } from '@tmagic/form/headless';
import { removeDataSourceFieldPrefix } from '@tmagic/utils';
import dataSourceService from '@editor/services/dataSource';
import { getCascaderOptionsFromFields, getFieldType, resolveFieldByPath } from '@editor/utils/data-source';
import { stickyAddButton } from './stickyAddButton';
@ -37,17 +38,15 @@ export const createDisplayCondsConfig = (
name: string,
parentFields: string[],
): GroupListConfig => {
const resolveFieldPath = (formState: FormState | undefined, path: Array<string | number>) => {
const resolveFieldPath = (path: Array<string | number>) => {
const [id, ...fieldNames] = path;
const ds = id
? formState?.services?.dataSourceService.getDataSourceById(removeDataSourceFieldPrefix(`${id}`))
: undefined;
const ds = id ? dataSourceService.getDataSourceById(removeDataSourceFieldPrefix(`${id}`)) : undefined;
return { ds, fieldNames };
};
// 字段变更后按新字段的类型把已填的值转成对应类型,避免类型校验与运行期取值不一致
const fieldOnChange = (formState: FormState | undefined, v: string[], { model }: { model: Record<string, any> }) => {
const { ds, fieldNames } = resolveFieldPath(formState, [...parentFields, ...v]);
const fieldOnChange = (_formState: FormState | undefined, v: string[], { model }: { model: Record<string, any> }) => {
const { ds, fieldNames } = resolveFieldPath([...parentFields, ...v]);
const type = getFieldType(ds, fieldNames);
if (type === 'number') {
model.value = Number(model.value);
@ -64,8 +63,8 @@ export const createDisplayCondsConfig = (
const fieldItem = parentFields.length
? {
type: 'cascader',
options: (formState: FormState | undefined) => {
const { ds, fieldNames } = resolveFieldPath(formState, parentFields);
options: () => {
const { ds, fieldNames } = resolveFieldPath(parentFields);
if (!ds) {
return [];
}
@ -142,8 +141,8 @@ export const createDisplayCondsConfig = (
{
name: 'value',
text: '值',
type: (mForm: FormState | undefined, { model }: any) => {
const { ds, fieldNames } = resolveFieldPath(mForm, [...parentFields, ...(model.field || [])]);
type: (_mForm: FormState | undefined, { model }: any) => {
const { ds, fieldNames } = resolveFieldPath([...parentFields, ...(model.field || [])]);
const type = getFieldType(ds, fieldNames);
if (type === 'number') {

View File

@ -31,14 +31,18 @@ import type {
} from '@tmagic/form/headless';
import { defineFormItem } from '@tmagic/form/headless';
import type { Services } from '@editor/type';
import codeBlockService from '@editor/services/codeBlock';
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 {
getCompActionAllowedValues,
getCompActionOptions,
getEventNameAllowedValues,
getEventNameOptions,
normalizeCompActionValue,
} from '@editor/utils/event';
} from '@editor/utils';
import { stickyAddButton } from './stickyAddButton';
@ -63,14 +67,11 @@ export const createEventNameConfig = (config: EventSelectConfig) => {
labelWidth: '70px',
checkStrictly: () => config.src !== 'component',
valueSeparator: '.',
options: (mForm: FormState, { formValue }: any) =>
mForm?.services ? getEventNameOptions(config.src, formValue, mForm.services) : [],
options: (_mForm: FormState, { formValue }: any) => getEventNameOptions(config.src, formValue),
rules: [
{
validator: ({ value, callback }: any, { formValue }: any, mForm?: FormState) => {
const allowedNames = mForm?.services
? getEventNameAllowedValues(config as any, formValue, mForm.services)
: null;
validator: ({ value, callback }: any, { formValue }: any) => {
const allowedNames = getEventNameAllowedValues(config as any, formValue);
if (allowedNames && allowedNames.size > 0 && value && !allowedNames.has(value)) {
return callback(`事件名(${value})不存在`);
}
@ -83,7 +84,7 @@ export const createEventNameConfig = (config: EventSelectConfig) => {
return { ...defaultEventNameConfig, ...config.eventNameConfig };
};
const createActionTypeOptions = (services?: Services) => {
const createActionTypeOptions = () => {
const o: {
text: string;
label: string;
@ -97,12 +98,6 @@ const createActionTypeOptions = (services?: Services) => {
},
];
if (!services) {
return o;
}
const { propsService, codeBlockService } = services;
if (!propsService.getDisabledCodeBlock()) {
o.push({
text: '代码',
@ -130,7 +125,7 @@ const createActionTypeConfig = (config: EventSelectConfig) => {
text: '联动类型',
type: 'select',
defaultValue: ActionType.COMP,
options: (mForm: FormState | undefined) => createActionTypeOptions(mForm?.services),
options: createActionTypeOptions(),
rules: [
{
required: true,
@ -176,8 +171,8 @@ const createCompActionConfig = (config: EventSelectConfig) => {
const defaultCompActionConfig: DynamicTypeConfig = {
name: 'method',
text: '动作',
type: (mForm: FormState | undefined, { model }: any) => {
const to = mForm?.services?.editorService.getNodeById(model.to);
type: (_mForm: FormState | undefined, { model }: any) => {
const to = editorService.getNodeById(model.to);
if (to?.type === 'page-fragment-container' && to.pageFragmentId) {
return 'cascader';
@ -187,15 +182,12 @@ const createCompActionConfig = (config: EventSelectConfig) => {
},
checkStrictly: () => config.src !== 'component',
display: (_mForm: FormState | undefined, { model }: any) => model.actionType === ActionType.COMP,
options: (mForm: FormState, { model }: any) =>
mForm?.services ? getCompActionOptions(model.to, mForm.services) : [],
options: (_mForm: FormState, { model }: any) => getCompActionOptions(model.to),
rules: [
{
trigger: 'blur',
validator: ({ value, callback }: any, { model }: any, mForm?: FormState) => {
const allowedMethods = mForm?.services
? getCompActionAllowedValues(config as any, model, mForm.services)
: null;
validator: ({ value, callback }: any, { model }: any) => {
const allowedMethods = getCompActionAllowedValues(config as any, model);
const normalized = normalizeCompActionValue(value);
if (allowedMethods && allowedMethods.size > 0 && normalized && !allowedMethods.has(normalized)) {
return callback(`动作名(${normalized})不存在`);
@ -214,7 +206,7 @@ const createCodeActionConfig = (config: EventSelectConfig) => {
type: 'code-select-col',
text: '代码块',
name: 'codeId',
notEditable: (mForm: FormState | undefined) => !mForm?.services?.codeBlockService.getEditStatus(),
notEditable: () => !codeBlockService.getEditStatus(),
display: (_mForm, { model }) => model.actionType === ActionType.CODE,
};
return { ...defaultCodeActionConfig, ...config.codeActionConfig };
@ -226,7 +218,7 @@ const createDataSourceActionConfig = (config: EventSelectConfig) => {
type: 'data-source-method-select',
text: '数据源方法',
name: 'dataSourceMethod',
notEditable: (mForm: FormState | undefined) => !mForm?.services?.dataSourceService.get('editable'),
notEditable: () => !dataSourceService.get('editable'),
display: (_mForm, { model }) => model.actionType === ActionType.DATA_SOURCE,
};
return { ...defaultDataSourceActionConfig, ...config.dataSourceActionConfig };
@ -280,17 +272,13 @@ export const createLegacyTableConfig = (config: EventSelectConfig): TableConfig
name: 'name',
label: '事件名',
type: createEventNameConfig(config).type,
options: (mForm: FormState, { formValue }: any) => {
const services = mForm?.services;
if (!services) return [];
return services.eventsService
.getEvent(formValue.type, { node: services.editorService.getNodeById(formValue.id) })
options: (_mForm: FormState, { formValue }: any) =>
eventsService
.getEvent(formValue.type, { node: editorService.getNodeById(formValue.id) })
.map((option: any) => ({
text: option.label,
value: option.value,
}));
},
})),
},
{
name: 'to',
@ -301,14 +289,11 @@ export const createLegacyTableConfig = (config: EventSelectConfig): TableConfig
name: 'method',
label: '动作',
type: createCompActionConfig(config).type,
options: (mForm: FormState, { model, formValue }: any) => {
const services = mForm?.services;
if (!services) return [];
const node = services.editorService.getNodeById(model.to) || (formValue as MNode);
options: (_mForm: FormState, { model, formValue }: any) => {
const node = editorService.getNodeById(model.to) || (formValue as MNode);
if (!node?.type) return [];
return services.eventsService.getMethod(node.type, { targetId: model.to, node }).map((option: any) => ({
return eventsService.getMethod(node.type, { targetId: model.to, node }).map((option: any) => ({
text: option.label,
value: option.value,
}));

View File

@ -4,7 +4,7 @@ import type { MNode } from '@tmagic/core';
import StageCore, { GuidesType, RemoveEventData, SortEventData, UpdateEventData } from '@tmagic/stage';
import { getIdFromEl } from '@tmagic/utils';
import { editorService } from '@editor/services/sdk-instance';
import editorService from '@editor/services/editor';
import uiService from '@editor/services/ui';
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';
@ -31,7 +31,6 @@ export const useStage = (stageOptions: StageOptions) => {
containerHighlightAddOnly: stageOptions.containerHighlightAddOnly,
disabledDragStart: stageOptions.disabledDragStart,
renderType: stageOptions.renderType,
iframeSandbox: stageOptions.iframeSandbox,
canSelect: (el, event, stop) => {
if (!stageOptions.canSelect) return true;

View File

@ -37,19 +37,17 @@ export * from './utils';
export { isGlobalFlat } from './utils/config';
export { default as TMagicEditor } from './Editor.vue';
export { default as TMagicCodeEditor } from './layouts/CodeEditor.vue';
export {
codeBlockService,
componentListService,
dataSourceService,
depService,
editorService,
eventsService,
historyService,
propsService,
storageService,
} from './services/sdk-instance';
export { default as editorService } from './services/editor';
export { default as propsService } from './services/props';
export { default as historyService } from './services/history';
export { default as storageService } from './services/storage';
export { default as eventsService } from './services/events';
export { default as dataSourceService } from './services/dataSource';
export { default as stageOverlayService } from './services/stageOverlay';
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 ComponentListPanel } from './layouts/sidebar/ComponentListPanel.vue';
export { default as LayerPanel } from './layouts/sidebar/layer/LayerPanel.vue';

View File

@ -12,22 +12,23 @@ import type {
MPage,
MPageFragment,
} from '@tmagic/core';
import { createCodeBlockTarget, DepTargetType, NodeType, Target, updateNode } from '@tmagic/core';
import {
createCodeBlockTarget,
createDataSourceCondTarget,
createDataSourceMethodTarget,
createDataSourceTarget,
DepTargetType,
NODE_CONDS_KEY,
NodeType,
Target,
updateNode,
} from '@tmagic/core';
import { ChangeRecord } from '@tmagic/form';
import StageCore from '@tmagic/stage';
import { getDepNodeIds, getNodes, isPage } from '@tmagic/utils';
import { getDepNodeIds, getNodes, isPage, isValueIncludeDataSource } from '@tmagic/utils';
import PropsPanel from './layouts/PropsPanel.vue';
import type { Reactivity } from './sdk/type';
import {
addDataSourceTargets,
classifyUpdatedNodes,
getDataSourceRecollectTypes,
removeDataSourceTargets,
removeTargetDeps,
resetDepTargets,
writeTargetDeps,
} from './sdk/utils/dep';
import { isIncludeDataSource } from './utils/editor';
import { error as logError } from './utils/logger';
import { EditorProps } from './editorProps';
import { Services } from './type';
@ -246,16 +247,6 @@ 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(() => {
editorService.resetState();
historyService.resetState();
@ -485,6 +476,12 @@ 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) => {
if (!value) return;
@ -494,12 +491,15 @@ export const initServiceEvents = (
codeBlockService.setCodeDsl(value.codeBlocks);
dataSourceService.set('dataSources', value.dataSources);
resetDepTargets(
depService,
value.codeBlocks,
dataSourceService.get('dataSources'),
reactive as Reactivity['reactive'],
);
depService.clearTargets();
for (const [id, code] of Object.entries(value.codeBlocks)) {
depService.addTarget(createCodeBlockTarget(id, code));
}
for (const ds of dataSourceService.get('dataSources')) {
initDataSourceDepTarget(ds);
}
if (Array.isArray(value.items)) {
updateStageDsl(value);
@ -549,7 +549,49 @@ export const initServiceEvents = (
// 节点更新,收集依赖
// 仅当修改到数据源相关的才收集
const nodeUpdateHandler = (data: { newNode: MComponent; oldNode: MComponent; changeRecords?: ChangeRecord[] }[]) => {
const { needRecollectNodes, normalNodes } = classifyUpdatedNodes({ dep: depService }, data);
const needRecollectNodes: MComponent[] = [];
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) {
// 有数据源依赖,需要等依赖重新收集完才更新stage
@ -561,14 +603,13 @@ export const initServiceEvents = (
handler();
} else {
updateStageNodes(normalNodes);
}
// 代码块、数据源方法依赖对所有被更新的节点都要收集:同一批里既改了数据源引用又改了方法时不能漏掉
const updatedNodes = [...needRecollectNodes, ...normalNodes];
Promise.all([
collectIdle(updatedNodes, true, DepTargetType.CODE_BLOCK),
collectIdle(updatedNodes, true, DepTargetType.DATA_SOURCE_METHOD),
]);
// 在上面判断是否需要收集数据源依赖中已经更新stage
Promise.all([
collectIdle(normalNodes, true, DepTargetType.CODE_BLOCK),
collectIdle(normalNodes, true, DepTargetType.DATA_SOURCE_METHOD),
]);
}
};
// 节点删除,清除对齐的依赖收集
@ -593,7 +634,7 @@ export const initServiceEvents = (
const dataSourceAddHandler = (config: DataSourceSchema) => {
const handler = async () => {
addDataSourceTargets(depService, config, reactive as Reactivity['reactive']);
initDataSourceDepTarget(config);
const app = await getTMagicApp();
if (!app?.dataSourceManager) {
@ -628,31 +669,84 @@ export const initServiceEvents = (
ds.setData(config.mocks?.find((mock) => mock.useInEditor)?.data || ds.getDefaultData());
};
const types = getDataSourceRecollectTypes(changeRecords);
const root = editorService.get('root');
let needRecollectDep = false;
let isModifyField = false;
let isModifyMock = false;
let isModifyMethod = false;
for (const changeRecord of changeRecords) {
if (!changeRecord.propPath) {
continue;
}
if (!types) {
if (root?.dataSources) updateDsData();
return;
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;
}
}
if (!Array.isArray(root?.items)) return;
const root = editorService.get('root');
if (needRecollectDep) {
if (Array.isArray(root?.items)) {
depService.clearIdleTasks();
depService.clearIdleTasks();
removeDataSourceTargets(depService, config.id, types);
addDataSourceTargets(depService, config, reactive as Reactivity['reactive'], types);
let collectIdlePromises: Promise<boolean>[] = [];
if (isModifyField) {
depService.removeTarget(config.id, DepTargetType.DATA_SOURCE);
depService.removeTarget(config.id, DepTargetType.DATA_SOURCE_COND);
const collectIdlePromises = types.map((type) => collectIdle(root.items, true, type));
depService.addTarget(createDataSourceTarget(config, reactive({})));
depService.addTarget(createDataSourceCondTarget(config, reactive({})));
const handler = async () => {
const results = await Promise.all(collectIdlePromises);
if (!results.every(Boolean)) return;
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);
updateDataSourceSchema();
await updateDsData();
updateStageNodes(root.items);
};
handler();
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) => {
depService.removeTarget(id, DepTargetType.DATA_SOURCE);
depService.removeTarget(id, DepTargetType.DATA_SOURCE_COND);
depService.removeTarget(id, DepTargetType.DATA_SOURCE_METHOD);
};
const dataSourceRemoveHandler = (id: string) => {
@ -679,7 +773,7 @@ export const initServiceEvents = (
app?.dataSourceManager?.removeDataSource(id);
updateStageNodes(nodes);
removeDataSourceTargets(depService, id);
removeDataSourceTarget(id);
};
handler();
@ -705,10 +799,45 @@ export const initServiceEvents = (
codeBlockService.on('addOrUpdate', codeBlockAddOrUpdateHandler);
codeBlockService.on('remove', codeBlockRemoveHandler);
const targetAddHandler = (target: Target) => writeTargetDeps(editorService.get('root'), target);
const targetAddHandler = (target: Target) => {
const root = editorService.get('root');
if (!root) return;
const targetRemoveHandler = (id: string | number, type: DepTargetType) =>
removeTargetDeps(editorService.get('root'), id, type);
if (target.type === DepTargetType.DATA_SOURCE) {
if (!root.dataSourceDeps) {
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('remove-target', targetRemoveHandler);

View File

@ -76,7 +76,7 @@ import type { MPage, MPageFragment } from '@tmagic/core';
import SplitView from '@editor/components/SplitView.vue';
import { useServices } from '@editor/hooks/use-services';
import { Protocol } from '@editor/services/sdk-instance';
import { Protocol } from '@editor/services/storage';
import type { FrameworkSlots, GetColumnWidth, PageBarSortOptions } from '@editor/type';
import { getEditorConfig } from '@editor/utils/config';
import {

View File

@ -62,14 +62,13 @@ const props = withDefaults(
width?: number;
/** 子元素之间的间距,需与 SCSS gap 保持一致 */
gap?: number;
/**
* Popover 内容宽度。不传时按内容撑开,避免下拉框超出菜单。
*/
popoverWidth?: number | string;
/** Popover 内容宽度 */
popoverWidth?: number;
}>(),
{
items: () => [],
gap: 3,
popoverWidth: 180,
},
);
@ -111,18 +110,7 @@ const containerWidth = ref(0);
const hiddenIndexSet = ref<Set<number>>(new Set());
const hasOverflow = computed(() => hiddenIndexSet.value.size > 0);
// 更多菜单是纵向列表:分割线改成横线;收进去的首尾分割线没有分组意义,直接去掉。
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 overflowItems = computed(() => props.items.filter((_, index) => hiddenIndexSet.value.has(index)));
const measureAndCompute = () => {
if (!columnEl.value) return;

View File

@ -13,14 +13,18 @@
:data="{
type: 'button',
text: '页面',
handler: addNormalPage,
handler: () => {
addPage(NodeType.PAGE);
},
}"
></ToolButton>
<ToolButton
:data="{
type: 'button',
text: '页面片',
handler: addPageFragment,
handler: () => {
addPage(NodeType.PAGE_FRAGMENT);
},
}"
></ToolButton>
</TMagicPopover>
@ -58,7 +62,4 @@ const addPage = (type: NodeType.PAGE | NodeType.PAGE_FRAGMENT) => {
};
editorService.add(pageConfig);
};
const addNormalPage = () => addPage(NodeType.PAGE);
const addPageFragment = () => addPage(NodeType.PAGE_FRAGMENT);
</script>

View File

@ -66,7 +66,7 @@ import MIcon from '@editor/components/Icon.vue';
import Resizer from '@editor/components/Resizer.vue';
import { ENABLE_PROPS_FORM_VALIDATE } from '@editor/editorProps';
import { useServices } from '@editor/hooks/use-services';
import { Protocol } from '@editor/services/sdk-instance';
import { Protocol } from '@editor/services/storage';
import type { NodeInvalidSource, PropsPanelSlots } from '@editor/type';
import { styleTabConfig } from '@editor/utils';
import { PROPS_PANEL_WIDTH_STORAGE_KEY } from '@editor/utils/const';

View File

@ -1,6 +1,6 @@
import { computed, type Ref, watch } from 'vue';
import { Protocol } from '@editor/services/sdk-instance';
import { Protocol } from '@editor/services/storage';
import { Services } from '@editor/type';
import { RIGHT_COLUMN_WIDTH_STORAGE_KEY } from '@editor/utils/const';

View File

@ -1,19 +0,0 @@
/*
* 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';

View File

@ -1,264 +0,0 @@
/*
* 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';

View File

@ -1,191 +0,0 @@
/*
* 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

View File

@ -1,196 +0,0 @@
/*
* 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];
};

View File

@ -1,114 +0,0 @@
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;
};

View File

@ -1,23 +0,0 @@
/*
* 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';

View File

@ -1,368 +0,0 @@
/* 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];
};

View File

@ -1,501 +0,0 @@
/*
* 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));
},
};
};

View File

@ -1,211 +0,0 @@
/*
* 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);
}
},
};
};

View File

@ -16,6 +16,7 @@
* limitations under the License.
*/
import { reactive } from 'vue';
import { cloneDeep, get, keys, pick } from 'lodash-es';
import type { Writable } from 'type-fest';
@ -24,6 +25,9 @@ import { Target, Watcher } from '@tmagic/core';
import type { TableColumnConfig } from '@tmagic/form';
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 {
AsyncHookPlugin,
CodeBlockStepValue,
@ -33,13 +37,11 @@ import type {
HistoryOpOptionsWithChangeRecords,
} 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 { createStackStep, describeRevertStep, getLastPushedHistoryIds } from '@editor/utils/history';
import type { SdkContext } from '../type';
import BaseService from './BaseService';
import { Protocol } from './WebStorage';
const canUsePluginMethods = {
async: ['setCodeDslById', 'setEditStatus', 'setCombineIds', 'setUndeleteableList', 'deleteCodeDslByIds'] as const,
@ -49,7 +51,13 @@ const canUsePluginMethods = {
type AsyncMethodName = Writable<(typeof canUsePluginMethods)['async']>;
class CodeBlock extends BaseService {
private state: CodeState;
private state = reactive<CodeState>({
codeDsl: null,
editable: true,
combineIds: [],
undeletableList: [],
paramsColConfig: undefined,
});
/**
* 最近一次写入历史栈的代码块历史记录 uuid(单条写入场景:新增 / 更新)。
@ -62,18 +70,11 @@ class CodeBlock extends BaseService {
*/
private lastDeletedHistoryIds: string[] = [];
constructor(private ctx: SdkContext) {
constructor() {
super([
...canUsePluginMethods.async.map((methodName) => ({ name: methodName, isAsync: true })),
...canUsePluginMethods.sync.map((methodName) => ({ name: methodName, isAsync: false })),
]);
this.state = ctx.reactivity.reactive<CodeState>({
codeDsl: null,
editable: true,
combineIds: [],
undeletableList: [],
paramsColConfig: undefined,
});
}
/**
@ -169,9 +170,9 @@ class CodeBlock extends BaseService {
const codeConfigProcessed = cloneDeep(codeConfig);
if (codeConfigProcessed.content) {
// 在保存的时候转换代码内容
// 没有注入 parseDSL(如 Node 默认)时保持源码字符串,不会执行代码,导出的 DSL 里仍是字符串
if (typeof codeConfigProcessed.content === 'string' && this.ctx.options.parseDSL) {
codeConfigProcessed.content = this.ctx.parseDSL(codeConfigProcessed.content) as (...args: any[]) => any;
const parseDSL = getEditorConfig('parseDSL');
if (typeof codeConfigProcessed.content === 'string') {
codeConfigProcessed.content = parseDSL<(...args: any[]) => any>(codeConfigProcessed.content);
}
}
@ -195,7 +196,7 @@ class CodeBlock extends BaseService {
historyDescription,
source: historySource,
});
this.lastPushedHistoryId = (step ? this.ctx.history.push('codeBlock', step, id) : null)?.uuid ?? null;
this.lastPushedHistoryId = (step ? historyService.push('codeBlock', step, id) : null)?.uuid ?? null;
}
this.emit('addOrUpdate', id, codeDsl[id]);
@ -267,21 +268,21 @@ class CodeBlock extends BaseService {
* 设置代码草稿
*/
public setCodeDraft(codeId: Id, content: string): void {
this.ctx.storage.getStorage().setItem(`${CODE_DRAFT_STORAGE_KEY}_${codeId}`, content);
globalThis.localStorage.setItem(`${CODE_DRAFT_STORAGE_KEY}_${codeId}`, content);
}
/**
* 获取代码草稿
*/
public getCodeDraft(codeId: Id): string | null {
return this.ctx.storage.getStorage().getItem(`${CODE_DRAFT_STORAGE_KEY}_${codeId}`);
return globalThis.localStorage.getItem(`${CODE_DRAFT_STORAGE_KEY}_${codeId}`);
}
/**
* 删除代码草稿
*/
public removeCodeDraft(codeId: Id): void {
this.ctx.storage.getStorage().removeItem(`${CODE_DRAFT_STORAGE_KEY}_${codeId}`);
globalThis.localStorage.removeItem(`${CODE_DRAFT_STORAGE_KEY}_${codeId}`);
}
/**
@ -313,7 +314,7 @@ class CodeBlock extends BaseService {
historyDescription,
source: historySource,
});
const uuid = step ? this.ctx.history.push('codeBlock', step, id)?.uuid : undefined;
const uuid = step ? historyService.push('codeBlock', step, id)?.uuid : undefined;
if (uuid) this.lastDeletedHistoryIds.push(uuid);
}
@ -389,7 +390,7 @@ class CodeBlock extends BaseService {
* @returns 撤销的 step;栈不存在或已无可撤销时返回 null
*/
public async undo(id: Id): Promise<CodeBlockStepValue | null> {
const step = this.ctx.history.undo('codeBlock', id);
const step = historyService.undo('codeBlock', id);
if (!step) return null;
await this.applyHistoryStep(step, true);
return step;
@ -401,7 +402,7 @@ class CodeBlock extends BaseService {
* @returns 重做的 step;栈不存在或已无可重做时返回 null
*/
public async redo(id: Id): Promise<CodeBlockStepValue | null> {
const step = this.ctx.history.redo('codeBlock', id);
const step = historyService.redo('codeBlock', id);
if (!step) return null;
await this.applyHistoryStep(step, false);
return step;
@ -409,12 +410,12 @@ class CodeBlock extends BaseService {
/** 是否可对指定代码块撤销。 */
public canUndo(id: Id): boolean {
return this.ctx.history.canUndo('codeBlock', id);
return historyService.canUndo('codeBlock', id);
}
/** 是否可对指定代码块重做。 */
public canRedo(id: Id): boolean {
return this.ctx.history.canRedo('codeBlock', id);
return historyService.canRedo('codeBlock', id);
}
/**
@ -425,7 +426,7 @@ class CodeBlock extends BaseService {
* @returns 实际移动到的最终游标位置
*/
public async goto(id: Id, targetCursor: number): Promise<number> {
let cursor = this.ctx.history.getCursor('codeBlock', id);
let cursor = historyService.getCursor('codeBlock', id);
const target = Math.max(0, targetCursor);
while (cursor > target) {
const step = await this.undo(id);
@ -451,7 +452,7 @@ class CodeBlock extends BaseService {
* @returns 反向后产生的新 step;目标不存在 / 未应用时返回 null
*/
public async revert(id: Id, index: number): Promise<CodeBlockStepValue | null> {
const list = this.ctx.history.getStepList('codeBlock', id);
const list = historyService.getStepList('codeBlock', id);
const entry = list[index];
if (!entry?.applied) return null;
// 更新类步骤(前后 content 都存在)必须带 changeRecords 才支持回滚,否则只能整内容替换,会冲掉后续无关变更。
@ -472,7 +473,7 @@ class CodeBlock extends BaseService {
public async revertById(uuids: string[]): Promise<(CodeBlockStepValue | null)[]> {
const results: (CodeBlockStepValue | null)[] = [];
for (const uuid of uuids) {
const location = this.ctx.history.findStepLocationByUuid('codeBlock', uuid);
const location = historyService.findStepLocationByUuid('codeBlock', uuid);
results.push(location ? await this.revert(location.id, location.index) : null);
}
return results;
@ -512,7 +513,7 @@ class CodeBlock extends BaseService {
coperWatcher.collect(copyNodes, {}, true, collectorOptions.type);
Object.keys(customTarget.deps).forEach((nodeId: Id) => {
const node = this.ctx.editor.getNodeById(nodeId);
const node = editorService.getNodeById(nodeId);
if (!node) return;
customTarget!.deps[nodeId].keys.forEach((key) => {
const relateCodeId = get(node, key);
@ -526,7 +527,7 @@ class CodeBlock extends BaseService {
});
});
}
this.ctx.storage.setItem(COPY_CODE_STORAGE_KEY, copyData, {
storageService.setItem(COPY_CODE_STORAGE_KEY, copyData, {
protocol: Protocol.OBJECT,
});
}
@ -536,7 +537,7 @@ class CodeBlock extends BaseService {
* @returns
*/
public paste() {
const codeDsl: CodeBlockDSL = this.ctx.storage.getItem(COPY_CODE_STORAGE_KEY);
const codeDsl: CodeBlockDSL = storageService.getItem(COPY_CODE_STORAGE_KEY);
Object.keys(codeDsl).forEach((codeId: Id) => {
// 不覆盖同样id的代码块
this.setCodeDslByIdSync(codeId, codeDsl[codeId], false);
@ -574,16 +575,13 @@ class CodeBlock extends BaseService {
// 原本是新增 → revert 即删除
if (!oldSchema && newSchema) {
await this.deleteCodeDslByIds([id], { historyDescription, historySource: 'rollback' });
return this.ctx.history.getStepList('codeBlock', id).slice(-1)[0]?.step ?? null;
return historyService.getStepList('codeBlock', id).slice(-1)[0]?.step ?? null;
}
// 原本是删除 → revert 即写回
if (oldSchema && !newSchema) {
this.setCodeDslByIdSync(id, cloneDeep(oldSchema), true, {
historyDescription,
historySource: 'rollback',
});
return this.ctx.history.getStepList('codeBlock', id).slice(-1)[0]?.step ?? null;
this.setCodeDslByIdSync(id, cloneDeep(oldSchema), true, { historyDescription, historySource: 'rollback' });
return historyService.getStepList('codeBlock', id).slice(-1)[0]?.step ?? null;
}
if (!oldSchema || !newSchema) return null;
@ -607,14 +605,11 @@ class CodeBlock extends BaseService {
historyDescription,
historySource: 'rollback',
});
return this.ctx.history.getStepList('codeBlock', id).slice(-1)[0]?.step ?? null;
return historyService.getStepList('codeBlock', id).slice(-1)[0]?.step ?? null;
}
this.setCodeDslByIdSync(id, cloneDeep(oldSchema), true, {
historyDescription,
historySource: 'rollback',
});
return this.ctx.history.getStepList('codeBlock', id).slice(-1)[0]?.step ?? null;
this.setCodeDslByIdSync(id, cloneDeep(oldSchema), true, { historyDescription, historySource: 'rollback' });
return historyService.getStepList('codeBlock', id).slice(-1)[0]?.step ?? null;
}
/**
@ -685,4 +680,4 @@ class CodeBlock extends BaseService {
export type CodeBlockService = CodeBlock;
export default CodeBlock;
export default new CodeBlock();

View File

@ -16,20 +16,19 @@
* limitations under the License.
*/
import type { ComponentGroup, ComponentGroupState } from '@editor/type';
import { shallowReactive } from 'vue';
import type { SdkContext } from '../type';
import type { ComponentGroup, ComponentGroupState } from '@editor/type';
import BaseService from './BaseService';
class ComponentList extends BaseService {
private state: ComponentGroupState;
private state = shallowReactive<ComponentGroupState>({
list: [],
});
constructor(ctx: SdkContext) {
constructor() {
super([]);
this.state = ctx.reactivity.shallowReactive<ComponentGroupState>({
list: [],
});
}
/**
@ -56,4 +55,4 @@ class ComponentList extends BaseService {
export type ComponentListService = ComponentList;
export default ComponentList;
export default new ComponentList();

View File

@ -1,3 +1,4 @@
import { reactive } from 'vue';
import { cloneDeep, get } from 'lodash-es';
import type { Writable } from 'type-fest';
@ -6,6 +7,9 @@ import { Target, Watcher } from '@tmagic/core';
import type { FormConfig } from '@tmagic/form';
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 {
DataSourceStepValue,
DatasourceTypeOption,
@ -18,10 +22,7 @@ import { getFormConfig, getFormValue } from '@editor/utils/data-source';
import { COPY_DS_STORAGE_KEY } from '@editor/utils/editor';
import { createStackStep, describeRevertStep, getLastPushedHistoryIds } from '@editor/utils/history';
import type { SdkContext } from '../type';
import BaseService from './BaseService';
import { Protocol } from './WebStorage';
interface State {
datasourceTypeList: DatasourceTypeOption[];
@ -56,7 +57,15 @@ const canUsePluginMethods = {
type SyncMethodName = Writable<(typeof canUsePluginMethods)['sync']>;
class DataSource extends BaseService {
private state: State;
private state = reactive<State>({
datasourceTypeList: [],
dataSources: [],
editable: true,
configs: {},
values: {},
events: {},
methods: {},
});
/**
* 最近一次写入历史栈的数据源历史记录 uuid。
@ -65,17 +74,8 @@ class DataSource extends BaseService {
*/
private lastPushedHistoryId: string | null = null;
constructor(private ctx: SdkContext) {
constructor() {
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) {
@ -129,10 +129,6 @@ class DataSource extends BaseService {
config: DataSourceSchema,
{ doNotPushHistory = false, historyDescription, historySource }: HistoryOpOptions = {},
) {
// 数据源的新增 / 更新是同步方法,这里只能做同步的结构校验(类型是否可用);
// 属性表单校验见 `sdk.validate.dataSource(id)`
this.ctx.validator.guardDataSource(config, true);
const newConfig = {
...config,
id: config.id && !this.getDataSourceById(config.id) ? config.id : this.createId(),
@ -147,7 +143,7 @@ class DataSource extends BaseService {
historyDescription,
source: historySource,
});
this.lastPushedHistoryId = (step ? this.ctx.history.push('dataSource', step, newConfig.id) : null)?.uuid ?? null;
this.lastPushedHistoryId = (step ? historyService.push('dataSource', step, newConfig.id) : null)?.uuid ?? null;
}
this.emit('add', newConfig);
@ -173,8 +169,6 @@ class DataSource extends BaseService {
historySource,
}: HistoryOpOptionsWithChangeRecords = {},
) {
this.ctx.validator.guardDataSource(config, false);
const dataSources = this.get('dataSources');
const index = dataSources.findIndex((ds) => ds.id === config.id);
@ -192,7 +186,7 @@ class DataSource extends BaseService {
historyDescription,
source: historySource,
});
this.lastPushedHistoryId = (step ? this.ctx.history.push('dataSource', step, newConfig.id) : null)?.uuid ?? null;
this.lastPushedHistoryId = (step ? historyService.push('dataSource', step, newConfig.id) : null)?.uuid ?? null;
}
this.emit('update', newConfig, {
@ -224,7 +218,7 @@ class DataSource extends BaseService {
historyDescription,
source: historySource,
});
this.lastPushedHistoryId = (step ? this.ctx.history.push('dataSource', step, id) : null)?.uuid ?? null;
this.lastPushedHistoryId = (step ? historyService.push('dataSource', step, id) : null)?.uuid ?? null;
}
this.emit('remove', id);
@ -279,7 +273,7 @@ class DataSource extends BaseService {
* @returns 撤销的 step;栈不存在或已无可撤销时返回 null
*/
public undo(id: Id) {
const step = this.ctx.history.undo('dataSource', id);
const step = historyService.undo('dataSource', id);
if (!step) return null;
this.applyHistoryStep(step, true);
return step;
@ -291,7 +285,7 @@ class DataSource extends BaseService {
* @returns 重做的 step;栈不存在或已无可重做时返回 null
*/
public redo(id: Id) {
const step = this.ctx.history.redo('dataSource', id);
const step = historyService.redo('dataSource', id);
if (!step) return null;
this.applyHistoryStep(step, false);
return step;
@ -299,12 +293,12 @@ class DataSource extends BaseService {
/** 是否可对指定数据源撤销。 */
public canUndo(id: Id): boolean {
return this.ctx.history.canUndo('dataSource', id);
return historyService.canUndo('dataSource', id);
}
/** 是否可对指定数据源重做。 */
public canRedo(id: Id): boolean {
return this.ctx.history.canRedo('dataSource', id);
return historyService.canRedo('dataSource', id);
}
/**
@ -315,7 +309,7 @@ class DataSource extends BaseService {
* @returns 实际移动到的最终游标位置
*/
public goto(id: Id, targetCursor: number): number {
let cursor = this.ctx.history.getCursor('dataSource', id);
let cursor = historyService.getCursor('dataSource', id);
const target = Math.max(0, targetCursor);
while (cursor > target) {
if (!this.undo(id)) break;
@ -339,7 +333,7 @@ class DataSource extends BaseService {
* @returns 反向后产生的新 step;目标不存在 / 未应用时返回 null
*/
public revert(id: Id, index: number): DataSourceStepValue | null {
const list = this.ctx.history.getStepList('dataSource', id);
const list = historyService.getStepList('dataSource', id);
const entry = list[index];
if (!entry?.applied) return null;
// 更新类步骤(前后 schema 都存在)必须带 changeRecords 才支持回滚,否则只能整 schema 替换,会冲掉后续无关变更。
@ -358,7 +352,7 @@ class DataSource extends BaseService {
*/
public revertById(uuids: string[]): (DataSourceStepValue | null)[] {
return uuids.map((uuid) => {
const location = this.ctx.history.findStepLocationByUuid('dataSource', uuid);
const location = historyService.findStepLocationByUuid('dataSource', uuid);
if (!location) return null;
return this.revert(location.id, location.index);
});
@ -407,7 +401,7 @@ class DataSource extends BaseService {
coperWatcher.collect(copyNodes, {}, true, collectorOptions.type);
Object.keys(customTarget.deps).forEach((nodeId: Id) => {
const node = this.ctx.editor.getNodeById(nodeId);
const node = editorService.getNodeById(nodeId);
if (!node) return;
customTarget!.deps[nodeId].keys.forEach((key) => {
const [relateDsId] = get(node, key);
@ -421,7 +415,7 @@ class DataSource extends BaseService {
});
});
}
this.ctx.storage.setItem(COPY_DS_STORAGE_KEY, copyData, {
storageService.setItem(COPY_DS_STORAGE_KEY, copyData, {
protocol: Protocol.OBJECT,
});
}
@ -430,7 +424,7 @@ class DataSource extends BaseService {
* @returns
*/
public paste() {
const dataSource: DataSourceSchema[] = this.ctx.storage.getItem(COPY_DS_STORAGE_KEY);
const dataSource: DataSourceSchema[] = storageService.getItem(COPY_DS_STORAGE_KEY);
dataSource.forEach((item: DataSourceSchema) => {
// 不覆盖同样id的数据源
if (!this.getDataSourceById(item.id)) {
@ -450,13 +444,13 @@ class DataSource extends BaseService {
// 原本是新增 → revert 即删除
if (!oldSchema && newSchema) {
this.remove(`${id}`, { historyDescription, historySource: 'rollback' });
return this.ctx.history.getStepList('dataSource', id).slice(-1)[0]?.step ?? null;
return historyService.getStepList('dataSource', id).slice(-1)[0]?.step ?? null;
}
// 原本是删除 → revert 即重新加回
if (oldSchema && !newSchema) {
this.add(cloneDeep(oldSchema), { historyDescription, historySource: 'rollback' });
return this.ctx.history.getStepList('dataSource', id).slice(-1)[0]?.step ?? null;
return historyService.getStepList('dataSource', id).slice(-1)[0]?.step ?? null;
}
if (!oldSchema || !newSchema) return null;
@ -480,11 +474,11 @@ class DataSource extends BaseService {
historyDescription,
historySource: 'rollback',
});
return this.ctx.history.getStepList('dataSource', id).slice(-1)[0]?.step ?? null;
return historyService.getStepList('dataSource', id).slice(-1)[0]?.step ?? null;
}
this.update(cloneDeep(oldSchema), { historyDescription, historySource: 'rollback' });
return this.ctx.history.getStepList('dataSource', id).slice(-1)[0]?.step ?? null;
return historyService.getStepList('dataSource', id).slice(-1)[0]?.step ?? null;
}
/**
@ -555,4 +549,4 @@ class DataSource extends BaseService {
export type DataSourceService = DataSource;
export default DataSource;
export default new DataSource();

View File

@ -15,28 +15,19 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { reactive, shallowReactive } from 'vue';
import { throttle } from 'lodash-es';
import type { DepData, DepExtendedData, Id, MApp, MNode, Target } from '@tmagic/core';
import { DepTargetType, traverseTarget, Watcher } from '@tmagic/core';
import { isPage } from '@tmagic/utils';
import { CollectWorkerClient } from '@editor/utils/dep/collect-worker-client';
import { IdleTask } from '@editor/utils/dep/idle-task';
import type { DepsData } from '@editor/utils/dep/worker';
import type { CollectWorkerLike, SdkContext } from '../type';
import BaseService from './BaseService';
/** Node 下没有 worker 时使用:始终不可用,依赖收集退回主线程空闲队列 */
const noopCollectWorker: CollectWorkerLike = {
isSupported: false,
collectDsl: () => Promise.resolve(null),
collect: () => Promise.resolve(null),
terminate: () => {},
abort: () => {},
};
export interface DepEvents {
'add-target': [target: Target];
'remove-target': [id: string | number, type: string | DepTargetType];
@ -68,13 +59,16 @@ interface CollectBatch {
}
class Dep extends BaseService {
private state: State;
private state = shallowReactive<State>({
collecting: false,
taskLength: 0,
});
private idleTask = new IdleTask<void>();
private collectWorker: CollectWorkerLike;
private collectWorker = new CollectWorkerClient();
private watcher: Watcher;
private watcher = new Watcher({ initialTargets: reactive({}) });
private waitingWorker?: Promise<void>;
@ -84,16 +78,9 @@ class Dep extends BaseService {
private activeBatches = new Set<CollectBatch>();
constructor(private ctx: SdkContext) {
constructor() {
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(
'update-task-length',
throttle(({ length }) => {
@ -150,10 +137,8 @@ class Dep extends BaseService {
const targets = this.watcher.getCollectableTargets(type);
if (targets.length) {
// 先统一删除所有节点的旧依赖,再统一收集。
// 如果逐节点“删除 + 收集”,共用 id 的多棵树(如复制出的页面片),
// 后一棵树的删除会清掉前一棵树刚写入的依赖。
for (const node of nodes) {
// 先删除原有依赖,再批量收集
if (isPage(node)) {
for (const target of targets) {
this.removePageDep(target, depExtendedData);
@ -161,8 +146,6 @@ class Dep extends BaseService {
} else {
this.watcher.removeTargetsDep(targets, node);
}
}
for (const node of nodes) {
this.watcher.collectItems(node, targets, depExtendedData, deep);
}
}
@ -246,7 +229,7 @@ class Dep extends BaseService {
traverseTarget(this.watcher.getTargetsList(), (target) => {
if (depsData[target.type]?.[target.id]) {
target.deps = this.ctx.reactivity.reactive(depsData[target.type][target.id]);
target.deps = reactive(depsData[target.type][target.id]);
if (target.type === DepTargetType.DATA_SOURCE && dsl.dataSourceDeps) {
dsl.dataSourceDeps[target.id] = target.deps;
@ -571,4 +554,4 @@ class Dep extends BaseService {
export type DepService = Dep;
export default Dep;
export default new Dep();

View File

@ -16,11 +16,13 @@
* limitations under the License.
*/
import { nextTick, reactive, toRaw } from 'vue';
import { cloneDeep, isEmpty, isEqual, isObject, mergeWith, uniq } from 'lodash-es';
import type { Id, MApp, MContainer, MNode, MPage, MPageFragment, TargetOptions } from '@tmagic/core';
import { NodeType } from '@tmagic/core';
import type { ChangeRecord } from '@tmagic/form';
import { isFixed } from '@tmagic/stage';
import {
getNodeInfo,
getNodePath,
@ -33,11 +35,14 @@ import {
traverseNode,
} 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 {
AddMNode,
AsyncHookPlugin,
AsyncMethodName,
CanDropInFunction,
DslOpOptions,
DslOpWithHistoryIdsResult,
EditorEvents,
@ -75,13 +80,7 @@ import {
toggleFixedPosition,
} from '@editor/utils/editor';
import { getLastPushedHistoryIds } from '@editor/utils/history';
import type { SdkContext } from '../type';
import { beforePaste, getAddParent } from '../utils/operator';
import { isFixed } from '../utils/position';
import BaseService from './BaseService';
import { Protocol } from './WebStorage';
import { beforePaste, getAddParent } from '@editor/utils/operator';
type MoveItem = { node: MNode; parent: MContainer; pageForOp: { name: string; id: Id } | null };
@ -103,13 +102,23 @@ const buildUpdateDiff = (
...(changeRecords?.length ? { changeRecords } : {}),
}));
export interface EditorServiceConfig {
disabledPageFragment?: boolean;
canDropIn?: CanDropInFunction;
}
class Editor extends BaseService {
public state: StoreState;
public state: StoreState = reactive({
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;
/**
* 操作前的节点校验错误快照,与 selectionBeforeOp 同时在 captureSelectionBeforeOp 中捕获,
@ -128,32 +137,12 @@ class Editor extends BaseService {
*/
private lastAdded: { selectedId: Id; nodeId: Id } | null = null;
/** 与编辑器 props 中 `disabledPageFragment` / `canDropIn` 对应,供结构合法性校验使用 */
private config: EditorServiceConfig = {};
constructor(private ctx: SdkContext) {
constructor() {
super(
canUsePluginMethods.async.map((methodName) => ({ name: methodName, isAsync: true })),
// 需要注意循环依赖问题,如果函数间有相互调用的话,不能设置为串行调用
['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,
});
}
/**
@ -196,8 +185,8 @@ class Editor extends BaseService {
// 重复创建,set root 不额外产生记录,由恢复出的历史栈作为当前状态来源。
// 标记不进入撤销/重做栈,仅作为该页历史列表底部的初始基线展示。
app.items?.forEach((pageNode) => {
if (pageNode?.id !== undefined && !this.ctx.history.getMarker('page', pageNode.id)) {
this.ctx.history.setMarker('page', pageNode.id, {
if (pageNode?.id !== undefined && !historyService.getMarker('page', pageNode.id)) {
historyService.setMarker('page', pageNode.id, {
name: pageNode.name,
source: options.historySource,
});
@ -235,7 +224,7 @@ class Editor extends BaseService {
public getNodeInfo(id: Id, raw = true): EditorNodeInfo {
let root = this.get('root');
if (raw) {
root = this.toRaw(root);
root = toRaw(root);
}
if (!root) {
@ -351,11 +340,11 @@ class Editor extends BaseService {
}
public async selectNextNode(): Promise<MNode | null> | never {
const node = this.toRaw(this.get('node'));
const node = toRaw(this.get('node'));
if (!node || isPage(node) || node.type === NodeType.ROOT) return node;
const parent = this.toRaw(this.getParentById(node.id));
const parent = toRaw(this.getParentById(node.id));
if (!parent) return node;
@ -370,8 +359,8 @@ class Editor extends BaseService {
}
public async selectNextPage(): Promise<MNode> | never {
const root = this.toRaw(this.get('root'));
const page = this.toRaw(this.get('page'));
const root = toRaw(this.get('root'));
const page = toRaw(this.get('page'));
if (!page) throw new Error('page不能为空');
if (!root) throw new Error('root不能为空');
@ -425,15 +414,6 @@ class Editor extends BaseService {
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> {
const root = this.get('root');
@ -459,7 +439,7 @@ class Editor extends BaseService {
parent.items.splice(insertIndex, 0, node);
this.lastAdded = { selectedId: curNode.id, nodeId: node.id };
const layout = await this.getLayout(this.toRaw(parent), node as MNode);
const layout = await this.getLayout(toRaw(parent), node as MNode);
node.style = getInitPositionStyle(node.style, layout);
await stage?.add({
@ -513,7 +493,7 @@ class Editor extends BaseService {
if (!type) throw new Error('组件类型不能为空');
addNodes.push({ ...this.toRaw(await this.ctx.props.getPropsValue(type, config)) });
addNodes.push({ ...toRaw(await propsService.getPropsValue(type, config)) });
} else {
addNodes.push(...addNode);
}
@ -521,24 +501,13 @@ class Editor extends BaseService {
// 必须串行:每个节点的插入位置依赖上一个已插入的节点,并行时插入顺序会受
// doAdd 前置耗时(如异步 beforeDoAdd 插件钩子)影响而错乱
const newNodes: MNode[] = [];
const targets: { node: MNode; parentNode: MContainer }[] = [];
for (const node of addNodes) {
const root = this.get('root');
const parentNode = isPageOrFragment(node) && root ? root : (parent ?? getAddParent(this.ctx, node));
const parentNode = isPageOrFragment(node) && root ? root : (parent ?? getAddParent(node));
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));
}
// record 模式:在写入历史记录之前落库,使历史快照包含本次的错误状态
commitInvalid?.();
if (newNodes.length > 1) {
// 多选时只要任一新增节点位于非当前页面,触发的 multiSelect 就会引起页面切换
const wouldSwitchPage = newNodes.some((n) => this.isOnDifferentPage(n));
@ -575,7 +544,7 @@ class Editor extends BaseService {
const p = this.getParentById(n.id, false) as MContainer;
const idx = p ? getNodeIndex(n.id, p) : -1;
return {
newSchema: cloneDeep(this.toRaw(n)),
newSchema: cloneDeep(toRaw(n)),
parentId,
index: typeof idx === 'number' ? idx : -1,
};
@ -600,7 +569,7 @@ class Editor extends BaseService {
// 页面 / 页面片新增不入历史栈(见上方 isPageOrFragment 分支),这里合并补发一次结构变更通知
const addedPages = newNodes.filter((node) => isPageOrFragment(node)) as (MPage | MPageFragment)[];
if (addedPages.length) {
this.ctx.history.notifyPageStructureChange({ add: addedPages, remove: [] });
historyService.notifyPageStructureChange({ add: addedPages, remove: [] });
}
return Array.isArray(addNode) ? newNodes : newNodes[0];
@ -657,7 +626,7 @@ class Editor extends BaseService {
// nextTick 用于等 Stage 的 page watch 把新页面 id 同步给 runtime。
await this.select(rootItems[0]);
stage?.select(rootItems[0].id);
await this.ctx.reactivity.nextTick();
await nextTick();
stage?.remove(removeData);
} else {
// page 置空会让 Workspace 卸载 Stage 并销毁 renderer,删除通知必须在卸载前发出,
@ -684,7 +653,7 @@ class Editor extends BaseService {
if (!rootItems.length) {
this.resetModifiedNodeId();
this.ctx.history.reset();
historyService.reset();
}
}
@ -716,7 +685,7 @@ class Editor extends BaseService {
}
const idx = getNodeIndex(curNode.id, parent);
removedItems.push({
oldSchema: cloneDeep(this.toRaw(curNode)),
oldSchema: cloneDeep(toRaw(curNode)),
parentId: parent.id,
index: typeof idx === 'number' ? idx : -1,
});
@ -748,7 +717,7 @@ class Editor extends BaseService {
// 页面 / 页面片删除不入历史栈(见上方 isPageOrFragment 分支),这里合并补发一次结构变更通知
const removedPages = nodes.filter((node) => isPageOrFragment(node)) as (MPage | MPageFragment)[];
if (removedPages.length) {
this.ctx.history.notifyPageStructureChange({ add: [], remove: removedPages });
historyService.notifyPageStructureChange({ add: [], remove: removedPages });
}
}
@ -766,12 +735,12 @@ class Editor extends BaseService {
if (!info.node) throw new Error(`获取不到id为${config.id}的节点`);
const node = this.toRaw(info.node);
const node = toRaw(info.node);
// replace=true 时跳过 toggleFixedPosition / mergeWith / setChildrenLayout,直接用传入配置整节点替换
let newConfig = replace
? cloneDeep(this.toRaw(config))
: await toggleFixedPosition(this.toRaw(config), node, info.path, this.getLayout);
? cloneDeep(toRaw(config))
: await toggleFixedPosition(toRaw(config), node, info.path, this.getLayout);
if (!replace) {
newConfig = mergeWith(cloneDeep(node), newConfig, editorNodeMergeCustomizer);
@ -852,8 +821,6 @@ class Editor extends BaseService {
const nodes = Array.isArray(config) ? config : [config];
const commitInvalid = await this.runGuard(() => this.ctx.validator.guardUpdate(nodes, { replace: data.replace }));
// 多节点必须使用 changeRecordList 为每个节点提供独立的记录;
// 否则同一份 changeRecords 会被复用到每个节点上,nodeUpdateHandler / 历史回放都会按错误的 propPath 处理。
const updateData = await Promise.all(
@ -865,7 +832,6 @@ class Editor extends BaseService {
// 校验错误信息在 pushOpHistory 之前落库,保证历史快照包含本次变更对应的错误状态。
this.applyInvalidInfo(config, invalidInfo);
commitInvalid?.();
if (updateData[0].oldNode?.type !== NodeType.ROOT) {
const curNodes = this.get('nodes');
@ -925,7 +891,7 @@ class Editor extends BaseService {
const node = this.get('node');
if (!node) throw new Error('当前节点为空');
const parent = cloneDeep(this.toRaw(this.get('parent')));
const parent = cloneDeep(toRaw(this.get('parent')));
if (!parent) throw new Error('父节点为空');
const index2 = parent.items.findIndex((node: MNode) => `${node.id}` === `${id2}`);
@ -953,7 +919,7 @@ class Editor extends BaseService {
* @returns
*/
public copy(config: MNode | MNode[]): void {
this.ctx.storage.setItem(COPY_STORAGE_KEY, Array.isArray(config) ? config : [config], {
storageService.setItem(COPY_STORAGE_KEY, Array.isArray(config) ? config : [config], {
protocol: Protocol.OBJECT,
});
}
@ -970,7 +936,7 @@ class Editor extends BaseService {
collectRelatedNodes(copyNodes, collectorOptions, (id) => this.getNodeById(id));
}
this.ctx.storage.setItem(COPY_STORAGE_KEY, copyNodes, {
storageService.setItem(COPY_STORAGE_KEY, copyNodes, {
protocol: Protocol.OBJECT,
});
}
@ -996,7 +962,7 @@ class Editor extends BaseService {
historySource,
}: DslOpOptions = {},
): Promise<MNode | MNode[] | void> {
const config: MNode[] = this.ctx.storage.getItem(COPY_STORAGE_KEY);
const config: MNode[] = storageService.getItem(COPY_STORAGE_KEY);
if (!Array.isArray(config)) return;
const node = this.get('node');
@ -1012,7 +978,7 @@ class Editor extends BaseService {
const pasteConfigs = await this.doPaste(config, position);
if (collectorOptions && typeof collectorOptions.isTarget === 'function') {
this.ctx.props.replaceRelateId(config, pasteConfigs, collectorOptions);
propsService.replaceRelateId(config, pasteConfigs, collectorOptions);
}
return this.add(pasteConfigs, parent, {
@ -1025,9 +991,9 @@ class Editor extends BaseService {
}
public async doPaste(config: MNode[], position: PastePosition = {}): Promise<MNode[]> {
this.ctx.props.clearRelateId();
propsService.clearRelateId();
const doc = this.get('stage')?.renderer?.contentWindow?.document;
const pasteConfigs = beforePaste(this.ctx, position, cloneDeep(config), doc);
const pasteConfigs = beforePaste(position, cloneDeep(config), doc);
return pasteConfigs;
}
@ -1035,7 +1001,7 @@ class Editor extends BaseService {
const parent = this.getParentById(config.id);
if (!parent) throw new Error('找不到父节点');
const node = cloneDeep(this.toRaw(config));
const node = cloneDeep(toRaw(config));
const layout = await this.getLayout(parent, node);
const doc = this.get('stage')?.renderer?.contentWindow?.document;
const newStyle = calcAlignCenterStyle(node, parent, layout, doc);
@ -1064,11 +1030,7 @@ class Editor extends BaseService {
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 (newNodes.length > 1) {
@ -1113,7 +1075,7 @@ class Editor extends BaseService {
return;
}
const oldParent = cloneDeep(this.toRaw(parent));
const oldParent = cloneDeep(toRaw(parent));
brothers.splice(index, 1);
brothers.splice(offsetIndex, 0, node);
@ -1121,7 +1083,7 @@ class Editor extends BaseService {
const grandparent = this.getParentById(parent.id);
this.get('stage')?.update({
config: cloneDeep(this.toRaw(parent)),
config: cloneDeep(toRaw(parent)),
parentId: grandparent?.id,
root: cloneDeep(root),
});
@ -1133,7 +1095,7 @@ class Editor extends BaseService {
'update',
{
diff: buildUpdateDiff([{ oldNode: oldParent, newNode: cloneDeep(this.toRaw(parent)) }]),
diff: buildUpdateDiff([{ oldNode: oldParent, newNode: cloneDeep(toRaw(parent)) }]),
pageData: { name: pageForOp?.name || '', id: pageForOp!.id },
historyDescription,
source: historySource,
@ -1194,20 +1156,12 @@ class Editor extends BaseService {
}
const root = this.get('root');
const stage = this.get('stage');
if (!root) {
throw new Error('root 为空');
if (!root || !stage) {
throw new Error('root 或 stage为空');
}
// 校验放在过滤之前:源节点不存在也要作为校验错误报出
await this.runGuard(() =>
this.ctx.validator.guardMove(
configs.map((item) => item.id),
target,
'stage-drag',
),
);
// 收集 (节点, 源父) 信息,过滤掉异常节点(找不到父或源父等于目标本身)
const moves: MoveItem[] = [];
for (const { id } of configs) {
@ -1222,10 +1176,10 @@ class Editor extends BaseService {
// 记录所有涉及的源父容器(按 id 去重)+ 目标容器的前置快照;同一父容器只快照一次。
const beforeSnapshots = new Map<Id, MNode>();
beforeSnapshots.set(target.id, cloneDeep(this.toRaw(target)));
beforeSnapshots.set(target.id, cloneDeep(toRaw(target)));
for (const { parent } of moves) {
if (!beforeSnapshots.has(parent.id)) {
beforeSnapshots.set(parent.id, cloneDeep(this.toRaw(parent)));
beforeSnapshots.set(parent.id, cloneDeep(toRaw(parent)));
}
}
@ -1244,7 +1198,7 @@ class Editor extends BaseService {
// 整批只入栈一条历史:updatedItems 包含所有源父容器 + 目标容器的前后快照(撤销/重做最小依赖)。
const updatedItems = Array.from(beforeSnapshots.entries()).map(([id, oldNode]) => ({
oldNode,
newNode: cloneDeep(this.toRaw(this.getNodeById(id, false))) as MNode,
newNode: cloneDeep(toRaw(this.getNodeById(id, false))) as MNode,
}));
const historyPage = moves[0].pageForOp ?? { name: '', id: target.id };
this.pushOpHistory('update', {
@ -1272,23 +1226,15 @@ class Editor extends BaseService {
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>();
for (const cfg of configs) {
const { parent } = this.getNodeInfo(cfg.id, false);
if (parent && !beforeSnapshots.has(`${parent.id}`)) {
beforeSnapshots.set(`${parent.id}`, cloneDeep(this.toRaw(parent)));
beforeSnapshots.set(`${parent.id}`, cloneDeep(toRaw(parent)));
}
}
if (!beforeSnapshots.has(`${targetParent.id}`)) {
beforeSnapshots.set(`${targetParent.id}`, cloneDeep(this.toRaw(targetParent)));
beforeSnapshots.set(`${targetParent.id}`, cloneDeep(toRaw(targetParent)));
}
const newLayout = await this.getLayout(targetParent);
@ -1330,7 +1276,7 @@ class Editor extends BaseService {
for (const oldNode of beforeSnapshots.values()) {
const newNode = this.getNodeById(oldNode.id, false);
if (newNode) {
updatedItems.push({ oldNode, newNode: cloneDeep(this.toRaw(newNode)) });
updatedItems.push({ oldNode, newNode: cloneDeep(toRaw(newNode)) });
}
}
if (!doNotPushHistory) {
@ -1437,7 +1383,7 @@ class Editor extends BaseService {
public async undo(): Promise<StepValue | null> {
const pageId = this.get('page')?.id;
if (pageId === undefined) return null;
const value = this.ctx.history.undo('page', pageId);
const value = historyService.undo('page', pageId);
if (value) {
await this.applyHistoryOp(value, true);
}
@ -1451,7 +1397,7 @@ class Editor extends BaseService {
public async redo(): Promise<StepValue | null> {
const pageId = this.get('page')?.id;
if (pageId === undefined) return null;
const value = this.ctx.history.redo('page', pageId);
const value = historyService.redo('page', pageId);
if (value) {
await this.applyHistoryOp(value, false);
}
@ -1475,7 +1421,7 @@ class Editor extends BaseService {
* @returns 反向后产生的新 step;目标不存在 / 未应用 / 反向失败时返回 null
*/
public async revertPageStep(index: number): Promise<StepValue | null> {
const list = this.ctx.history.getStepList('page', this.get('page')?.id);
const list = historyService.getStepList('page', this.get('page')?.id);
const entry = list[index];
if (!entry?.applied) return null;
@ -1497,16 +1443,11 @@ class Editor extends BaseService {
const captureRevert = (_pageId: Id, s: StepValue) => {
revertedStep = s;
};
this.ctx.history.once('change', captureRevert);
historyService.once('change', captureRevert);
const historyDescription = `回滚 #${index + 1}: ${describeStepForRevert(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 {
switch (step.opType) {
@ -1565,11 +1506,11 @@ class Editor extends BaseService {
}
}
} finally {
this.ctx.history.off('change', captureRevert);
historyService.off('change', captureRevert);
}
// 通知一次 history-change,让上层(如属性面板)按当前最新 DSL 刷新
const page = this.toRaw(this.get('page'));
const page = toRaw(this.get('page'));
if (page) {
this.emit('history-change', page as MPage | MPageFragment);
}
@ -1588,7 +1529,7 @@ class Editor extends BaseService {
const results: (StepValue | null)[] = [];
const pageId = this.get('page')?.id;
for (const uuid of uuids) {
const location = this.ctx.history.findStepLocationByUuid('page', uuid, pageId);
const location = historyService.findStepLocationByUuid('page', uuid, pageId);
results.push(!location ? null : await this.revertPageStep(location.index));
}
return results;
@ -1605,8 +1546,8 @@ class Editor extends BaseService {
*/
public async gotoPageStep(targetCursor: number): Promise<number> {
const pageId = this.get('page')?.id;
let cursor = this.ctx.history.getCursor('page', pageId);
const { length } = this.ctx.history.getStepList('page', pageId);
let cursor = historyService.getCursor('page', pageId);
const { length } = historyService.getStepList('page', pageId);
const target = Math.max(0, Math.min(targetCursor, length));
while (cursor > target) {
const step = await this.undo();
@ -1626,7 +1567,7 @@ class Editor extends BaseService {
top: number,
{ doNotPushHistory = false, historyDescription, historySource }: DslOpOptions = {},
) {
const node = this.toRaw(this.get('node'));
const node = toRaw(this.get('node'));
if (!node || isPage(node)) return;
const newStyle = calcMoveStyle(node.style || {}, left, top);
@ -1819,20 +1760,6 @@ class Editor extends BaseService {
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() {
if (this.selectionBeforeOp) return;
this.selectionBeforeOp = this.get('nodes').map((n) => n.id);
@ -1867,22 +1794,15 @@ class Editor extends BaseService {
this.pushPageDiffStep(
'add',
nextPage,
{
newSchema: cloneDeep(this.toRaw(nextPage)),
parentId: nextRoot.id,
index: indexInItems(nextRoot, nextPage.id),
},
{ newSchema: cloneDeep(toRaw(nextPage)), parentId: nextRoot.id, index: indexInItems(nextRoot, nextPage.id) },
source,
);
addedPages.push(nextPage);
} else if (!isEqual(this.toRaw(prevPage), this.toRaw(nextPage))) {
} else if (!isEqual(toRaw(prevPage), toRaw(nextPage))) {
this.pushPageDiffStep(
'update',
nextPage,
{
oldSchema: cloneDeep(this.toRaw(prevPage)),
newSchema: cloneDeep(this.toRaw(nextPage)),
},
{ oldSchema: cloneDeep(toRaw(prevPage)), newSchema: cloneDeep(toRaw(nextPage)) },
source,
);
}
@ -1893,11 +1813,7 @@ class Editor extends BaseService {
this.pushPageDiffStep(
'remove',
prevPage,
{
oldSchema: cloneDeep(this.toRaw(prevPage)),
parentId: preRoot.id,
index: indexInItems(preRoot, prevPage.id),
},
{ oldSchema: cloneDeep(toRaw(prevPage)), parentId: preRoot.id, index: indexInItems(preRoot, prevPage.id) },
source,
);
removedPages.push(prevPage);
@ -1905,7 +1821,7 @@ class Editor extends BaseService {
});
if (addedPages.length || removedPages.length) {
this.ctx.history.notifyPageStructureChange({ add: addedPages, remove: removedPages });
historyService.notifyPageStructureChange({ add: addedPages, remove: removedPages });
}
}
@ -1936,11 +1852,11 @@ class Editor extends BaseService {
};
if (source) step.source = source;
const top = this.ctx.history.getCurrentPageStep(page.id);
const top = historyService.getCurrentPageStep(page.id);
if (top?.rootStep && top.source === source) {
this.ctx.history.replaceCurrentStep('page', step, page.id);
historyService.replaceCurrentStep('page', step, page.id);
} else {
this.ctx.history.push('page', step, page.id);
historyService.push('page', step, page.id);
}
}
@ -1976,7 +1892,7 @@ class Editor extends BaseService {
if (source) step.source = source;
// 显式按 step.data.id 入栈:跨页操作(如 moveToContainer 从源页搬到目标页)
// 必须落到正确的页面栈,否则会把记录错误地推到当前活动页 / 操作发起页。
const pushed = this.ctx.history.push('page', step, pageData.id);
const pushed = historyService.push('page', step, pageData.id);
// push 返回 null 表示当前没有可写入的页面栈(未真正入栈),此时不应返回 uuid。
const historyId = pushed ? step.uuid : null;
this.lastPushedHistoryId = historyId;
@ -2135,7 +2051,7 @@ class Editor extends BaseService {
this.set('invalidNodeIds', new Map(invalidToRestore));
this.emit('invalid-node-change', this.get('invalidNodeIds'));
const page = this.toRaw(this.get('page'));
const page = toRaw(this.get('page'));
if (page) {
const selectIds = (reverse ? step.extra?.selectedBefore : step.extra?.selectedAfter) ?? [];
setTimeout(() => {
@ -2173,13 +2089,8 @@ class Editor extends BaseService {
doNotPushHistory: true,
});
}
/** 经由 reactivity 注入的 toRaw(默认是 Vue 的 toRaw) */
private toRaw<T>(value: T): T {
return this.ctx.reactivity.toRaw(value);
}
}
export type EditorService = Editor;
export default Editor;
export default new Editor();

View File

@ -16,6 +16,7 @@
* limitations under the License.
*/
import { reactive } from 'vue';
import { cloneDeep } from 'lodash-es';
import type { Writable } from 'type-fest';
@ -24,8 +25,6 @@ import { toLine } from '@tmagic/utils';
import type { AsyncHookPlugin, SyncHookPlugin } from '@editor/type';
import type { SdkContext } from '../type';
import BaseService from './BaseService';
const canUsePluginMethods = {
@ -36,17 +35,15 @@ const canUsePluginMethods = {
type AsyncMethodName = Writable<(typeof canUsePluginMethods)['async']>;
type SyncMethodName = Writable<(typeof canUsePluginMethods)['sync']>;
class Events extends BaseService {
private eventMap: Record<string, EventOption[]>;
private methodMap: Record<string, EventOption[]>;
const eventMap: Record<string, EventOption[]> = reactive({});
const methodMap: Record<string, EventOption[]> = reactive({});
constructor(ctx: SdkContext) {
class Events extends BaseService {
constructor() {
super([
...canUsePluginMethods.async.map((methodName) => ({ name: methodName, isAsync: true })),
...canUsePluginMethods.sync.map((methodName) => ({ name: methodName, isAsync: false })),
]);
this.eventMap = ctx.reactivity.reactive({});
this.methodMap = ctx.reactivity.reactive({});
}
public setEvents(events: Record<string, EventOption[]>) {
@ -56,11 +53,11 @@ class Events extends BaseService {
}
public setEvent(type: string, events: EventOption[]) {
this.eventMap[toLine(type)] = [...events];
eventMap[toLine(type)] = [...events];
}
public getEvent(type: string, _data: { node?: MNode | null } = {}): EventOption[] {
return cloneDeep(this.eventMap[toLine(type)]) || [];
return cloneDeep(eventMap[toLine(type)]) || [];
}
public setMethods(methods: Record<string, EventOption[]>) {
@ -70,17 +67,17 @@ class Events extends BaseService {
}
public setMethod(type: string, method: EventOption[]) {
this.methodMap[toLine(type)] = [...method];
methodMap[toLine(type)] = [...method];
}
public getMethod(type: string, _data: { node?: MNode | null; targetId?: Id } = {}) {
return cloneDeep(this.methodMap[toLine(type)]) || [];
return cloneDeep(methodMap[toLine(type)]) || [];
}
public resetState() {
// 原地清空而不是重新赋值,保留 reactive 代理身份,已建立的 computed / 渲染副作用才能收到更新
Object.keys(this.eventMap).forEach((type) => delete this.eventMap[type]);
Object.keys(this.methodMap).forEach((type) => delete this.methodMap[type]);
Object.keys(eventMap).forEach((type) => delete eventMap[type]);
Object.keys(methodMap).forEach((type) => delete methodMap[type]);
}
public destroy() {
@ -96,4 +93,4 @@ class Events extends BaseService {
export type EventsService = Events;
export default Events;
export default new Events();

View File

@ -16,6 +16,7 @@
* limitations under the License.
*/
import { reactive } from 'vue';
import type { Writable } from 'type-fest';
import type { Id, MPage, MPageFragment } from '@tmagic/core';
@ -36,6 +37,7 @@ import type {
StepValue,
SyncHookPlugin,
} from '@editor/type';
import { getEditorConfig } from '@editor/utils/config';
import {
deserializeStacks,
getOrCreateStack,
@ -44,11 +46,11 @@ import {
serializeStacks,
undoFloor,
} from '@editor/utils/history';
import { idbGet, idbSet } from '@editor/utils/indexed-db';
import { UndoRedo } from '@editor/utils/undo-redo';
import type { SdkContext } from '../type';
import BaseService from './BaseService';
import editorService from './editor';
const canUsePluginMethods = {
sync: ['push', 'undo', 'redo'] as const,
@ -78,57 +80,18 @@ const DEFAULT_KEY: IDBValidKey = 'default';
// 全部历史栈统一收敛到 steps 字段(见 HistorySteps)。
const PERSIST_VERSION = 3;
const EXTRA_MAP_KEYS = ['modifiedNodeIds', 'invalidNodeIdsBefore', 'invalidNodeIdsAfter'] as const;
/**
* 转换各栈 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;
public state = reactive<HistoryState>({
steps: {
page: {},
codeBlock: {},
dataSource: {},
},
stepNames: { ...DEFAULT_STEP_NAMES },
});
constructor(private ctx: SdkContext) {
constructor() {
super([...canUsePluginMethods.sync.map((methodName) => ({ name: methodName, isAsync: false }))]);
this.state = ctx.reactivity.reactive<HistoryState>({
steps: {
page: {},
codeBlock: {},
dataSource: {},
},
stepNames: { ...DEFAULT_STEP_NAMES },
});
}
/**
@ -185,12 +148,7 @@ class History extends BaseService {
public setMarker(
stepType: HistoryStepType,
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 {
if (!this.isValidStackId(id)) return null;
@ -427,46 +385,21 @@ class History extends BaseService {
public async saveToIndexedDB(options: HistoryPersistOptions = {}): Promise<PersistedHistoryState> {
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(可能含函数)序列化成字符串,其余字段原样保留,
// 因此整份快照可直接按对象写入 IndexedDB(结构化克隆),避免序列化整份快照的开销;读取时再用 parseDSL 还原 diff。
const steps: PersistedHistoryState['steps'] = { page: {}, codeBlock: {}, dataSource: {} };
Object.entries(this.state.steps).forEach(([stepType, bucket]) => {
steps[stepType] = serializeStacks(bucket);
// `extra` 里的校验错误记录是 Map,JSON 无法表示;jsonSafe 时转成 entries 数组,restore 时会还原
if (jsonSafe) steps[stepType] = convertExtraMaps(steps[stepType], false) as any;
});
return {
const snapshot: PersistedHistoryState = {
version: PERSIST_VERSION,
steps,
savedAt: Date.now(),
};
}
/**
* 用 {@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;
await idbSet(this.resolveDbName(dbName, appId), storeName, key, snapshot);
this.emit('save-to-indexed-db', snapshot);
return snapshot;
}
@ -481,14 +414,17 @@ class History extends BaseService {
public async restoreFromIndexedDB(options: HistoryPersistOptions = {}): Promise<PersistedHistoryState | null> {
const { dbName = DEFAULT_DB_NAME, storeName = DEFAULT_STORE_NAME, key = DEFAULT_KEY, appId } = options;
const snapshot = await this.getPersistence().get<PersistedHistoryState>(
this.resolveDbName(dbName, appId),
storeName,
key,
);
const snapshot = await idbGet<PersistedHistoryState>(this.resolveDbName(dbName, appId), storeName, key);
if (!snapshot) return null;
this.restore(snapshot);
// 各 step 的 diff 以序列化字符串存储(含函数),由 deserializeStacks 逐条用 parseDSL 还原。
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 维护。
this.emit('restore-from-indexed-db', snapshot);
return snapshot;
@ -647,25 +583,17 @@ 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 拼成最终库名,实现不同应用历史隔离。
* 取不到 app id(如尚未加载 DSL)时退回基础 dbName。
*/
private resolveDbName(dbName: string, appId?: Id): string {
// 优先用显式传入的 appId(「先恢复再 set root」时 root 尚未就绪);否则回退到当前 root.id。
const resolvedAppId = appId ?? this.ctx.editor.get('root')?.id;
const resolvedAppId = appId ?? editorService.get('root')?.id;
return resolvedAppId ? `${dbName}-${resolvedAppId}` : dbName;
}
}
export type HistoryService = History;
export default History;
export default new History();

View File

@ -2,10 +2,10 @@ import KeyController, { KeyControllerEvent } from 'keycon';
import { isPageOrFragment } from '@tmagic/utils';
import BaseService from '@editor/sdk/services/BaseService';
import { KeyBindingCacheItem, KeyBindingCommand, KeyBindingItem } from '@editor/type';
import { editorService } from './sdk-instance';
import BaseService from './BaseService';
import editorService from './editor';
import uiService from './ui';
class Keybinding extends BaseService {

View File

@ -16,6 +16,7 @@
* limitations under the License.
*/
import { reactive } from 'vue';
import { cloneDeep, mergeWith } from 'lodash-es';
import type { Writable } from 'type-fest';
@ -24,6 +25,7 @@ import { Target, Watcher } from '@tmagic/core';
import type { FormConfig } from '@tmagic/form';
import { getNodePath, getValueByKeyPath, guid, setValueByKeyPath, toLine } from '@tmagic/utils';
import editorService from '@editor/services/editor';
import type {
AsyncHookPlugin,
PropsFormConfigFunction,
@ -31,9 +33,7 @@ import type {
PropsState,
SyncHookPlugin,
} from '@editor/type';
import type { SdkContext } from '../type';
import { fillConfig } from '../utils/props';
import { fillConfig } from '@editor/utils/props';
import BaseService from './BaseService';
@ -53,22 +53,21 @@ type AsyncMethodName = Writable<(typeof canUsePluginMethods)['async']>;
type SyncMethodName = Writable<(typeof canUsePluginMethods)['sync']>;
class Props extends BaseService {
private state: PropsState;
private state = reactive<PropsState>({
propsConfigMap: {},
propsValueMap: {},
relateIdMap: {},
/** 禁用数据源 */
disabledDataSource: false,
/** 禁用代码块 */
disabledCodeBlock: false,
});
constructor(private ctx: SdkContext) {
constructor() {
super([
...canUsePluginMethods.async.map((methodName) => ({ name: methodName, isAsync: true })),
...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) {
@ -87,9 +86,10 @@ class Props extends BaseService {
return this.state.disabledCodeBlock;
}
/** 批量设置组件属性表单配置,返回的 Promise 在全部配置处理完(含异步的)后 resolve */
public async setPropsConfigs(configs: Record<string, FormConfig | PropsFormConfigFunction>) {
await Promise.all(Object.keys(configs).map((type: string) => this.setPropsConfig(toLine(type), configs[type])));
public setPropsConfigs(configs: Record<string, FormConfig | PropsFormConfigFunction>) {
Object.keys(configs).forEach((type: string) => {
this.setPropsConfig(toLine(type), configs[type]);
});
this.emit('props-configs-change');
}
@ -102,15 +102,13 @@ class Props extends BaseService {
labelWidth,
disabledDataSource: this.getDisabledDataSource(),
disabledCodeBlock: this.getDisabledCodeBlock(),
copyText: this.ctx.options.copyText,
notify: this.ctx.options.notify,
});
}
public async setPropsConfig(type: string, config: FormConfig | PropsFormConfigFunction) {
let c: FormConfig;
if (typeof config === 'function') {
c = config({ editorService: this.ctx.editor });
c = config({ editorService });
} else {
c = config;
}
@ -136,7 +134,9 @@ class Props extends BaseService {
}
public setPropsValues(values: Record<string, Partial<MNode> | PropsFormValueFunction>) {
return Promise.all(Object.keys(values).map((type: string) => this.setPropsValue(toLine(type), values[type])));
Object.keys(values).forEach((type: string) => {
this.setPropsValue(toLine(type), values[type]);
});
}
public getPropsValues(): Record<string, Partial<MNode>> {
@ -151,7 +151,7 @@ class Props extends BaseService {
public async setPropsValue(type: string, value: Partial<MNode> | PropsFormValueFunction) {
let v = value;
if (typeof value === 'function') {
v = value({ editorService: this.ctx.editor });
v = value({ editorService });
}
this.state.propsValueMap[toLine(type)] = v;
}
@ -206,7 +206,7 @@ class Props extends BaseService {
*/
/* eslint no-param-reassign: ["error", { "props": false }] */
public setNewItemId(config: MNode, force = true) {
if (force || this.ctx.editor.getNodeById(config.id)) {
if (force || editorService.getNodeById(config.id)) {
const newId = this.createId(config.type || 'component');
this.setRelateId(config.id, newId);
config.id = newId;
@ -325,4 +325,4 @@ class Props extends BaseService {
export type PropsService = Props;
export default Props;
export default new Props();

View File

@ -1,66 +0,0 @@
/*
* 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;

View File

@ -5,8 +5,8 @@ import StageCore from '@tmagic/stage';
import { getIdFromEl } from '@tmagic/utils';
import { useStage } from '@editor/hooks/use-stage';
import BaseService from '@editor/sdk/services/BaseService';
import { editorService } from '@editor/services/sdk-instance';
import BaseService from '@editor/services//BaseService';
import editorService from '@editor/services//editor';
import type { StageOptions, StageOverlayState, SyncHookPlugin } from '@editor/type';
const canUsePluginMethods = {

View File

@ -2,8 +2,7 @@ import serialize from 'serialize-javascript';
import type { Writable } from 'type-fest';
import type { SyncHookPlugin } from '@editor/type';
import type { SdkContext, StorageLike } from '../type';
import { getEditorConfig } from '@editor/utils/config';
import BaseService from './BaseService';
@ -27,48 +26,15 @@ const canUsePluginMethods = {
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 {
private storage: StorageLike;
private storage: Storage = globalThis.localStorage;
private namespace = 'tmagic';
constructor(
private ctx: SdkContext,
storage?: StorageLike,
) {
constructor() {
super(canUsePluginMethods.sync.map((methodName) => ({ name: methodName, isAsync: false })));
this.storage = storage ?? new MemoryStorage();
}
/**
@ -76,12 +42,12 @@ export class WebStorage extends BaseService {
* const storageService = new StorageService();
* storageService.usePlugin({
* // 替换存储对象为 sessionStorage
* async afterGetStorage(): Promise<StorageLike> {
* async afterGetStorage(): Promise<Storage> {
* return window.sessionStorage;
* },
* });
*/
public getStorage(): StorageLike {
public getStorage(): Storage {
return this.storage;
}
@ -110,7 +76,7 @@ export class WebStorage extends BaseService {
switch (protocol) {
case Protocol.OBJECT:
return this.ctx.parseDSL(`(${item})`);
return getEditorConfig('parseDSL')(`(${item})`);
case Protocol.JSON:
return JSON.parse(item);
case Protocol.NUMBER:
@ -187,4 +153,4 @@ export class WebStorage extends BaseService {
}
export type StorageService = WebStorage;
export default WebStorage;
export default new WebStorage();

View File

@ -21,8 +21,7 @@ import type { Writable } from 'type-fest';
import { convertToNumber } from '@tmagic/utils';
import BaseService from '@editor/sdk/services/BaseService';
import { editorService } from '@editor/services/sdk-instance';
import editorService from '@editor/services/editor';
import type { AsyncHookPlugin, StageRect, UiState } from '@editor/type';
import {
DEFAULT_LEFT_COLUMN_WIDTH,
@ -34,7 +33,8 @@ import {
RIGHT_COLUMN_WIDTH_STORAGE_KEY,
} from '@editor/utils/const';
import { Protocol, storageService } from './sdk-instance';
import BaseService from './BaseService';
import storageService, { Protocol } from './storage';
const state = shallowReactive<UiState>({
uiSelectMode: false,

View File

@ -120,12 +120,7 @@
}
}
.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-popover {
.m-editor-nav-menu-overflow-list {
display: flex;
flex-direction: column;
@ -144,30 +139,8 @@
}
&.divider {
display: flex;
align-items: center;
align-self: stretch;
width: auto;
height: auto;
padding: 4px 12px;
padding: 0;
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 {
background-color: transparent;

View File

@ -45,17 +45,17 @@ import type {
UpdateDragEl,
} from '@tmagic/stage';
import type { CodeBlockService } from './sdk/services/CodeBlock';
import type { ComponentListService } from './sdk/services/ComponentList';
import type { DataSourceService } from './sdk/services/DataSource';
import type { DepService } from './sdk/services/Dep';
import type { EditorService } from './sdk/services/Editor';
import type { EventsService } from './sdk/services/Events';
import type { HistoryService } from './sdk/services/History';
import type { PropsService } from './sdk/services/Props';
import type { StorageService } from './sdk/services/WebStorage';
import type { CodeBlockService } from './services/codeBlock';
import type { ComponentListService } from './services/componentList';
import type { DataSourceService } from './services/dataSource';
import type { DepService } from './services/dep';
import type { EditorService } from './services/editor';
import type { EventsService } from './services/events';
import type { HistoryService } from './services/history';
import type { KeybindingService } from './services/keybinding';
import type { PropsService } from './services/props';
import type { StageOverlayService } from './services/stageOverlay';
import type { StorageService } from './services/storage';
import type { UiService } from './services/ui';
import type { SerializedUndoRedo, UndoRedo } from './utils/undo-redo';
@ -221,8 +221,6 @@ export interface StageOptions {
*/
disabledFlashTip?: boolean;
zoom?: number;
/** 画布 iframe 的 sandbox 属性值,不传 / false 表示不设置,见 @tmagic/stage 的 StageRenderConfig.iframeSandbox */
iframeSandbox?: string | false;
/** 画布双击前的钩子函数,返回 false 则阻止默认的双击行为 */
beforeDblclick?: (event: MouseEvent) => Promise<boolean | void> | boolean | void;
}

View File

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

View File

@ -1,4 +1,4 @@
import { defineFormConfig } from '@tmagic/form/headless';
import { defineFormConfig } from '@tmagic/form';
export default defineFormConfig([
{

View File

@ -1,19 +1,16 @@
import serialize from 'serialize-javascript';
import type { MApp } from '@tmagic/core';
import type { Id, MApp } from '@tmagic/core';
import { error } from '../logger';
import type {
CollectWorkerPayload,
CollectWorkerRequest,
CollectWorkerResponse,
CollectWorkerResult,
DepsData,
} from './worker';
import type { CollectWorkerPayload, CollectWorkerRequest, CollectWorkerResponse, DepsData } from './worker';
import CollectWorker from './worker.ts?worker&inline';
export type { CollectWorkerResult };
export interface CollectWorkerResult {
deps: DepsData;
nodeIds: Id[];
}
interface CollectTask {
id: number;

View File

@ -19,11 +19,6 @@ import { error } from '../logger';
export type DepsData = Record<string, Record<Id, DepData>>;
export interface CollectWorkerResult {
deps: DepsData;
nodeIds: Id[];
}
/** 全量收集请求:worker 中根据 DSL 重建代码块/数据源 target */
export interface DslCollectRequest {
/** 请求 id,用于把结果与调用方对应起来 */

View File

@ -23,6 +23,7 @@ import serialize from 'serialize-javascript';
import type { Id, MApp, MContainer, MNode, MPage, MPageFragment, TargetOptions } from '@tmagic/core';
import { NODE_CONDS_KEY, NodeType, Target, Watcher } from '@tmagic/core';
import type StageCore from '@tmagic/stage';
import { isFixed } from '@tmagic/stage';
import {
calcValueByFontsize,
getElById,
@ -37,8 +38,6 @@ import {
import type { EditorNodeInfo, StepValue } from '@editor/type';
import { LayerOffset, Layout } from '@editor/type';
import { isFixed } from '../sdk/utils/position';
export const COPY_STORAGE_KEY = '$MagicEditorCopyData';
export const COPY_CODE_STORAGE_KEY = '$MagicEditorCopyCode';
export const COPY_DS_STORAGE_KEY = '$MagicEditorCopyDataSource';

View File

@ -20,16 +20,11 @@ import type { EventOption, Id, MComponent, MContainer, MNode } from '@tmagic/cor
import type { CascaderOption } from '@tmagic/form';
import { DATA_SOURCE_FIELDS_CHANGE_EVENT_PREFIX, traverseNode } from '@tmagic/utils';
import { dataSourceService, editorService, eventsService } from '@editor/services/sdk-instance';
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 { 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 type EventNameSelectOption = { text: string; value: string };
@ -55,21 +50,17 @@ export const normalizeCompActionValue = (value: unknown): string => {
export const getEventNameOptions = (
src: string | undefined,
formValue: Record<string, any> = {},
services: EventUtilServices = getDefaultServices(),
): EventNameOption[] => {
if (!formValue.type) {
return [];
}
if (src === 'component') {
const sourceNode = services.editorService.getNodeById(formValue.id) || (formValue as MNode);
let events: EventOption[] | CascaderOption[] =
services.eventsService.getEvent(formValue.type, { node: sourceNode }) || [];
const sourceNode = editorService.getNodeById(formValue.id) || (formValue as MNode);
let events: EventOption[] | CascaderOption[] = eventsService.getEvent(formValue.type, { node: sourceNode }) || [];
if (formValue.type === 'page-fragment-container' && formValue.pageFragmentId) {
const pageFragment = services.editorService
.get('root')
?.items?.find((page) => page.id === formValue.pageFragmentId);
const pageFragment = editorService.get('root')?.items?.find((page) => page.id === formValue.pageFragmentId);
if (!pageFragment) {
return [];
}
@ -88,8 +79,8 @@ export const getEventNameOptions = (
return;
}
const node = services.editorService.getNodeById(current.id) || current;
const nodeEvents = services.eventsService.getEvent(current.type, { node }) || [];
const node = editorService.getNodeById(current.id) || current;
const nodeEvents = eventsService.getEvent(current.type, { node }) || [];
(events as CascaderOption[]).push({
label: `${current.name}_${current.id}`,
value: `${current.id}`,
@ -108,8 +99,8 @@ export const getEventNameOptions = (
}
if (src === 'datasource') {
const events: EventNameOption[] = services.dataSourceService.getFormEvent(formValue.type) || [];
const dataSource = services.dataSourceService.getDataSourceById(formValue.id);
const events: EventNameOption[] = dataSourceService.getFormEvent(formValue.type) || [];
const dataSource = dataSourceService.getDataSourceById(formValue.id);
const fields = dataSource?.fields || [];
if (fields.length > 0) {
@ -166,7 +157,6 @@ export const collectEventNameOptionValues = (
export const getEventNameAllowedValues = (
config: { src?: string; eventNameConfig?: { options?: unknown } } = {},
formValue: Record<string, any> = {},
services: EventUtilServices = getDefaultServices(),
): Set<string> | null => {
if (typeof config.eventNameConfig?.options !== 'undefined') {
return null;
@ -176,33 +166,26 @@ export const getEventNameAllowedValues = (
return null;
}
return collectEventNameOptionValues(
getEventNameOptions(config.src, formValue, services),
isEventNameCheckStrictly(config.src),
);
return collectEventNameOptionValues(getEventNameOptions(config.src, formValue), isEventNameCheckStrictly(config.src));
};
/**
* 组装联动组件动作下拉/级联 options,与 EventSelect 默认 compActionConfig.options 逻辑一致。
*/
export const getCompActionOptions = (
toId?: Id,
services: EventUtilServices = getDefaultServices(),
): EventNameOption[] => {
export const getCompActionOptions = (toId?: Id): EventNameOption[] => {
if (typeof toId === 'undefined' || toId === null || toId === '') {
return [];
}
const node = services.editorService.getNodeById(toId);
const node = editorService.getNodeById(toId);
if (!node?.type) {
return [];
}
let methods: EventOption[] | CascaderOption[] =
services.eventsService.getMethod(node.type, { targetId: toId, node }) || [];
let methods: EventOption[] | CascaderOption[] = eventsService.getMethod(node.type, { targetId: toId, node }) || [];
if (node.type === 'page-fragment-container' && node.pageFragmentId) {
const pageFragment = services.editorService.get('root')?.items?.find((page) => page.id === node.pageFragmentId);
const pageFragment = editorService.get('root')?.items?.find((page) => page.id === node.pageFragmentId);
if (!pageFragment) {
return [];
}
@ -210,13 +193,13 @@ export const getCompActionOptions = (
methods = [];
(pageFragment.items || []).forEach((item: MComponent | MContainer) => {
traverseNode<MComponent | MContainer>(item, (current) => {
const node = services.editorService.getNodeById(current.id) || current;
const node = editorService.getNodeById(current.id) || current;
if (!current.type) {
return;
}
const nodeMethods = services.eventsService.getMethod(current.type, { targetId: current.id, node }) || [];
const nodeMethods = eventsService.getMethod(current.type, { targetId: current.id, node }) || [];
if (!nodeMethods.length) {
return;
}
@ -245,11 +228,10 @@ export const getCompActionOptions = (
export const getCompActionAllowedValues = (
config: { src?: string; compActionConfig?: { options?: unknown } } = {},
model: { to?: Id } = {},
services: EventUtilServices = getDefaultServices(),
): Set<string> | null => {
if (typeof config.compActionConfig?.options !== 'undefined') {
return null;
}
return collectEventNameOptionValues(getCompActionOptions(model.to, services), isEventNameCheckStrictly(config.src));
return collectEventNameOptionValues(getCompActionOptions(model.to), isEventNameCheckStrictly(config.src));
};

View File

@ -1,22 +1,14 @@
import { toRaw } from 'vue';
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 { editorService, propsService } from '@editor/services/sdk-instance';
import editorService from '@editor/services/editor';
import propsService from '@editor/services/props';
import type { AddMNode, PastePosition } from '@editor/type';
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,
});
import { generatePageNameByApp, getInitPositionStyle } from '@editor/utils/editor';
/**
* 粘贴前置操作:返回分配了新id以及校准了坐标的配置
@ -24,8 +16,55 @@ const getContext = () => ({
* @param config 待粘贴的元素配置(复制时保存的那份配置)
* @returns
*/
export const beforePaste = (position: PastePosition, config: MNode[], doc?: Document): MNode[] =>
sdkBeforePaste(getContext(), position, config, doc);
export const beforePaste = (position: PastePosition, config: MNode[], doc?: Document): MNode[] => {
if (!config[0]?.style) return config;
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;
};
/**
* 将元素粘贴到容器内时,将相对于画布坐标转换为相对于容器的坐标
@ -33,10 +72,37 @@ export const beforePaste = (position: PastePosition, config: MNode[], doc?: Docu
* @param id 元素id
* @returns PastePosition 转换后的坐标
*/
export const getPositionInContainer = (position: PastePosition = {}, id: Id, doc?: Document) =>
sdkGetPositionInContainer(getContext(), position, id, doc);
export const getPositionInContainer = (position: PastePosition = {}, id: Id, doc?: Document) => {
let { left = 0, top = 0 } = position;
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) => sdkGetAddParent(getContext(), node);
export const getAddParent = (node: MNode) => {
const curNode = editorService.get('node');
export const getDefaultConfig = (addNode: AddMNode, parentNode: MContainer) =>
sdkGetDefaultConfig(getContext(), addNode, parentNode);
let parentNode;
if (isPage(node)) {
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;
};

View File

@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/naming-convention */
/*
* Tencent is pleased to support the open source community by making TMagicEditor available.
*
@ -15,27 +16,341 @@
* 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 { tMagicMessage } from '@tmagic/design';
import type { FormConfig } from '@tmagic/form';
import type { ChildConfig, DisplayCondsConfig, FormConfig, TabConfig, TabPaneConfig } from '@tmagic/form';
import type { FillConfigOptions } from '@editor/sdk/utils/props';
import { fillConfig as sdkFillConfig } from '@editor/sdk/utils/props';
export const arrayOptions = [
{ text: '包含', value: 'include' },
{ text: '不包含', value: 'not_include' },
];
export * from '@editor/sdk/utils/props';
export const eqOptions = [
{ text: '等于', value: '=' },
{ text: '不等于', value: '!=' },
];
/** 浏览器端默认用 `navigator.clipboard` 复制,并用 `tMagicMessage` 提示 */
export const copyTextToClipboard = (text: string): Promise<void> => navigator.clipboard.writeText(text);
export const numberOptions = [
{ text: '大于', value: '>' },
{ text: '大于等于', value: '>=' },
{ text: '小于', value: '<' },
{ text: '小于等于', value: '<=' },
{ text: '在范围内', value: 'between' },
{ text: '不在范围内', value: 'not_between' },
];
export const notifyByDesign = (type: 'success' | 'error' | 'warning' | 'info', message: string) => {
const fn = (tMagicMessage as any)[type] ?? tMagicMessage;
fn(message);
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];
};
/** 与 SDK 的 `fillConfig` 一致,区别是默认接入浏览器的剪贴板与消息提示 */
export const fillConfig = (config: FormConfig = [], options: FillConfigOptions = {}): FormConfig =>
sdkFillConfig(config, {
copyText: copyTextToClipboard,
notify: notifyByDesign,
...options,
});
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 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];
};

View File

@ -35,7 +35,9 @@ import {
removeDataSourceFieldPrefix,
} from '@tmagic/utils';
import { codeBlockService, dataSourceService, editorService } from '@editor/services/sdk-instance';
import codeBlockService from '@editor/services/codeBlock';
import dataSourceService from '@editor/services/dataSource';
import editorService from '@editor/services/editor';
import { getFieldType, resolveFieldByPath } from '@editor/utils/data-source';
import {
arrayOptions,

View File

@ -25,55 +25,35 @@ vi.mock('@editor/initService', () => ({
initServiceState: initServiceStateMock,
}));
vi.mock('@editor/services/sdk-instance', async (importOriginal) => {
const actual = await importOriginal<any>();
const modCodeBlock = await (() => ({ 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,
};
});
vi.mock('@editor/services/codeBlock', () => ({ default: {} }));
vi.mock('@editor/services/componentList', () => ({ default: {} }));
vi.mock('@editor/services/dataSource', () => ({ default: {} }));
vi.mock('@editor/services/dep', () => ({ default: {} }));
const { stageStub } = vi.hoisted(() => ({ stageStub: { name: 'stage-instance' } }));
// 用响应式 ref 承载 stage,模拟真实 editorService 的 reactive state,
// 这样 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', () => ({
default: { register: vi.fn(), registerEl: vi.fn() },
}));
vi.mock('@editor/services/props', () => ({ default: {} }));
vi.mock('@editor/services/stageOverlay', () => ({
default: { set: vi.fn() },
}));
vi.mock('@editor/services/storage', () => ({ default: {}, Protocol: {} }));
vi.mock('@editor/services/ui', () => ({ default: {} }));
vi.mock('@editor/utils/keybinding-config', () => ({ default: {}, KeyBindingContainerKey: { STAGE: 'stage' } }));
@ -84,15 +64,6 @@ vi.mock('@editor/layouts/Framework.vue', () => ({
setup(_p, { slots }) {
return () =>
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.nav?.({ editorService: {} }),
slots.sidebar?.({ editorService: {} }),
@ -118,52 +89,25 @@ vi.mock('@editor/layouts/sidebar/Sidebar.vue', () => ({
default: defineComponent({
name: 'FakeSidebar',
emits: ['layer-node-dblclick'],
setup(_p, { emit, slots }) {
setup(_p, { emit }) {
return () =>
h(
'div',
{
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']?.(),
],
);
h('div', {
class: 'fake-sidebar',
onClick: () => emit('layer-node-dblclick', new MouseEvent('dblclick'), { id: 'a' }),
});
},
}),
}));
vi.mock('@editor/layouts/workspace/Workspace.vue', () => ({
default: defineComponent({
name: 'FakeWorkspace',
setup:
(_p, { slots }) =>
() =>
h('div', { class: 'fake-workspace' }, [
slots['stage-top']?.(),
slots.stage?.(),
slots['workspace-content']?.(),
]),
}),
default: defineComponent({ name: 'FakeWorkspace', setup: () => () => h('div', { class: 'fake-workspace' }) }),
}));
vi.mock('@editor/layouts/props-panel/PropsPanel.vue', () => ({
default: defineComponent({
name: 'PropsPanel',
emits: ['mounted', 'unmounted', 'submit-error', 'form-error'],
setup(_p, { emit, slots }) {
setup(_p, { emit }) {
injectedFormContext = inject(FORM_CONTEXT_KEY, undefined);
injectedEnablePropsFormValidate = inject(
ENABLE_PROPS_FORM_VALIDATE,
@ -171,7 +115,6 @@ vi.mock('@editor/layouts/props-panel/PropsPanel.vue', () => ({
);
return () =>
h('div', { class: 'fake-props-panel' }, [
slots['props-panel-header']?.(),
h('button', { class: 'mounted-btn', onClick: () => emit('mounted', { proxy: true }) }),
h('button', { class: 'unmounted-btn', onClick: () => emit('unmounted') }),
h('button', { class: 'submit-err', onClick: () => emit('submit-error', new Error('e')) }),
@ -298,16 +241,16 @@ describe('Editor', () => {
});
test('stage 走 computed 读时求值,切换画布后能读到新实例', async () => {
const editorServiceMod = (await import('@editor/services/sdk-instance')) as any;
const editorServiceMod = (await import('@editor/services/editor')) as any;
mount(Editor, { props: {} as any });
await nextTick();
expect((injectedFormContext!.value as any).stage).toBe(stageStub);
const nextStage = { name: 'next-stage' };
editorServiceMod.editorService.__setStage(nextStage);
editorServiceMod.default.__setStage(nextStage);
expect((injectedFormContext!.value as any).stage).toBe(nextStage);
editorServiceMod.editorService.__setStage(stageStub);
editorServiceMod.default.__setStage(stageStub);
});
test('ENABLE_PROPS_FORM_VALIDATE 默认注入 false', async () => {
@ -333,44 +276,4 @@ describe('Editor', () => {
expect((wrapper.vm as any).editorService).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);
});
});

View File

@ -10,7 +10,7 @@ import { mount } from '@vue/test-utils';
import CodeSelect from '@editor/fields/CodeSelect.vue';
// 表单配置由 fields/configs/codeSelect.ts 产出(组件与无渲染校验的嵌套配置共用),
// 那里通过 mForm.services 取服务,因此这里构造一个带 services 的 mForm
// 那里直接用服务单例,因此这里 mock 服务模块本身
const { dataSourceService, codeBlockService } = vi.hoisted(() => ({
dataSourceService: {
get: vi.fn(() => true),
@ -22,7 +22,8 @@ const { dataSourceService, codeBlockService } = vi.hoisted(() => ({
},
}));
const mForm = { services: { dataSourceService, codeBlockService } } as any;
vi.mock('@editor/services/dataSource', () => ({ default: dataSourceService }));
vi.mock('@editor/services/codeBlock', () => ({ default: codeBlockService }));
vi.mock('@tmagic/form', async (importOriginal) => {
const actual = await importOriginal<any>();
@ -72,7 +73,7 @@ describe('CodeSelect', () => {
const wrapper = mount(CodeSelect, { props: baseProps() as any });
const container = wrapper.findComponent({ name: 'MContainer' });
const config = container.props('config') as any;
const title = config.title(mForm, { model: { codeType: 'code', codeId: 'c1' }, index: 0 });
const title = config.title(undefined, { model: { codeType: 'code', codeId: 'c1' }, index: 0 });
expect(title).toBe('code-name');
});
@ -80,7 +81,7 @@ describe('CodeSelect', () => {
const wrapper = mount(CodeSelect, { props: baseProps() as any });
const container = wrapper.findComponent({ name: 'MContainer' });
const config = container.props('config') as any;
const title = config.title(mForm, {
const title = config.title(undefined, {
model: { codeType: 'data-source-method', codeId: ['ds1', 'doFetch'] },
index: 0,
});
@ -91,7 +92,7 @@ describe('CodeSelect', () => {
const wrapper = mount(CodeSelect, { props: baseProps() as any });
const container = wrapper.findComponent({ name: 'MContainer' });
const config = container.props('config') as any;
const title = config.title(mForm, {
const title = config.title(undefined, {
model: { codeType: 'data-source-method', codeId: ['ds1'] },
index: 5,
});
@ -103,7 +104,7 @@ describe('CodeSelect', () => {
const wrapper = mount(CodeSelect, { props: baseProps() as any });
const container = wrapper.findComponent({ name: 'MContainer' });
const config = container.props('config') as any;
const title = config.title(mForm, { model: { codeType: 'code', codeId: 'unknown' }, index: 0 });
const title = config.title(undefined, { model: { codeType: 'code', codeId: 'unknown' }, index: 0 });
expect(title).toBe('unknown');
});
@ -164,8 +165,8 @@ describe('CodeSelect', () => {
const wrapper = mount(CodeSelect, { props: baseProps() as any });
const container = wrapper.findComponent({ name: 'MContainer' });
const config = container.props('config') as any;
expect(config.items[1].notEditable(mForm)).toBe(true);
expect(config.items[2].notEditable(mForm)).toBe(true);
expect(config.items[1].notEditable()).toBe(true);
expect(config.items[2].notEditable()).toBe(true);
codeBlockService.getEditStatus.mockReturnValue(true);
dataSourceService.get.mockReturnValue(true);
});

View File

@ -15,7 +15,7 @@ const { dataSourceService } = vi.hoisted(() => ({
dataSourceService: { getDataSourceById: vi.fn() },
}));
const mForm = { services: { dataSourceService } } as any;
vi.mock('@editor/services/dataSource', () => ({ default: dataSourceService }));
const { fieldTypeMock } = vi.hoisted(() => ({
fieldTypeMock: vi.fn((_ds: any, names: string[]) => {
@ -111,7 +111,7 @@ describe('DisplayConds', () => {
});
const item = capturedConfig.items[0].items[0];
expect(item.type).toBe('cascader');
expect(item.options(mForm)).toEqual([{ label: 'f1', value: 'f1' }]);
expect(item.options()).toEqual([{ label: 'f1', value: 'f1' }]);
});
test('parentFields 为空时使用 data-source-field-select', () => {
@ -160,10 +160,10 @@ describe('DisplayConds', () => {
props: { config: { titlePrefix: 't', parentFields: ['ds1'] }, model: {}, name: 'conds' } as any,
});
const valueItem = capturedConfig.items[0].items[2];
expect(valueItem.type(mForm, { model: { field: ['numField'] } })).toBe('number');
expect(valueItem.type(mForm, { model: { field: ['boolField'] } })).toBe('select');
expect(valueItem.type(mForm, { model: { field: ['nullField'] } })).toBe('display');
expect(valueItem.type(mForm, { model: { field: ['anyField'] } })).toBe('text');
expect(valueItem.type(undefined, { model: { field: ['numField'] } })).toBe('number');
expect(valueItem.type(undefined, { model: { field: ['boolField'] } })).toBe('select');
expect(valueItem.type(undefined, { model: { field: ['nullField'] } })).toBe('display');
expect(valueItem.type(undefined, { model: { field: ['anyField'] } })).toBe('text');
});
test('value display 函数', () => {
@ -218,7 +218,7 @@ describe('DisplayConds', () => {
} as any,
});
const item = capturedConfig.items[0].items[0];
expect(item.options(mForm)).toEqual([]);
expect(item.options()).toEqual([]);
});
test('isCompare 但无 lastValues 时不进入对比', () => {

View File

@ -37,9 +37,11 @@ vi.mock('@editor/hooks/use-services', () => ({
useServices: () => ({ editorService, dataSourceService, eventsService, codeBlockService, propsService }),
}));
const mForm = {
services: { editorService, dataSourceService, eventsService, codeBlockService, propsService },
} as any;
vi.mock('@editor/services/editor', () => ({ default: editorService }));
vi.mock('@editor/services/dataSource', () => ({ default: dataSourceService }));
vi.mock('@editor/services/events', () => ({ default: eventsService }));
vi.mock('@editor/services/codeBlock', () => ({ default: codeBlockService }));
vi.mock('@editor/services/props', () => ({ default: propsService }));
vi.mock('@editor/utils/data-source', async () => {
const actual = await vi.importActual<any>('@editor/utils/data-source');
@ -186,8 +188,8 @@ describe('EventSelect', () => {
model: { events: [{ name: 'a', actions: [] }] },
});
const cfg = eventNameCfg();
expect(cfg.type(mForm, { formValue: { type: 'btn' } })).toBe('select');
const opts = cfg.options(mForm, { formValue: { type: 'btn' } });
expect(cfg.type(undefined, { formValue: { type: 'btn' } })).toBe('select');
const opts = cfg.options(undefined, { formValue: { type: 'btn' } });
expect(Array.isArray(opts)).toBe(true);
expect(opts[0]).toMatchObject({ text: 'click', value: 'click' });
});
@ -200,16 +202,16 @@ describe('EventSelect', () => {
const [rule] = cfg.rules;
const okCb = vi.fn();
rule.validator({ value: 'click', callback: okCb }, { formValue: { type: 'btn' } }, mForm);
rule.validator({ value: 'click', callback: okCb }, { formValue: { type: 'btn' } });
expect(okCb).toHaveBeenCalledWith();
const errCb = vi.fn();
rule.validator({ value: 'unknown', callback: errCb }, { formValue: { type: 'btn' } }, mForm);
rule.validator({ value: 'unknown', callback: errCb }, { formValue: { type: 'btn' } });
expect(errCb).toHaveBeenCalledWith('事件名(unknown)不存在');
// 空值不做枚举校验
const emptyCb = vi.fn();
rule.validator({ value: '', callback: emptyCb }, { formValue: { type: 'btn' } }, mForm);
rule.validator({ value: '', callback: emptyCb }, { formValue: { type: 'btn' } });
expect(emptyCb).toHaveBeenCalledWith();
});
@ -222,7 +224,7 @@ describe('EventSelect', () => {
const [rule] = cfg.rules;
const cb = vi.fn();
rule.validator({ value: 'whatever', callback: cb }, { formValue: { type: 'btn' } }, mForm);
rule.validator({ value: 'whatever', callback: cb }, { formValue: { type: 'btn' } });
expect(cb).toHaveBeenCalledWith();
});
@ -232,8 +234,10 @@ describe('EventSelect', () => {
model: { events: [{ name: 'a', actions: [] }] },
});
const cfg = eventNameCfg();
expect(cfg.type(mForm, { formValue: { type: 'page-fragment-container', pageFragmentId: 'pf1' } })).toBe('cascader');
const opts = cfg.options(mForm, { formValue: { type: 'page-fragment-container', pageFragmentId: 'pf1' } });
expect(cfg.type(undefined, { formValue: { type: 'page-fragment-container', pageFragmentId: 'pf1' } })).toBe(
'cascader',
);
const opts = cfg.options(undefined, { formValue: { type: 'page-fragment-container', pageFragmentId: 'pf1' } });
expect(Array.isArray(opts)).toBe(true);
});
@ -244,7 +248,7 @@ describe('EventSelect', () => {
model: { events: [{ name: 'a', actions: [] }] },
});
const cfg = eventNameCfg();
const opts = cfg.options(mForm, { formValue: { type: 'ds', id: 'd1' } });
const opts = cfg.options(undefined, { formValue: { type: 'ds', id: 'd1' } });
expect(opts).toEqual([{ label: '数据变化', value: 'ds_change_', children: [] }]);
});
@ -255,34 +259,10 @@ describe('EventSelect', () => {
model: { events: [{ name: 'a', actions: [] }] },
});
const cfg = eventNameCfg();
const opts = cfg.options(mForm, { formValue: { type: 'ds', id: 'd1' } });
const opts = cfg.options(undefined, { formValue: { type: 'ds', id: 'd1' } });
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 含 组件/代码/数据源', () => {
propsService.getDisabledCodeBlock.mockReturnValue(false);
propsService.getDisabledDataSource.mockReturnValue(false);
@ -290,7 +270,7 @@ describe('EventSelect', () => {
model: { events: [{ name: 'a', actions: [] }] },
});
const actionType = actionsCfg().items[0];
const opts = typeof actionType.options === 'function' ? actionType.options(mForm) : actionType.options;
const opts = typeof actionType.options === 'function' ? actionType.options() : actionType.options;
expect(opts.map((o: any) => o.value).sort()).toEqual(['code', 'comp', 'data-source'].sort());
});
@ -301,7 +281,7 @@ describe('EventSelect', () => {
model: { events: [{ name: 'a', actions: [] }] },
});
const actionType = actionsCfg().items[0];
const opts = typeof actionType.options === 'function' ? actionType.options(mForm) : actionType.options;
const opts = typeof actionType.options === 'function' ? actionType.options() : actionType.options;
expect(opts.map((o: any) => o.value)).toEqual(['comp']);
propsService.getDisabledCodeBlock.mockReturnValue(false);
propsService.getDisabledDataSource.mockReturnValue(false);
@ -324,8 +304,8 @@ describe('EventSelect', () => {
model: { events: [{ name: 'a', actions: [] }] },
});
const compAction = actionsCfg().items[2];
expect(compAction.type(mForm, { model: { to: '1' } })).toBe('select');
expect(Array.isArray(compAction.options(mForm, { model: { to: '1' } }))).toBe(true);
expect(compAction.type(undefined, { model: { to: '1' } })).toBe('select');
expect(Array.isArray(compAction.options(undefined, { model: { to: '1' } }))).toBe(true);
});
test('compActionConfig type cascader 当 page-fragment-container', () => {
@ -335,8 +315,8 @@ describe('EventSelect', () => {
model: { events: [{ name: 'a', actions: [] }] },
});
const compAction = actionsCfg().items[2];
expect(compAction.type(mForm, { model: { to: '1' } })).toBe('cascader');
const opts = compAction.options(mForm, { model: { to: '1' } });
expect(compAction.type(undefined, { model: { to: '1' } })).toBe('cascader');
const opts = compAction.options(undefined, { model: { to: '1' } });
expect(Array.isArray(opts)).toBe(true);
});
@ -346,7 +326,7 @@ describe('EventSelect', () => {
model: { events: [{ name: 'a', actions: [] }] },
});
const compAction = actionsCfg().items[2];
expect(compAction.options(mForm, { model: { to: 'unknown' } })).toEqual([]);
expect(compAction.options(undefined, { model: { to: 'unknown' } })).toEqual([]);
});
test('compActionConfig.rules 仅校验动作名是否在可选项中', () => {
@ -359,15 +339,15 @@ describe('EventSelect', () => {
const [rule] = compAction.rules;
const okCb = vi.fn();
rule.validator({ value: 'open', callback: okCb }, { model: { to: '1' } }, mForm);
rule.validator({ value: 'open', callback: okCb }, { model: { to: '1' } });
expect(okCb).toHaveBeenCalledWith();
const errCb = vi.fn();
rule.validator({ value: 'unknown', callback: errCb }, { model: { to: '1' } }, mForm);
rule.validator({ value: 'unknown', callback: errCb }, { model: { to: '1' } });
expect(errCb).toHaveBeenCalledWith('动作名(unknown)不存在');
const emptyCb = vi.fn();
rule.validator({ value: '', callback: emptyCb }, { model: { to: '1' } }, mForm);
rule.validator({ value: '', callback: emptyCb }, { model: { to: '1' } });
expect(emptyCb).toHaveBeenCalledWith();
});
@ -385,7 +365,7 @@ describe('EventSelect', () => {
const [rule] = compAction.rules;
const cb = vi.fn();
rule.validator({ value: 'whatever', callback: cb }, { model: { to: '1' } }, mForm);
rule.validator({ value: 'whatever', callback: cb }, { model: { to: '1' } });
expect(cb).toHaveBeenCalledWith();
});
@ -400,11 +380,11 @@ describe('EventSelect', () => {
const [rule] = compAction.rules;
const okCb = vi.fn();
rule.validator({ value: ['c1', 'open'], callback: okCb }, { model: { to: '1' } }, mForm);
rule.validator({ value: ['c1', 'open'], callback: okCb }, { model: { to: '1' } });
expect(okCb).toHaveBeenCalledWith();
const errCb = vi.fn();
rule.validator({ value: ['c1', 'missing'], callback: errCb }, { model: { to: '1' } }, mForm);
rule.validator({ value: ['c1', 'missing'], callback: errCb }, { model: { to: '1' } });
expect(errCb).toHaveBeenCalledWith('动作名(c1.missing)不存在');
});
@ -415,7 +395,7 @@ describe('EventSelect', () => {
});
const codeAction = actionsCfg().items[3];
expect(codeAction.display(undefined, { model: { actionType: 'code' } })).toBe(true);
expect(codeAction.notEditable(mForm)).toBe(true);
expect(codeAction.notEditable()).toBe(true);
codeBlockService.getEditStatus.mockReturnValue(true);
});
@ -426,7 +406,7 @@ describe('EventSelect', () => {
});
const dsAction = actionsCfg().items[4];
expect(dsAction.display(undefined, { model: { actionType: 'data-source' } })).toBe(true);
expect(dsAction.notEditable(mForm)).toBe(true);
expect(dsAction.notEditable()).toBe(true);
});
test('table 配置中 method options', () => {
@ -438,10 +418,10 @@ describe('EventSelect', () => {
});
const tableCfg = wrapper.findComponent({ name: 'MTable' }).props('config') as any;
const methodCol = tableCfg.items.find((it: any) => it.name === 'method');
const opts = methodCol.options(mForm, { model: { to: '1' } });
const opts = methodCol.options(undefined, { model: { to: '1' } });
expect(opts).toEqual([{ text: 'open', value: 'open' }]);
editorService.getNodeById.mockReturnValue(null);
expect(methodCol.options(mForm, { model: { to: '1' } })).toEqual([]);
expect(methodCol.options(undefined, { model: { to: '1' } })).toEqual([]);
});
describe('对比模式', () => {

View File

@ -6,7 +6,7 @@
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { useStage } from '@editor/hooks/use-stage';
import { editorService } from '@editor/services/sdk-instance';
import editorService from '@editor/services/editor';
import uiService from '@editor/services/ui';
const { stageInstance, StageCoreCtor, getIdFromElFn } = vi.hoisted(() => {
@ -56,29 +56,22 @@ const editorState: Record<string, any> = {
alwaysMultiSelect: false,
};
vi.mock('@editor/services/sdk-instance', async (importOriginal) => {
const actual = await importOriginal<any>();
const modEditor = await (() => ({
default: {
get: (k: string) => editorState[k],
set: vi.fn((k: string, v: any) => {
editorState[k] = v;
}),
select: vi.fn(),
multiSelect: vi.fn(),
highlight: vi.fn(),
moveToContainer: vi.fn(),
update: vi.fn(),
sort: vi.fn(),
remove: vi.fn(),
getNodeById: vi.fn((id: string) => ({ id })),
},
}))();
return {
...actual,
editorService: modEditor.default,
};
});
vi.mock('@editor/services/editor', () => ({
default: {
get: (k: string) => editorState[k],
set: vi.fn((k: string, v: any) => {
editorState[k] = v;
}),
select: vi.fn(),
multiSelect: vi.fn(),
highlight: vi.fn(),
moveToContainer: vi.fn(),
update: vi.fn(),
sort: vi.fn(),
remove: vi.fn(),
getNodeById: vi.fn((id: string) => ({ id })),
},
}));
const uiState: Record<string, any> = { zoom: 1, uiSelectMode: false };
vi.mock('@editor/services/ui', () => ({

View File

@ -35,7 +35,6 @@ const mkServices = () => {
const editorService: any = {
...mkSvc('editor'),
state: {} as any,
setConfig: vi.fn(),
set: vi.fn((k: string, v: any) => (editorService.state[k] = v)),
get: vi.fn((k: string) => editorService.state[k]),
select: vi.fn(),

View File

@ -76,14 +76,8 @@ vi.mock('@editor/layouts/page-bar/PageBar.vue', () => ({
default: defineComponent({
name: 'PageBar',
props: ['disabledPageFragment'],
setup(_p, { slots }) {
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: [] }),
]);
setup() {
return () => h('div', { class: 'fake-page-bar' });
},
}),
}));
@ -160,24 +154,4 @@ describe('Framework', () => {
expect(uiService.set).toHaveBeenCalledWith('columnWidth', { left: 100, right: 200, center: 600 });
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();
});
});

View File

@ -21,8 +21,7 @@ vi.mock('@editor/components/ToolButton.vue', () => ({
'button',
{
ref: rootEl,
class: ['tool-btn', (props.data as any)?.className, (props.data as any)?.type],
'data-direction': (props.data as any)?.direction || '',
class: ['tool-btn', (props.data as any)?.className],
},
(props.data as any)?.text || '',
);
@ -120,15 +119,6 @@ describe('NavMenuColumn', () => {
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 () => {
const wrapper = mount(NavMenuColumn as any, {
props: { columnKey: 'left', items: [{ type: 'button', text: 'A' }] },
@ -354,127 +344,6 @@ describe('NavMenuColumn', () => {
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', () => {
const wrapper = mount(NavMenuColumn as any, {
props: { columnKey: 'left', items: [{ type: 'button', text: 'A' }] },

View File

@ -7,7 +7,7 @@ import { afterEach, describe, expect, test, vi } from 'vitest';
import { defineComponent, h, nextTick } from 'vue';
import { mount } from '@vue/test-utils';
import { historyService } from '@editor/services/sdk-instance';
import historyService from '@editor/services/history';
import { createStackStep } from '@editor/utils/history';
// pushCodeBlock / pushDataSource 已合入统一的 push(stepType, step, id);用等价小工具沿用既有用例。

View File

@ -18,7 +18,7 @@ import {
opLabel,
} from '@editor/layouts/history-list/composables';
import { useHistoryList } from '@editor/layouts/history-list/useHistoryList';
import { historyService } from '@editor/services/sdk-instance';
import historyService from '@editor/services/history';
import type { HistoryGroup, HistoryStepEntry, StepValue } from '@editor/type';
import { createStackStep } from '@editor/utils/history';

View File

@ -21,9 +21,10 @@ import { mount } from '@vue/test-utils';
import { NodeType } from '@tmagic/core';
import Stage from '@editor/layouts/workspace/viewer/Stage.vue';
import editorService from '@editor/services/editor';
import keybindingService from '@editor/services/keybinding';
import { editorService, storageService } from '@editor/services/sdk-instance';
import stageOverlayService from '@editor/services/stageOverlay';
import storageService from '@editor/services/storage';
import uiService from '@editor/services/ui';
globalThis.ResizeObserver =

View File

@ -1,173 +0,0 @@
// @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([]);
});
});

View File

@ -1,40 +0,0 @@
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,
});

View File

@ -1,213 +0,0 @@
// @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();
});
});

View File

@ -1,163 +0,0 @@
// @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);
});
});

View File

@ -1,463 +0,0 @@
// @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);
});
});

View File

@ -1,173 +0,0 @@
// @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);
});
});

View File

@ -1,305 +0,0 @@
// @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();
});
});

View File

@ -1,206 +0,0 @@
// @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);
});
});

View File

@ -5,7 +5,7 @@
*/
import { describe, expect, test, vi } from 'vitest';
import BaseService from '@editor/sdk/services/BaseService';
import BaseService from '@editor/services/BaseService';
class SyncService extends BaseService {
public count = 0;

View File

@ -5,23 +5,18 @@
*/
import { afterEach, beforeAll, beforeEach, describe, expect, test, vi } from 'vitest';
import { codeBlockService, historyService, Protocol, storageService } from '@editor/services/sdk-instance';
import codeBlockService from '@editor/services/codeBlock';
import historyService from '@editor/services/history';
import storageService, { Protocol } from '@editor/services/storage';
import { CODE_DRAFT_STORAGE_KEY } from '@editor/type';
import { setEditorConfig } from '@editor/utils/config';
import { COPY_CODE_STORAGE_KEY } from '@editor/utils/editor';
vi.mock('@editor/services/sdk-instance', async (importOriginal) => {
const actual = await importOriginal<any>();
const modEditor = await (() => ({
default: {
getNodeById: vi.fn((id: string) => ({ id, code: 'code1' })),
},
}))();
return {
...actual,
editorService: modEditor.default,
};
});
vi.mock('@editor/services/editor', () => ({
default: {
getNodeById: vi.fn((id: string) => ({ id, code: 'code1' })),
},
}));
beforeAll(() => {
setEditorConfig({

View File

@ -5,7 +5,7 @@
*/
import { afterEach, describe, expect, test, vi } from 'vitest';
import { componentListService as componentList } from '@editor/services/sdk-instance';
import componentList from '@editor/services/componentList';
afterEach(() => {
componentList.resetState();

View File

@ -5,27 +5,17 @@
*/
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import {
dataSourceService as dataSource,
historyService,
Protocol,
storageService,
} from '@editor/services/sdk-instance';
import dataSource from '@editor/services/dataSource';
import historyService from '@editor/services/history';
import storageService, { Protocol } from '@editor/services/storage';
import { setEditorConfig } from '@editor/utils/config';
import { COPY_DS_STORAGE_KEY } from '@editor/utils/editor';
vi.mock('@editor/services/sdk-instance', async (importOriginal) => {
const actual = await importOriginal<any>();
const modEditor = await (() => ({
default: {
getNodeById: vi.fn((id: string) => ({ id, dsBinding: ['ds1'] })),
},
}))();
return {
...actual,
editorService: modEditor.default,
};
});
vi.mock('@editor/services/editor', () => ({
default: {
getNodeById: vi.fn((id: string) => ({ id, dsBinding: ['ds1'] })),
},
}));
setEditorConfig({
// eslint-disable-next-line no-new-func

View File

@ -8,7 +8,7 @@ import { reactive } from 'vue';
import { createDataSourceTarget, DepTargetType, Target } from '@tmagic/core';
import { depService } from '@editor/services/sdk-instance';
import depService from '@editor/services/dep';
// 全量收集({ id, dsl })与增量收集({ id, payload })共用一个常驻 worker
vi.mock('@editor/utils/dep/worker.ts?worker&inline', () => ({
@ -227,49 +227,6 @@ describe('Dep service', () => {
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 与普通节点两条路径', () => {
const target = makeTarget('node-target');
depService.addTarget(target);

View File

@ -4,7 +4,8 @@ import { cloneDeep } from 'lodash-es';
import type { MApp, MContainer } from '@tmagic/core';
import { NodeType } from '@tmagic/core';
import { editorService, historyService } from '@editor/services/sdk-instance';
import editorService from '@editor/services/editor';
import historyService from '@editor/services/history';
import { setEditorConfig } from '@editor/utils';
setEditorConfig({

View File

@ -23,7 +23,9 @@ import type { MApp, MContainer, MNode } from '@tmagic/core';
import { NodeType } from '@tmagic/core';
import { getNodePath } from '@tmagic/utils';
import { editorService, historyService, storageService } from '@editor/services/sdk-instance';
import editorService from '@editor/services/editor';
import historyService from '@editor/services/history';
import storageService from '@editor/services/storage';
import type { EditorChangeEvent } from '@editor/type';
import { COPY_STORAGE_KEY, setEditorConfig } from '@editor/utils';

View File

@ -19,7 +19,7 @@
import { beforeEach, describe, expect, test, vi } from 'vitest';
import { computed } from 'vue';
import { eventsService as events } from '@editor/services/sdk-instance';
import events from '@editor/services/events';
describe('events', () => {
beforeEach(() => {

View File

@ -17,7 +17,7 @@
*/
import { afterEach, beforeAll, beforeEach, describe, expect, test, vi } from 'vitest';
import { historyService as history } from '@editor/services/sdk-instance';
import history from '@editor/services/history';
import { setEditorConfig } from '@editor/utils/config';
import { createStackStep } from '@editor/utils/history';
import * as indexedDb from '@editor/utils/indexed-db';

View File

@ -5,7 +5,7 @@
*/
import { afterEach, describe, expect, test, vi } from 'vitest';
import { historyService as history } from '@editor/services/sdk-instance';
import history from '@editor/services/history';
import { createStackStep } from '@editor/utils/history';
// pushCodeBlock / pushDataSource 已合入统一的 push(stepType, step, id)。

View File

@ -25,14 +25,7 @@ const uiService = vi.hoisted(() => ({
calcZoom: vi.fn(async () => 1.2),
}));
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/editor', () => ({ default: editorService }));
vi.mock('@editor/services/ui', () => ({ default: uiService }));
afterEach(() => {

View File

@ -2,7 +2,7 @@ import { describe, expect, test } from 'vitest';
import { NodeType } from '@tmagic/core';
import { propsService as props } from '@editor/services/sdk-instance';
import props from '@editor/services/props';
test('createId', async () => {
const id = await props.createId('text');

View File

@ -28,33 +28,26 @@ vi.mock('@editor/hooks/use-stage', () => ({
}));
const editorEvents: Record<string, ((..._args: any[]) => any)[]> = {};
vi.mock('@editor/services/sdk-instance', async (importOriginal) => {
const actual = await importOriginal<any>();
const modEditor = await (() => ({
default: {
on: vi.fn((evt: string, fn: any) => {
editorEvents[evt] ||= [];
editorEvents[evt].push(fn);
}),
off: vi.fn(),
get: vi.fn((k: string) => {
if (k === 'nodes') return [{ id: 'n1' }];
if (k === 'stage') {
return {
renderer: {
getDocument: () => ({ documentElement: document.createElement('html') }),
},
};
}
return null;
}),
},
}))();
return {
...actual,
editorService: modEditor.default,
};
});
vi.mock('@editor/services/editor', () => ({
default: {
on: vi.fn((evt: string, fn: any) => {
editorEvents[evt] ||= [];
editorEvents[evt].push(fn);
}),
off: vi.fn(),
get: vi.fn((k: string) => {
if (k === 'nodes') return [{ id: 'n1' }];
if (k === 'stage') {
return {
renderer: {
getDocument: () => ({ documentElement: document.createElement('html') }),
},
};
}
return null;
}),
},
}));
vi.mock('@tmagic/utils', async () => {
const actual = await vi.importActual<any>('@tmagic/utils');
@ -199,8 +192,8 @@ describe('stageOverlay', () => {
});
test('updateSelectStatus 多节点调用 multiSelect', async () => {
const editorMod = (await import('@editor/services/sdk-instance')) as any;
editorMod.editorService.get.mockImplementation((k: string) => {
const editorMod = (await import('@editor/services/editor')) as any;
editorMod.default.get.mockImplementation((k: string) => {
if (k === 'nodes') return [{ id: 'n1' }, { id: 'n2' }];
return null;
});

View File

@ -5,7 +5,7 @@
*/
import { afterEach, beforeEach, describe, expect, test } from 'vitest';
import { Protocol, storageService as storage } from '@editor/services/sdk-instance';
import storage, { Protocol } from '@editor/services/storage';
import { setEditorConfig } from '@editor/utils/config';
describe('storage service', () => {

View File

@ -29,14 +29,9 @@ const { editorService, dataSourceService, eventsService } = vi.hoisted(() => ({
},
}));
const services = { editorService, dataSourceService, eventsService } as any;
// 不传 services 时使用默认实例(保持历史签名可用)
vi.mock('@editor/services/sdk-instance', async (importOriginal) => {
const actual = await importOriginal<any>();
return { ...actual, editorService, dataSourceService, eventsService };
});
vi.mock('@editor/services/editor', () => ({ default: editorService }));
vi.mock('@editor/services/dataSource', () => ({ default: dataSourceService }));
vi.mock('@editor/services/events', () => ({ default: eventsService }));
vi.mock('@editor/utils/data-source', async () => {
const actual = await vi.importActual<any>('@editor/utils/data-source');
return { ...actual, getCascaderOptionsFromFields: vi.fn(() => [{ label: 'f1', value: 'f1' }]) };
@ -59,14 +54,14 @@ describe('event utils', () => {
test('getCompActionOptions 普通组件返回 select options', () => {
const node = { type: 'btn', id: '1' };
editorService.getNodeById.mockReturnValue(node);
expect(getCompActionOptions('1', services)).toEqual([{ text: 'open', value: 'open' }]);
expect(getCompActionOptions('1')).toEqual([{ text: 'open', value: 'open' }]);
expect(eventsService.getMethod).toHaveBeenCalledWith('btn', { targetId: '1', node });
});
test('getCompActionOptions 无节点返回空', () => {
editorService.getNodeById.mockReturnValue(null);
expect(getCompActionOptions('missing', services)).toEqual([]);
expect(getCompActionOptions(undefined, services)).toEqual([]);
expect(getCompActionOptions('missing')).toEqual([]);
expect(getCompActionOptions(undefined)).toEqual([]);
});
test('getCompActionOptions 页面片容器返回 cascader options', () => {
@ -74,7 +69,7 @@ describe('event utils', () => {
const child = { id: 'c1', type: 'btn', name: 'b' };
editorService.getNodeById.mockImplementation((id: string) => (id === '1' ? container : null));
editorService.get.mockReturnValue({ items: [{ id: 'pf1', items: [child] }] });
expect(getCompActionOptions('1', services)).toEqual([
expect(getCompActionOptions('1')).toEqual([
{
label: 'b_c1',
value: 'c1',
@ -86,25 +81,25 @@ describe('event utils', () => {
test('getCompActionAllowedValues 枚举合法动作', () => {
editorService.getNodeById.mockReturnValue({ type: 'btn', id: '1' });
expect(getCompActionAllowedValues({ src: 'component' }, { to: '1' }, services)).toEqual(new Set(['open']));
expect(getCompActionAllowedValues({ src: 'component' }, { to: '1' })).toEqual(new Set(['open']));
});
test('getCompActionAllowedValues 自定义 options 返回 null', () => {
expect(
getCompActionAllowedValues({ src: 'component', compActionConfig: { options: () => [] } }, { to: '1' }, services),
getCompActionAllowedValues({ src: 'component', compActionConfig: { options: () => [] } }, { to: '1' }),
).toBeNull();
});
test('getCompActionAllowedValues 页面片路径用 . 拼接', () => {
editorService.getNodeById.mockReturnValue({ type: 'page-fragment-container', id: '1', pageFragmentId: 'pf1' });
editorService.get.mockReturnValue({ items: [{ id: 'pf1', items: [{ id: 'c1', type: 'btn', name: 'b' }] }] });
expect(getCompActionAllowedValues({ src: 'component' }, { to: '1' }, services)).toEqual(new Set(['c1.open']));
expect(getCompActionAllowedValues({ src: 'component' }, { to: '1' })).toEqual(new Set(['c1.open']));
});
test('getEventNameAllowedValues / collectEventNameOptionValues', () => {
expect(getEventNameAllowedValues({ src: 'component' }, { type: 'btn' }, services)).toEqual(new Set(['click']));
expect(getEventNameAllowedValues({ src: 'component' }, { type: 'btn' })).toEqual(new Set(['click']));
expect(
getEventNameAllowedValues({ src: 'component', eventNameConfig: { options: [] } }, { type: 'btn' }, services),
getEventNameAllowedValues({ src: 'component', eventNameConfig: { options: [] } }, { type: 'btn' }),
).toBeNull();
expect(collectEventNameOptionValues([{ value: 'a', children: [{ value: 'b' }] }], false)).toEqual(new Set(['a.b']));
});
@ -117,7 +112,7 @@ describe('event utils', () => {
items: [{ id: 'pf1', name: '页面片', items: [child] }],
});
expect(getEventNameOptions('component', formValue, services)).toEqual([
expect(getEventNameOptions('component', formValue)).toEqual([
{
label: '页面片',
value: 'pf1',
@ -134,18 +129,7 @@ describe('event utils', () => {
test('getEventNameOptions datasource 追加数据变化', () => {
dataSourceService.getDataSourceById.mockReturnValue({ fields: [{ name: 'f1' }] });
const opts = getEventNameOptions('datasource', { type: 'ds', id: 'd1' }, services);
const opts = getEventNameOptions('datasource', { type: 'ds', id: 'd1' });
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());
});
});

View File

@ -7,7 +7,8 @@ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { NodeType } from '@tmagic/core';
import { editorService, propsService } from '@editor/services/sdk-instance';
import editorService from '@editor/services/editor';
import propsService from '@editor/services/props';
import { beforePaste, getAddParent, getDefaultConfig, getPositionInContainer } from '@editor/utils/operator';
const { editorState } = vi.hoisted(() => {
@ -15,27 +16,20 @@ const { editorState } = vi.hoisted(() => {
return { editorState: state };
});
vi.mock('@editor/services/sdk-instance', async (importOriginal) => {
const actual = await importOriginal<any>();
const modEditor = await (() => ({
default: {
get: (k: string) => editorState[k],
getParentById: vi.fn((id: string) => ({ id: `parent-of-${id}`, items: [] })),
getLayout: vi.fn(async () => 'absolute'),
},
}))();
const modProps = await (() => ({
default: {
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('@editor/services/editor', () => ({
default: {
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: {
setNewItemId: vi.fn((node: any) => ({ ...node, id: `new-${node.id}` })),
getPropsValue: vi.fn(async (type: string, cfg: any) => ({ type, ...cfg })),
},
}));
vi.mock('@tmagic/utils', async () => {
const actual = (await vi.importActual('@tmagic/utils')) as Record<string, unknown>;

View File

@ -33,44 +33,37 @@ const componentEventsState = vi.hoisted((): { value: Record<string, { label: str
const rootState = vi.hoisted(() => ({ value: null as any }));
const nodesState = vi.hoisted((): { value: Record<string, any> } => ({ value: {} }));
vi.mock('@editor/services/sdk-instance', async (importOriginal) => {
const actual = await importOriginal<any>();
const modCodeBlock = await (() => ({
default: {
getCodeDsl: () => codeDslState.value,
vi.mock('@editor/services/codeBlock', () => ({
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;
},
}))();
const modDataSource = await (() => ({
default: {
get: (key: string) => {
if (key === 'dataSources') return dataSourcesState.value;
if (key === 'events') return {};
return undefined;
},
getFormMethod: () => formMethodsState.value,
getFormEvent: () => formEventsState.value,
getDataSourceById: (id: string) => dataSourcesState.value.find((ds) => `${ds.id}` === `${id}`),
},
}))();
const modEditor = await (() => ({
default: {
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,
};
});
getFormMethod: () => formMethodsState.value,
getFormEvent: () => formEventsState.value,
getDataSourceById: (id: string) => dataSourcesState.value.find((ds) => `${ds.id}` === `${id}`),
},
}));
vi.mock('@editor/services/editor', () => ({
default: {
get: (key: string) => (key === 'root' ? rootState.value : undefined),
getNodeById: (id: string | number) => nodesState.value[`${id}`] || null,
},
}));
vi.mock('@editor/services/events', () => ({
default: {
getEvent: (type: string) => componentEventsState.value[type] || [],
},
}));
const ctx = (config: Record<string, any> = {}, model: Record<string, any> = {}, message?: string) => ({
fieldType: config.type || '',

View File

@ -1,5 +1,5 @@
{
"version": "1.8.3",
"version": "1.8.2",
"name": "@tmagic/element-plus-adapter",
"type": "module",
"sideEffects": false,

View File

@ -1,5 +1,5 @@
{
"version": "1.8.3",
"version": "1.8.2",
"name": "@tmagic/form-schema",
"type": "module",
"sideEffects": false,

View File

@ -832,47 +832,6 @@ export interface 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
/** group-list 卡片标题;仅 sticky 开启时吸顶 */
export interface GroupListHeaderConfig {
@ -903,18 +862,6 @@ export interface TableGroupListCommonConfig extends FormItem {
/** 新增后滚动到最后一项(group-list 形态,默认关闭) */
scrollLastItemIntoView?: boolean;
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 形态忽略 */
header?: GroupListHeaderConfig;
}
@ -951,11 +898,6 @@ export interface TableConfig<T = never> extends TableGroupListCommonConfig {
showIndex?: boolean;
/** 操作栏宽度 */
operateColWidth?: number | string;
/**
* 操作列。`text` 是列标题,默认「操作」。
* `props` 透传给列(如 width、align、fixed、minWidth),可覆盖 operateColWidth / fixed。
*/
operateColConfig?: ActionButtonConfig;
pagination?: boolean;
/** 表格标题前缀 ,新增xxx,暂无xxx数据,表明表格内容是什么 */
titlePrefix?: string;
@ -977,8 +919,6 @@ export interface TableConfig<T = never> extends TableGroupListCommonConfig {
dropActionButtonIcon?: any;
copyActionButtonIcon?: any;
deleteActionButtonIcon?: any;
/** 拖动排序按钮,text 作为提示,默认「拖动排序」 */
dropSortButtonConfig?: ActionButtonConfig;
/** 是否显示全屏按钮 */
enableFullscreen?: boolean;
fixed?: boolean | 'left' | 'right';

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