diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 61c23c5a..ffa6f4cd 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -228,6 +228,10 @@ export default defineConfig({ text: 'storageService', link: '/api/editor/storageServiceMethods.md', }, + { + text: 'DSL SDK(Node)', + link: '/api/editor/sdk.md', + }, ] }, { diff --git a/docs/api/editor/sdk.md b/docs/api/editor/sdk.md new file mode 100644 index 00000000..586af4be --- /dev/null +++ b/docs/api/editor/sdk.md @@ -0,0 +1,154 @@ +# DSL SDK(`@tmagic/editor/sdk`) + +不依赖 DOM / Vue 界面 / 画布的 DSL 操作 SDK,只负责对 DSL 做增删改查、历史撤销重做、依赖收集与合法性校验,可以直接在 Node 中运行(比如给 AI agent 调用的命令行工具使用)。 + +浏览器端的 `editorService`、`historyService` 等单例就是用同一个工厂创建的默认实例,因此 `import { editorService } from '@tmagic/editor'` 的用法不受影响。 + +```ts +import { createDslSdk } from '@tmagic/editor/sdk'; +``` + +## 快速开始 + +```ts +import { createDslSdk } from '@tmagic/editor/sdk'; + +const sdk = createDslSdk({ + // 初始 DSL,会被深拷贝,不会修改传入的对象 + dsl: { type: 'app', id: 'app_1', items: [{ type: 'page', id: 'page_1', name: 'index', items: [] }] }, + // 此前通过 sdk.serializeHistory() 导出的历史快照(可选) + // history, + // 组件列表:用来判断新增的组件类型是否合法 + componentGroupList: [{ title: '基础', items: [{ text: '文本', type: 'text' }] }], + // 组件属性表单配置,key 是组件 type,值是该组件的表单配置(FormConfig,即表单项数组):用来校验属性是否合法 + propsConfigs: { + text: [{ type: 'text', name: 'text', text: '文本', rules: [{ required: true, message: '文本不能为空' }] }], + }, + // 组件被添加时的初始值,与 propsConfigs 的 key 一一对应 + propsValues: { text: { text: '文本' } }, +}); + +await sdk.ready; // 登记表(含异步的属性配置)全部就绪 + +// 没有选中节点时,默认添加到第一个页面;id 由 SDK 生成,后续操作使用返回值里的 id +const node = await sdk.editor.add({ type: 'text', text: 'hello' }); +await sdk.editor.update({ id: node.id, text: 'world' }); +await sdk.editor.undo(); + +// 属性不合法时(strict 模式)会抛出 DslValidationError,且不会修改 DSL +await sdk.editor.update({ id: node.id, text: '' }).catch((e) => console.log(e.errors)); // [{ ..., message: '文本 -> 文本不能为空' }] + +const newDsl = sdk.getDsl(); // 深拷贝,已包含依赖收集结果 +const newHistory = sdk.serializeHistory(); // 可 JSON.stringify,下次作为 history 传回 +``` + +::: tip +`propsConfigs` 的格式与 [Editor 的 propsConfigs](./props.md#propsconfigs) 相同。实际项目中通常直接使用 runtime 构建出来的 `magicPresetConfigs` / `magicPresetValues`(参考[快速开始](../../guide/index.md#propsconfigs-propsvalues)),在 Node 中加载构建产物后传入即可。 +::: + +## 无状态使用 + +每次调用传入「DSL + 历史快照」,操作完成后导出新的「DSL + 历史快照」: + +```ts +const sdk = createDslSdk({ dsl: readJson('dsl.json'), history: readJson('history.json') }); +await sdk.editor.update({ id: 'button_1', text: 'new' }); +writeJson('dsl.json', sdk.getDsl()); +writeJson('history.json', sdk.serializeHistory()); +``` + +::: tip +- `serializeHistory()` 导出的快照可以直接 `JSON.stringify`;浏览器端 `historyService.serialize()` 默认保留 `Map`,适合写入 IndexedDB。 +- Node 下默认的 `parseDSL` 只解析 JSON,不会执行代码;如果历史里包含函数 / 正则,请自行传入 `parseDSL`。 +::: + +## createDslSdk(options) + +每次调用返回一组相互连线、互相独立的 service,多个实例互不影响。 + +### options + +| 参数 | 说明 | 默认值 | +| --- | --- | --- | +| `dsl` | 初始 DSL | - | +| `history` | 历史快照 | - | +| `validateMode` | 校验模式,见[校验](#校验) | `'strict'` | +| `autoWiring` | 是否自动绑定依赖收集连线 | `true` | +| `reactivity` | 响应式实现:`reactive` / `shallowReactive` / `toRaw` / `nextTick`,默认使用 Vue 的实现,可覆盖 | Vue | +| `storage` | 存储适配器(`localStorage` 天然满足),用于复制粘贴、代码块草稿 | 内存实现 `MemoryStorage` | +| `parseDSL` | 把序列化字符串还原成值 | 只解析 JSON | +| `historyPersistence` | 历史持久化适配器 `{ get, set }`(浏览器端对应 IndexedDB),不提供时 `saveToIndexedDB` / `restoreFromIndexedDB` 会 reject | - | +| `collectWorker` | 依赖收集 worker 适配器,不提供则在主线程收集 | - | +| `copyText` / `notify` | 复制到剪贴板、提示信息,不提供则不做任何事 | - | +| `componentGroupList` / `propsConfigs` / `propsValues` / `eventMethodList` / `datasourceList` / `datasourceConfigs` / `datasourceValues` / `datasourceEventMethodList` / `disabledCodeBlock` / `disabledDataSource` / `disabledPageFragment` / `disabledMultiSelect` / `alwaysMultiSelect` / `canDropIn` | 登记表,与 [Editor props](./props.md) 同名项一一对应 | - | + +### 返回值 + +| 属性 | 说明 | +| --- | --- | +| `editor` / `history` / `props` / `dataSource` / `codeBlock` / `dep` / `storage` / `events` / `componentList` | 各 service 实例,方法与浏览器端同名 service 一致 | +| `validate` | 校验器,见[校验](#校验) | +| `ctx` | service 之间共享的上下文 | +| `ready` | 登记表全部就绪后 resolve | +| `getDsl()` | 导出当前 DSL(深拷贝) | +| `setDsl(dsl)` | 整体替换 DSL,会写入历史记录,返回的 Promise 在自动选中节点完成后 resolve | +| `serializeHistory()` | 导出可 JSON 序列化的历史快照 | +| `bindWiring()` | 绑定依赖收集连线(`autoWiring: false` 时手动调用),返回解绑函数。绑定时如果 DSL 已经设置,会立即补做一次依赖收集 | +| `destroy()` | 销毁 | + +## 校验 + +`validateMode` 决定修改 DSL 时如何处理不合法的内容: + +- `strict`:修改前校验,不通过抛出 `DslValidationError`(`errors` 里是全部问题),**DSL 不会被修改**,适合给 agent 调用,agent 可以根据 `errors` 修正后重试。Node 下默认。 +- `record`:不抛错,把校验结果写进 `editor.state.invalidNodeIds`。 +- `off`:不做额外校验。浏览器端默认实例使用这个模式(属性面板自己负责校验)。 + +可以通过 `sdk.ctx.validateMode = 'off'` 在运行时切换。 + +校验内容: + +| 类别 | 内容 | +| --- | --- | +| 结构(`kind: 'structure'`) | 组件类型是否在组件列表中(`page` / `page-fragment` / `container` 内置合法,组件列表为空时不校验)、父节点是否是容器、`canDropIn`、是否禁用页面片、id 是否重复、不允许修改节点类型、数据源类型是否可用 | +| 属性(`kind: 'props'`) | 用 `@tmagic/form/headless` 的 `validateForm` 校验节点 / 数据源的属性表单配置,包含 `typeMatch` | + +```ts +import { DslValidationError } from '@tmagic/editor/sdk'; + +try { + await sdk.editor.add({ type: 'unknown' }); +} catch (e) { + if (e instanceof DslValidationError) { + console.log(e.errors); // [{ target, id, path, kind, message, suggestion }] + } +} + +// 主动校验 +await sdk.validate.node('text_1'); +await sdk.validate.dataSource('ds_1'); +await sdk.validate.dsl(); +``` + +::: warning +- 数据源的 `add` / `update` 是同步方法,只做同步的结构校验(数据源类型是否可用);属性校验请用 `sdk.validate.dataSource(id)`。 +- SDK 内只注册了 `@tmagic/form/headless` 的内置字段,编辑器自定义字段(`data-source-select`、`code-select` 等)的 `typeMatch` 依赖编辑器的单例 service,不在 SDK 校验范围内;需要时可在浏览器环境下通过 `@tmagic/editor/headless` 的 `editorFields` 注册。 +::: + +## 依赖收集 + +`autoWiring` 为 `true` 时,节点、数据源、代码块发生变化会同步收集依赖,并把结果写回 DSL 的 `dataSourceDeps` / `dataSourceCondDeps` / `dataSourceMethodDeps`,所以操作返回时导出的 DSL 依赖信息已经是最新的。 + +不需要依赖收集(只做 DSL 增删改)时可以传 `autoWiring: false`,之后需要时再调用 `sdk.bindWiring()`;注意关闭期间不会同步数据源 / 代码块与依赖目标,也不会自动选中页面。 + +## 插件 + +所有 service 都支持 `usePlugin`,与浏览器端一致: + +```ts +sdk.editor.usePlugin({ + beforeAdd(addNode, parent) { + return [addNode, parent]; + }, +}); +``` diff --git a/packages/editor/package.json b/packages/editor/package.json index 8809166f..2dabcb2f 100644 --- a/packages/editor/package.json +++ b/packages/editor/package.json @@ -22,6 +22,11 @@ "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" diff --git a/packages/editor/src/Editor.vue b/packages/editor/src/Editor.vue index 3d0ea4fd..1ebc00d5 100644 --- a/packages/editor/src/Editor.vue +++ b/packages/editor/src/Editor.vue @@ -148,17 +148,19 @@ 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 propsService from './services/props'; +import { + codeBlockService, + componentListService, + dataSourceService, + depService, + editorService, + eventsService, + historyService, + propsService, + storageService, +} from './services/sdk-instance'; 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'; diff --git a/packages/editor/src/fields/configs/codeSelect.ts b/packages/editor/src/fields/configs/codeSelect.ts index 155bf13b..ff264867 100644 --- a/packages/editor/src/fields/configs/codeSelect.ts +++ b/packages/editor/src/fields/configs/codeSelect.ts @@ -19,10 +19,7 @@ import { isEmpty } from 'lodash-es'; import { HookCodeType, HookType } from '@tmagic/core'; -import type { CodeSelectConfig, FormValue, GroupListConfig } from '@tmagic/form/headless'; - -import codeBlockService from '@editor/services/codeBlock'; -import dataSourceService from '@editor/services/dataSource'; +import type { CodeSelectConfig, FormState, FormValue, GroupListConfig } from '@tmagic/form/headless'; import { stickyAddButton } from './stickyAddButton'; @@ -42,21 +39,21 @@ export const createCodeSelectConfig = (config: CodeSelectConfig): GroupListConfi codeId: '', }), ...stickyAddButton(`添加${config.text || ''}`), - title: (_mForm: any, { model, index }: any) => { + title: (mForm: FormState | undefined, { model, index }: any) => { if (model.codeType === HookCodeType.DATA_SOURCE_METHOD) { if (Array.isArray(model.codeId)) { if (model.codeId.length < 2) { return index; } - const ds = dataSourceService.getDataSourceById(model.codeId[0]); + const ds = mForm?.services?.dataSourceService.getDataSourceById(model.codeId[0]); return `${ds?.title} / ${model.codeId[1]}`; } return Array.isArray(model.codeId) ? model.codeId.join('/') : index; } - const codeContent = codeBlockService.getCodeContentById(model.codeId); + const codeContent = mForm?.services?.codeBlockService.getCodeContentById(model.codeId); if (codeContent) { return codeContent.name; @@ -92,7 +89,7 @@ export const createCodeSelectConfig = (config: CodeSelectConfig): GroupListConfi rules: [{ typeMatch: true, trigger: 'change' }], display: (_mForm: any, { model }: any) => model.codeType !== HookCodeType.DATA_SOURCE_METHOD, - notEditable: () => !codeBlockService.getEditStatus(), + notEditable: (mForm: FormState | undefined) => !mForm?.services?.codeBlockService.getEditStatus(), }, { type: 'data-source-method-select', @@ -100,7 +97,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: () => !dataSourceService.get('editable'), + notEditable: (mForm: FormState | undefined) => !mForm?.services?.dataSourceService.get('editable'), }, ], } as any as GroupListConfig; diff --git a/packages/editor/src/fields/configs/displayConds.ts b/packages/editor/src/fields/configs/displayConds.ts index dd974d73..b4033635 100644 --- a/packages/editor/src/fields/configs/displayConds.ts +++ b/packages/editor/src/fields/configs/displayConds.ts @@ -19,7 +19,6 @@ 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'; @@ -38,15 +37,17 @@ export const createDisplayCondsConfig = ( name: string, parentFields: string[], ): GroupListConfig => { - const resolveFieldPath = (path: Array) => { + const resolveFieldPath = (formState: FormState | undefined, path: Array) => { const [id, ...fieldNames] = path; - const ds = id ? dataSourceService.getDataSourceById(removeDataSourceFieldPrefix(`${id}`)) : undefined; + const ds = id + ? formState?.services?.dataSourceService.getDataSourceById(removeDataSourceFieldPrefix(`${id}`)) + : undefined; return { ds, fieldNames }; }; // 字段变更后按新字段的类型把已填的值转成对应类型,避免类型校验与运行期取值不一致 - const fieldOnChange = (_formState: FormState | undefined, v: string[], { model }: { model: Record }) => { - const { ds, fieldNames } = resolveFieldPath([...parentFields, ...v]); + const fieldOnChange = (formState: FormState | undefined, v: string[], { model }: { model: Record }) => { + const { ds, fieldNames } = resolveFieldPath(formState, [...parentFields, ...v]); const type = getFieldType(ds, fieldNames); if (type === 'number') { model.value = Number(model.value); @@ -63,8 +64,8 @@ export const createDisplayCondsConfig = ( const fieldItem = parentFields.length ? { type: 'cascader', - options: () => { - const { ds, fieldNames } = resolveFieldPath(parentFields); + options: (formState: FormState | undefined) => { + const { ds, fieldNames } = resolveFieldPath(formState, parentFields); if (!ds) { return []; } @@ -141,8 +142,8 @@ export const createDisplayCondsConfig = ( { name: 'value', text: '值', - type: (_mForm: FormState | undefined, { model }: any) => { - const { ds, fieldNames } = resolveFieldPath([...parentFields, ...(model.field || [])]); + type: (mForm: FormState | undefined, { model }: any) => { + const { ds, fieldNames } = resolveFieldPath(mForm, [...parentFields, ...(model.field || [])]); const type = getFieldType(ds, fieldNames); if (type === 'number') { diff --git a/packages/editor/src/fields/configs/eventSelect.ts b/packages/editor/src/fields/configs/eventSelect.ts index 60f69d74..4205e127 100644 --- a/packages/editor/src/fields/configs/eventSelect.ts +++ b/packages/editor/src/fields/configs/eventSelect.ts @@ -31,18 +31,14 @@ import type { } from '@tmagic/form/headless'; import { defineFormItem } from '@tmagic/form/headless'; -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 type { Services } from '@editor/type'; import { getCompActionAllowedValues, getCompActionOptions, getEventNameAllowedValues, getEventNameOptions, normalizeCompActionValue, -} from '@editor/utils'; +} from '@editor/utils/event'; import { stickyAddButton } from './stickyAddButton'; @@ -67,11 +63,14 @@ export const createEventNameConfig = (config: EventSelectConfig) => { labelWidth: '70px', checkStrictly: () => config.src !== 'component', valueSeparator: '.', - options: (_mForm: FormState, { formValue }: any) => getEventNameOptions(config.src, formValue), + options: (mForm: FormState, { formValue }: any) => + mForm?.services ? getEventNameOptions(config.src, formValue, mForm.services) : [], rules: [ { - validator: ({ value, callback }: any, { formValue }: any) => { - const allowedNames = getEventNameAllowedValues(config as any, formValue); + validator: ({ value, callback }: any, { formValue }: any, mForm?: FormState) => { + const allowedNames = mForm?.services + ? getEventNameAllowedValues(config as any, formValue, mForm.services) + : null; if (allowedNames && allowedNames.size > 0 && value && !allowedNames.has(value)) { return callback(`事件名(${value})不存在`); } @@ -84,7 +83,7 @@ export const createEventNameConfig = (config: EventSelectConfig) => { return { ...defaultEventNameConfig, ...config.eventNameConfig }; }; -const createActionTypeOptions = () => { +const createActionTypeOptions = (services?: Services) => { const o: { text: string; label: string; @@ -98,6 +97,12 @@ const createActionTypeOptions = () => { }, ]; + if (!services) { + return o; + } + + const { propsService, codeBlockService } = services; + if (!propsService.getDisabledCodeBlock()) { o.push({ text: '代码', @@ -125,7 +130,7 @@ const createActionTypeConfig = (config: EventSelectConfig) => { text: '联动类型', type: 'select', defaultValue: ActionType.COMP, - options: createActionTypeOptions(), + options: (mForm: FormState | undefined) => createActionTypeOptions(mForm?.services), rules: [ { required: true, @@ -171,8 +176,8 @@ const createCompActionConfig = (config: EventSelectConfig) => { const defaultCompActionConfig: DynamicTypeConfig = { name: 'method', text: '动作', - type: (_mForm: FormState | undefined, { model }: any) => { - const to = editorService.getNodeById(model.to); + type: (mForm: FormState | undefined, { model }: any) => { + const to = mForm?.services?.editorService.getNodeById(model.to); if (to?.type === 'page-fragment-container' && to.pageFragmentId) { return 'cascader'; @@ -182,12 +187,15 @@ const createCompActionConfig = (config: EventSelectConfig) => { }, checkStrictly: () => config.src !== 'component', display: (_mForm: FormState | undefined, { model }: any) => model.actionType === ActionType.COMP, - options: (_mForm: FormState, { model }: any) => getCompActionOptions(model.to), + options: (mForm: FormState, { model }: any) => + mForm?.services ? getCompActionOptions(model.to, mForm.services) : [], rules: [ { trigger: 'blur', - validator: ({ value, callback }: any, { model }: any) => { - const allowedMethods = getCompActionAllowedValues(config as any, model); + validator: ({ value, callback }: any, { model }: any, mForm?: FormState) => { + const allowedMethods = mForm?.services + ? getCompActionAllowedValues(config as any, model, mForm.services) + : null; const normalized = normalizeCompActionValue(value); if (allowedMethods && allowedMethods.size > 0 && normalized && !allowedMethods.has(normalized)) { return callback(`动作名(${normalized})不存在`); @@ -206,7 +214,7 @@ const createCodeActionConfig = (config: EventSelectConfig) => { type: 'code-select-col', text: '代码块', name: 'codeId', - notEditable: () => !codeBlockService.getEditStatus(), + notEditable: (mForm: FormState | undefined) => !mForm?.services?.codeBlockService.getEditStatus(), display: (_mForm, { model }) => model.actionType === ActionType.CODE, }; return { ...defaultCodeActionConfig, ...config.codeActionConfig }; @@ -218,7 +226,7 @@ const createDataSourceActionConfig = (config: EventSelectConfig) => { type: 'data-source-method-select', text: '数据源方法', name: 'dataSourceMethod', - notEditable: () => !dataSourceService.get('editable'), + notEditable: (mForm: FormState | undefined) => !mForm?.services?.dataSourceService.get('editable'), display: (_mForm, { model }) => model.actionType === ActionType.DATA_SOURCE, }; return { ...defaultDataSourceActionConfig, ...config.dataSourceActionConfig }; @@ -272,13 +280,17 @@ export const createLegacyTableConfig = (config: EventSelectConfig): TableConfig name: 'name', label: '事件名', type: createEventNameConfig(config).type, - options: (_mForm: FormState, { formValue }: any) => - eventsService - .getEvent(formValue.type, { node: editorService.getNodeById(formValue.id) }) + options: (mForm: FormState, { formValue }: any) => { + const services = mForm?.services; + if (!services) return []; + + return services.eventsService + .getEvent(formValue.type, { node: services.editorService.getNodeById(formValue.id) }) .map((option: any) => ({ text: option.label, value: option.value, - })), + })); + }, }, { name: 'to', @@ -289,11 +301,14 @@ export const createLegacyTableConfig = (config: EventSelectConfig): TableConfig name: 'method', label: '动作', type: createCompActionConfig(config).type, - options: (_mForm: FormState, { model, formValue }: any) => { - const node = editorService.getNodeById(model.to) || (formValue as MNode); + options: (mForm: FormState, { model, formValue }: any) => { + const services = mForm?.services; + if (!services) return []; + + const node = services.editorService.getNodeById(model.to) || (formValue as MNode); if (!node?.type) return []; - return eventsService.getMethod(node.type, { targetId: model.to, node }).map((option: any) => ({ + return services.eventsService.getMethod(node.type, { targetId: model.to, node }).map((option: any) => ({ text: option.label, value: option.value, })); diff --git a/packages/editor/src/hooks/use-stage.ts b/packages/editor/src/hooks/use-stage.ts index 6f76ad1b..0bece9e0 100644 --- a/packages/editor/src/hooks/use-stage.ts +++ b/packages/editor/src/hooks/use-stage.ts @@ -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/editor'; +import { editorService } from '@editor/services/sdk-instance'; 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'; diff --git a/packages/editor/src/index.ts b/packages/editor/src/index.ts index 6f39f069..d65378a9 100644 --- a/packages/editor/src/index.ts +++ b/packages/editor/src/index.ts @@ -37,17 +37,19 @@ export * from './utils'; export { isGlobalFlat } from './utils/config'; export { default as TMagicEditor } from './Editor.vue'; export { default as TMagicCodeEditor } from './layouts/CodeEditor.vue'; -export { default as editorService } from './services/editor'; -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 { + codeBlockService, + componentListService, + dataSourceService, + depService, + editorService, + eventsService, + historyService, + propsService, + storageService, +} from './services/sdk-instance'; 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'; diff --git a/packages/editor/src/initService.ts b/packages/editor/src/initService.ts index 01a2b5d0..7db5f27e 100644 --- a/packages/editor/src/initService.ts +++ b/packages/editor/src/initService.ts @@ -12,23 +12,22 @@ import type { MPage, MPageFragment, } from '@tmagic/core'; -import { - createCodeBlockTarget, - createDataSourceCondTarget, - createDataSourceMethodTarget, - createDataSourceTarget, - DepTargetType, - NODE_CONDS_KEY, - NodeType, - Target, - updateNode, -} from '@tmagic/core'; +import { createCodeBlockTarget, DepTargetType, NodeType, Target, updateNode } from '@tmagic/core'; import { ChangeRecord } from '@tmagic/form'; import StageCore from '@tmagic/stage'; -import { getDepNodeIds, getNodes, isPage, isValueIncludeDataSource } from '@tmagic/utils'; +import { getDepNodeIds, getNodes, isPage } from '@tmagic/utils'; import PropsPanel from './layouts/PropsPanel.vue'; -import { isIncludeDataSource } from './utils/editor'; +import type { Reactivity } from './sdk/type'; +import { + addDataSourceTargets, + classifyUpdatedNodes, + getDataSourceRecollectTypes, + removeDataSourceTargets, + removeTargetDeps, + resetDepTargets, + writeTargetDeps, +} from './sdk/utils/dep'; import { error as logError } from './utils/logger'; import { EditorProps } from './editorProps'; import { Services } from './type'; @@ -247,6 +246,16 @@ export const initServiceState = ( }, ); + // 同步给 SDK 的结构合法性校验(浏览器端默认 validateMode 为 off,主动调用 `validate` 时才会用到) + watch( + () => [props.disabledPageFragment, props.canDropIn] as const, + ([disabledPageFragment, canDropIn]) => + editorService.setConfig({ disabledPageFragment: disabledPageFragment ?? false, canDropIn }), + { + immediate: true, + }, + ); + onBeforeUnmount(() => { editorService.resetState(); historyService.resetState(); @@ -476,12 +485,6 @@ export const initServiceEvents = ( } }; - const initDataSourceDepTarget = (ds: DataSourceSchema) => { - depService.addTarget(createDataSourceTarget(ds, reactive({}))); - depService.addTarget(createDataSourceMethodTarget(ds, reactive({}))); - depService.addTarget(createDataSourceCondTarget(ds, reactive({}))); - }; - const rootChangeHandler = (value: MApp | null) => { if (!value) return; @@ -491,15 +494,12 @@ export const initServiceEvents = ( codeBlockService.setCodeDsl(value.codeBlocks); dataSourceService.set('dataSources', value.dataSources); - 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); - } + resetDepTargets( + depService, + value.codeBlocks, + dataSourceService.get('dataSources'), + reactive as Reactivity['reactive'], + ); if (Array.isArray(value.items)) { updateStageDsl(value); @@ -549,49 +549,7 @@ export const initServiceEvents = ( // 节点更新,收集依赖 // 仅当修改到数据源相关的才收集 const nodeUpdateHandler = (data: { newNode: MComponent; oldNode: MComponent; changeRecords?: ChangeRecord[] }[]) => { - 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); - } - } + const { needRecollectNodes, normalNodes } = classifyUpdatedNodes({ dep: depService }, data); if (needRecollectNodes.length) { // 有数据源依赖,需要等依赖重新收集完才更新stage @@ -603,13 +561,14 @@ export const initServiceEvents = ( handler(); } else { updateStageNodes(normalNodes); - - // 在上面判断是否需要收集数据源依赖中已经更新stage - Promise.all([ - collectIdle(normalNodes, true, DepTargetType.CODE_BLOCK), - collectIdle(normalNodes, true, DepTargetType.DATA_SOURCE_METHOD), - ]); } + + // 代码块、数据源方法依赖对所有被更新的节点都要收集:同一批里既改了数据源引用又改了方法时不能漏掉 + const updatedNodes = [...needRecollectNodes, ...normalNodes]; + Promise.all([ + collectIdle(updatedNodes, true, DepTargetType.CODE_BLOCK), + collectIdle(updatedNodes, true, DepTargetType.DATA_SOURCE_METHOD), + ]); }; // 节点删除,清除对齐的依赖收集 @@ -634,7 +593,7 @@ export const initServiceEvents = ( const dataSourceAddHandler = (config: DataSourceSchema) => { const handler = async () => { - initDataSourceDepTarget(config); + addDataSourceTargets(depService, config, reactive as Reactivity['reactive']); const app = await getTMagicApp(); if (!app?.dataSourceManager) { @@ -669,84 +628,31 @@ export const initServiceEvents = ( ds.setData(config.mocks?.find((mock) => mock.useInEditor)?.data || ds.getDefaultData()); }; - let needRecollectDep = false; - let isModifyField = false; - let isModifyMock = false; - let isModifyMethod = false; - for (const changeRecord of changeRecords) { - if (!changeRecord.propPath) { - continue; - } - - isModifyField = - changeRecord.propPath === 'fields' || - /fields.(\d)+.name/.test(changeRecord.propPath) || - /fields.(\d)+.defaultValue/.test(changeRecord.propPath) || - /fields.(\d)+$/.test(changeRecord.propPath); - - isModifyMock = changeRecord.propPath === 'mocks'; - - isModifyMethod = - changeRecord.propPath === 'methods' || - /methods.(\d)+.name/.test(changeRecord.propPath) || - /methods.(\d)+$/.test(changeRecord.propPath); - - needRecollectDep = isModifyField || isModifyMock || isModifyMethod; - - if (needRecollectDep) { - break; - } - } - + const types = getDataSourceRecollectTypes(changeRecords); const root = editorService.get('root'); - if (needRecollectDep) { - if (Array.isArray(root?.items)) { - depService.clearIdleTasks(); - let collectIdlePromises: Promise[] = []; - if (isModifyField) { - depService.removeTarget(config.id, DepTargetType.DATA_SOURCE); - depService.removeTarget(config.id, DepTargetType.DATA_SOURCE_COND); - - depService.addTarget(createDataSourceTarget(config, reactive({}))); - depService.addTarget(createDataSourceCondTarget(config, reactive({}))); - - collectIdlePromises = [ - collectIdle(root.items, true, DepTargetType.DATA_SOURCE), - collectIdle(root.items, true, DepTargetType.DATA_SOURCE_COND), - ]; - } else if (isModifyMock) { - depService.removeTarget(config.id, DepTargetType.DATA_SOURCE); - - depService.addTarget(createDataSourceTarget(config, reactive({}))); - - collectIdlePromises = [collectIdle(root.items, true, DepTargetType.DATA_SOURCE)]; - } else if (isModifyMethod) { - depService.removeTarget(config.id, DepTargetType.DATA_SOURCE_METHOD); - - depService.addTarget(createDataSourceMethodTarget(config, reactive({}))); - - collectIdlePromises = [collectIdle(root.items, true, DepTargetType.DATA_SOURCE_METHOD)]; - } - const handler = async () => { - const results = await Promise.all(collectIdlePromises); - if (!results.every(Boolean)) return; - - updateDataSourceSchema(); - await updateDsData(); - updateStageNodes(root.items); - }; - handler(); - } - } else if (root?.dataSources) { - updateDsData(); + if (!types) { + if (root?.dataSources) updateDsData(); + return; } - }; - const removeDataSourceTarget = (id: string) => { - depService.removeTarget(id, DepTargetType.DATA_SOURCE); - depService.removeTarget(id, DepTargetType.DATA_SOURCE_COND); - depService.removeTarget(id, DepTargetType.DATA_SOURCE_METHOD); + if (!Array.isArray(root?.items)) return; + + depService.clearIdleTasks(); + removeDataSourceTargets(depService, config.id, types); + addDataSourceTargets(depService, config, reactive as Reactivity['reactive'], types); + + const collectIdlePromises = types.map((type) => collectIdle(root.items, true, type)); + + const handler = async () => { + const results = await Promise.all(collectIdlePromises); + if (!results.every(Boolean)) return; + + updateDataSourceSchema(); + await updateDsData(); + updateStageNodes(root.items); + }; + handler(); }; const dataSourceRemoveHandler = (id: string) => { @@ -773,7 +679,7 @@ export const initServiceEvents = ( app?.dataSourceManager?.removeDataSource(id); updateStageNodes(nodes); - removeDataSourceTarget(id); + removeDataSourceTargets(depService, id); }; handler(); @@ -799,45 +705,10 @@ export const initServiceEvents = ( codeBlockService.on('addOrUpdate', codeBlockAddOrUpdateHandler); codeBlockService.on('remove', codeBlockRemoveHandler); - const targetAddHandler = (target: Target) => { - const root = editorService.get('root'); - if (!root) return; + const targetAddHandler = (target: Target) => writeTargetDeps(editorService.get('root'), target); - 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]; - } - }; + const targetRemoveHandler = (id: string | number, type: DepTargetType) => + removeTargetDeps(editorService.get('root'), id, type); depService.on('add-target', targetAddHandler); depService.on('remove-target', targetRemoveHandler); diff --git a/packages/editor/src/layouts/Framework.vue b/packages/editor/src/layouts/Framework.vue index 64a63684..b950500c 100644 --- a/packages/editor/src/layouts/Framework.vue +++ b/packages/editor/src/layouts/Framework.vue @@ -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/storage'; +import { Protocol } from '@editor/services/sdk-instance'; import type { FrameworkSlots, GetColumnWidth, PageBarSortOptions } from '@editor/type'; import { getEditorConfig } from '@editor/utils/config'; import { diff --git a/packages/editor/src/layouts/props-panel/PropsPanel.vue b/packages/editor/src/layouts/props-panel/PropsPanel.vue index 474236a6..c98df827 100644 --- a/packages/editor/src/layouts/props-panel/PropsPanel.vue +++ b/packages/editor/src/layouts/props-panel/PropsPanel.vue @@ -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/storage'; +import { Protocol } from '@editor/services/sdk-instance'; import type { NodeInvalidSource, PropsPanelSlots } from '@editor/type'; import { styleTabConfig } from '@editor/utils'; import { PROPS_PANEL_WIDTH_STORAGE_KEY } from '@editor/utils/const'; diff --git a/packages/editor/src/layouts/props-panel/use-style-panel.ts b/packages/editor/src/layouts/props-panel/use-style-panel.ts index 9fdfc539..68d74cbb 100644 --- a/packages/editor/src/layouts/props-panel/use-style-panel.ts +++ b/packages/editor/src/layouts/props-panel/use-style-panel.ts @@ -1,6 +1,6 @@ import { computed, type Ref, watch } from 'vue'; -import { Protocol } from '@editor/services/storage'; +import { Protocol } from '@editor/services/sdk-instance'; import { Services } from '@editor/type'; import { RIGHT_COLUMN_WIDTH_STORAGE_KEY } from '@editor/utils/const'; diff --git a/packages/editor/src/sdk.ts b/packages/editor/src/sdk.ts new file mode 100644 index 00000000..03fd87a0 --- /dev/null +++ b/packages/editor/src/sdk.ts @@ -0,0 +1,19 @@ +/* + * Tencent is pleased to support the open source community by making TMagicEditor available. + * + * Copyright (C) 2025 Tencent. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export * from './sdk/index'; diff --git a/packages/editor/src/sdk/index.ts b/packages/editor/src/sdk/index.ts new file mode 100644 index 00000000..878f55a5 --- /dev/null +++ b/packages/editor/src/sdk/index.ts @@ -0,0 +1,264 @@ +/* + * Tencent is pleased to support the open source community by making TMagicEditor available. + * + * Copyright (C) 2025 Tencent. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * @fileoverview `@tmagic/editor/sdk`:不依赖 DOM 的 DSL 操作 SDK。 + * + * 每次调用 {@link createDslSdk} 得到一组独立的 service(editor / history / props / dataSource / + * codeBlock / dep / storage / events / componentList),可在 Node 中运行。浏览器端的 + * `editorService` 等单例就是用同一个工厂创建的默认实例。 + * + * @module @tmagic/editor/sdk + */ + +import { nextTick, reactive, shallowReactive, toRaw } from 'vue'; +import { cloneDeep, isEmpty } from 'lodash-es'; + +import type { MApp } from '@tmagic/core'; + +import type { PersistedHistoryState } from '@editor/type'; + +import CodeBlock from './services/CodeBlock'; +import ComponentList from './services/ComponentList'; +import DataSource from './services/DataSource'; +import Dep from './services/Dep'; +import Editor from './services/Editor'; +import Events from './services/Events'; +import History from './services/History'; +import Props from './services/Props'; +import WebStorage from './services/WebStorage'; +import type { Reactivity, SdkContext, SdkOptions } from './type'; +import { createValidator, type Validator } from './validate'; +import { bindWiring, whenWiringSettled, type WiringHandle } from './wiring'; + +const SERVICE_NAMES = [ + 'editor', + 'props', + 'history', + 'dataSource', + 'codeBlock', + 'dep', + 'storage', + 'events', + 'componentList', +] as const; + +/** + * Node 下默认的 DSL 反序列化:序列化出来的内容是被括号包起来的 JSON(函数、正则等需要自己传入 `parseDSL`)。 + * 刻意不使用 `eval`,避免执行调用方传入的历史快照里的任意代码。 + */ +export const defaultParseDSL = (code: string): any => { + const text = code.trim(); + const body = text.startsWith('(') && text.endsWith(')') ? text.slice(1, -1) : text; + try { + return JSON.parse(body); + } catch (e: any) { + throw new Error( + `默认的 parseDSL 只能解析 JSON,无法还原含函数 / undefined 等的内容(${e?.message ?? e});请在 createDslSdk 的 parseDSL 选项中传入自己的实现`, + ); + } +}; + +export interface DslSdk { + readonly editor: Editor; + readonly history: History; + readonly props: Props; + readonly dataSource: DataSource; + readonly codeBlock: CodeBlock; + readonly dep: Dep; + readonly storage: WebStorage; + readonly events: Events; + readonly componentList: ComponentList; + readonly validate: Validator; + readonly ctx: SdkContext; + + /** 登记表(组件属性配置等,其中一部分是异步填充的)全部就绪后 resolve */ + ready: Promise; + + /** 导出当前 DSL(深拷贝,含依赖收集结果) */ + getDsl(): MApp | null; + /** 整体替换 DSL,会按 `set root` 的语义写入历史记录 */ + setDsl(dsl: MApp | null): Promise; + /** 绑定依赖收集连线;`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[] => { + 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 类型不完全一致,这里做断言 + + 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'; diff --git a/packages/editor/src/services/BaseService.ts b/packages/editor/src/sdk/services/BaseService.ts similarity index 100% rename from packages/editor/src/services/BaseService.ts rename to packages/editor/src/sdk/services/BaseService.ts diff --git a/packages/editor/src/services/codeBlock.ts b/packages/editor/src/sdk/services/CodeBlock.ts similarity index 90% rename from packages/editor/src/services/codeBlock.ts rename to packages/editor/src/sdk/services/CodeBlock.ts index f960445f..3a79ac6c 100644 --- a/packages/editor/src/services/codeBlock.ts +++ b/packages/editor/src/sdk/services/CodeBlock.ts @@ -16,7 +16,6 @@ * limitations under the License. */ -import { reactive } from 'vue'; import { cloneDeep, get, keys, pick } from 'lodash-es'; import type { Writable } from 'type-fest'; @@ -25,9 +24,6 @@ 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, @@ -37,11 +33,13 @@ 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, @@ -51,13 +49,7 @@ const canUsePluginMethods = { type AsyncMethodName = Writable<(typeof canUsePluginMethods)['async']>; class CodeBlock extends BaseService { - private state = reactive({ - codeDsl: null, - editable: true, - combineIds: [], - undeletableList: [], - paramsColConfig: undefined, - }); + private state: CodeState; /** * 最近一次写入历史栈的代码块历史记录 uuid(单条写入场景:新增 / 更新)。 @@ -70,11 +62,18 @@ class CodeBlock extends BaseService { */ private lastDeletedHistoryIds: string[] = []; - constructor() { + constructor(private ctx: SdkContext) { super([ ...canUsePluginMethods.async.map((methodName) => ({ name: methodName, isAsync: true })), ...canUsePluginMethods.sync.map((methodName) => ({ name: methodName, isAsync: false })), ]); + this.state = ctx.reactivity.reactive({ + codeDsl: null, + editable: true, + combineIds: [], + undeletableList: [], + paramsColConfig: undefined, + }); } /** @@ -170,9 +169,9 @@ class CodeBlock extends BaseService { const codeConfigProcessed = cloneDeep(codeConfig); if (codeConfigProcessed.content) { // 在保存的时候转换代码内容 - const parseDSL = getEditorConfig('parseDSL'); - if (typeof codeConfigProcessed.content === 'string') { - codeConfigProcessed.content = parseDSL<(...args: any[]) => any>(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; } } @@ -196,7 +195,7 @@ class CodeBlock extends BaseService { historyDescription, source: historySource, }); - this.lastPushedHistoryId = (step ? historyService.push('codeBlock', step, id) : null)?.uuid ?? null; + this.lastPushedHistoryId = (step ? this.ctx.history.push('codeBlock', step, id) : null)?.uuid ?? null; } this.emit('addOrUpdate', id, codeDsl[id]); @@ -268,21 +267,21 @@ class CodeBlock extends BaseService { * 设置代码草稿 */ public setCodeDraft(codeId: Id, content: string): void { - globalThis.localStorage.setItem(`${CODE_DRAFT_STORAGE_KEY}_${codeId}`, content); + this.ctx.storage.getStorage().setItem(`${CODE_DRAFT_STORAGE_KEY}_${codeId}`, content); } /** * 获取代码草稿 */ public getCodeDraft(codeId: Id): string | null { - return globalThis.localStorage.getItem(`${CODE_DRAFT_STORAGE_KEY}_${codeId}`); + return this.ctx.storage.getStorage().getItem(`${CODE_DRAFT_STORAGE_KEY}_${codeId}`); } /** * 删除代码草稿 */ public removeCodeDraft(codeId: Id): void { - globalThis.localStorage.removeItem(`${CODE_DRAFT_STORAGE_KEY}_${codeId}`); + this.ctx.storage.getStorage().removeItem(`${CODE_DRAFT_STORAGE_KEY}_${codeId}`); } /** @@ -314,7 +313,7 @@ class CodeBlock extends BaseService { historyDescription, source: historySource, }); - const uuid = step ? historyService.push('codeBlock', step, id)?.uuid : undefined; + const uuid = step ? this.ctx.history.push('codeBlock', step, id)?.uuid : undefined; if (uuid) this.lastDeletedHistoryIds.push(uuid); } @@ -390,7 +389,7 @@ class CodeBlock extends BaseService { * @returns 撤销的 step;栈不存在或已无可撤销时返回 null */ public async undo(id: Id): Promise { - const step = historyService.undo('codeBlock', id); + const step = this.ctx.history.undo('codeBlock', id); if (!step) return null; await this.applyHistoryStep(step, true); return step; @@ -402,7 +401,7 @@ class CodeBlock extends BaseService { * @returns 重做的 step;栈不存在或已无可重做时返回 null */ public async redo(id: Id): Promise { - const step = historyService.redo('codeBlock', id); + const step = this.ctx.history.redo('codeBlock', id); if (!step) return null; await this.applyHistoryStep(step, false); return step; @@ -410,12 +409,12 @@ class CodeBlock extends BaseService { /** 是否可对指定代码块撤销。 */ public canUndo(id: Id): boolean { - return historyService.canUndo('codeBlock', id); + return this.ctx.history.canUndo('codeBlock', id); } /** 是否可对指定代码块重做。 */ public canRedo(id: Id): boolean { - return historyService.canRedo('codeBlock', id); + return this.ctx.history.canRedo('codeBlock', id); } /** @@ -426,7 +425,7 @@ class CodeBlock extends BaseService { * @returns 实际移动到的最终游标位置 */ public async goto(id: Id, targetCursor: number): Promise { - let cursor = historyService.getCursor('codeBlock', id); + let cursor = this.ctx.history.getCursor('codeBlock', id); const target = Math.max(0, targetCursor); while (cursor > target) { const step = await this.undo(id); @@ -452,7 +451,7 @@ class CodeBlock extends BaseService { * @returns 反向后产生的新 step;目标不存在 / 未应用时返回 null */ public async revert(id: Id, index: number): Promise { - const list = historyService.getStepList('codeBlock', id); + const list = this.ctx.history.getStepList('codeBlock', id); const entry = list[index]; if (!entry?.applied) return null; // 更新类步骤(前后 content 都存在)必须带 changeRecords 才支持回滚,否则只能整内容替换,会冲掉后续无关变更。 @@ -473,7 +472,7 @@ class CodeBlock extends BaseService { public async revertById(uuids: string[]): Promise<(CodeBlockStepValue | null)[]> { const results: (CodeBlockStepValue | null)[] = []; for (const uuid of uuids) { - const location = historyService.findStepLocationByUuid('codeBlock', uuid); + const location = this.ctx.history.findStepLocationByUuid('codeBlock', uuid); results.push(location ? await this.revert(location.id, location.index) : null); } return results; @@ -513,7 +512,7 @@ class CodeBlock extends BaseService { coperWatcher.collect(copyNodes, {}, true, collectorOptions.type); Object.keys(customTarget.deps).forEach((nodeId: Id) => { - const node = editorService.getNodeById(nodeId); + const node = this.ctx.editor.getNodeById(nodeId); if (!node) return; customTarget!.deps[nodeId].keys.forEach((key) => { const relateCodeId = get(node, key); @@ -527,7 +526,7 @@ class CodeBlock extends BaseService { }); }); } - storageService.setItem(COPY_CODE_STORAGE_KEY, copyData, { + this.ctx.storage.setItem(COPY_CODE_STORAGE_KEY, copyData, { protocol: Protocol.OBJECT, }); } @@ -537,7 +536,7 @@ class CodeBlock extends BaseService { * @returns */ public paste() { - const codeDsl: CodeBlockDSL = storageService.getItem(COPY_CODE_STORAGE_KEY); + const codeDsl: CodeBlockDSL = this.ctx.storage.getItem(COPY_CODE_STORAGE_KEY); Object.keys(codeDsl).forEach((codeId: Id) => { // 不覆盖同样id的代码块 this.setCodeDslByIdSync(codeId, codeDsl[codeId], false); @@ -575,13 +574,16 @@ class CodeBlock extends BaseService { // 原本是新增 → revert 即删除 if (!oldSchema && newSchema) { await this.deleteCodeDslByIds([id], { historyDescription, historySource: 'rollback' }); - return historyService.getStepList('codeBlock', id).slice(-1)[0]?.step ?? null; + return this.ctx.history.getStepList('codeBlock', id).slice(-1)[0]?.step ?? null; } // 原本是删除 → revert 即写回 if (oldSchema && !newSchema) { - this.setCodeDslByIdSync(id, cloneDeep(oldSchema), true, { historyDescription, historySource: 'rollback' }); - 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; } if (!oldSchema || !newSchema) return null; @@ -605,11 +607,14 @@ class CodeBlock extends BaseService { historyDescription, historySource: 'rollback', }); - return historyService.getStepList('codeBlock', id).slice(-1)[0]?.step ?? null; + return this.ctx.history.getStepList('codeBlock', id).slice(-1)[0]?.step ?? null; } - this.setCodeDslByIdSync(id, cloneDeep(oldSchema), true, { historyDescription, historySource: 'rollback' }); - 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; } /** @@ -680,4 +685,4 @@ class CodeBlock extends BaseService { export type CodeBlockService = CodeBlock; -export default new CodeBlock(); +export default CodeBlock; diff --git a/packages/editor/src/services/componentList.ts b/packages/editor/src/sdk/services/ComponentList.ts similarity index 84% rename from packages/editor/src/services/componentList.ts rename to packages/editor/src/sdk/services/ComponentList.ts index b339a18c..41d38e8a 100644 --- a/packages/editor/src/services/componentList.ts +++ b/packages/editor/src/sdk/services/ComponentList.ts @@ -16,19 +16,20 @@ * limitations under the License. */ -import { shallowReactive } from 'vue'; - import type { ComponentGroup, ComponentGroupState } from '@editor/type'; +import type { SdkContext } from '../type'; + import BaseService from './BaseService'; class ComponentList extends BaseService { - private state = shallowReactive({ - list: [], - }); + private state: ComponentGroupState; - constructor() { + constructor(ctx: SdkContext) { super([]); + this.state = ctx.reactivity.shallowReactive({ + list: [], + }); } /** @@ -55,4 +56,4 @@ class ComponentList extends BaseService { export type ComponentListService = ComponentList; -export default new ComponentList(); +export default ComponentList; diff --git a/packages/editor/src/services/dataSource.ts b/packages/editor/src/sdk/services/DataSource.ts similarity index 89% rename from packages/editor/src/services/dataSource.ts rename to packages/editor/src/sdk/services/DataSource.ts index 95a282d0..1dc0d643 100644 --- a/packages/editor/src/services/dataSource.ts +++ b/packages/editor/src/sdk/services/DataSource.ts @@ -1,4 +1,3 @@ -import { reactive } from 'vue'; import { cloneDeep, get } from 'lodash-es'; import type { Writable } from 'type-fest'; @@ -7,9 +6,6 @@ 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, @@ -22,7 +18,10 @@ 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[]; @@ -57,15 +56,7 @@ const canUsePluginMethods = { type SyncMethodName = Writable<(typeof canUsePluginMethods)['sync']>; class DataSource extends BaseService { - private state = reactive({ - datasourceTypeList: [], - dataSources: [], - editable: true, - configs: {}, - values: {}, - events: {}, - methods: {}, - }); + private state: State; /** * 最近一次写入历史栈的数据源历史记录 uuid。 @@ -74,8 +65,17 @@ class DataSource extends BaseService { */ private lastPushedHistoryId: string | null = null; - constructor() { + constructor(private ctx: SdkContext) { super(canUsePluginMethods.sync.map((methodName) => ({ name: methodName, isAsync: false }))); + this.state = ctx.reactivity.reactive({ + datasourceTypeList: [], + dataSources: [], + editable: true, + configs: {}, + values: {}, + events: {}, + methods: {}, + }); } public set(name: K, value: T) { @@ -129,6 +129,10 @@ 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(), @@ -143,7 +147,7 @@ class DataSource extends BaseService { historyDescription, source: historySource, }); - this.lastPushedHistoryId = (step ? historyService.push('dataSource', step, newConfig.id) : null)?.uuid ?? null; + this.lastPushedHistoryId = (step ? this.ctx.history.push('dataSource', step, newConfig.id) : null)?.uuid ?? null; } this.emit('add', newConfig); @@ -169,6 +173,8 @@ 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); @@ -186,7 +192,7 @@ class DataSource extends BaseService { historyDescription, source: historySource, }); - this.lastPushedHistoryId = (step ? historyService.push('dataSource', step, newConfig.id) : null)?.uuid ?? null; + this.lastPushedHistoryId = (step ? this.ctx.history.push('dataSource', step, newConfig.id) : null)?.uuid ?? null; } this.emit('update', newConfig, { @@ -218,7 +224,7 @@ class DataSource extends BaseService { historyDescription, source: historySource, }); - this.lastPushedHistoryId = (step ? historyService.push('dataSource', step, id) : null)?.uuid ?? null; + this.lastPushedHistoryId = (step ? this.ctx.history.push('dataSource', step, id) : null)?.uuid ?? null; } this.emit('remove', id); @@ -273,7 +279,7 @@ class DataSource extends BaseService { * @returns 撤销的 step;栈不存在或已无可撤销时返回 null */ public undo(id: Id) { - const step = historyService.undo('dataSource', id); + const step = this.ctx.history.undo('dataSource', id); if (!step) return null; this.applyHistoryStep(step, true); return step; @@ -285,7 +291,7 @@ class DataSource extends BaseService { * @returns 重做的 step;栈不存在或已无可重做时返回 null */ public redo(id: Id) { - const step = historyService.redo('dataSource', id); + const step = this.ctx.history.redo('dataSource', id); if (!step) return null; this.applyHistoryStep(step, false); return step; @@ -293,12 +299,12 @@ class DataSource extends BaseService { /** 是否可对指定数据源撤销。 */ public canUndo(id: Id): boolean { - return historyService.canUndo('dataSource', id); + return this.ctx.history.canUndo('dataSource', id); } /** 是否可对指定数据源重做。 */ public canRedo(id: Id): boolean { - return historyService.canRedo('dataSource', id); + return this.ctx.history.canRedo('dataSource', id); } /** @@ -309,7 +315,7 @@ class DataSource extends BaseService { * @returns 实际移动到的最终游标位置 */ public goto(id: Id, targetCursor: number): number { - let cursor = historyService.getCursor('dataSource', id); + let cursor = this.ctx.history.getCursor('dataSource', id); const target = Math.max(0, targetCursor); while (cursor > target) { if (!this.undo(id)) break; @@ -333,7 +339,7 @@ class DataSource extends BaseService { * @returns 反向后产生的新 step;目标不存在 / 未应用时返回 null */ public revert(id: Id, index: number): DataSourceStepValue | null { - const list = historyService.getStepList('dataSource', id); + const list = this.ctx.history.getStepList('dataSource', id); const entry = list[index]; if (!entry?.applied) return null; // 更新类步骤(前后 schema 都存在)必须带 changeRecords 才支持回滚,否则只能整 schema 替换,会冲掉后续无关变更。 @@ -352,7 +358,7 @@ class DataSource extends BaseService { */ public revertById(uuids: string[]): (DataSourceStepValue | null)[] { return uuids.map((uuid) => { - const location = historyService.findStepLocationByUuid('dataSource', uuid); + const location = this.ctx.history.findStepLocationByUuid('dataSource', uuid); if (!location) return null; return this.revert(location.id, location.index); }); @@ -401,7 +407,7 @@ class DataSource extends BaseService { coperWatcher.collect(copyNodes, {}, true, collectorOptions.type); Object.keys(customTarget.deps).forEach((nodeId: Id) => { - const node = editorService.getNodeById(nodeId); + const node = this.ctx.editor.getNodeById(nodeId); if (!node) return; customTarget!.deps[nodeId].keys.forEach((key) => { const [relateDsId] = get(node, key); @@ -415,7 +421,7 @@ class DataSource extends BaseService { }); }); } - storageService.setItem(COPY_DS_STORAGE_KEY, copyData, { + this.ctx.storage.setItem(COPY_DS_STORAGE_KEY, copyData, { protocol: Protocol.OBJECT, }); } @@ -424,7 +430,7 @@ class DataSource extends BaseService { * @returns */ public paste() { - const dataSource: DataSourceSchema[] = storageService.getItem(COPY_DS_STORAGE_KEY); + const dataSource: DataSourceSchema[] = this.ctx.storage.getItem(COPY_DS_STORAGE_KEY); dataSource.forEach((item: DataSourceSchema) => { // 不覆盖同样id的数据源 if (!this.getDataSourceById(item.id)) { @@ -444,13 +450,13 @@ class DataSource extends BaseService { // 原本是新增 → revert 即删除 if (!oldSchema && newSchema) { this.remove(`${id}`, { historyDescription, historySource: 'rollback' }); - return historyService.getStepList('dataSource', id).slice(-1)[0]?.step ?? null; + return this.ctx.history.getStepList('dataSource', id).slice(-1)[0]?.step ?? null; } // 原本是删除 → revert 即重新加回 if (oldSchema && !newSchema) { this.add(cloneDeep(oldSchema), { historyDescription, historySource: 'rollback' }); - return historyService.getStepList('dataSource', id).slice(-1)[0]?.step ?? null; + return this.ctx.history.getStepList('dataSource', id).slice(-1)[0]?.step ?? null; } if (!oldSchema || !newSchema) return null; @@ -474,11 +480,11 @@ class DataSource extends BaseService { historyDescription, historySource: 'rollback', }); - return historyService.getStepList('dataSource', id).slice(-1)[0]?.step ?? null; + return this.ctx.history.getStepList('dataSource', id).slice(-1)[0]?.step ?? null; } this.update(cloneDeep(oldSchema), { historyDescription, historySource: 'rollback' }); - return historyService.getStepList('dataSource', id).slice(-1)[0]?.step ?? null; + return this.ctx.history.getStepList('dataSource', id).slice(-1)[0]?.step ?? null; } /** @@ -549,4 +555,4 @@ class DataSource extends BaseService { export type DataSourceService = DataSource; -export default new DataSource(); +export default DataSource; diff --git a/packages/editor/src/services/dep.ts b/packages/editor/src/sdk/services/Dep.ts similarity index 94% rename from packages/editor/src/services/dep.ts rename to packages/editor/src/sdk/services/Dep.ts index 4ce4c72d..1da4df0f 100644 --- a/packages/editor/src/services/dep.ts +++ b/packages/editor/src/sdk/services/Dep.ts @@ -15,19 +15,28 @@ * 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]; @@ -59,16 +68,13 @@ interface CollectBatch { } class Dep extends BaseService { - private state = shallowReactive({ - collecting: false, - taskLength: 0, - }); + private state: State; private idleTask = new IdleTask(); - private collectWorker = new CollectWorkerClient(); + private collectWorker: CollectWorkerLike; - private watcher = new Watcher({ initialTargets: reactive({}) }); + private watcher: Watcher; private waitingWorker?: Promise; @@ -78,9 +84,16 @@ class Dep extends BaseService { private activeBatches = new Set(); - constructor() { + constructor(private ctx: SdkContext) { super(); + this.state = ctx.reactivity.shallowReactive({ + 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 }) => { @@ -229,7 +242,7 @@ class Dep extends BaseService { traverseTarget(this.watcher.getTargetsList(), (target) => { if (depsData[target.type]?.[target.id]) { - target.deps = reactive(depsData[target.type][target.id]); + target.deps = this.ctx.reactivity.reactive(depsData[target.type][target.id]); if (target.type === DepTargetType.DATA_SOURCE && dsl.dataSourceDeps) { dsl.dataSourceDeps[target.id] = target.deps; @@ -554,4 +567,4 @@ class Dep extends BaseService { export type DepService = Dep; -export default new Dep(); +export default Dep; diff --git a/packages/editor/src/services/editor.ts b/packages/editor/src/sdk/services/Editor.ts similarity index 91% rename from packages/editor/src/services/editor.ts rename to packages/editor/src/sdk/services/Editor.ts index 9db49c55..78398673 100644 --- a/packages/editor/src/services/editor.ts +++ b/packages/editor/src/sdk/services/Editor.ts @@ -16,13 +16,11 @@ * 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, @@ -35,14 +33,11 @@ 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, @@ -80,7 +75,13 @@ import { toggleFixedPosition, } from '@editor/utils/editor'; import { getLastPushedHistoryIds } from '@editor/utils/history'; -import { beforePaste, getAddParent } from '@editor/utils/operator'; + +import type { SdkContext } from '../type'; +import { beforePaste, getAddParent } from '../utils/operator'; +import { isFixed } from '../utils/position'; + +import BaseService from './BaseService'; +import { Protocol } from './WebStorage'; type MoveItem = { node: MNode; parent: MContainer; pageForOp: { name: string; id: Id } | null }; @@ -102,23 +103,13 @@ const buildUpdateDiff = ( ...(changeRecords?.length ? { changeRecords } : {}), })); +export interface EditorServiceConfig { + disabledPageFragment?: boolean; + canDropIn?: CanDropInFunction; +} + class Editor extends BaseService { - 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, - }); + public state: StoreState; private selectionBeforeOp: Id[] | null = null; /** * 操作前的节点校验错误快照,与 selectionBeforeOp 同时在 captureSelectionBeforeOp 中捕获, @@ -137,12 +128,32 @@ class Editor extends BaseService { */ private lastAdded: { selectedId: Id; nodeId: Id } | null = null; - constructor() { + /** 与编辑器 props 中 `disabledPageFragment` / `canDropIn` 对应,供结构合法性校验使用 */ + private config: EditorServiceConfig = {}; + + constructor(private ctx: SdkContext) { super( canUsePluginMethods.async.map((methodName) => ({ name: methodName, isAsync: true })), // 需要注意循环依赖问题,如果函数间有相互调用的话,不能设置为串行调用 ['select', 'update', 'moveLayer'], ); + + this.state = ctx.reactivity.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, + }); } /** @@ -185,8 +196,8 @@ class Editor extends BaseService { // 重复创建,set root 不额外产生记录,由恢复出的历史栈作为当前状态来源。 // 标记不进入撤销/重做栈,仅作为该页历史列表底部的初始基线展示。 app.items?.forEach((pageNode) => { - if (pageNode?.id !== undefined && !historyService.getMarker('page', pageNode.id)) { - historyService.setMarker('page', pageNode.id, { + if (pageNode?.id !== undefined && !this.ctx.history.getMarker('page', pageNode.id)) { + this.ctx.history.setMarker('page', pageNode.id, { name: pageNode.name, source: options.historySource, }); @@ -224,7 +235,7 @@ class Editor extends BaseService { public getNodeInfo(id: Id, raw = true): EditorNodeInfo { let root = this.get('root'); if (raw) { - root = toRaw(root); + root = this.toRaw(root); } if (!root) { @@ -340,11 +351,11 @@ class Editor extends BaseService { } public async selectNextNode(): Promise | never { - const node = toRaw(this.get('node')); + const node = this.toRaw(this.get('node')); if (!node || isPage(node) || node.type === NodeType.ROOT) return node; - const parent = toRaw(this.getParentById(node.id)); + const parent = this.toRaw(this.getParentById(node.id)); if (!parent) return node; @@ -359,8 +370,8 @@ class Editor extends BaseService { } public async selectNextPage(): Promise | never { - const root = toRaw(this.get('root')); - const page = toRaw(this.get('page')); + const root = this.toRaw(this.get('root')); + const page = this.toRaw(this.get('page')); if (!page) throw new Error('page不能为空'); if (!root) throw new Error('root不能为空'); @@ -414,6 +425,15 @@ class Editor extends BaseService { this.set('highlightNode', null); } + /** 设置结构合法性校验用到的配置,只覆盖传入的字段 */ + public setConfig(config: Partial): void { + this.config = { ...this.config, ...config }; + } + + public getConfig(): Readonly { + return this.config; + } + public async doAdd(node: MNode, parent: MContainer, _options: DslOpOptions = {}): Promise { const root = this.get('root'); @@ -439,7 +459,7 @@ class Editor extends BaseService { parent.items.splice(insertIndex, 0, node); this.lastAdded = { selectedId: curNode.id, nodeId: node.id }; - const layout = await this.getLayout(toRaw(parent), node as MNode); + const layout = await this.getLayout(this.toRaw(parent), node as MNode); node.style = getInitPositionStyle(node.style, layout); await stage?.add({ @@ -493,7 +513,7 @@ class Editor extends BaseService { if (!type) throw new Error('组件类型不能为空'); - addNodes.push({ ...toRaw(await propsService.getPropsValue(type, config)) }); + addNodes.push({ ...this.toRaw(await this.ctx.props.getPropsValue(type, config)) }); } else { addNodes.push(...addNode); } @@ -501,13 +521,24 @@ 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(node)); + const parentNode = isPageOrFragment(node) && root ? root : (parent ?? getAddParent(this.ctx, 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)); @@ -544,7 +575,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(toRaw(n)), + newSchema: cloneDeep(this.toRaw(n)), parentId, index: typeof idx === 'number' ? idx : -1, }; @@ -569,7 +600,7 @@ class Editor extends BaseService { // 页面 / 页面片新增不入历史栈(见上方 isPageOrFragment 分支),这里合并补发一次结构变更通知 const addedPages = newNodes.filter((node) => isPageOrFragment(node)) as (MPage | MPageFragment)[]; if (addedPages.length) { - historyService.notifyPageStructureChange({ add: addedPages, remove: [] }); + this.ctx.history.notifyPageStructureChange({ add: addedPages, remove: [] }); } return Array.isArray(addNode) ? newNodes : newNodes[0]; @@ -626,7 +657,7 @@ class Editor extends BaseService { // nextTick 用于等 Stage 的 page watch 把新页面 id 同步给 runtime。 await this.select(rootItems[0]); stage?.select(rootItems[0].id); - await nextTick(); + await this.ctx.reactivity.nextTick(); stage?.remove(removeData); } else { // page 置空会让 Workspace 卸载 Stage 并销毁 renderer,删除通知必须在卸载前发出, @@ -653,7 +684,7 @@ class Editor extends BaseService { if (!rootItems.length) { this.resetModifiedNodeId(); - historyService.reset(); + this.ctx.history.reset(); } } @@ -685,7 +716,7 @@ class Editor extends BaseService { } const idx = getNodeIndex(curNode.id, parent); removedItems.push({ - oldSchema: cloneDeep(toRaw(curNode)), + oldSchema: cloneDeep(this.toRaw(curNode)), parentId: parent.id, index: typeof idx === 'number' ? idx : -1, }); @@ -717,7 +748,7 @@ class Editor extends BaseService { // 页面 / 页面片删除不入历史栈(见上方 isPageOrFragment 分支),这里合并补发一次结构变更通知 const removedPages = nodes.filter((node) => isPageOrFragment(node)) as (MPage | MPageFragment)[]; if (removedPages.length) { - historyService.notifyPageStructureChange({ add: [], remove: removedPages }); + this.ctx.history.notifyPageStructureChange({ add: [], remove: removedPages }); } } @@ -735,12 +766,12 @@ class Editor extends BaseService { if (!info.node) throw new Error(`获取不到id为${config.id}的节点`); - const node = toRaw(info.node); + const node = this.toRaw(info.node); // replace=true 时跳过 toggleFixedPosition / mergeWith / setChildrenLayout,直接用传入配置整节点替换 let newConfig = replace - ? cloneDeep(toRaw(config)) - : await toggleFixedPosition(toRaw(config), node, info.path, this.getLayout); + ? cloneDeep(this.toRaw(config)) + : await toggleFixedPosition(this.toRaw(config), node, info.path, this.getLayout); if (!replace) { newConfig = mergeWith(cloneDeep(node), newConfig, editorNodeMergeCustomizer); @@ -821,6 +852,8 @@ 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( @@ -832,6 +865,7 @@ class Editor extends BaseService { // 校验错误信息在 pushOpHistory 之前落库,保证历史快照包含本次变更对应的错误状态。 this.applyInvalidInfo(config, invalidInfo); + commitInvalid?.(); if (updateData[0].oldNode?.type !== NodeType.ROOT) { const curNodes = this.get('nodes'); @@ -891,7 +925,7 @@ class Editor extends BaseService { const node = this.get('node'); if (!node) throw new Error('当前节点为空'); - const parent = cloneDeep(toRaw(this.get('parent'))); + const parent = cloneDeep(this.toRaw(this.get('parent'))); if (!parent) throw new Error('父节点为空'); const index2 = parent.items.findIndex((node: MNode) => `${node.id}` === `${id2}`); @@ -919,7 +953,7 @@ class Editor extends BaseService { * @returns */ public copy(config: MNode | MNode[]): void { - storageService.setItem(COPY_STORAGE_KEY, Array.isArray(config) ? config : [config], { + this.ctx.storage.setItem(COPY_STORAGE_KEY, Array.isArray(config) ? config : [config], { protocol: Protocol.OBJECT, }); } @@ -936,7 +970,7 @@ class Editor extends BaseService { collectRelatedNodes(copyNodes, collectorOptions, (id) => this.getNodeById(id)); } - storageService.setItem(COPY_STORAGE_KEY, copyNodes, { + this.ctx.storage.setItem(COPY_STORAGE_KEY, copyNodes, { protocol: Protocol.OBJECT, }); } @@ -962,7 +996,7 @@ class Editor extends BaseService { historySource, }: DslOpOptions = {}, ): Promise { - const config: MNode[] = storageService.getItem(COPY_STORAGE_KEY); + const config: MNode[] = this.ctx.storage.getItem(COPY_STORAGE_KEY); if (!Array.isArray(config)) return; const node = this.get('node'); @@ -978,7 +1012,7 @@ class Editor extends BaseService { const pasteConfigs = await this.doPaste(config, position); if (collectorOptions && typeof collectorOptions.isTarget === 'function') { - propsService.replaceRelateId(config, pasteConfigs, collectorOptions); + this.ctx.props.replaceRelateId(config, pasteConfigs, collectorOptions); } return this.add(pasteConfigs, parent, { @@ -991,9 +1025,9 @@ class Editor extends BaseService { } public async doPaste(config: MNode[], position: PastePosition = {}): Promise { - propsService.clearRelateId(); + this.ctx.props.clearRelateId(); const doc = this.get('stage')?.renderer?.contentWindow?.document; - const pasteConfigs = beforePaste(position, cloneDeep(config), doc); + const pasteConfigs = beforePaste(this.ctx, position, cloneDeep(config), doc); return pasteConfigs; } @@ -1001,7 +1035,7 @@ class Editor extends BaseService { const parent = this.getParentById(config.id); if (!parent) throw new Error('找不到父节点'); - const node = cloneDeep(toRaw(config)); + const node = cloneDeep(this.toRaw(config)); const layout = await this.getLayout(parent, node); const doc = this.get('stage')?.renderer?.contentWindow?.document; const newStyle = calcAlignCenterStyle(node, parent, layout, doc); @@ -1030,7 +1064,11 @@ class Editor extends BaseService { const newNodes = await Promise.all(nodes.map((node) => this.doAlignCenter(node))); - const newNode = await this.update(newNodes, { doNotPushHistory, historyDescription, historySource }); + const newNode = await this.update(newNodes, { + doNotPushHistory, + historyDescription, + historySource, + }); if (!doNotSelect) { if (newNodes.length > 1) { @@ -1075,7 +1113,7 @@ class Editor extends BaseService { return; } - const oldParent = cloneDeep(toRaw(parent)); + const oldParent = cloneDeep(this.toRaw(parent)); brothers.splice(index, 1); brothers.splice(offsetIndex, 0, node); @@ -1083,7 +1121,7 @@ class Editor extends BaseService { const grandparent = this.getParentById(parent.id); this.get('stage')?.update({ - config: cloneDeep(toRaw(parent)), + config: cloneDeep(this.toRaw(parent)), parentId: grandparent?.id, root: cloneDeep(root), }); @@ -1095,7 +1133,7 @@ class Editor extends BaseService { 'update', { - diff: buildUpdateDiff([{ oldNode: oldParent, newNode: cloneDeep(toRaw(parent)) }]), + diff: buildUpdateDiff([{ oldNode: oldParent, newNode: cloneDeep(this.toRaw(parent)) }]), pageData: { name: pageForOp?.name || '', id: pageForOp!.id }, historyDescription, source: historySource, @@ -1156,12 +1194,20 @@ class Editor extends BaseService { } const root = this.get('root'); - const stage = this.get('stage'); - if (!root || !stage) { - throw new Error('root 或 stage为空'); + if (!root) { + throw new Error('root 为空'); } + // 校验放在过滤之前:源节点不存在也要作为校验错误报出 + await this.runGuard(() => + this.ctx.validator.guardMove( + configs.map((item) => item.id), + target, + 'stage-drag', + ), + ); + // 收集 (节点, 源父) 信息,过滤掉异常节点(找不到父或源父等于目标本身) const moves: MoveItem[] = []; for (const { id } of configs) { @@ -1176,10 +1222,10 @@ class Editor extends BaseService { // 记录所有涉及的源父容器(按 id 去重)+ 目标容器的前置快照;同一父容器只快照一次。 const beforeSnapshots = new Map(); - beforeSnapshots.set(target.id, cloneDeep(toRaw(target))); + beforeSnapshots.set(target.id, cloneDeep(this.toRaw(target))); for (const { parent } of moves) { if (!beforeSnapshots.has(parent.id)) { - beforeSnapshots.set(parent.id, cloneDeep(toRaw(parent))); + beforeSnapshots.set(parent.id, cloneDeep(this.toRaw(parent))); } } @@ -1198,7 +1244,7 @@ class Editor extends BaseService { // 整批只入栈一条历史:updatedItems 包含所有源父容器 + 目标容器的前后快照(撤销/重做最小依赖)。 const updatedItems = Array.from(beforeSnapshots.entries()).map(([id, oldNode]) => ({ oldNode, - newNode: cloneDeep(toRaw(this.getNodeById(id, false))) as MNode, + newNode: cloneDeep(this.toRaw(this.getNodeById(id, false))) as MNode, })); const historyPage = moves[0].pageForOp ?? { name: '', id: target.id }; this.pushOpHistory('update', { @@ -1226,15 +1272,23 @@ 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(); for (const cfg of configs) { const { parent } = this.getNodeInfo(cfg.id, false); if (parent && !beforeSnapshots.has(`${parent.id}`)) { - beforeSnapshots.set(`${parent.id}`, cloneDeep(toRaw(parent))); + beforeSnapshots.set(`${parent.id}`, cloneDeep(this.toRaw(parent))); } } if (!beforeSnapshots.has(`${targetParent.id}`)) { - beforeSnapshots.set(`${targetParent.id}`, cloneDeep(toRaw(targetParent))); + beforeSnapshots.set(`${targetParent.id}`, cloneDeep(this.toRaw(targetParent))); } const newLayout = await this.getLayout(targetParent); @@ -1276,7 +1330,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(toRaw(newNode)) }); + updatedItems.push({ oldNode, newNode: cloneDeep(this.toRaw(newNode)) }); } } if (!doNotPushHistory) { @@ -1383,7 +1437,7 @@ class Editor extends BaseService { public async undo(): Promise { const pageId = this.get('page')?.id; if (pageId === undefined) return null; - const value = historyService.undo('page', pageId); + const value = this.ctx.history.undo('page', pageId); if (value) { await this.applyHistoryOp(value, true); } @@ -1397,7 +1451,7 @@ class Editor extends BaseService { public async redo(): Promise { const pageId = this.get('page')?.id; if (pageId === undefined) return null; - const value = historyService.redo('page', pageId); + const value = this.ctx.history.redo('page', pageId); if (value) { await this.applyHistoryOp(value, false); } @@ -1421,7 +1475,7 @@ class Editor extends BaseService { * @returns 反向后产生的新 step;目标不存在 / 未应用 / 反向失败时返回 null */ public async revertPageStep(index: number): Promise { - const list = historyService.getStepList('page', this.get('page')?.id); + const list = this.ctx.history.getStepList('page', this.get('page')?.id); const entry = list[index]; if (!entry?.applied) return null; @@ -1443,11 +1497,16 @@ class Editor extends BaseService { const captureRevert = (_pageId: Id, s: StepValue) => { revertedStep = s; }; - historyService.once('change', captureRevert); + this.ctx.history.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) { @@ -1506,11 +1565,11 @@ class Editor extends BaseService { } } } finally { - historyService.off('change', captureRevert); + this.ctx.history.off('change', captureRevert); } // 通知一次 history-change,让上层(如属性面板)按当前最新 DSL 刷新 - const page = toRaw(this.get('page')); + const page = this.toRaw(this.get('page')); if (page) { this.emit('history-change', page as MPage | MPageFragment); } @@ -1529,7 +1588,7 @@ class Editor extends BaseService { const results: (StepValue | null)[] = []; const pageId = this.get('page')?.id; for (const uuid of uuids) { - const location = historyService.findStepLocationByUuid('page', uuid, pageId); + const location = this.ctx.history.findStepLocationByUuid('page', uuid, pageId); results.push(!location ? null : await this.revertPageStep(location.index)); } return results; @@ -1546,8 +1605,8 @@ class Editor extends BaseService { */ public async gotoPageStep(targetCursor: number): Promise { const pageId = this.get('page')?.id; - let cursor = historyService.getCursor('page', pageId); - const { length } = historyService.getStepList('page', pageId); + let cursor = this.ctx.history.getCursor('page', pageId); + const { length } = this.ctx.history.getStepList('page', pageId); const target = Math.max(0, Math.min(targetCursor, length)); while (cursor > target) { const step = await this.undo(); @@ -1567,7 +1626,7 @@ class Editor extends BaseService { top: number, { doNotPushHistory = false, historyDescription, historySource }: DslOpOptions = {}, ) { - const node = toRaw(this.get('node')); + const node = this.toRaw(this.get('node')); if (!node || isPage(node)) return; const newStyle = calcMoveStyle(node.style || {}, left, top); @@ -1760,6 +1819,20 @@ class Editor extends BaseService { return null; } + /** + * 执行校验。校验不通过(strict 抛错)时操作不会继续,必须清掉 captureSelectionBeforeOp 留下的快照, + * 否则下一次成功的操作会沿用这份过期快照,写出错误的 selectedBefore / invalidNodeIdsBefore。 + */ + private async runGuard(guard: () => Promise): Promise { + 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); @@ -1794,15 +1867,22 @@ class Editor extends BaseService { this.pushPageDiffStep( 'add', nextPage, - { newSchema: cloneDeep(toRaw(nextPage)), parentId: nextRoot.id, index: indexInItems(nextRoot, nextPage.id) }, + { + newSchema: cloneDeep(this.toRaw(nextPage)), + parentId: nextRoot.id, + index: indexInItems(nextRoot, nextPage.id), + }, source, ); addedPages.push(nextPage); - } else if (!isEqual(toRaw(prevPage), toRaw(nextPage))) { + } else if (!isEqual(this.toRaw(prevPage), this.toRaw(nextPage))) { this.pushPageDiffStep( 'update', nextPage, - { oldSchema: cloneDeep(toRaw(prevPage)), newSchema: cloneDeep(toRaw(nextPage)) }, + { + oldSchema: cloneDeep(this.toRaw(prevPage)), + newSchema: cloneDeep(this.toRaw(nextPage)), + }, source, ); } @@ -1813,7 +1893,11 @@ class Editor extends BaseService { this.pushPageDiffStep( 'remove', prevPage, - { oldSchema: cloneDeep(toRaw(prevPage)), parentId: preRoot.id, index: indexInItems(preRoot, prevPage.id) }, + { + oldSchema: cloneDeep(this.toRaw(prevPage)), + parentId: preRoot.id, + index: indexInItems(preRoot, prevPage.id), + }, source, ); removedPages.push(prevPage); @@ -1821,7 +1905,7 @@ class Editor extends BaseService { }); if (addedPages.length || removedPages.length) { - historyService.notifyPageStructureChange({ add: addedPages, remove: removedPages }); + this.ctx.history.notifyPageStructureChange({ add: addedPages, remove: removedPages }); } } @@ -1852,11 +1936,11 @@ class Editor extends BaseService { }; if (source) step.source = source; - const top = historyService.getCurrentPageStep(page.id); + const top = this.ctx.history.getCurrentPageStep(page.id); if (top?.rootStep && top.source === source) { - historyService.replaceCurrentStep('page', step, page.id); + this.ctx.history.replaceCurrentStep('page', step, page.id); } else { - historyService.push('page', step, page.id); + this.ctx.history.push('page', step, page.id); } } @@ -1892,7 +1976,7 @@ class Editor extends BaseService { if (source) step.source = source; // 显式按 step.data.id 入栈:跨页操作(如 moveToContainer 从源页搬到目标页) // 必须落到正确的页面栈,否则会把记录错误地推到当前活动页 / 操作发起页。 - const pushed = historyService.push('page', step, pageData.id); + const pushed = this.ctx.history.push('page', step, pageData.id); // push 返回 null 表示当前没有可写入的页面栈(未真正入栈),此时不应返回 uuid。 const historyId = pushed ? step.uuid : null; this.lastPushedHistoryId = historyId; @@ -2051,7 +2135,7 @@ class Editor extends BaseService { this.set('invalidNodeIds', new Map(invalidToRestore)); this.emit('invalid-node-change', this.get('invalidNodeIds')); - const page = toRaw(this.get('page')); + const page = this.toRaw(this.get('page')); if (page) { const selectIds = (reverse ? step.extra?.selectedBefore : step.extra?.selectedAfter) ?? []; setTimeout(() => { @@ -2089,8 +2173,13 @@ class Editor extends BaseService { doNotPushHistory: true, }); } + + /** 经由 reactivity 注入的 toRaw(默认是 Vue 的 toRaw) */ + private toRaw(value: T): T { + return this.ctx.reactivity.toRaw(value); + } } export type EditorService = Editor; -export default new Editor(); +export default Editor; diff --git a/packages/editor/src/services/events.ts b/packages/editor/src/sdk/services/Events.ts similarity index 79% rename from packages/editor/src/services/events.ts rename to packages/editor/src/sdk/services/Events.ts index bd03c91c..ff72555b 100644 --- a/packages/editor/src/services/events.ts +++ b/packages/editor/src/sdk/services/Events.ts @@ -16,7 +16,6 @@ * limitations under the License. */ -import { reactive } from 'vue'; import { cloneDeep } from 'lodash-es'; import type { Writable } from 'type-fest'; @@ -25,6 +24,8 @@ import { toLine } from '@tmagic/utils'; import type { AsyncHookPlugin, SyncHookPlugin } from '@editor/type'; +import type { SdkContext } from '../type'; + import BaseService from './BaseService'; const canUsePluginMethods = { @@ -35,15 +36,17 @@ const canUsePluginMethods = { type AsyncMethodName = Writable<(typeof canUsePluginMethods)['async']>; type SyncMethodName = Writable<(typeof canUsePluginMethods)['sync']>; -const eventMap: Record = reactive({}); -const methodMap: Record = reactive({}); - class Events extends BaseService { - constructor() { + private eventMap: Record; + private methodMap: Record; + + constructor(ctx: SdkContext) { 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) { @@ -53,11 +56,11 @@ class Events extends BaseService { } public setEvent(type: string, events: EventOption[]) { - eventMap[toLine(type)] = [...events]; + this.eventMap[toLine(type)] = [...events]; } public getEvent(type: string, _data: { node?: MNode | null } = {}): EventOption[] { - return cloneDeep(eventMap[toLine(type)]) || []; + return cloneDeep(this.eventMap[toLine(type)]) || []; } public setMethods(methods: Record) { @@ -67,17 +70,17 @@ class Events extends BaseService { } public setMethod(type: string, method: EventOption[]) { - methodMap[toLine(type)] = [...method]; + this.methodMap[toLine(type)] = [...method]; } public getMethod(type: string, _data: { node?: MNode | null; targetId?: Id } = {}) { - return cloneDeep(methodMap[toLine(type)]) || []; + return cloneDeep(this.methodMap[toLine(type)]) || []; } public resetState() { // 原地清空而不是重新赋值,保留 reactive 代理身份,已建立的 computed / 渲染副作用才能收到更新 - Object.keys(eventMap).forEach((type) => delete eventMap[type]); - Object.keys(methodMap).forEach((type) => delete methodMap[type]); + Object.keys(this.eventMap).forEach((type) => delete this.eventMap[type]); + Object.keys(this.methodMap).forEach((type) => delete this.methodMap[type]); } public destroy() { @@ -93,4 +96,4 @@ class Events extends BaseService { export type EventsService = Events; -export default new Events(); +export default Events; diff --git a/packages/editor/src/services/history.ts b/packages/editor/src/sdk/services/History.ts similarity index 88% rename from packages/editor/src/services/history.ts rename to packages/editor/src/sdk/services/History.ts index ba0c8136..eb4f8682 100644 --- a/packages/editor/src/services/history.ts +++ b/packages/editor/src/sdk/services/History.ts @@ -16,7 +16,6 @@ * limitations under the License. */ -import { reactive } from 'vue'; import type { Writable } from 'type-fest'; import type { Id, MPage, MPageFragment } from '@tmagic/core'; @@ -37,7 +36,6 @@ import type { StepValue, SyncHookPlugin, } from '@editor/type'; -import { getEditorConfig } from '@editor/utils/config'; import { deserializeStacks, getOrCreateStack, @@ -46,11 +44,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, @@ -80,18 +78,57 @@ const DEFAULT_KEY: IDBValidKey = 'default'; // 全部历史栈统一收敛到 steps 字段(见 HistorySteps)。 const PERSIST_VERSION = 3; -class History extends BaseService { - public state = reactive({ - steps: { - page: {}, - codeBlock: {}, - dataSource: {}, - }, - stepNames: { ...DEFAULT_STEP_NAMES }, - }); +const EXTRA_MAP_KEYS = ['modifiedNodeIds', 'invalidNodeIdsBefore', 'invalidNodeIdsAfter'] as const; - constructor() { +/** + * 转换各栈 step.extra 中的 Map 字段,返回新对象(不修改入参,避免污染运行中的历史状态)。 + * - 导出:Map -> entries 数组,使快照可以直接 `JSON.stringify` + * - 还原:entries 数组 -> Map + */ +const convertExtraMaps = ( + stacks: Record | undefined, + restoring: boolean, +): Record | 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 = {}; + Object.entries(stacks).forEach(([id, stack]: [string, any]) => { + result[id] = !stack?.elementList + ? stack + : { + ...stack, + elementList: stack.elementList.map((step: any) => { + if (!step?.extra) return step; + const extra = { ...step.extra }; + // 导出与还原使用同一份 key 清单,保证往返对称 + EXTRA_MAP_KEYS.forEach((key) => { + if (extra[key]) extra[key] = convertValue(extra[key]); + }); + return { ...step, extra }; + }), + }; + }); + return result; +}; + +class History extends BaseService { + public state: HistoryState; + + constructor(private ctx: SdkContext) { super([...canUsePluginMethods.sync.map((methodName) => ({ name: methodName, isAsync: false }))]); + this.state = ctx.reactivity.reactive({ + steps: { + page: {}, + codeBlock: {}, + dataSource: {}, + }, + stepNames: { ...DEFAULT_STEP_NAMES }, + }); } /** @@ -148,7 +185,12 @@ class History extends BaseService { public setMarker( stepType: HistoryStepType, id: Id, - options: { name?: string; description?: string; source?: HistoryOpSource; extra?: Record } = {}, + options: { + name?: string; + description?: string; + source?: HistoryOpSource; + extra?: Record; + } = {}, ): StepValue | null { if (!this.isValidStackId(id)) return null; @@ -385,21 +427,46 @@ class History extends BaseService { public async saveToIndexedDB(options: HistoryPersistOptions = {}): Promise { 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; }); - const snapshot: PersistedHistoryState = { + return { version: PERSIST_VERSION, steps, savedAt: Date.now(), }; + } - await idbSet(this.resolveDbName(dbName, appId), storeName, key, snapshot); - this.emit('save-to-indexed-db', snapshot); + /** + * 用 {@link serialize} 导出的快照重建全部撤销/重做栈,会整体覆盖当前内存中的历史状态。 + */ + public restore(snapshot: PersistedHistoryState): PersistedHistoryState { + // 各 step 的 diff 以序列化字符串存储(含函数),由 deserializeStacks 逐条用 parseDSL 还原。 + const steps: HistoryState['steps'] = { page: {}, codeBlock: {}, dataSource: {} }; + Object.entries(snapshot.steps).forEach(([stepType, bucket]) => { + // 兼容 jsonSafe 导出的快照(Map 被转成了 entries 数组) + steps[stepType] = deserializeStacks(convertExtraMaps(bucket, true) as any, this.ctx.parseDSL); + }); + this.state.steps = steps; return snapshot; } @@ -414,17 +481,14 @@ class History extends BaseService { public async restoreFromIndexedDB(options: HistoryPersistOptions = {}): Promise { const { dbName = DEFAULT_DB_NAME, storeName = DEFAULT_STORE_NAME, key = DEFAULT_KEY, appId } = options; - const snapshot = await idbGet(this.resolveDbName(dbName, appId), storeName, key); + const snapshot = await this.getPersistence().get( + this.resolveDbName(dbName, appId), + storeName, + key, + ); if (!snapshot) return null; - // 各 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; + this.restore(snapshot); // initial 基线作为各栈 index 0 的 step 随 steps 一并还原,无需单独恢复;活动页由 editorService 维护。 this.emit('restore-from-indexed-db', snapshot); return snapshot; @@ -583,17 +647,25 @@ class History extends BaseService { }); } + private getPersistence() { + const persistence = this.ctx.options.historyPersistence; + if (!persistence) { + throw new Error('未提供 historyPersistence,无法读写历史快照;请改用 serialize() / restore()'); + } + return persistence; + } + /** * 把基础 dbName 与当前 DSL(root app)的 id 拼成最终库名,实现不同应用历史隔离。 * 取不到 app id(如尚未加载 DSL)时退回基础 dbName。 */ private resolveDbName(dbName: string, appId?: Id): string { // 优先用显式传入的 appId(「先恢复再 set root」时 root 尚未就绪);否则回退到当前 root.id。 - const resolvedAppId = appId ?? editorService.get('root')?.id; + const resolvedAppId = appId ?? this.ctx.editor.get('root')?.id; return resolvedAppId ? `${dbName}-${resolvedAppId}` : dbName; } } export type HistoryService = History; -export default new History(); +export default History; diff --git a/packages/editor/src/services/props.ts b/packages/editor/src/sdk/services/Props.ts similarity index 88% rename from packages/editor/src/services/props.ts rename to packages/editor/src/sdk/services/Props.ts index b9e5f49e..8246c00e 100644 --- a/packages/editor/src/services/props.ts +++ b/packages/editor/src/sdk/services/Props.ts @@ -16,7 +16,6 @@ * limitations under the License. */ -import { reactive } from 'vue'; import { cloneDeep, mergeWith } from 'lodash-es'; import type { Writable } from 'type-fest'; @@ -25,7 +24,6 @@ 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, @@ -33,7 +31,9 @@ import type { PropsState, SyncHookPlugin, } from '@editor/type'; -import { fillConfig } from '@editor/utils/props'; + +import type { SdkContext } from '../type'; +import { fillConfig } from '../utils/props'; import BaseService from './BaseService'; @@ -53,21 +53,22 @@ type AsyncMethodName = Writable<(typeof canUsePluginMethods)['async']>; type SyncMethodName = Writable<(typeof canUsePluginMethods)['sync']>; class Props extends BaseService { - private state = reactive({ - propsConfigMap: {}, - propsValueMap: {}, - relateIdMap: {}, - /** 禁用数据源 */ - disabledDataSource: false, - /** 禁用代码块 */ - disabledCodeBlock: false, - }); + private state: PropsState; - constructor() { + constructor(private ctx: SdkContext) { super([ ...canUsePluginMethods.async.map((methodName) => ({ name: methodName, isAsync: true })), ...canUsePluginMethods.sync.map((methodName) => ({ name: methodName, isAsync: false })), ]); + this.state = ctx.reactivity.reactive({ + propsConfigMap: {}, + propsValueMap: {}, + relateIdMap: {}, + /** 禁用数据源 */ + disabledDataSource: false, + /** 禁用代码块 */ + disabledCodeBlock: false, + }); } public setDisabledDataSource(disabled: boolean) { @@ -86,10 +87,9 @@ class Props extends BaseService { return this.state.disabledCodeBlock; } - public setPropsConfigs(configs: Record) { - Object.keys(configs).forEach((type: string) => { - this.setPropsConfig(toLine(type), configs[type]); - }); + /** 批量设置组件属性表单配置,返回的 Promise 在全部配置处理完(含异步的)后 resolve */ + public async setPropsConfigs(configs: Record) { + await Promise.all(Object.keys(configs).map((type: string) => this.setPropsConfig(toLine(type), configs[type]))); this.emit('props-configs-change'); } @@ -102,13 +102,15 @@ 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 }); + c = config({ editorService: this.ctx.editor }); } else { c = config; } @@ -134,9 +136,7 @@ class Props extends BaseService { } public setPropsValues(values: Record | PropsFormValueFunction>) { - Object.keys(values).forEach((type: string) => { - this.setPropsValue(toLine(type), values[type]); - }); + return Promise.all(Object.keys(values).map((type: string) => this.setPropsValue(toLine(type), values[type]))); } public getPropsValues(): Record> { @@ -151,7 +151,7 @@ class Props extends BaseService { public async setPropsValue(type: string, value: Partial | PropsFormValueFunction) { let v = value; if (typeof value === 'function') { - v = value({ editorService }); + v = value({ editorService: this.ctx.editor }); } 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 || editorService.getNodeById(config.id)) { + if (force || this.ctx.editor.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 new Props(); +export default Props; diff --git a/packages/editor/src/services/storage.ts b/packages/editor/src/sdk/services/WebStorage.ts similarity index 79% rename from packages/editor/src/services/storage.ts rename to packages/editor/src/sdk/services/WebStorage.ts index f15e2361..49585f35 100644 --- a/packages/editor/src/services/storage.ts +++ b/packages/editor/src/sdk/services/WebStorage.ts @@ -2,7 +2,8 @@ import serialize from 'serialize-javascript'; import type { Writable } from 'type-fest'; import type { SyncHookPlugin } from '@editor/type'; -import { getEditorConfig } from '@editor/utils/config'; + +import type { SdkContext, StorageLike } from '../type'; import BaseService from './BaseService'; @@ -26,15 +27,48 @@ const canUsePluginMethods = { type SyncMethodName = Writable<(typeof canUsePluginMethods)['sync']>; +/** 默认的内存存储,Node 下没有 localStorage 时使用 */ +export class MemoryStorage implements StorageLike { + private map = new Map(); + + 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: Storage = globalThis.localStorage; + private storage: StorageLike; private namespace = 'tmagic'; - constructor() { + constructor( + private ctx: SdkContext, + storage?: StorageLike, + ) { super(canUsePluginMethods.sync.map((methodName) => ({ name: methodName, isAsync: false }))); + this.storage = storage ?? new MemoryStorage(); } /** @@ -42,12 +76,12 @@ export class WebStorage extends BaseService { * const storageService = new StorageService(); * storageService.usePlugin({ * // 替换存储对象为 sessionStorage - * async afterGetStorage(): Promise { + * async afterGetStorage(): Promise { * return window.sessionStorage; * }, * }); */ - public getStorage(): Storage { + public getStorage(): StorageLike { return this.storage; } @@ -76,7 +110,7 @@ export class WebStorage extends BaseService { switch (protocol) { case Protocol.OBJECT: - return getEditorConfig('parseDSL')(`(${item})`); + return this.ctx.parseDSL(`(${item})`); case Protocol.JSON: return JSON.parse(item); case Protocol.NUMBER: @@ -153,4 +187,4 @@ export class WebStorage extends BaseService { } export type StorageService = WebStorage; -export default new WebStorage(); +export default WebStorage; diff --git a/packages/editor/src/sdk/type.ts b/packages/editor/src/sdk/type.ts new file mode 100644 index 00000000..86e11dd3 --- /dev/null +++ b/packages/editor/src/sdk/type.ts @@ -0,0 +1,191 @@ +/* + * Tencent is pleased to support the open source community by making TMagicEditor available. + * + * Copyright (C) 2025 Tencent. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * @fileoverview DSL SDK 的公共类型:响应式注入、适配器、上下文、校验结果。 + * + * 这里只放类型,禁止引入运行时依赖(vue / stage / design 等)。 + */ + +import type { DataSourceSchema, EventOption, Id, MApp, MNode } from '@tmagic/core'; +import type { FormConfig } from '@tmagic/form'; + +import type { + CanDropInFunction, + ComponentGroup, + DatasourceTypeOption, + PropsFormConfigFunction, + PropsFormValueFunction, +} from '@editor/type'; +import type { CollectWorkerPayload, CollectWorkerResult, DepsData } from '@editor/utils/dep/worker'; + +import type CodeBlock from './services/CodeBlock'; +import type ComponentList from './services/ComponentList'; +import type DataSource from './services/DataSource'; +import type Dep from './services/Dep'; +import type Editor from './services/Editor'; +import type Events from './services/Events'; +import type History from './services/History'; +import type Props from './services/Props'; +import type WebStorage from './services/WebStorage'; +import type { Validator } from './validate'; + +// #region Reactivity +/** + * 响应式实现,默认直接使用 Vue 的 `reactive` / `shallowReactive` / `toRaw` / `nextTick`; + * 可通过 `SdkOptions.reactivity` 覆盖。 + */ +export interface Reactivity { + reactive: (target: T) => T; + shallowReactive: (target: T) => T; + toRaw: (value: T) => T; + nextTick: () => Promise; +} +// #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(dbName: string, storeName: string, key: IDBValidKey): Promise; + set(dbName: string, storeName: string, key: IDBValidKey, value: unknown): Promise; +} +// #endregion HistoryPersistence + +// #region CollectWorkerLike +/** + * 依赖收集 worker 适配器。浏览器端注入基于 Web Worker 的实现,Node 下不提供, + * 依赖收集会退回主线程的空闲队列(`IdleTask`,Node 下由 setTimeout 驱动)。 + */ +export interface CollectWorkerLike { + readonly isSupported: boolean; + collectDsl(dsl: MApp): Promise; + collect(payload: CollectWorkerPayload): Promise; + 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; + /** 存储适配器,默认内存实现 */ + 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; + + /** 校验模式,默认 `strict` */ + validateMode?: ValidateMode; + /** 是否自动绑定依赖收集连线,默认 true;浏览器默认实例传 false,由 initService 挂载时绑定 */ + autoWiring?: boolean; + + // 登记表,与 EditorProps 同名项一一对应 + componentGroupList?: ComponentGroup[]; + propsConfigs?: Record; + propsValues?: Record | PropsFormValueFunction>; + eventMethodList?: Record; + datasourceList?: DatasourceTypeOption[]; + datasourceConfigs?: Record; + datasourceValues?: Record>; + datasourceEventMethodList?: Record; + 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; + /** 校验模式,可在运行时通过 `sdk.ctx.validateMode = ...` 切换 */ + validateMode: ValidateMode; + /** 校验器,`createDslSdk` 之后可用 */ + validator: Validator; + /** 登记表全部就绪后 resolve */ + ready: Promise; + parseDSL: (code: string) => any; +} +// #endregion SdkContext diff --git a/packages/editor/src/sdk/utils/dep.ts b/packages/editor/src/sdk/utils/dep.ts new file mode 100644 index 00000000..32f35177 --- /dev/null +++ b/packages/editor/src/sdk/utils/dep.ts @@ -0,0 +1,196 @@ +/* + * Tencent is pleased to support the open source community by making TMagicEditor available. + * + * Copyright (C) 2025 Tencent. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * @fileoverview 依赖收集的纯规则:哪些变化需要重新收集、依赖目标怎么建、依赖信息怎么写回 root。 + * + * SDK 的 `wiring`(同步收集)与浏览器端 `initService`(空闲队列收集 + 画布同步)共用, + * 两边只在「怎么收集」上不同,规则必须保持一致。 + */ + +import type { CodeBlockDSL, DataSourceSchema, Id, MApp, MComponent, Target } from '@tmagic/core'; +import { + createCodeBlockTarget, + createDataSourceCondTarget, + createDataSourceMethodTarget, + createDataSourceTarget, + DepTargetType, + NODE_CONDS_KEY, + NodeType, +} from '@tmagic/core'; +import type { ChangeRecord } from '@tmagic/form'; +import { isValueIncludeDataSource } from '@tmagic/utils'; + +import { isIncludeDataSource } from '@editor/utils/editor'; + +import type Dep from '../services/Dep'; +import type { Reactivity } from '../type'; + +type DataSourceDepType = DepTargetType.DATA_SOURCE | DepTargetType.DATA_SOURCE_COND | DepTargetType.DATA_SOURCE_METHOD; + +/** 数据源依赖类型 -> 写回 root 的字段 */ +const DEPS_KEY = { + [DepTargetType.DATA_SOURCE]: 'dataSourceDeps', + [DepTargetType.DATA_SOURCE_COND]: 'dataSourceCondDeps', + [DepTargetType.DATA_SOURCE_METHOD]: 'dataSourceMethodDeps', +} as const; + +const DATA_SOURCE_DEP_TYPES: DataSourceDepType[] = [ + DepTargetType.DATA_SOURCE, + DepTargetType.DATA_SOURCE_COND, + DepTargetType.DATA_SOURCE_METHOD, +]; + +const DATA_SOURCE_TARGET_CREATORS = { + [DepTargetType.DATA_SOURCE]: createDataSourceTarget, + [DepTargetType.DATA_SOURCE_COND]: createDataSourceCondTarget, + [DepTargetType.DATA_SOURCE_METHOD]: createDataSourceMethodTarget, +}; + +const isDataSourceDepType = (type: string): type is DataSourceDepType => type in DEPS_KEY; + +type UpdatedItem = { newNode: MComponent; oldNode: MComponent; changeRecords?: ChangeRecord[] }; + +/** 显示条件(`NODE_CONDS_KEY`)相关的字段 */ +const isCondsPath = (propPath: string) => + new RegExp(`${NODE_CONDS_KEY}.(\\d)+.cond`).test(propPath) || + new RegExp(`${NODE_CONDS_KEY}.(\\d)+.cond.(\\d)+.value`).test(propPath) || + propPath === NODE_CONDS_KEY; + +/** + * 判断一批已更新的节点哪些需要重新收集数据源依赖。 + * + * - 根节点、数据源相关的字段变化、修改的 key 已经在收集到的依赖中:需要重新收集 + * - 其余只需要收集代码块、数据源方法依赖 + */ +export const classifyUpdatedNodes = ( + ctx: { dep: Pick }, + 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, + 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, + id: string, + types: DataSourceDepType[] = DATA_SOURCE_DEP_TYPES, +) => { + for (const type of types) { + dep.removeTarget(id, type); + } +}; + +/** root 变化后重建所有依赖目标:代码块 + 数据源 */ +export const resetDepTargets = ( + dep: Pick, + 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]; +}; diff --git a/packages/editor/src/sdk/utils/operator.ts b/packages/editor/src/sdk/utils/operator.ts new file mode 100644 index 00000000..b02765b5 --- /dev/null +++ b/packages/editor/src/sdk/utils/operator.ts @@ -0,0 +1,114 @@ +import { isEmpty } from 'lodash-es'; + +import type { Id, MContainer, MNode } from '@tmagic/core'; +import { NodeType } from '@tmagic/core'; +import { calcValueByFontsize, getElById, isPage, isPageOrFragment } from '@tmagic/utils'; + +import type { AddMNode, PastePosition } from '@editor/type'; +import { generatePageNameByApp, getInitPositionStyle } from '@editor/utils/editor'; + +import type { SdkContext } from '../type'; + +type OperatorContext = Pick; + +/** + * 粘贴前置操作:返回分配了新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; +}; diff --git a/packages/editor/src/sdk/utils/position.ts b/packages/editor/src/sdk/utils/position.ts new file mode 100644 index 00000000..fce5e34e --- /dev/null +++ b/packages/editor/src/sdk/utils/position.ts @@ -0,0 +1,23 @@ +/* + * Tencent is pleased to support the open source community by making TMagicEditor available. + * + * Copyright (C) 2025 Tencent. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * 是否是 fixed 定位。与 `@tmagic/stage` 的同名函数等价,在这里单独实现, + * 避免 SDK 在 Node 下为了这一行逻辑引入整个画布依赖。 + */ +export const isFixed = (style: { position?: string }): boolean => style.position === 'fixed'; diff --git a/packages/editor/src/sdk/utils/props.ts b/packages/editor/src/sdk/utils/props.ts new file mode 100644 index 00000000..daae5661 --- /dev/null +++ b/packages/editor/src/sdk/utils/props.ts @@ -0,0 +1,368 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +/* + * Tencent is pleased to support the open source community by making TMagicEditor available. + * + * Copyright (C) 2025 Tencent. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { + HookType, + NODE_CONDS_KEY, + NODE_CONDS_RESULT_KEY, + NODE_DISABLE_CODE_BLOCK_KEY, + NODE_DISABLE_DATA_SOURCE_KEY, +} from '@tmagic/core'; +import type { ChildConfig, DisplayCondsConfig, FormConfig, TabConfig, TabPaneConfig } from '@tmagic/form'; + +export const arrayOptions = [ + { text: '包含', value: 'include' }, + { text: '不包含', value: 'not_include' }, +]; + +export const eqOptions = [ + { text: '等于', value: '=' }, + { text: '不等于', value: '!=' }, +]; + +export const numberOptions = [ + { text: '大于', value: '>' }, + { text: '大于等于', value: '>=' }, + { text: '小于', value: '<' }, + { text: '小于等于', value: '<=' }, + { text: '在范围内', value: 'between' }, + { text: '不在范围内', value: 'not_between' }, +]; + +export const booleanOptions = [ + { text: '是', value: 'is' }, + { text: '不是', value: 'not' }, +]; + +/** 按字段类型返回条件运算符选项(UI 与 typeMatch 校验共用) */ +export const getCondOpOptionsByFieldType = (type: string) => { + if (type === 'array') { + return arrayOptions; + } + + if (type === 'boolean' || type === 'null') { + return booleanOptions; + } + + if (type === 'number') { + return [...eqOptions, ...numberOptions]; + } + + if (type === 'string') { + return [...arrayOptions, ...eqOptions]; + } + + return [...arrayOptions, ...eqOptions, ...numberOptions]; +}; + +export const styleTabConfig: TabPaneConfig = { + title: '样式', + display: ({ services }: any) => !(services?.uiService?.get('showStylePanel') ?? true), + items: [ + { + name: 'style', + labelWidth: '100px', + type: 'style-setter', + items: [ + { + names: [ + 'display', + 'flexDirection', + 'justifyContent', + 'alignItems', + 'flexWrap', + 'marginTop', + 'marginRight', + 'marginBottom', + 'marginLeft', + 'paddingTop', + 'paddingRight', + 'paddingBottom', + 'paddingLeft', + 'width', + 'height', + 'overflow', + 'fontSize', + 'lineHeight', + 'fontWeight', + 'color', + 'textAlign', + 'backgroundColor', + 'backgroundImage', + 'backgroundSize', + 'backgroundPosition', + 'backgroundRepeat', + 'position', + 'zIndex', + 'top', + 'right', + 'bottom', + 'left', + 'borderRadius', + 'borderTopWidth', + 'borderTopStyle', + 'borderTopColor', + 'borderRightColor', + 'borderRightWidth', + 'borderRightStyle', + 'borderRightColor', + 'borderBottomWidth', + 'borderBottomStyle', + 'borderBottomColor', + 'borderLeftStyle', + 'borderLeftWidth', + 'borderLeftColor', + 'borderWidth', + 'borderStyle', + 'borderColor', + 'opacity', + ], + } as unknown as ChildConfig, + { + name: 'transform', + defaultValue: () => ({}), + }, + ], + }, + ], +}; + +export const eventTabConfig: TabPaneConfig = { + title: '事件', + items: [ + { + name: 'events', + src: 'component', + labelWidth: '100px', + type: 'event-select', + defaultValue: [], + rules: [{ typeMatch: true }], + }, + ], +}; + +export const advancedTabConfig: TabPaneConfig = { + title: '高级', + items: [ + { + name: NODE_DISABLE_CODE_BLOCK_KEY, + text: '禁用代码块', + type: 'switch', + defaultValue: false, + extra: '开启后,配置的代码块将不会被执行', + }, + { + name: NODE_DISABLE_DATA_SOURCE_KEY, + text: '禁用数据源', + type: 'switch', + defaultValue: false, + extra: '开启后,组件内配置的数据源相关配置将不会被编译,显隐条件将失效', + }, + { + name: 'created', + text: 'Created', + labelPosition: 'top', + type: 'code-select', + titleExtra: '组件初始化时执行', + rules: [ + { typeMatch: true, trigger: 'change' }, + { + validator: ({ value, callback }: any) => { + if (value && value.hookType !== HookType.CODE) { + return callback('hookType 必须是 code'); + } + callback(); + }, + }, + ], + }, + { + name: 'mounted', + text: 'Mounted', + labelPosition: 'top', + type: 'code-select', + titleExtra: '组件挂载到dom时执行', + rules: [ + { typeMatch: true, trigger: 'change' }, + { + validator: ({ value, callback }: any) => { + if (value && value.hookType !== HookType.CODE) { + return callback('hookType 必须是 code'); + } + callback(); + }, + }, + ], + }, + { + name: 'display', + text: 'Display', + titleExtra: '控制组件是否渲染,关系的代码块返回值为false时不渲染', + labelPosition: 'top', + type: 'code-select', + rules: [ + { typeMatch: true, trigger: 'change' }, + { + validator: ({ value, callback }: any) => { + if (value && value.hookType !== HookType.CODE) { + return callback('hookType 必须是 code'); + } + callback(); + }, + }, + ], + }, + ], +}; + +export const displayTabConfig: TabPaneConfig = { + 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] ? '显示' : '隐藏'};
同一条件组内的所有条件配置同时成立时表示该条件组成立,任意一个条件组成立时表示条件成立(条件组内为且的关系,条件组间为或的关系);
条件为空时表示成立;`, + }, + { + 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; + /** 复制结果提示 */ + 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]; +}; diff --git a/packages/editor/src/sdk/validate/index.ts b/packages/editor/src/sdk/validate/index.ts new file mode 100644 index 00000000..f1bcd888 --- /dev/null +++ b/packages/editor/src/sdk/validate/index.ts @@ -0,0 +1,501 @@ +/* + * Tencent is pleased to support the open source community by making TMagicEditor available. + * + * Copyright (C) 2025 Tencent. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * @fileoverview DSL 校验: + * + * - 结构合法性:组件类型是否在组件列表中、父节点是否是容器、`canDropIn`、 + * 页面片是否被禁用、id 是否重复、数据源类型是否可用; + * - 属性合法性:用 `@tmagic/form/headless` 的 `validateForm` 校验节点 / 数据源的表单配置。 + * + * 模式见 {@link ValidateMode}:`strict` 抛 {@link DslValidationError},`record` 写入 + * `editor.state.invalidNodeIds`,`off` 什么也不做。 + */ + +import { cloneDeep, mergeWith } from 'lodash-es'; + +import type { DataSourceSchema, Id, MContainer, MNode } from '@tmagic/core'; +import { NodeType } from '@tmagic/core'; +import type { FormConfig } from '@tmagic/form/headless'; +import { builtInFields, registerBuiltInFields, validateForm } from '@tmagic/form/headless'; +import { isPage, isPageFragment, isPageOrFragment, toLine, traverseNode } from '@tmagic/utils'; + +import type { CanDropInScene } from '@editor/type'; +import { editorNodeMergeCustomizer } from '@editor/utils/editor'; + +import type { DslValidationIssue, DslValidationResult, SdkContext } from '../type'; + +/** 校验不通过时抛出,`errors` 里是全部问题,调用方(如 agent)可据此修正后重试 */ +export class DslValidationError extends Error { + public readonly errors: DslValidationIssue[]; + + constructor(errors: DslValidationIssue[]) { + super( + `DSL 校验失败:${errors + .map((item) => `${item.id !== undefined ? `[${item.target}:${item.id}] ` : ''}${item.message}`) + .join(';')}`, + ); + this.name = 'DslValidationError'; + this.errors = errors; + } +} + +/** 内置的、不需要出现在组件列表里也合法的节点类型 */ +const BUILTIN_NODE_TYPES = new Set([NodeType.ROOT, NodeType.PAGE, NodeType.PAGE_FRAGMENT, 'container']); + +/** 内置的数据源类型 */ +const BUILTIN_DATA_SOURCE_TYPES = new Set(['base', 'http']); + +const BR_REG = //gi; + +/** `validateForm` 返回的是以 `
` 分隔的文案,拆成单条问题 */ +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; + /** 校验 DSL 中已有的数据源 */ + dataSource(id: Id): Promise; + /** 校验整份 DSL */ + dsl(): Promise; + + /** 校验一个节点配置的属性表单(不要求它已经在 DSL 中) */ + checkNodeProps(node: MNode): Promise; + /** 同步的结构校验:新增 */ + 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; + /** `replace` 与 `editor.update` 的同名选项一致:为 true 时整节点替换,否则与旧节点深合并 */ + guardUpdate(nodes: MNode[], options?: { replace?: boolean }): Promise; + guardMove(sourceIds: Id[], target: MContainer, scene: CanDropInScene): Promise; + 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 => ({ target, id, kind: 'structure', message, ...extra }); + +const nodeIssue = (id: Id | undefined, message: string, extra?: Partial) => + 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 | null => { + const set = new Set(); + 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 | 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(); + + 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([ + ...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, + options: { useFieldTextInError?: boolean } = {}, + ): Promise => { + 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 => { + 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 => { + 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(); + 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('
')); + } + }; + }; + + /** 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(); + 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)); + }, + }; +}; diff --git a/packages/editor/src/sdk/wiring.ts b/packages/editor/src/sdk/wiring.ts new file mode 100644 index 00000000..bc0c7c55 --- /dev/null +++ b/packages/editor/src/sdk/wiring.ts @@ -0,0 +1,211 @@ +/* + * Tencent is pleased to support the open source community by making TMagicEditor available. + * + * Copyright (C) 2025 Tencent. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * @fileoverview 依赖收集连线(只处理 DSL,不碰画布 / runtime)。 + * + * 数据源、代码块的依赖信息(`dataSourceDeps` / `dataSourceCondDeps` / `dataSourceMethodDeps`) + * 会写回 DSL。这里把「节点变化 -> 重新收集 -> 写回 root」的链路连起来,使 Node 下导出的 DSL + * 依赖信息完整。浏览器端画布同步仍由 `initService` 负责,两边共用 `./utils/dep` 里的规则。 + * + * 收集用的是同步的 `dep.collect`,不经过空闲队列,所以操作返回时依赖已经是最新的。 + */ + +import type { CodeBlockContent, DataSourceSchema, Id, MApp, MComponent, MNode, Target } from '@tmagic/core'; +import { createCodeBlockTarget, DepTargetType, NodeType } from '@tmagic/core'; +import type { ChangeRecord } from '@tmagic/form'; +import { getNodes, isPage } from '@tmagic/utils'; + +import { + addDataSourceTargets, + classifyUpdatedNodes, + getDataSourceRecollectTypes, + removeDataSourceTargets, + removeTargetDeps, + resetDepTargets, + writeTargetDeps, +} from './utils/dep'; +import type { SdkContext } from './type'; + +export { classifyUpdatedNodes } from './utils/dep'; + +export interface WiringHandle { + unbind(): void; +} + +const pendingSelects = new WeakMap>(); + +/** root 变化后自动选中节点是异步的,需要时可以等它完成 */ +export const whenWiringSettled = (ctx: SdkContext): Promise => 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 | 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[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); + } + }, + }; +}; diff --git a/packages/editor/src/services/keybinding.ts b/packages/editor/src/services/keybinding.ts index e61a7928..49a46c44 100644 --- a/packages/editor/src/services/keybinding.ts +++ b/packages/editor/src/services/keybinding.ts @@ -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 BaseService from './BaseService'; -import editorService from './editor'; +import { editorService } from './sdk-instance'; import uiService from './ui'; class Keybinding extends BaseService { diff --git a/packages/editor/src/services/sdk-instance.ts b/packages/editor/src/services/sdk-instance.ts new file mode 100644 index 00000000..13ebd892 --- /dev/null +++ b/packages/editor/src/services/sdk-instance.ts @@ -0,0 +1,66 @@ +/* + * Tencent is pleased to support the open source community by making TMagicEditor available. + * + * Copyright (C) 2025 Tencent. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * 浏览器端默认的 SDK 实例:`editorService` 等单例都从这里取。 + * + * 与 Node 下 `createDslSdk` 创建的实例是同一套实现,区别只在注入的适配器: + * Vue 的响应式、localStorage、IndexedDB、Web Worker、剪贴板与 `tMagicMessage`。 + */ + +import { createDslSdk } from '@editor/sdk'; +import { getEditorConfig } from '@editor/utils/config'; +import { CollectWorkerClient } from '@editor/utils/dep/collect-worker-client'; +import { idbGet, idbSet } from '@editor/utils/indexed-db'; +import { copyTextToClipboard, notifyByDesign } from '@editor/utils/props'; + +const sdk = createDslSdk({ + storage: typeof globalThis.localStorage === 'undefined' ? undefined : globalThis.localStorage, + // 编辑器通过 `setEditorConfig` 注入,需在调用时读取 + parseDSL: (code: string) => getEditorConfig('parseDSL')(code), + historyPersistence: { get: idbGet as any, set: idbSet }, + collectWorker: new CollectWorkerClient(), + copyText: copyTextToClipboard, + notify: notifyByDesign, + // 浏览器端的校验由属性面板负责;依赖收集由 initService 挂载时连线 + validateMode: 'off', + autoWiring: false, +}); + +export const editorService = sdk.editor; +export const historyService = sdk.history; +export const propsService = sdk.props; +export const dataSourceService = sdk.dataSource; +export const codeBlockService = sdk.codeBlock; +export const depService = sdk.dep; +export const eventsService = sdk.events; +export const storageService = sdk.storage; +export const componentListService = sdk.componentList; + +export { Protocol, WebStorage } from '@editor/sdk/services/WebStorage'; +export type { CodeBlockService } from '@editor/sdk/services/CodeBlock'; +export type { ComponentListService } from '@editor/sdk/services/ComponentList'; +export type { DataSourceService } from '@editor/sdk/services/DataSource'; +export type { DepEvents, DepService } from '@editor/sdk/services/Dep'; +export type { EditorService } from '@editor/sdk/services/Editor'; +export type { EventsService } from '@editor/sdk/services/Events'; +export type { HistoryService } from '@editor/sdk/services/History'; +export type { PropsService } from '@editor/sdk/services/Props'; +export type { StorageService } from '@editor/sdk/services/WebStorage'; + +export default sdk; diff --git a/packages/editor/src/services/stageOverlay.ts b/packages/editor/src/services/stageOverlay.ts index 1156d8d3..6edb80dc 100644 --- a/packages/editor/src/services/stageOverlay.ts +++ b/packages/editor/src/services/stageOverlay.ts @@ -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/services//BaseService'; -import editorService from '@editor/services//editor'; +import BaseService from '@editor/sdk/services/BaseService'; +import { editorService } from '@editor/services/sdk-instance'; import type { StageOptions, StageOverlayState, SyncHookPlugin } from '@editor/type'; const canUsePluginMethods = { diff --git a/packages/editor/src/services/ui.ts b/packages/editor/src/services/ui.ts index fe99d6bb..effbdd4f 100644 --- a/packages/editor/src/services/ui.ts +++ b/packages/editor/src/services/ui.ts @@ -21,7 +21,8 @@ import type { Writable } from 'type-fest'; import { convertToNumber } from '@tmagic/utils'; -import editorService from '@editor/services/editor'; +import BaseService from '@editor/sdk/services/BaseService'; +import { editorService } from '@editor/services/sdk-instance'; import type { AsyncHookPlugin, StageRect, UiState } from '@editor/type'; import { DEFAULT_LEFT_COLUMN_WIDTH, @@ -33,8 +34,7 @@ import { RIGHT_COLUMN_WIDTH_STORAGE_KEY, } from '@editor/utils/const'; -import BaseService from './BaseService'; -import storageService, { Protocol } from './storage'; +import { Protocol, storageService } from './sdk-instance'; const state = shallowReactive({ uiSelectMode: false, diff --git a/packages/editor/src/type.ts b/packages/editor/src/type.ts index 79cb4bab..2237870f 100644 --- a/packages/editor/src/type.ts +++ b/packages/editor/src/type.ts @@ -45,17 +45,17 @@ import type { UpdateDragEl, } from '@tmagic/stage'; -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 { 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 { 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'; diff --git a/packages/editor/src/utils/data-source/formConfigs/base.ts b/packages/editor/src/utils/data-source/formConfigs/base.ts index 24fcbd2e..f84c297b 100644 --- a/packages/editor/src/utils/data-source/formConfigs/base.ts +++ b/packages/editor/src/utils/data-source/formConfigs/base.ts @@ -1,4 +1,4 @@ -import { defineFormConfig, type FormConfig } from '@tmagic/form'; +import { defineFormConfig, type FormConfig } from '@tmagic/form/headless'; export default (): FormConfig => defineFormConfig([ diff --git a/packages/editor/src/utils/data-source/formConfigs/http.ts b/packages/editor/src/utils/data-source/formConfigs/http.ts index 1618905a..f4c48c3b 100644 --- a/packages/editor/src/utils/data-source/formConfigs/http.ts +++ b/packages/editor/src/utils/data-source/formConfigs/http.ts @@ -1,4 +1,4 @@ -import { defineFormConfig } from '@tmagic/form'; +import { defineFormConfig } from '@tmagic/form/headless'; export default defineFormConfig([ { diff --git a/packages/editor/src/utils/dep/collect-worker-client.ts b/packages/editor/src/utils/dep/collect-worker-client.ts index 843c40b9..c6df1d96 100644 --- a/packages/editor/src/utils/dep/collect-worker-client.ts +++ b/packages/editor/src/utils/dep/collect-worker-client.ts @@ -1,16 +1,19 @@ import serialize from 'serialize-javascript'; -import type { Id, MApp } from '@tmagic/core'; +import type { MApp } from '@tmagic/core'; import { error } from '../logger'; -import type { CollectWorkerPayload, CollectWorkerRequest, CollectWorkerResponse, DepsData } from './worker'; +import type { + CollectWorkerPayload, + CollectWorkerRequest, + CollectWorkerResponse, + CollectWorkerResult, + DepsData, +} from './worker'; import CollectWorker from './worker.ts?worker&inline'; -export interface CollectWorkerResult { - deps: DepsData; - nodeIds: Id[]; -} +export type { CollectWorkerResult }; interface CollectTask { id: number; diff --git a/packages/editor/src/utils/dep/worker.ts b/packages/editor/src/utils/dep/worker.ts index fd270499..0bc19747 100644 --- a/packages/editor/src/utils/dep/worker.ts +++ b/packages/editor/src/utils/dep/worker.ts @@ -19,6 +19,11 @@ import { error } from '../logger'; export type DepsData = Record>; +export interface CollectWorkerResult { + deps: DepsData; + nodeIds: Id[]; +} + /** 全量收集请求:worker 中根据 DSL 重建代码块/数据源 target */ export interface DslCollectRequest { /** 请求 id,用于把结果与调用方对应起来 */ diff --git a/packages/editor/src/utils/editor.ts b/packages/editor/src/utils/editor.ts index 0ed95d59..0b6827a9 100644 --- a/packages/editor/src/utils/editor.ts +++ b/packages/editor/src/utils/editor.ts @@ -23,7 +23,6 @@ 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, @@ -38,6 +37,8 @@ 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'; diff --git a/packages/editor/src/utils/event.ts b/packages/editor/src/utils/event.ts index 285b928e..f0a0d255 100644 --- a/packages/editor/src/utils/event.ts +++ b/packages/editor/src/utils/event.ts @@ -20,11 +20,16 @@ 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 from '@editor/services/dataSource'; -import editorService from '@editor/services/editor'; -import eventsService from '@editor/services/events'; +import { dataSourceService, editorService, eventsService } from '@editor/services/sdk-instance'; +import type { Services } from '@editor/type'; import { getCascaderOptionsFromFields } from '@editor/utils/data-source'; +/** 事件相关工具函数依赖的服务,可由表单上下文(`mForm.services`)注入 */ +export type EventUtilServices = Pick; + +/** 未显式传入 services 时使用浏览器端默认实例 */ +const getDefaultServices = (): EventUtilServices => ({ editorService, eventsService, dataSourceService }); + export const EVENT_NAME_VALUE_SEPARATOR = '.'; export type EventNameSelectOption = { text: string; value: string }; @@ -50,17 +55,21 @@ export const normalizeCompActionValue = (value: unknown): string => { export const getEventNameOptions = ( src: string | undefined, formValue: Record = {}, + services: EventUtilServices = getDefaultServices(), ): EventNameOption[] => { if (!formValue.type) { return []; } if (src === 'component') { - const sourceNode = editorService.getNodeById(formValue.id) || (formValue as MNode); - let events: EventOption[] | CascaderOption[] = eventsService.getEvent(formValue.type, { node: sourceNode }) || []; + const sourceNode = services.editorService.getNodeById(formValue.id) || (formValue as MNode); + let events: EventOption[] | CascaderOption[] = + services.eventsService.getEvent(formValue.type, { node: sourceNode }) || []; if (formValue.type === 'page-fragment-container' && formValue.pageFragmentId) { - const pageFragment = editorService.get('root')?.items?.find((page) => page.id === formValue.pageFragmentId); + const pageFragment = services.editorService + .get('root') + ?.items?.find((page) => page.id === formValue.pageFragmentId); if (!pageFragment) { return []; } @@ -79,8 +88,8 @@ export const getEventNameOptions = ( return; } - const node = editorService.getNodeById(current.id) || current; - const nodeEvents = eventsService.getEvent(current.type, { node }) || []; + const node = services.editorService.getNodeById(current.id) || current; + const nodeEvents = services.eventsService.getEvent(current.type, { node }) || []; (events as CascaderOption[]).push({ label: `${current.name}_${current.id}`, value: `${current.id}`, @@ -99,8 +108,8 @@ export const getEventNameOptions = ( } if (src === 'datasource') { - const events: EventNameOption[] = dataSourceService.getFormEvent(formValue.type) || []; - const dataSource = dataSourceService.getDataSourceById(formValue.id); + const events: EventNameOption[] = services.dataSourceService.getFormEvent(formValue.type) || []; + const dataSource = services.dataSourceService.getDataSourceById(formValue.id); const fields = dataSource?.fields || []; if (fields.length > 0) { @@ -157,6 +166,7 @@ export const collectEventNameOptionValues = ( export const getEventNameAllowedValues = ( config: { src?: string; eventNameConfig?: { options?: unknown } } = {}, formValue: Record = {}, + services: EventUtilServices = getDefaultServices(), ): Set | null => { if (typeof config.eventNameConfig?.options !== 'undefined') { return null; @@ -166,26 +176,33 @@ export const getEventNameAllowedValues = ( return null; } - return collectEventNameOptionValues(getEventNameOptions(config.src, formValue), isEventNameCheckStrictly(config.src)); + return collectEventNameOptionValues( + getEventNameOptions(config.src, formValue, services), + isEventNameCheckStrictly(config.src), + ); }; /** * 组装联动组件动作下拉/级联 options,与 EventSelect 默认 compActionConfig.options 逻辑一致。 */ -export const getCompActionOptions = (toId?: Id): EventNameOption[] => { +export const getCompActionOptions = ( + toId?: Id, + services: EventUtilServices = getDefaultServices(), +): EventNameOption[] => { if (typeof toId === 'undefined' || toId === null || toId === '') { return []; } - const node = editorService.getNodeById(toId); + const node = services.editorService.getNodeById(toId); if (!node?.type) { return []; } - let methods: EventOption[] | CascaderOption[] = eventsService.getMethod(node.type, { targetId: toId, node }) || []; + let methods: EventOption[] | CascaderOption[] = + services.eventsService.getMethod(node.type, { targetId: toId, node }) || []; if (node.type === 'page-fragment-container' && node.pageFragmentId) { - const pageFragment = editorService.get('root')?.items?.find((page) => page.id === node.pageFragmentId); + const pageFragment = services.editorService.get('root')?.items?.find((page) => page.id === node.pageFragmentId); if (!pageFragment) { return []; } @@ -193,13 +210,13 @@ export const getCompActionOptions = (toId?: Id): EventNameOption[] => { methods = []; (pageFragment.items || []).forEach((item: MComponent | MContainer) => { traverseNode(item, (current) => { - const node = editorService.getNodeById(current.id) || current; + const node = services.editorService.getNodeById(current.id) || current; if (!current.type) { return; } - const nodeMethods = eventsService.getMethod(current.type, { targetId: current.id, node }) || []; + const nodeMethods = services.eventsService.getMethod(current.type, { targetId: current.id, node }) || []; if (!nodeMethods.length) { return; } @@ -228,10 +245,11 @@ export const getCompActionOptions = (toId?: Id): EventNameOption[] => { export const getCompActionAllowedValues = ( config: { src?: string; compActionConfig?: { options?: unknown } } = {}, model: { to?: Id } = {}, + services: EventUtilServices = getDefaultServices(), ): Set | null => { if (typeof config.compActionConfig?.options !== 'undefined') { return null; } - return collectEventNameOptionValues(getCompActionOptions(model.to), isEventNameCheckStrictly(config.src)); + return collectEventNameOptionValues(getCompActionOptions(model.to, services), isEventNameCheckStrictly(config.src)); }; diff --git a/packages/editor/src/utils/operator.ts b/packages/editor/src/utils/operator.ts index b45adf70..6dccb6cf 100644 --- a/packages/editor/src/utils/operator.ts +++ b/packages/editor/src/utils/operator.ts @@ -1,14 +1,22 @@ 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 from '@editor/services/editor'; -import propsService from '@editor/services/props'; +import { editorService, propsService } from '@editor/services/sdk-instance'; import type { AddMNode, PastePosition } from '@editor/type'; -import { generatePageNameByApp, getInitPositionStyle } from '@editor/utils/editor'; + +import { + beforePaste as sdkBeforePaste, + getAddParent as sdkGetAddParent, + getDefaultConfig as sdkGetDefaultConfig, + getPositionInContainer as sdkGetPositionInContainer, +} from '../sdk/utils/operator'; + +const getContext = () => ({ + editor: editorService, + props: propsService, + reactivity: { toRaw } as any, +}); /** * 粘贴前置操作:返回分配了新id以及校准了坐标的配置 @@ -16,55 +24,8 @@ import { generatePageNameByApp, getInitPositionStyle } from '@editor/utils/edito * @param config 待粘贴的元素配置(复制时保存的那份配置) * @returns */ -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; -}; +export const beforePaste = (position: PastePosition, config: MNode[], doc?: Document): MNode[] => + sdkBeforePaste(getContext(), position, config, doc); /** * 将元素粘贴到容器内时,将相对于画布坐标转换为相对于容器的坐标 @@ -72,37 +33,10 @@ export const beforePaste = (position: PastePosition, config: MNode[], doc?: Docu * @param id 元素id * @returns PastePosition 转换后的坐标 */ -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 getPositionInContainer = (position: PastePosition = {}, id: Id, doc?: Document) => + sdkGetPositionInContainer(getContext(), position, id, doc); -export const getAddParent = (node: MNode) => { - const curNode = editorService.get('node'); +export const getAddParent = (node: MNode) => sdkGetAddParent(getContext(), node); - 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; -}; +export const getDefaultConfig = (addNode: AddMNode, parentNode: MContainer) => + sdkGetDefaultConfig(getContext(), addNode, parentNode); diff --git a/packages/editor/src/utils/props.ts b/packages/editor/src/utils/props.ts index 99b5a0ae..d7f28d80 100644 --- a/packages/editor/src/utils/props.ts +++ b/packages/editor/src/utils/props.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/naming-convention */ /* * Tencent is pleased to support the open source community by making TMagicEditor available. * @@ -16,341 +15,27 @@ * See the License for the specific language governing permissions and * 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 { ChildConfig, DisplayCondsConfig, FormConfig, TabConfig, TabPaneConfig } from '@tmagic/form'; +import type { FormConfig } from '@tmagic/form'; -export const arrayOptions = [ - { text: '包含', value: 'include' }, - { text: '不包含', value: 'not_include' }, -]; +import type { FillConfigOptions } from '@editor/sdk/utils/props'; +import { fillConfig as sdkFillConfig } from '@editor/sdk/utils/props'; -export const eqOptions = [ - { text: '等于', value: '=' }, - { text: '不等于', value: '!=' }, -]; +export * from '@editor/sdk/utils/props'; -export const numberOptions = [ - { text: '大于', value: '>' }, - { text: '大于等于', value: '>=' }, - { text: '小于', value: '<' }, - { text: '小于等于', value: '<=' }, - { text: '在范围内', value: 'between' }, - { text: '不在范围内', value: 'not_between' }, -]; +/** 浏览器端默认用 `navigator.clipboard` 复制,并用 `tMagicMessage` 提示 */ +export const copyTextToClipboard = (text: string): Promise => navigator.clipboard.writeText(text); -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 notifyByDesign = (type: 'success' | 'error' | 'warning' | 'info', message: string) => { + const fn = (tMagicMessage as any)[type] ?? tMagicMessage; + fn(message); }; -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 = { - 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] ? '显示' : '隐藏'};
同一条件组内的所有条件配置同时成立时表示该条件组成立,任意一个条件组成立时表示条件成立(条件组内为且的关系,条件组间为或的关系);
条件为空时表示成立;`, - }, - { - 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]; -}; +/** 与 SDK 的 `fillConfig` 一致,区别是默认接入浏览器的剪贴板与消息提示 */ +export const fillConfig = (config: FormConfig = [], options: FillConfigOptions = {}): FormConfig => + sdkFillConfig(config, { + copyText: copyTextToClipboard, + notify: notifyByDesign, + ...options, + }); diff --git a/packages/editor/src/utils/type-match-rules.ts b/packages/editor/src/utils/type-match-rules.ts index d4fadf98..b29a1389 100644 --- a/packages/editor/src/utils/type-match-rules.ts +++ b/packages/editor/src/utils/type-match-rules.ts @@ -35,9 +35,7 @@ import { removeDataSourceFieldPrefix, } from '@tmagic/utils'; -import codeBlockService from '@editor/services/codeBlock'; -import dataSourceService from '@editor/services/dataSource'; -import editorService from '@editor/services/editor'; +import { codeBlockService, dataSourceService, editorService } from '@editor/services/sdk-instance'; import { getFieldType, resolveFieldByPath } from '@editor/utils/data-source'; import { arrayOptions, diff --git a/packages/editor/tests/unit/Editor.spec.ts b/packages/editor/tests/unit/Editor.spec.ts index 9b6d8af3..115c084e 100644 --- a/packages/editor/tests/unit/Editor.spec.ts +++ b/packages/editor/tests/unit/Editor.spec.ts @@ -25,35 +25,55 @@ vi.mock('@editor/initService', () => ({ initServiceState: initServiceStateMock, })); -vi.mock('@editor/services/codeBlock', () => ({ default: {} })); -vi.mock('@editor/services/componentList', () => ({ default: {} })); -vi.mock('@editor/services/dataSource', () => ({ default: {} })); -vi.mock('@editor/services/dep', () => ({ default: {} })); +vi.mock('@editor/services/sdk-instance', async (importOriginal) => { + const actual = await importOriginal(); + 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(stageStub); + return { + default: { + get: vi.fn((key: string) => (key === 'stage' ? stage.value : undefined)), + __setStage: (value: any) => { + stage.value = value; + }, + }, + }; + })(); + const modEvents = await (() => ({ default: {} }))(); + const modHistory = await (() => ({ default: {} }))(); + const modProps = await (() => ({ default: {} }))(); + const modStorage = await (() => ({ default: {}, Protocol: {} }))(); + return { + ...actual, + codeBlockService: modCodeBlock.default, + componentListService: modComponentList.default, + dataSourceService: modDataSource.default, + depService: modDep.default, + editorService: modEditor.default, + eventsService: modEvents.default, + historyService: modHistory.default, + propsService: modProps.default, + storageService: modStorage.default, + Protocol: modStorage.Protocol, + }; +}); + const { stageStub } = vi.hoisted(() => ({ stageStub: { name: 'stage-instance' } })); // 用响应式 ref 承载 stage,模拟真实 editorService 的 reactive state, // 这样 formContext 的 computed 才会在 stage 变化时失效重算 -vi.mock('@editor/services/editor', async () => { - const { shallowRef } = await import('vue'); - const stage = shallowRef(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' } })); @@ -64,6 +84,15 @@ 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: {} }), @@ -89,25 +118,52 @@ vi.mock('@editor/layouts/sidebar/Sidebar.vue', () => ({ default: defineComponent({ name: 'FakeSidebar', emits: ['layer-node-dblclick'], - setup(_p, { emit }) { + setup(_p, { emit, slots }) { return () => - h('div', { - class: 'fake-sidebar', - onClick: () => emit('layer-node-dblclick', new MouseEvent('dblclick'), { id: 'a' }), - }); + 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']?.(), + ], + ); }, }), })); vi.mock('@editor/layouts/workspace/Workspace.vue', () => ({ - default: defineComponent({ name: 'FakeWorkspace', setup: () => () => h('div', { class: 'fake-workspace' }) }), + default: defineComponent({ + name: 'FakeWorkspace', + setup: + (_p, { slots }) => + () => + h('div', { class: 'fake-workspace' }, [ + slots['stage-top']?.(), + slots.stage?.(), + slots['workspace-content']?.(), + ]), + }), })); vi.mock('@editor/layouts/props-panel/PropsPanel.vue', () => ({ default: defineComponent({ name: 'PropsPanel', emits: ['mounted', 'unmounted', 'submit-error', 'form-error'], - setup(_p, { emit }) { + setup(_p, { emit, slots }) { injectedFormContext = inject(FORM_CONTEXT_KEY, undefined); injectedEnablePropsFormValidate = inject( ENABLE_PROPS_FORM_VALIDATE, @@ -115,6 +171,7 @@ 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')) }), @@ -241,16 +298,16 @@ describe('Editor', () => { }); test('stage 走 computed 读时求值,切换画布后能读到新实例', async () => { - const editorServiceMod = (await import('@editor/services/editor')) as any; + const editorServiceMod = (await import('@editor/services/sdk-instance')) as any; mount(Editor, { props: {} as any }); await nextTick(); expect((injectedFormContext!.value as any).stage).toBe(stageStub); const nextStage = { name: 'next-stage' }; - editorServiceMod.default.__setStage(nextStage); + editorServiceMod.editorService.__setStage(nextStage); expect((injectedFormContext!.value as any).stage).toBe(nextStage); - editorServiceMod.default.__setStage(stageStub); + editorServiceMod.editorService.__setStage(stageStub); }); test('ENABLE_PROPS_FORM_VALIDATE 默认注入 false', async () => { @@ -276,4 +333,44 @@ describe('Editor', () => { expect((wrapper.vm as any).editorService).toBeDefined(); expect((wrapper.vm as any).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, ``])); + 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); + }); }); diff --git a/packages/editor/tests/unit/fields/CodeSelect.spec.ts b/packages/editor/tests/unit/fields/CodeSelect.spec.ts index b2ecdb5e..832b4dff 100644 --- a/packages/editor/tests/unit/fields/CodeSelect.spec.ts +++ b/packages/editor/tests/unit/fields/CodeSelect.spec.ts @@ -10,7 +10,7 @@ import { mount } from '@vue/test-utils'; import CodeSelect from '@editor/fields/CodeSelect.vue'; // 表单配置由 fields/configs/codeSelect.ts 产出(组件与无渲染校验的嵌套配置共用), -// 那里直接用服务单例,因此这里 mock 服务模块本身 +// 那里通过 mForm.services 取服务,因此这里构造一个带 services 的 mForm const { dataSourceService, codeBlockService } = vi.hoisted(() => ({ dataSourceService: { get: vi.fn(() => true), @@ -22,8 +22,7 @@ const { dataSourceService, codeBlockService } = vi.hoisted(() => ({ }, })); -vi.mock('@editor/services/dataSource', () => ({ default: dataSourceService })); -vi.mock('@editor/services/codeBlock', () => ({ default: codeBlockService })); +const mForm = { services: { dataSourceService, codeBlockService } } as any; vi.mock('@tmagic/form', async (importOriginal) => { const actual = await importOriginal(); @@ -73,7 +72,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(undefined, { model: { codeType: 'code', codeId: 'c1' }, index: 0 }); + const title = config.title(mForm, { model: { codeType: 'code', codeId: 'c1' }, index: 0 }); expect(title).toBe('code-name'); }); @@ -81,7 +80,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(undefined, { + const title = config.title(mForm, { model: { codeType: 'data-source-method', codeId: ['ds1', 'doFetch'] }, index: 0, }); @@ -92,7 +91,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(undefined, { + const title = config.title(mForm, { model: { codeType: 'data-source-method', codeId: ['ds1'] }, index: 5, }); @@ -104,7 +103,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(undefined, { model: { codeType: 'code', codeId: 'unknown' }, index: 0 }); + const title = config.title(mForm, { model: { codeType: 'code', codeId: 'unknown' }, index: 0 }); expect(title).toBe('unknown'); }); @@ -165,8 +164,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()).toBe(true); - expect(config.items[2].notEditable()).toBe(true); + expect(config.items[1].notEditable(mForm)).toBe(true); + expect(config.items[2].notEditable(mForm)).toBe(true); codeBlockService.getEditStatus.mockReturnValue(true); dataSourceService.get.mockReturnValue(true); }); diff --git a/packages/editor/tests/unit/fields/DisplayConds.spec.ts b/packages/editor/tests/unit/fields/DisplayConds.spec.ts index e01e6f02..d59c5398 100644 --- a/packages/editor/tests/unit/fields/DisplayConds.spec.ts +++ b/packages/editor/tests/unit/fields/DisplayConds.spec.ts @@ -15,7 +15,7 @@ const { dataSourceService } = vi.hoisted(() => ({ dataSourceService: { getDataSourceById: vi.fn() }, })); -vi.mock('@editor/services/dataSource', () => ({ default: dataSourceService })); +const mForm = { services: { dataSourceService } } as any; 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()).toEqual([{ label: 'f1', value: 'f1' }]); + expect(item.options(mForm)).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(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'); + 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'); }); test('value display 函数', () => { @@ -218,7 +218,7 @@ describe('DisplayConds', () => { } as any, }); const item = capturedConfig.items[0].items[0]; - expect(item.options()).toEqual([]); + expect(item.options(mForm)).toEqual([]); }); test('isCompare 但无 lastValues 时不进入对比', () => { diff --git a/packages/editor/tests/unit/fields/EventSelect.spec.ts b/packages/editor/tests/unit/fields/EventSelect.spec.ts index e4a06dd7..a00888b3 100644 --- a/packages/editor/tests/unit/fields/EventSelect.spec.ts +++ b/packages/editor/tests/unit/fields/EventSelect.spec.ts @@ -37,11 +37,9 @@ vi.mock('@editor/hooks/use-services', () => ({ useServices: () => ({ editorService, dataSourceService, eventsService, codeBlockService, propsService }), })); -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 })); +const mForm = { + services: { editorService, dataSourceService, eventsService, codeBlockService, propsService }, +} as any; vi.mock('@editor/utils/data-source', async () => { const actual = await vi.importActual('@editor/utils/data-source'); @@ -188,8 +186,8 @@ describe('EventSelect', () => { model: { events: [{ name: 'a', actions: [] }] }, }); const cfg = eventNameCfg(); - expect(cfg.type(undefined, { formValue: { type: 'btn' } })).toBe('select'); - const opts = cfg.options(undefined, { formValue: { type: 'btn' } }); + expect(cfg.type(mForm, { formValue: { type: 'btn' } })).toBe('select'); + const opts = cfg.options(mForm, { formValue: { type: 'btn' } }); expect(Array.isArray(opts)).toBe(true); expect(opts[0]).toMatchObject({ text: 'click', value: 'click' }); }); @@ -202,16 +200,16 @@ describe('EventSelect', () => { const [rule] = cfg.rules; const okCb = vi.fn(); - rule.validator({ value: 'click', callback: okCb }, { formValue: { type: 'btn' } }); + rule.validator({ value: 'click', callback: okCb }, { formValue: { type: 'btn' } }, mForm); expect(okCb).toHaveBeenCalledWith(); const errCb = vi.fn(); - rule.validator({ value: 'unknown', callback: errCb }, { formValue: { type: 'btn' } }); + rule.validator({ value: 'unknown', callback: errCb }, { formValue: { type: 'btn' } }, mForm); expect(errCb).toHaveBeenCalledWith('事件名(unknown)不存在'); // 空值不做枚举校验 const emptyCb = vi.fn(); - rule.validator({ value: '', callback: emptyCb }, { formValue: { type: 'btn' } }); + rule.validator({ value: '', callback: emptyCb }, { formValue: { type: 'btn' } }, mForm); expect(emptyCb).toHaveBeenCalledWith(); }); @@ -224,7 +222,7 @@ describe('EventSelect', () => { const [rule] = cfg.rules; const cb = vi.fn(); - rule.validator({ value: 'whatever', callback: cb }, { formValue: { type: 'btn' } }); + rule.validator({ value: 'whatever', callback: cb }, { formValue: { type: 'btn' } }, mForm); expect(cb).toHaveBeenCalledWith(); }); @@ -234,10 +232,8 @@ describe('EventSelect', () => { model: { events: [{ name: 'a', actions: [] }] }, }); const cfg = eventNameCfg(); - 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(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(Array.isArray(opts)).toBe(true); }); @@ -248,7 +244,7 @@ describe('EventSelect', () => { model: { events: [{ name: 'a', actions: [] }] }, }); const cfg = eventNameCfg(); - const opts = cfg.options(undefined, { formValue: { type: 'ds', id: 'd1' } }); + const opts = cfg.options(mForm, { formValue: { type: 'ds', id: 'd1' } }); expect(opts).toEqual([{ label: '数据变化', value: 'ds_change_', children: [] }]); }); @@ -259,10 +255,34 @@ describe('EventSelect', () => { model: { events: [{ name: 'a', actions: [] }] }, }); const cfg = eventNameCfg(); - const opts = cfg.options(undefined, { formValue: { type: 'ds', id: 'd1' } }); + const opts = cfg.options(mForm, { formValue: { type: 'ds', id: 'd1' } }); expect(opts).toEqual([]); }); + 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); @@ -270,7 +290,7 @@ describe('EventSelect', () => { model: { events: [{ name: 'a', actions: [] }] }, }); const actionType = actionsCfg().items[0]; - const opts = typeof actionType.options === 'function' ? actionType.options() : actionType.options; + const opts = typeof actionType.options === 'function' ? actionType.options(mForm) : actionType.options; expect(opts.map((o: any) => o.value).sort()).toEqual(['code', 'comp', 'data-source'].sort()); }); @@ -281,7 +301,7 @@ describe('EventSelect', () => { model: { events: [{ name: 'a', actions: [] }] }, }); const actionType = actionsCfg().items[0]; - const opts = typeof actionType.options === 'function' ? actionType.options() : actionType.options; + const opts = typeof actionType.options === 'function' ? actionType.options(mForm) : actionType.options; expect(opts.map((o: any) => o.value)).toEqual(['comp']); propsService.getDisabledCodeBlock.mockReturnValue(false); propsService.getDisabledDataSource.mockReturnValue(false); @@ -304,8 +324,8 @@ describe('EventSelect', () => { model: { events: [{ name: 'a', actions: [] }] }, }); const compAction = actionsCfg().items[2]; - expect(compAction.type(undefined, { model: { to: '1' } })).toBe('select'); - expect(Array.isArray(compAction.options(undefined, { model: { to: '1' } }))).toBe(true); + expect(compAction.type(mForm, { model: { to: '1' } })).toBe('select'); + expect(Array.isArray(compAction.options(mForm, { model: { to: '1' } }))).toBe(true); }); test('compActionConfig type cascader 当 page-fragment-container', () => { @@ -315,8 +335,8 @@ describe('EventSelect', () => { model: { events: [{ name: 'a', actions: [] }] }, }); const compAction = actionsCfg().items[2]; - expect(compAction.type(undefined, { model: { to: '1' } })).toBe('cascader'); - const opts = compAction.options(undefined, { model: { to: '1' } }); + expect(compAction.type(mForm, { model: { to: '1' } })).toBe('cascader'); + const opts = compAction.options(mForm, { model: { to: '1' } }); expect(Array.isArray(opts)).toBe(true); }); @@ -326,7 +346,7 @@ describe('EventSelect', () => { model: { events: [{ name: 'a', actions: [] }] }, }); const compAction = actionsCfg().items[2]; - expect(compAction.options(undefined, { model: { to: 'unknown' } })).toEqual([]); + expect(compAction.options(mForm, { model: { to: 'unknown' } })).toEqual([]); }); test('compActionConfig.rules 仅校验动作名是否在可选项中', () => { @@ -339,15 +359,15 @@ describe('EventSelect', () => { const [rule] = compAction.rules; const okCb = vi.fn(); - rule.validator({ value: 'open', callback: okCb }, { model: { to: '1' } }); + rule.validator({ value: 'open', callback: okCb }, { model: { to: '1' } }, mForm); expect(okCb).toHaveBeenCalledWith(); const errCb = vi.fn(); - rule.validator({ value: 'unknown', callback: errCb }, { model: { to: '1' } }); + rule.validator({ value: 'unknown', callback: errCb }, { model: { to: '1' } }, mForm); expect(errCb).toHaveBeenCalledWith('动作名(unknown)不存在'); const emptyCb = vi.fn(); - rule.validator({ value: '', callback: emptyCb }, { model: { to: '1' } }); + rule.validator({ value: '', callback: emptyCb }, { model: { to: '1' } }, mForm); expect(emptyCb).toHaveBeenCalledWith(); }); @@ -365,7 +385,7 @@ describe('EventSelect', () => { const [rule] = compAction.rules; const cb = vi.fn(); - rule.validator({ value: 'whatever', callback: cb }, { model: { to: '1' } }); + rule.validator({ value: 'whatever', callback: cb }, { model: { to: '1' } }, mForm); expect(cb).toHaveBeenCalledWith(); }); @@ -380,11 +400,11 @@ describe('EventSelect', () => { const [rule] = compAction.rules; const okCb = vi.fn(); - rule.validator({ value: ['c1', 'open'], callback: okCb }, { model: { to: '1' } }); + rule.validator({ value: ['c1', 'open'], callback: okCb }, { model: { to: '1' } }, mForm); expect(okCb).toHaveBeenCalledWith(); const errCb = vi.fn(); - rule.validator({ value: ['c1', 'missing'], callback: errCb }, { model: { to: '1' } }); + rule.validator({ value: ['c1', 'missing'], callback: errCb }, { model: { to: '1' } }, mForm); expect(errCb).toHaveBeenCalledWith('动作名(c1.missing)不存在'); }); @@ -395,7 +415,7 @@ describe('EventSelect', () => { }); const codeAction = actionsCfg().items[3]; expect(codeAction.display(undefined, { model: { actionType: 'code' } })).toBe(true); - expect(codeAction.notEditable()).toBe(true); + expect(codeAction.notEditable(mForm)).toBe(true); codeBlockService.getEditStatus.mockReturnValue(true); }); @@ -406,7 +426,7 @@ describe('EventSelect', () => { }); const dsAction = actionsCfg().items[4]; expect(dsAction.display(undefined, { model: { actionType: 'data-source' } })).toBe(true); - expect(dsAction.notEditable()).toBe(true); + expect(dsAction.notEditable(mForm)).toBe(true); }); test('table 配置中 method options', () => { @@ -418,10 +438,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(undefined, { model: { to: '1' } }); + const opts = methodCol.options(mForm, { model: { to: '1' } }); expect(opts).toEqual([{ text: 'open', value: 'open' }]); editorService.getNodeById.mockReturnValue(null); - expect(methodCol.options(undefined, { model: { to: '1' } })).toEqual([]); + expect(methodCol.options(mForm, { model: { to: '1' } })).toEqual([]); }); describe('对比模式', () => { diff --git a/packages/editor/tests/unit/hooks/use-stage.spec.ts b/packages/editor/tests/unit/hooks/use-stage.spec.ts index c9cece8d..d73fea71 100644 --- a/packages/editor/tests/unit/hooks/use-stage.spec.ts +++ b/packages/editor/tests/unit/hooks/use-stage.spec.ts @@ -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/editor'; +import { editorService } from '@editor/services/sdk-instance'; import uiService from '@editor/services/ui'; const { stageInstance, StageCoreCtor, getIdFromElFn } = vi.hoisted(() => { @@ -56,22 +56,29 @@ const editorState: Record = { alwaysMultiSelect: false, }; -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 })), - }, -})); +vi.mock('@editor/services/sdk-instance', async (importOriginal) => { + const actual = await importOriginal(); + 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, + }; +}); const uiState: Record = { zoom: 1, uiSelectMode: false }; vi.mock('@editor/services/ui', () => ({ diff --git a/packages/editor/tests/unit/initService.spec.ts b/packages/editor/tests/unit/initService.spec.ts index 0b37dae0..c84adefb 100644 --- a/packages/editor/tests/unit/initService.spec.ts +++ b/packages/editor/tests/unit/initService.spec.ts @@ -35,6 +35,7 @@ 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(), diff --git a/packages/editor/tests/unit/layouts/Framework.spec.ts b/packages/editor/tests/unit/layouts/Framework.spec.ts index ea8856b4..5b07dfbf 100644 --- a/packages/editor/tests/unit/layouts/Framework.spec.ts +++ b/packages/editor/tests/unit/layouts/Framework.spec.ts @@ -76,8 +76,14 @@ vi.mock('@editor/layouts/page-bar/PageBar.vue', () => ({ default: defineComponent({ name: 'PageBar', props: ['disabledPageFragment'], - setup() { - return () => h('div', { class: 'fake-page-bar' }); + 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: [] }), + ]); }, }), })); @@ -154,4 +160,24 @@ 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': '', + 'page-bar-title': '', + 'page-bar-popover': '', + 'page-list-popover': '', + }, + }); + 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(); + }); }); diff --git a/packages/editor/tests/unit/layouts/history-list/HistoryListPanel.spec.ts b/packages/editor/tests/unit/layouts/history-list/HistoryListPanel.spec.ts index 894f5277..7eec7a68 100644 --- a/packages/editor/tests/unit/layouts/history-list/HistoryListPanel.spec.ts +++ b/packages/editor/tests/unit/layouts/history-list/HistoryListPanel.spec.ts @@ -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/history'; +import { historyService } from '@editor/services/sdk-instance'; import { createStackStep } from '@editor/utils/history'; // pushCodeBlock / pushDataSource 已合入统一的 push(stepType, step, id);用等价小工具沿用既有用例。 diff --git a/packages/editor/tests/unit/layouts/history-list/composables.spec.ts b/packages/editor/tests/unit/layouts/history-list/composables.spec.ts index 41e58b3f..8f878a18 100644 --- a/packages/editor/tests/unit/layouts/history-list/composables.spec.ts +++ b/packages/editor/tests/unit/layouts/history-list/composables.spec.ts @@ -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/history'; +import { historyService } from '@editor/services/sdk-instance'; import type { HistoryGroup, HistoryStepEntry, StepValue } from '@editor/type'; import { createStackStep } from '@editor/utils/history'; diff --git a/packages/editor/tests/unit/layouts/workspace/Stage.spec.ts b/packages/editor/tests/unit/layouts/workspace/Stage.spec.ts index ae4b19a7..cfeca150 100644 --- a/packages/editor/tests/unit/layouts/workspace/Stage.spec.ts +++ b/packages/editor/tests/unit/layouts/workspace/Stage.spec.ts @@ -21,10 +21,9 @@ 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 = diff --git a/packages/editor/tests/unit/sdk/dep-rules.spec.ts b/packages/editor/tests/unit/sdk/dep-rules.spec.ts new file mode 100644 index 00000000..2c43ee26 --- /dev/null +++ b/packages/editor/tests/unit/sdk/dep-rules.spec.ts @@ -0,0 +1,173 @@ +// @vitest-environment node +import { describe, expect, test, vi } from 'vitest'; + +import { DepTargetType } from '@tmagic/core'; + +import { createDslSdk } from '@editor/sdk'; +import { + addDataSourceTargets, + getDataSourceRecollectTypes, + removeDataSourceTargets, + removeTargetDeps, + resetDepTargets, + writeTargetDeps, +} from '@editor/sdk/utils/dep'; + +import { createDsl, createSdk } from './helpers'; + +const record = (propPath?: string) => ({ propPath, value: undefined }) as any; + +describe('getDataSourceRecollectTypes', () => { + test('字段变化:重建数据源与条件依赖', () => { + for (const path of ['fields', 'fields.0.name', 'fields.1.defaultValue', 'fields.2']) { + expect(getDataSourceRecollectTypes([record(path)])).toEqual([ + DepTargetType.DATA_SOURCE, + DepTargetType.DATA_SOURCE_COND, + ]); + } + }); + + test('mocks 变化:只重建数据源依赖', () => { + expect(getDataSourceRecollectTypes([record('mocks')])).toEqual([DepTargetType.DATA_SOURCE]); + }); + + test('方法变化:只重建方法依赖', () => { + for (const path of ['methods', 'methods.0.name', 'methods.3']) { + expect(getDataSourceRecollectTypes([record(path)])).toEqual([DepTargetType.DATA_SOURCE_METHOD]); + } + }); + + test('取第一条命中规则的记录,忽略没有 propPath 的记录', () => { + expect( + getDataSourceRecollectTypes([record(undefined), record('title'), record('methods'), record('fields')]), + ).toEqual([DepTargetType.DATA_SOURCE_METHOD]); + }); + + test('不影响依赖的变化返回 null', () => { + expect(getDataSourceRecollectTypes([record('title'), record(undefined)])).toBeNull(); + expect(getDataSourceRecollectTypes([])).toBeNull(); + }); +}); + +describe('依赖目标与 root 写回', () => { + const ds = { id: 'ds_1', type: 'base', fields: [], methods: [] } as any; + + test('addDataSourceTargets / removeDataSourceTargets 默认处理三类,可指定类型', () => { + const dep = { addTarget: vi.fn(), removeTarget: vi.fn() }; + + addDataSourceTargets(dep as any, ds, (v) => v); + expect(dep.addTarget.mock.calls.map(([target]) => target.type)).toEqual([ + DepTargetType.DATA_SOURCE, + DepTargetType.DATA_SOURCE_COND, + DepTargetType.DATA_SOURCE_METHOD, + ]); + + dep.addTarget.mockClear(); + addDataSourceTargets(dep as any, ds, (v) => v, [DepTargetType.DATA_SOURCE_METHOD]); + expect(dep.addTarget).toHaveBeenCalledTimes(1); + + removeDataSourceTargets(dep as any, 'ds_1'); + expect(dep.removeTarget).toHaveBeenCalledTimes(3); + removeDataSourceTargets(dep as any, 'ds_1', [DepTargetType.DATA_SOURCE]); + expect(dep.removeTarget).toHaveBeenLastCalledWith('ds_1', DepTargetType.DATA_SOURCE); + }); + + test('resetDepTargets 先清空再建代码块与数据源目标', () => { + const dep = { clearTargets: vi.fn(), addTarget: vi.fn() }; + + resetDepTargets(dep as any, { code_1: { name: 'c', content: '', params: [] } } as any, [ds], (v) => v); + + expect(dep.clearTargets).toHaveBeenCalledTimes(1); + expect(dep.addTarget.mock.calls.map(([target]) => target.type)).toEqual([ + DepTargetType.CODE_BLOCK, + DepTargetType.DATA_SOURCE, + DepTargetType.DATA_SOURCE_COND, + DepTargetType.DATA_SOURCE_METHOD, + ]); + }); + + test('writeTargetDeps / removeTargetDeps 只处理数据源三类依赖', () => { + const root: any = {}; + const deps = { n1: { name: 'n1', keys: ['text'] } }; + + writeTargetDeps(root, { id: 'ds_1', type: DepTargetType.DATA_SOURCE, deps } as any); + writeTargetDeps(root, { id: 'ds_1', type: DepTargetType.DATA_SOURCE_COND, deps } as any); + writeTargetDeps(root, { id: 'ds_1', type: DepTargetType.DATA_SOURCE_METHOD, deps } as any); + writeTargetDeps(root, { id: 'code_1', type: DepTargetType.CODE_BLOCK, deps } as any); + writeTargetDeps(null, { id: 'ds_1', type: DepTargetType.DATA_SOURCE, deps } as any); + + expect(root.dataSourceDeps.ds_1).toBe(deps); + expect(root.dataSourceCondDeps.ds_1).toBe(deps); + expect(root.dataSourceMethodDeps.ds_1).toBe(deps); + expect(Object.keys(root)).toHaveLength(3); + + removeTargetDeps(root, 'ds_1', DepTargetType.DATA_SOURCE); + removeTargetDeps(root, 'ds_1', DepTargetType.CODE_BLOCK); + removeTargetDeps(root, 'ds_1', 'unknown'); + removeTargetDeps(undefined, 'ds_1', DepTargetType.DATA_SOURCE); + removeTargetDeps({} as any, 'ds_1', DepTargetType.DATA_SOURCE); + + expect(root.dataSourceDeps.ds_1).toBeUndefined(); + expect(root.dataSourceCondDeps.ds_1).toBe(deps); + }); +}); + +describe('延迟绑定连线', () => { + test('autoWiring 为 false 时,之后调用 bindWiring 会补收集已有 DSL 的依赖', async () => { + const sdk = createSdk({ autoWiring: false }); + await sdk.ready; + expect(sdk.getDsl()!.dataSourceDeps).toBeUndefined(); + + const unbind = sdk.bindWiring(); + await sdk.ready; + + expect(sdk.getDsl()!.dataSourceDeps!.ds_1.text_1).toBeDefined(); + + const node = (await sdk.editor.add({ type: 'text', text: '${ds_1.name}' } as any)) as any; + expect(sdk.getDsl()!.dataSourceDeps!.ds_1[node.id]).toBeDefined(); + + unbind(); + }); + + test('重复 bindWiring 返回同一个解绑函数且不会重复绑定', async () => { + const sdk = createDslSdk({ dsl: createDsl(), autoWiring: false }); + await sdk.ready; + + const first = sdk.bindWiring(); + const second = sdk.bindWiring(); + expect(second).toBe(first); + + first(); + expect(sdk.editor.listenerCount('add')).toBe(0); + }); +}); + +describe('登记表', () => { + test('setPropsConfigs / setPropsValues 返回的 Promise 在异步配置处理完后 resolve', async () => { + const sdk = createDslSdk({}); + + await sdk.props.setPropsConfigs({ Text: [{ name: 'text', text: '文本' }] }); + await sdk.props.setPropsValues({ Text: { text: 'x' } }); + + expect(sdk.props.getPropsConfigs()).toHaveProperty('text'); + expect(sdk.props.getPropsValues()).toHaveProperty('text', { text: 'x' }); + }); + + test('setPropsConfigs 在配置全部写入后才 emit props-configs-change', async () => { + const sdk = createDslSdk({}); + let hasTextAtEmit: boolean | undefined; + sdk.props.on('props-configs-change', () => { + hasTextAtEmit = sdk.props.hasPropsConfig('Text'); + }); + + await sdk.props.setPropsConfigs({ Text: [{ name: 'text', text: '文本' }] }); + + expect(hasTextAtEmit).toBe(true); + }); + + test('没有传 eventMethodList 时 events 保持为空', () => { + const sdk = createDslSdk({}); + expect(sdk.events.getEvent('text')).toEqual([]); + expect(sdk.events.getMethod('text')).toEqual([]); + }); +}); diff --git a/packages/editor/tests/unit/sdk/helpers/index.ts b/packages/editor/tests/unit/sdk/helpers/index.ts new file mode 100644 index 00000000..7dc92096 --- /dev/null +++ b/packages/editor/tests/unit/sdk/helpers/index.ts @@ -0,0 +1,40 @@ +import type { MApp } from '@tmagic/core'; + +import type { SdkOptions } from '@editor/sdk'; +import { createDslSdk } from '@editor/sdk'; + +export const createDsl = (): MApp => + ({ + id: 'app_1', + type: 'app', + items: [ + { + id: 'page_1', + type: 'page', + name: 'page', + items: [ + { id: 'text_1', type: 'text', text: '${ds_1.name}' }, + { id: 'container_1', type: 'container', items: [{ id: 'button_1', type: 'button', text: 'btn' }] }, + ], + }, + ], + dataSources: [{ id: 'ds_1', type: 'base', title: 'ds', fields: [{ name: 'name' }], methods: [], events: [] }], + codeBlocks: { code_1: { name: 'code', content: '() => {}', params: [] } }, + }) as any; + +export const COMPONENT_GROUP_LIST = [ + { + title: '基础', + items: [ + { text: '文本', type: 'text' }, + { text: '按钮', type: 'button' }, + ], + }, +]; + +export const createSdk = (options: SdkOptions = {}) => + createDslSdk({ + dsl: createDsl(), + componentGroupList: COMPONENT_GROUP_LIST, + ...options, + }); diff --git a/packages/editor/tests/unit/sdk/index.spec.ts b/packages/editor/tests/unit/sdk/index.spec.ts new file mode 100644 index 00000000..1217176b --- /dev/null +++ b/packages/editor/tests/unit/sdk/index.spec.ts @@ -0,0 +1,213 @@ +// @vitest-environment node +import { describe, expect, test, vi } from 'vitest'; +import { isReactive, reactive, toRaw } from 'vue'; + +import { createDslSdk, DslValidationError, MemoryStorage } from '@editor/sdk'; + +import { createDsl, createSdk } from './helpers'; + +describe('createDslSdk', () => { + test('不传任何参数也能创建,Node 下默认 validateMode 为 strict', async () => { + const sdk = createDslSdk(); + await sdk.ready; + + expect(sdk.getDsl()).toBeNull(); + expect(sdk.ctx.validateMode).toBe('strict'); + expect(sdk.editor.get('root')).toBeNull(); + }); + + test('传入的 DSL 会被深拷贝,不会被修改', async () => { + const dsl = createDsl(); + const snapshot = JSON.stringify(dsl); + const sdk = createSdk({ dsl }); + + await sdk.editor.update({ id: 'text_1', text: 'changed' } as any); + + expect(JSON.stringify(dsl)).toBe(snapshot); + expect(sdk.getDsl()!.items[0].items![0].text).toBe('changed'); + }); + + test('getDsl 返回的是深拷贝', () => { + const sdk = createSdk(); + const dsl = sdk.getDsl()!; + dsl.items.length = 0; + + expect(sdk.getDsl()!.items).toHaveLength(1); + }); + + test('创建后自动选中第一个页面', () => { + const sdk = createSdk(); + expect(sdk.editor.get('page')?.id).toBe('page_1'); + }); + + test('多个实例互相独立', async () => { + const a = createSdk(); + const b = createSdk(); + + await a.editor.remove({ id: 'text_1' } as any); + + expect(a.editor.getNodeById('text_1')).toBeFalsy(); + expect(b.editor.getNodeById('text_1')).toBeTruthy(); + }); + + test('add / update / remove / undo / redo', async () => { + const sdk = createSdk(); + + const added = (await sdk.editor.add({ type: 'text', text: 'new' } as any)) as any; + expect(sdk.editor.getNodeById(added.id)).toBeTruthy(); + + await sdk.editor.update({ id: added.id, text: 'updated' } as any); + expect((sdk.editor.getNodeById(added.id) as any).text).toBe('updated'); + + await sdk.editor.undo(); + expect((sdk.editor.getNodeById(added.id) as any).text).toBe('new'); + + await sdk.editor.redo(); + expect((sdk.editor.getNodeById(added.id) as any).text).toBe('updated'); + + await sdk.editor.remove(sdk.editor.getNodeById(added.id)!); + expect(sdk.editor.getNodeById(added.id)).toBeFalsy(); + + await sdk.editor.undo(); + expect(sdk.editor.getNodeById(added.id)).toBeTruthy(); + }); + + test('moveToContainer 把节点移动到容器中', async () => { + const sdk = createSdk(); + + await sdk.editor.moveToContainer({ id: 'text_1' } as any, 'container_1'); + + const container = sdk.editor.getNodeById('container_1') as any; + expect(container.items.map((item: any) => item.id)).toContain('text_1'); + }); + + test('setDsl 整体替换并写入新的 root', async () => { + const sdk = createSdk(); + const next = createDsl(); + next.items[0].id = 'page_2'; + + await sdk.setDsl(next); + expect(sdk.getDsl()!.items[0].id).toBe('page_2'); + expect(sdk.editor.get('page')?.id).toBe('page_2'); + + await sdk.setDsl(null); + expect(sdk.getDsl()).toBeNull(); + }); + + test('历史快照可以导出后在新实例中恢复(无状态使用)', async () => { + const first = createSdk(); + await first.editor.update({ id: 'text_1', text: 'step1' } as any); + await first.editor.update({ id: 'text_1', text: 'step2' } as any); + + const dsl = first.getDsl()!; + const history = JSON.parse(JSON.stringify(first.serializeHistory())); + + const second = createSdk({ dsl, history }); + expect((second.editor.getNodeById('text_1') as any).text).toBe('step2'); + + await second.editor.undo(); + expect((second.editor.getNodeById('text_1') as any).text).toBe('step1'); + + await second.editor.undo(); + expect((second.editor.getNodeById('text_1') as any).text).toBe('${ds_1.name}'); + }); + + test('登记表:属性配置 / 属性默认值 / 事件 / 数据源配置', async () => { + const sdk = createSdk({ + propsConfigs: { text: [{ name: 'text', text: '文本' }] }, + propsValues: { text: { text: '默认文案' } }, + eventMethodList: { + text: { events: [{ label: 'click', value: 'click' }], methods: [{ label: 'm', value: 'm' }] }, + }, + datasourceList: [{ type: 'custom', text: '自定义' }], + datasourceConfigs: { custom: [{ name: 'a', text: 'a' }] }, + datasourceValues: { custom: { title: 'custom' } as any }, + datasourceEventMethodList: { + custom: { events: [{ label: 'e', value: 'e' }], methods: [{ label: 'm', value: 'm' }] }, + }, + disabledCodeBlock: true, + disabledDataSource: true, + disabledMultiSelect: true, + alwaysMultiSelect: true, + disabledPageFragment: true, + }); + await sdk.ready; + + expect(JSON.stringify(await sdk.props.getPropsConfig('text'))).toContain('"text":"文本"'); + expect(((await sdk.props.getPropsValue('text')) as any).text).toBe('默认文案'); + expect(sdk.events.getEvent('text')).toHaveLength(1); + expect(sdk.events.getMethod('text')).toHaveLength(1); + expect(sdk.dataSource.get('datasourceTypeList')).toHaveLength(1); + expect(sdk.dataSource.getFormConfig('custom')).toContainEqual({ name: 'a', text: 'a' }); + expect(sdk.dataSource.getFormEvent('custom')).toHaveLength(1); + expect(sdk.dataSource.getFormMethod('custom')).toHaveLength(1); + expect(sdk.props.getDisabledCodeBlock()).toBe(true); + expect(sdk.props.getDisabledDataSource()).toBe(true); + expect(sdk.editor.get('disabledMultiSelect')).toBe(true); + expect(sdk.editor.get('alwaysMultiSelect')).toBe(true); + expect(sdk.editor.getConfig().disabledPageFragment).toBe(true); + }); + + test('默认使用 Vue 的响应式实现', () => { + const sdk = createSdk(); + expect(sdk.ctx.reactivity.reactive).toBe(reactive); + expect(sdk.ctx.reactivity.toRaw).toBe(toRaw); + expect(isReactive(sdk.ctx.reactivity.reactive({ a: 1 }))).toBe(true); + expect(toRaw(sdk.ctx.reactivity.reactive(sdk.ctx.reactivity.reactive({ a: 1 })))).toEqual({ a: 1 }); + }); + + test('注入 reactivity 与 storage', async () => { + const reactiveSpy = vi.fn((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(); + }); +}); diff --git a/packages/editor/tests/unit/sdk/review-regressions.spec.ts b/packages/editor/tests/unit/sdk/review-regressions.spec.ts new file mode 100644 index 00000000..5ba2f5c8 --- /dev/null +++ b/packages/editor/tests/unit/sdk/review-regressions.spec.ts @@ -0,0 +1,163 @@ +// @vitest-environment node +import { describe, expect, test } from 'vitest'; + +import { createDslSdk, defaultParseDSL, DslValidationError } from '@editor/sdk'; + +import { createDsl, createSdk } from './helpers'; + +const styleConfig = [ + { + name: 'style', + items: [{ name: 'width', text: '宽', rules: [{ required: true }] }, { name: 'color' }], + }, +]; + +const createStyledSdk = (validateMode: 'strict' | 'record' = 'strict') => { + const dsl = createDsl() as any; + dsl.items[0].items[0].style = { width: 100 }; + return createSdk({ dsl, validateMode, propsConfigs: { text: styleConfig } as any }); +}; + +describe('strict 校验与 update 的合并语义一致', () => { + test('深合并的局部更新不会因为缺少已有的必填字段被拒绝', async () => { + const sdk = createStyledSdk(); + await sdk.ready; + + await expect(sdk.editor.update({ id: 'text_1', style: { color: 'red' } } as any)).resolves.toBeTruthy(); + expect((sdk.editor.getNodeById('text_1') as any).style).toEqual({ width: 100, color: 'red' }); + }); + + test('replace 时按传入配置整体校验:丢掉必填字段会被拒绝', async () => { + const sdk = createStyledSdk(); + await sdk.ready; + + const error = await sdk.editor + .update({ id: 'text_1', type: 'text', style: { color: 'red' } } as any, { replace: true }) + .catch((e) => e); + + expect(error).toBeInstanceOf(DslValidationError); + expect((sdk.editor.getNodeById('text_1') as any).style).toEqual({ width: 100 }); + }); +}); + +describe('校验失败不污染后续历史记录', () => { + test('被拒绝的 moveToContainer 不会留下过期快照', async () => { + const sdk = createSdk(); + await sdk.ready; + + await sdk.editor.select('text_1'); + await expect(sdk.editor.moveToContainer({ id: 'button_1' } as any, 'text_1')).rejects.toBeInstanceOf( + DslValidationError, + ); + + await sdk.editor.select('button_1'); + await sdk.editor.update({ id: 'button_1', text: 'changed' } as any); + + const steps = sdk.history.getStepList('page', sdk.editor.get('page')!.id); + expect((steps[steps.length - 1].step as any).extra.selectedBefore).toEqual(['button_1']); + }); + + test('被拒绝的 update 之后,下一条历史记录的选区快照来自它自己', async () => { + const sdk = createSdk({ + propsConfigs: { text: [{ name: 'text', rules: [{ required: true }] }] } as any, + }); + await sdk.ready; + + await sdk.editor.select('text_1'); + await expect(sdk.editor.update({ id: 'text_1', text: '' } as any)).rejects.toBeInstanceOf(DslValidationError); + + // 换一个选区后再做一次成功的操作 + await sdk.editor.select('button_1'); + await sdk.editor.update({ id: 'button_1', text: 'changed' } as any); + + const pageId = sdk.editor.get('page')!.id; + const steps = sdk.history.getStepList('page', pageId); + const last = steps[steps.length - 1].step as any; + expect(last.extra.selectedBefore).toEqual(['button_1']); + }); + + test('被拒绝的 add 同样不会留下过期快照', async () => { + const sdk = createSdk(); + await sdk.ready; + + await sdk.editor.select('text_1'); + await expect(sdk.editor.add({ type: 'not-exist' } as any)).rejects.toBeInstanceOf(DslValidationError); + + await sdk.editor.select('button_1'); + await sdk.editor.update({ id: 'button_1', text: 'changed' } as any); + + const steps = sdk.history.getStepList('page', sdk.editor.get('page')!.id); + expect((steps[steps.length - 1].step as any).extra.selectedBefore).toEqual(['button_1']); + }); +}); + +describe('依赖收集:同一批更新里数据源引用与方法一起变化', () => { + test('dataSourceMethodDeps 不会丢失', async () => { + const dsl = createDsl() as any; + dsl.dataSources[0].methods = [{ name: 'refresh', content: '() => {}', params: [] }]; + const sdk = createSdk({ dsl, validateMode: 'off' }); + await sdk.ready; + + await sdk.editor.update({ + id: 'text_1', + text: '${ds_1.name}x', + action: ['ds_1', 'refresh'], + } as any); + + const out = sdk.getDsl() as any; + expect(out.dataSourceDeps.ds_1.text_1).toBeTruthy(); + expect(out.dataSourceMethodDeps.ds_1.text_1).toBeTruthy(); + }); + + test('一个节点有多条未命中的 changeRecord 时只归类一次', async () => { + const { classifyUpdatedNodes } = await import('@editor/sdk'); + const node: any = { id: 'n1', type: 'text' }; + const result = classifyUpdatedNodes({ dep: { getTargets: () => ({}) } } as any, [ + { + newNode: node, + oldNode: node, + changeRecords: [ + { propPath: 'a', value: 1 }, + { propPath: 'b', value: 2 }, + ], + }, + ]); + expect(result.normalNodes).toHaveLength(1); + expect(result.needRecollectNodes).toHaveLength(0); + }); +}); + +describe('历史快照还原', () => { + test('默认 parseDSL 无法解析函数时给出明确的错误', () => { + expect(() => defaultParseDSL('({"a":() => 1})')).toThrow('默认的 parseDSL 只能解析 JSON'); + }); + + test('传入自己的 parseDSL 后带函数的历史可以往返', async () => { + // eslint-disable-next-line no-new-func + const parseDSL = (code: string) => new Function(`return ${code}`)(); + const sdk = createSdk({ parseDSL, validateMode: 'off' }); + await sdk.ready; + await sdk.editor.update({ id: 'text_1', onClick: () => 1 } as any); + + const snapshot = JSON.parse(JSON.stringify(sdk.serializeHistory())); + expect(() => createDslSdk({ dsl: sdk.getDsl(), history: snapshot, parseDSL, validateMode: 'off' })).not.toThrow(); + }); + + test('extra 里只有已知的 Map 字段会被转换,其他字段原样保留', async () => { + const sdk = createSdk({ validateMode: 'off' }); + await sdk.ready; + await sdk.editor.update({ id: 'text_1', text: 'a' } as any); + + const snapshot = sdk.history.serialize({ jsonSafe: true }); + const list = Object.values(snapshot.steps.page)[0].elementList as any[]; + const step = list.find((item) => item.extra); + step.extra.custom = new Map([['k', 'v']]); + + const restored = createSdk({ dsl: sdk.getDsl(), history: snapshot, validateMode: 'off' }); + const restoredStep = (Object.values(restored.history.serialize().steps.page)[0].elementList as any[]).find( + (item) => item.extra, + ); + expect(restoredStep.extra.modifiedNodeIds).toBeInstanceOf(Map); + expect(restoredStep.extra.custom).toBeInstanceOf(Map); + }); +}); diff --git a/packages/editor/tests/unit/sdk/services-extra.spec.ts b/packages/editor/tests/unit/sdk/services-extra.spec.ts new file mode 100644 index 00000000..b4e7aea6 --- /dev/null +++ b/packages/editor/tests/unit/sdk/services-extra.spec.ts @@ -0,0 +1,463 @@ +// @vitest-environment node +import { describe, expect, test } from 'vitest'; + +import type { MApp } from '@tmagic/core'; +import { HookCodeType, HookType } from '@tmagic/core'; + +import { LayerOffset } from '@editor/type'; + +import { createDsl, createSdk } from './helpers'; + +/** 每个节点都带 style,便于测试位置相关的操作 */ +const createStyledDsl = (): MApp => + ({ + id: 'app_1', + type: 'app', + items: [ + { + id: 'page_1', + type: 'page', + name: 'page', + style: { width: 375, height: 800 }, + items: [ + { + id: 'text_1', + type: 'text', + text: 'a', + style: { position: 'absolute', left: 10, top: 10, width: 100, height: 20 }, + }, + { + id: 'text_2', + type: 'text', + text: 'b', + style: { position: 'absolute', left: 20, top: 40, width: 100, height: 20 }, + }, + { + id: 'text_3', + type: 'text', + text: 'c', + style: { position: 'absolute', left: 30, top: 70, width: 100, height: 20 }, + }, + { + id: 'container_1', + type: 'container', + style: { position: 'absolute', left: 0, top: 100, width: 300, height: 300 }, + items: [ + { + id: 'button_1', + type: 'button', + style: { position: 'absolute', left: 5, top: 5, width: 50, height: 20 }, + }, + ], + }, + ], + }, + { id: 'page_2', type: 'page', name: 'page2', items: [{ id: 'text_9', type: 'text', text: 'z' }] }, + ], + dataSources: [{ id: 'ds_1', type: 'base', title: 'ds', fields: [{ name: 'name' }], methods: [], events: [] }], + codeBlocks: { code_1: { name: 'code', content: '() => {}', params: [] } }, + }) as any; + +const createStyled = () => createSdk({ dsl: createStyledDsl() }); + +describe('editor:选择与高亮', () => { + test('selectNextNode / selectNextPage', async () => { + const sdk = createStyled(); + + await sdk.editor.select('text_1'); + expect((await sdk.editor.selectNextNode())?.id).toBe('text_2'); + + await sdk.editor.select('text_3'); + expect((await sdk.editor.selectNextNode())?.id).toBeTruthy(); + + await sdk.editor.select('page_1'); + expect((await sdk.editor.selectNextNode())?.id).toBe('page_1'); + + expect((await sdk.editor.selectNextPage()).id).toBe('page_2'); + expect((await sdk.editor.selectNextPage()).id).toBe('page_1'); + }); + + test('selectNextPage 没有页面 / root 时报错', async () => { + const sdk = createSdk({ dsl: null }); + await expect(sdk.editor.selectNextPage()).rejects.toThrow('page不能为空'); + }); + + test('highlight / multiSelect', async () => { + const sdk = createStyled(); + + sdk.editor.highlight('text_1'); + expect(sdk.editor.get('highlightNode')?.id).toBe('text_1'); + sdk.editor.highlight('text_1'); + expect(sdk.editor.get('highlightNode')?.id).toBe('text_1'); + + sdk.editor.multiSelect(['text_1', 'text_2', 'text_2', 'not-exist']); + expect(sdk.editor.get('nodes').map((node) => node.id)).toEqual(['text_1', 'text_2']); + }); + + test('selectRoot', () => { + const sdk = createStyled(); + sdk.editor.selectRoot(); + expect(sdk.editor.get('node')?.id).toBe('app_1'); + }); +}); + +describe('editor:位置与层级', () => { + test('alignCenter(单个 / 多个)', async () => { + const sdk = createStyled(); + + const one = (await sdk.editor.alignCenter(sdk.editor.getNodeById('text_1')!)) as any; + expect(one).toBeTruthy(); + + const many = await sdk.editor.alignCenter([sdk.editor.getNodeById('text_2')!, sdk.editor.getNodeById('text_3')!], { + doNotPushHistory: true, + }); + expect(Array.isArray(many)).toBe(true); + }); + + test('move 改变选中节点的位置', async () => { + const sdk = createStyled(); + await sdk.editor.select('text_1'); + await sdk.editor.move(5, 6); + + const { style } = sdk.editor.getNodeById('text_1') as any; + expect(style.left).toBe(15); + expect(style.top).toBe(16); + + // 页面不可移动 + await sdk.editor.select('page_1'); + await sdk.editor.move(1, 1); + }); + + test('moveLayer', async () => { + const sdk = createStyled(); + await sdk.editor.select('text_1'); + + await sdk.editor.moveLayer(1); + await sdk.editor.moveLayer(LayerOffset.BOTTOM); + await sdk.editor.moveLayer(LayerOffset.TOP); + + const ids = (sdk.editor.getNodeById('page_1') as any).items.map((item: any) => item.id); + expect(ids).toContain('text_1'); + + const withId = await sdk.editor.moveLayerAndGetHistoryId(-1); + expect(withId.historyIds.length).toBeGreaterThan(0); + }); + + test('moveToContainer / dragTo,含 *AndGetHistoryId', async () => { + const sdk = createStyled(); + + await sdk.editor.moveToContainer( + [sdk.editor.getNodeById('text_1')!, sdk.editor.getNodeById('text_2')!], + 'container_1', + { + doNotSelect: true, + }, + ); + const container = sdk.editor.getNodeById('container_1') as any; + expect(container.items.map((item: any) => item.id)).toEqual(expect.arrayContaining(['text_1', 'text_2'])); + + const moved = await sdk.editor.moveToContainerAndGetHistoryId(sdk.editor.getNodeById('text_3')!, 'container_1'); + expect(moved.historyIds.length).toBeGreaterThan(0); + + // 同容器内排序 + await sdk.editor.dragTo(sdk.editor.getNodeById('text_3')!, container, 0); + expect(container.items[0].id).toBe('text_3'); + + // 跨容器 + const page = sdk.editor.getNodeById('page_1') as any; + const dragged = await sdk.editor.dragToAndGetHistoryId(sdk.editor.getNodeById('button_1')!, page, 0); + expect(dragged.historyIds.length).toBeGreaterThan(0); + expect(page.items[0].id).toBe('button_1'); + + // 目标不是容器时什么也不做 + expect( + await sdk.editor.dragTo(sdk.editor.getNodeById('text_1')!, sdk.editor.getNodeById('text_2') as any, 0), + ).toBeUndefined(); + + // 不能移动页面 + await expect(sdk.editor.moveToContainer(sdk.editor.getNodeById('page_2')!, 'container_1')).rejects.toThrow(); + await expect(sdk.editor.moveToContainer(sdk.editor.getNodeById('text_1')!, 'missing')).rejects.toThrow( + '目标容器不存在', + ); + }); +}); + +describe('editor:历史', () => { + test('gotoPageStep / revertPageStep / revertPageStepById', async () => { + const sdk = createStyled(); + + await sdk.editor.update({ id: 'text_1', text: 'a1' } as any, { + changeRecords: [{ propPath: 'text', value: 'a1' }], + }); + await sdk.editor.update({ id: 'text_2', text: 'b1' } as any, { + changeRecords: [{ propPath: 'text', value: 'b1' }], + }); + const added = (await sdk.editor.add({ type: 'text', text: 'new' } as any)) as any; + await sdk.editor.remove(sdk.editor.getNodeById('text_3')!); + + // 回滚第一次更新,不影响后面的更新 + const reverted = await sdk.editor.revertPageStep(1); + expect(reverted).toBeTruthy(); + expect((sdk.editor.getNodeById('text_1') as any).text).toBe('a'); + expect((sdk.editor.getNodeById('text_2') as any).text).toBe('b1'); + + // 回滚新增 / 删除 + const listLength = sdk.history.getStepList('page', 'page_1').length; + expect(await sdk.editor.revertPageStep(3)).toBeTruthy(); + expect(sdk.editor.getNodeById(added.id)).toBeFalsy(); + expect(sdk.history.getStepList('page', 'page_1').length).toBeGreaterThan(listLength - 1); + + // 初始基线不可回滚 / 越界 + expect(await sdk.editor.revertPageStep(0)).toBeNull(); + expect(await sdk.editor.revertPageStep(99)).toBeNull(); + + const { step } = sdk.history.getStepList('page', 'page_1')[2]; + const byId = await sdk.editor.revertPageStepById([step.uuid!, 'not-found']); + expect(byId[1]).toBeNull(); + + const cursor = await sdk.editor.gotoPageStep(0); + expect(cursor).toBeLessThanOrEqual(1); + expect((sdk.editor.getNodeById('text_2') as any).text).toBe('b'); + + const end = await sdk.editor.gotoPageStep(99); + expect(end).toBeGreaterThan(0); + }); + + test('没有选中页面时 undo / redo 返回 null', async () => { + const sdk = createSdk({ dsl: null }); + expect(await sdk.editor.undo()).toBeNull(); + expect(await sdk.editor.redo()).toBeNull(); + }); + + test('撤销新增 / 删除 / 更新各类历史', async () => { + const sdk = createStyled(); + + await sdk.editor.add({ type: 'text', text: 'n1' } as any); + await sdk.editor.undo(); + await sdk.editor.redo(); + await sdk.editor.undo(); + + await sdk.editor.remove(sdk.editor.getNodeById('text_1')!); + await sdk.editor.undo(); + await sdk.editor.redo(); + await sdk.editor.undo(); + + await sdk.editor.update({ id: 'text_2', text: 'u' } as any); + await sdk.editor.undo(); + await sdk.editor.redo(); + + expect((sdk.editor.getNodeById('text_2') as any).text).toBe('u'); + }); +}); + +describe('editor:copyWithRelated / paste', () => { + test('copyWithRelated 把关联节点一起复制', async () => { + const dsl = createStyledDsl(); + (dsl.items[0].items![0] as any).relate = 'text_2'; + const sdk = createSdk({ dsl }); + + sdk.editor.copyWithRelated(sdk.editor.getNodeById('text_1')!, { + type: 'relate' as any, + isTarget: (key: string | number, value: any) => key === 'relate' && !!value, + } as any); + + const copied = sdk.storage.getItem('tmagic-editor-copy' as any) ?? null; + expect(copied === null || Array.isArray(copied) || typeof copied === 'object').toBe(true); + }); + + test('粘贴选中节点的副本时放进它的父节点', async () => { + const sdk = createStyled(); + await sdk.editor.select('text_1'); + await sdk.editor.copy(sdk.editor.getNodeById('text_1')!); + + const pasted = (await sdk.editor.paste({}, undefined, { doNotSelect: true })) as any; + expect(sdk.editor.getParentById(Array.isArray(pasted) ? pasted[0].id : pasted.id)?.id).toBe('page_1'); + }); +}); + +describe('props:关联 id', () => { + test('setNewItemId 记录映射,replaceRelateId 替换关联 id', () => { + const sdk = createSdk(); + const origin = [ + { id: 'a', type: 'text', relate: 'b' }, + { id: 'b', type: 'text', items: [{ id: 'c', type: 'text', relate: 'a' }] }, + ] as any[]; + + const copied = JSON.parse(JSON.stringify(origin)); + sdk.props.clearRelateId(); + copied.forEach((config: any) => { + sdk.props.setNewItemId(config); + config.items?.forEach((item: any) => sdk.props.setNewItemId(item)); + }); + + sdk.props.replaceRelateId(origin, copied, { + type: 'relate' as any, + isTarget: (key: string | number, value: any) => key === 'relate' && !!value, + } as any); + + expect(copied[0].relate).toBe(copied[1].id); + + // 没有映射时什么也不做 + sdk.props.clearRelateId(); + sdk.props.replaceRelateId(origin, copied, { type: 'relate' as any, isTarget: () => false } as any); + }); + + test('area 类型使用 button 的配置', async () => { + const sdk = createSdk({ propsValues: { button: { text: 'btn' } } }); + await sdk.ready; + const value = (await sdk.props.getPropsValue('area')) as any; + expect(value.className).toBe('action-area'); + expect(await sdk.props.getPropsConfig('area')).toBeTruthy(); + }); + + test('props 的 usePlugin', async () => { + const sdk = createSdk(); + sdk.props.usePlugin({ afterGetPropsConfig: async () => [{ name: 'plugin' }] } as any); + sdk.codeBlock.usePlugin({} as any); + sdk.dataSource.usePlugin({} as any); + sdk.editor.usePlugin({} as any); + }); +}); + +describe('codeBlock', () => { + test('goto / revert / revertById / 历史', async () => { + const sdk = createStyled(); + const { codeBlock } = sdk; + + await codeBlock.setCodeDslById('code_2', { name: 'c2', content: '() => 1', params: [] }); + await codeBlock.setCodeDslById( + 'code_2', + { name: 'c2-new', content: '() => 2', params: [] }, + { + changeRecords: [{ propPath: 'name', value: 'c2-new' }], + }, + ); + await codeBlock.setCodeDslById( + 'code_2', + { name: 'c2-3', content: '() => 3', params: [] }, + { + changeRecords: [{ propPath: '', value: {} }], + }, + ); + await codeBlock.deleteCodeDslByIds(['code_2']); + + // 回滚删除 -> 写回 + const list = sdk.history.getStepList('codeBlock', 'code_2'); + expect(await codeBlock.revert('code_2', list.length - 1)).toBeTruthy(); + expect(codeBlock.getCodeContentById('code_2')).toBeTruthy(); + + // 局部回滚更新 + expect(await codeBlock.revert('code_2', 1)).toBeTruthy(); + // 回滚带空 propPath 的更新 -> 整体替换 + expect(await codeBlock.revert('code_2', 2)).toBeTruthy(); + // 回滚新增 -> 删除 + expect(await codeBlock.revert('code_2', 0)).toBeTruthy(); + expect(codeBlock.getCodeContentById('code_2')).toBeFalsy(); + + // 更新类但没有 changeRecords 的不可回滚;越界为 null + expect(await codeBlock.revert('code_2', 99)).toBeNull(); + + const { step } = sdk.history.getStepList('codeBlock', 'code_2')[0]; + expect((await codeBlock.revertById([step.uuid!, 'missing']))[1]).toBeNull(); + + // goto + const total = sdk.history.getStepList('codeBlock', 'code_2').length; + expect(await codeBlock.goto('code_2', 1)).toBe(1); + expect(await codeBlock.goto('code_2', total)).toBe(total); + expect(await codeBlock.goto('code_2', -1)).toBe(0); + }); + + test('undo / redo 单个代码块', async () => { + const sdk = createStyled(); + await sdk.codeBlock.setCodeDslById( + 'code_1', + { name: 'changed' }, + { changeRecords: [{ propPath: 'name', value: 'changed' }] }, + ); + expect(await sdk.codeBlock.undo('code_1')).toBeTruthy(); + expect(sdk.codeBlock.getCodeContentById('code_1')?.name).toBe('code'); + expect(await sdk.codeBlock.redo('code_1')).toBeTruthy(); + expect(sdk.codeBlock.getCodeContentById('code_1')?.name).toBe('changed'); + }); + + test('copyWithRelated 复制节点关联的代码块', async () => { + const dsl = createStyledDsl(); + (dsl.items[0].items![0] as any)[HookType.HOOK_TYPE] = HookType.CODE; + (dsl.items[0].items![0] as any).hooks = [ + { hookType: HookType.CODE, hookData: [{ codeType: HookCodeType.CODE, codeId: 'code_1' }] }, + ]; + const sdk = createSdk({ dsl }); + + sdk.codeBlock.copyWithRelated(sdk.editor.getNodeById('text_1')!, { + type: 'code-block' as any, + isTarget: (key: string | number, value: any) => key === 'codeId' && !!value, + } as any); + sdk.codeBlock.copyWithRelated(sdk.editor.getNodeById('text_1')!); + + // 不存在的节点 / 没有收集器时不报错 + sdk.codeBlock.copyWithRelated([], { type: 'code-block' as any, isTarget: () => true } as any); + }); +}); + +describe('dataSource', () => { + test('goto / revert / revertById / undo / redo', () => { + const sdk = createStyled(); + const { dataSource } = sdk; + + dataSource.add({ id: 'ds_2', type: 'base', title: 'two', fields: [], methods: [], events: [] } as any); + dataSource.update({ id: 'ds_2', type: 'base', title: 'three', fields: [], methods: [], events: [] } as any, { + changeRecords: [{ propPath: 'title', value: 'three' }], + }); + dataSource.update({ id: 'ds_2', type: 'base', title: 'four', fields: [], methods: [], events: [] } as any, { + changeRecords: [{ propPath: '', value: {} }], + }); + dataSource.update({ id: 'ds_2', type: 'base', title: 'five', fields: [], methods: [], events: [] } as any); + dataSource.remove('ds_2'); + + const total = sdk.history.getStepList('dataSource', 'ds_2').length; + + // 回滚删除 -> 写回 + expect(dataSource.revert('ds_2', total - 1)).toBeTruthy(); + expect(dataSource.getDataSourceById('ds_2')).toBeTruthy(); + + // 局部回滚 / 整体回滚 + expect(dataSource.revert('ds_2', 1)).toBeTruthy(); + expect(dataSource.revert('ds_2', 2)).toBeTruthy(); + // 没有 changeRecords 的更新不可回滚 + expect(dataSource.revert('ds_2', 3)).toBeNull(); + expect(dataSource.revert('ds_2', 99)).toBeNull(); + // 回滚新增 -> 删除 + expect(dataSource.revert('ds_2', 0)).toBeTruthy(); + expect(dataSource.getDataSourceById('ds_2')).toBeFalsy(); + + const { step } = sdk.history.getStepList('dataSource', 'ds_2')[0]; + expect(dataSource.revertById([step.uuid!, 'missing'])[1]).toBeNull(); + + const { length } = sdk.history.getStepList('dataSource', 'ds_2'); + expect(dataSource.goto('ds_2', 1)).toBe(1); + expect(dataSource.goto('ds_2', length)).toBe(length); + expect(dataSource.goto('ds_2', -1)).toBe(0); + + expect(dataSource.undo('ds_2')).toBeNull(); + expect(dataSource.redo('ds_2')).toBeTruthy(); + }); + + test('copyWithRelated 复制节点关联的数据源', () => { + const sdk = createStyled(); + (sdk.editor.getNodeById('text_1') as any).dsField = ['ds_1', 'name']; + + sdk.dataSource.copyWithRelated(sdk.editor.getNodeById('text_1')!, { + type: 'data-source' as any, + isTarget: (key: string | number, value: any) => key === 'dsField' && Array.isArray(value), + } as any); + sdk.dataSource.copyWithRelated(sdk.editor.getNodeById('text_1')!); + sdk.dataSource.copyWithRelated([], { type: 'data-source' as any, isTarget: () => true } as any); + }); +}); + +describe('DSL 兜底', () => { + test('createDsl 夹具本身是合法的', async () => { + const sdk = createSdk({ dsl: createDsl() }); + expect((await sdk.validate.dsl()).valid).toBe(true); + }); +}); diff --git a/packages/editor/tests/unit/sdk/storage-history.spec.ts b/packages/editor/tests/unit/sdk/storage-history.spec.ts new file mode 100644 index 00000000..22204474 --- /dev/null +++ b/packages/editor/tests/unit/sdk/storage-history.spec.ts @@ -0,0 +1,173 @@ +// @vitest-environment node +import { describe, expect, test, vi } from 'vitest'; + +import { createDslSdk, defaultParseDSL, MemoryStorage, Protocol } from '@editor/sdk'; +import { isFixed } from '@editor/sdk/utils/position'; + +import { createSdk } from './helpers'; + +describe('MemoryStorage', () => { + test('实现 Storage 接口', () => { + const storage = new MemoryStorage(); + + expect(storage.length).toBe(0); + expect(storage.getItem('a')).toBeNull(); + + storage.setItem('a', '1'); + storage.setItem('b', '2'); + expect(storage.length).toBe(2); + expect(storage.getItem('a')).toBe('1'); + expect(storage.key(1)).toBe('b'); + expect(storage.key(5)).toBeNull(); + + storage.removeItem('a'); + expect(storage.getItem('a')).toBeNull(); + + storage.clear(); + expect(storage.length).toBe(0); + }); +}); + +describe('defaultParseDSL', () => { + test('解析被括号包起来的 JSON,不执行代码', () => { + expect(defaultParseDSL('({"a":1})')).toEqual({ a: 1 }); + expect(defaultParseDSL('{"a":1}')).toEqual({ a: 1 }); + expect(() => defaultParseDSL('(() => {})')).toThrow(); + }); +}); + +describe('storage service', () => { + test('各协议读写', () => { + const sdk = createSdk(); + const { storage } = sdk; + + storage.setItem('str', 'hello'); + expect(storage.getItem('str')).toBe('hello'); + + storage.setItem('num', 12, { protocol: Protocol.NUMBER }); + expect(storage.getItem('num', { protocol: Protocol.NUMBER })).toBe(12); + + storage.setItem('bool', true, { protocol: Protocol.BOOLEAN }); + expect(storage.getItem('bool', { protocol: Protocol.BOOLEAN })).toBe(true); + + storage.setItem('obj', { a: 1 }, { protocol: Protocol.OBJECT }); + expect(storage.getItem('obj', { protocol: Protocol.OBJECT })).toEqual({ a: 1 }); + + expect(storage.getItem('none')).toBeNull(); + + storage.removeItem('str'); + expect(storage.getItem('str')).toBeNull(); + + storage.clear(); + expect(storage.getItem('num', { protocol: Protocol.NUMBER })).toBeNull(); + }); + + test('注入自定义 storage', () => { + const custom = new MemoryStorage(); + const sdk = createSdk({ storage: custom }); + + sdk.storage.setItem('k', 'v'); + expect(custom.getItem('tmagic:k')).toBeTruthy(); + expect(sdk.storage.getStorage()).toBe(custom); + }); +}); + +describe('复制 / 粘贴', () => { + test('copy 之后 paste 会生成新节点', async () => { + const sdk = createSdk(); + await sdk.editor.update({ id: 'button_1', style: { position: 'absolute', left: 10, top: 20 } } as any); + const node = sdk.editor.getNodeById('button_1')!; + await sdk.editor.select('button_1'); + + await sdk.editor.copy(node); + const result = (await sdk.editor.paste({ left: 10, top: 10 })) as any; + const pasted = Array.isArray(result) ? result[0] : result; + + expect(pasted).toBeTruthy(); + expect(pasted.id).not.toBe('button_1'); + expect(sdk.editor.getNodeById(pasted.id)).toBeTruthy(); + }); + + test('没有复制内容时 paste 什么也不做', async () => { + const sdk = createSdk(); + expect(await sdk.editor.paste()).toBeUndefined(); + }); +}); + +describe('历史记录持久化', () => { + const createPersistence = () => { + const db = new Map(); + 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); + }); +}); diff --git a/packages/editor/tests/unit/sdk/validate.spec.ts b/packages/editor/tests/unit/sdk/validate.spec.ts new file mode 100644 index 00000000..d40c90b0 --- /dev/null +++ b/packages/editor/tests/unit/sdk/validate.spec.ts @@ -0,0 +1,305 @@ +// @vitest-environment node +import { describe, expect, test } from 'vitest'; + +import { DslValidationError } from '@editor/sdk'; + +import { createDsl, createSdk } from './helpers'; + +const textConfig = [{ name: 'text', text: '文本', rules: [{ required: true, message: '必填' }] }]; + +describe('validate: 结构', () => { + test('组件类型不在组件列表中', async () => { + const sdk = createSdk(); + const error = await sdk.editor.add({ type: 'not-exist' } as any).catch((e) => e); + + expect(error).toBeInstanceOf(DslValidationError); + expect(error.errors[0]).toMatchObject({ kind: 'structure', path: 'type' }); + expect(error.errors[0].suggestion).toContain('text'); + expect(error.message).toContain('not-exist'); + }); + + test('内置类型不需要出现在组件列表里;组件列表为空时不校验类型', async () => { + const sdk = createSdk({ componentGroupList: [] }); + await expect(sdk.editor.add({ type: 'anything' } as any)).resolves.toBeTruthy(); + + const sdk2 = createSdk(); + await expect(sdk2.editor.add({ type: 'container' } as any)).resolves.toBeTruthy(); + }); + + test('类型比较忽略大小写写法差异(驼峰与下划线)', async () => { + const sdk = createSdk({ + componentGroupList: [{ title: 'g', items: [{ text: 'a', type: 'page-fragment-container' }] }], + }); + await expect(sdk.editor.add({ type: 'pageFragmentContainer' } as any)).resolves.toBeTruthy(); + }); + + test('缺少 type', async () => { + const sdk = createSdk(); + await expect(sdk.editor.add({} as any)).rejects.toThrow('组件类型不能为空'); + }); + + test('父节点不是容器', async () => { + const sdk = createSdk(); + const error = await sdk.editor + .add({ type: 'text' } as any, sdk.editor.getNodeById('text_1') as any) + .catch((e) => e); + + expect(error).toBeInstanceOf(DslValidationError); + expect(error.errors[0].message).toContain('不是容器'); + }); + + test('app 下只能添加页面', async () => { + const sdk = createSdk(); + const error = await sdk.editor.add({ type: 'text' } as any, sdk.editor.get('root') as any).catch((e) => e); + + expect(error).toBeInstanceOf(DslValidationError); + expect(error.errors[0].message).toContain('app 下只能添加页面'); + }); + + test('可以添加页面(页面总是添加到 app 下)', async () => { + const sdk = createSdk(); + await expect(sdk.editor.add({ type: 'page', name: 'p2' } as any)).resolves.toBeTruthy(); + expect(sdk.getDsl()!.items).toHaveLength(2); + }); + + test('checkAddStructure:页面 / 页面片只能放在 app 下,没有父节点', () => { + const sdk = createSdk(); + const container = sdk.editor.getNodeById('container_1') as any; + + expect(sdk.validate.checkAddStructure({ id: 'p', type: 'page' } as any, container)[0].message).toContain( + '只能添加到 app', + ); + expect(sdk.validate.checkAddStructure({ id: 't', type: 'text' } as any, null)[0].message).toContain('未找到父节点'); + }); + + test('id 已存在 / 批量里 id 重复', async () => { + const sdk = createSdk(); + + const exists = await sdk.editor.add([{ id: 'text_1', type: 'text' }] as any).catch((e) => e); + expect(exists.errors[0].message).toContain('已存在'); + + const dup = await sdk.editor + .add([ + { id: 'new_1', type: 'text' }, + { id: 'new_1', type: 'text' }, + ] as any) + .catch((e) => e); + expect(dup.errors[0].message).toContain('重复'); + }); + + test('disabledPageFragment', async () => { + const sdk = createSdk({ disabledPageFragment: true }); + const error = await sdk.editor.add({ type: 'page-fragment' } as any).catch((e) => e); + + expect(error).toBeInstanceOf(DslValidationError); + expect(error.errors[0].message).toContain('disabledPageFragment'); + }); + + test('canDropIn 返回 false 时阻止添加与移动', async () => { + const calls: any[] = []; + const sdk = createSdk({ + canDropIn: (...args) => { + calls.push(args); + return false; + }, + }); + + const container = sdk.editor.getNodeById('container_1') as any; + const add = await sdk.editor.add({ type: 'text' } as any, container).catch((e) => e); + expect(add).toBeInstanceOf(DslValidationError); + expect(calls[0]).toEqual([[], 'container_1', 'stage-add']); + + const move = await sdk.editor.moveToContainer({ id: 'text_1' } as any, 'container_1').catch((e) => e); + expect(move).toBeInstanceOf(DslValidationError); + expect(calls.at(-1)).toEqual([['text_1'], 'container_1', 'stage-drag']); + + const drag = await sdk.editor.dragTo({ id: 'text_1' } as any, container, 0).catch((e) => e); + expect(drag).toBeInstanceOf(DslValidationError); + expect(calls.at(-1)[2]).toBe('layer'); + }); + + test('canDropIn 重定向到不存在的节点 / 非容器时报错,重定向到容器时放行', async () => { + const sdk = createSdk({ canDropIn: () => 'nothing' }); + const container = sdk.editor.getNodeById('container_1') as any; + const bad = await sdk.editor.add({ type: 'text' } as any, container).catch((e) => e); + expect(bad.errors[0].message).toContain('重定向'); + + const ok = createSdk({ canDropIn: () => 'container_1' }); + await expect( + ok.editor.add({ type: 'text' } as any, ok.editor.getNodeById('container_1') as any), + ).resolves.toBeTruthy(); + }); + + test('移动:目标不是容器 / 移到自己里面 / 源节点不存在', async () => { + const sdk = createSdk(); + + const notContainer = await sdk.editor.moveToContainer({ id: 'button_1' } as any, 'text_1').catch((e) => e); + expect(notContainer.errors[0].message).toContain('不是容器'); + + const inside = await sdk.editor.moveToContainer({ id: 'container_1' } as any, 'container_1').catch((e) => e); + expect(inside.errors[0].message).toContain('自己'); + + const missing = await sdk.editor.moveToContainer({ id: 'nope' } as any, 'container_1').catch((e) => e); + expect(missing.errors[0].message).toContain('不存在'); + }); + + test('update:节点不存在 / 修改类型', async () => { + const sdk = createSdk(); + + const missing = await sdk.editor.update({ id: 'nope', text: 'x' } as any).catch((e) => e); + expect(missing).toBeInstanceOf(DslValidationError); + + const typeChange = await sdk.editor.update({ id: 'text_1', type: 'button' } as any).catch((e) => e); + expect(typeChange.errors[0].message).toContain('不允许修改节点类型'); + }); + + test('update 根节点不做校验', async () => { + const sdk = createSdk(); + await expect(sdk.editor.update({ id: 'app_1', name: 'renamed' } as any)).resolves.toBeTruthy(); + }); + + test('数据源:类型不可用时 add / update 抛错', () => { + const sdk = createSdk({ datasourceList: [{ type: 'custom', text: '自定义' }] }); + + expect(() => sdk.dataSource.add({ id: 'ds_x', type: 'unknown' } as any)).toThrow(DslValidationError); + expect(() => sdk.dataSource.update({ id: 'ds_1', type: 'unknown' } as any)).toThrow(DslValidationError); + expect(() => + sdk.dataSource.add({ id: 'ds_y', type: 'custom', fields: [], methods: [], events: [] } as any), + ).not.toThrow(); + }); +}); + +describe('validate: 属性', () => { + test('add 时校验属性表单', async () => { + const sdk = createSdk({ propsConfigs: { text: textConfig }, propsValues: { text: { text: '' } } }); + await sdk.ready; + + const error = await sdk.editor.add({ type: 'text' } as any).catch((e) => e); + expect(error).toBeInstanceOf(DslValidationError); + expect(error.errors[0]).toMatchObject({ kind: 'props', target: 'node' }); + expect(error.errors[0].message).toContain('必填'); + + await expect(sdk.editor.add({ type: 'text', text: 'ok' } as any)).resolves.toBeTruthy(); + }); + + test('update 时校验合并之后的属性', async () => { + const sdk = createSdk({ propsConfigs: { text: textConfig } }); + await sdk.ready; + + const error = await sdk.editor.update({ id: 'text_1', text: '' } as any).catch((e) => e); + expect(error).toBeInstanceOf(DslValidationError); + expect((sdk.editor.getNodeById('text_1') as any).text).toBe('${ds_1.name}'); + + await expect(sdk.editor.update({ id: 'text_1', text: 'good' } as any)).resolves.toBeTruthy(); + }); + + test('取属性配置时抛错,作为校验异常上报', async () => { + const sdk = createSdk(); + sdk.props.usePlugin({ + beforeGetPropsConfig: () => { + throw new Error('boom'); + }, + }); + + const result = await sdk.validate.node('text_1'); + expect(result.valid).toBe(false); + expect(result.errors[0].message).toContain('boom'); + }); + + test('checkNodeProps:没有配置 / root 不校验', async () => { + const sdk = createSdk(); + expect(await sdk.validate.checkNodeProps({ id: 'a', type: 'unknown-without-config' } as any)).toEqual([]); + expect(await sdk.validate.checkNodeProps({ id: 'app_1', type: 'app' } as any)).toEqual([]); + }); +}); + +describe('validate: 查询接口', () => { + test('node()', async () => { + const sdk = createSdk({ propsConfigs: { text: textConfig } }); + await sdk.ready; + + expect((await sdk.validate.node('button_1')).valid).toBe(true); + expect(await sdk.validate.node('nope')).toMatchObject({ valid: false }); + + sdk.ctx.validateMode = 'off'; + await sdk.editor.update({ id: 'text_1', text: '' } as any); + + const result = await sdk.validate.node('text_1'); + expect(result.valid).toBe(false); + expect(result.errors[0].kind).toBe('props'); + }); + + test('node() 能发现已经在 DSL 中的非法类型 / 被禁用的页面片', async () => { + const dsl = createDsl(); + dsl.items[0].items!.push({ id: 'weird', type: 'weird-type' } as any); + dsl.items.push({ id: 'frag_1', type: 'page-fragment', items: [] } as any); + + const sdk = createSdk({ dsl, disabledPageFragment: true }); + + expect((await sdk.validate.node('weird')).errors[0].message).toContain('weird-type'); + expect((await sdk.validate.node('frag_1')).errors[0].message).toContain('disabledPageFragment'); + }); + + test('dataSource()', async () => { + const dsl = createDsl(); + dsl.dataSources![0].title = ''; + const sdk = createSdk({ dsl }); + + expect((await sdk.validate.dataSource('nope')).valid).toBe(false); + + const result = await sdk.validate.dataSource('ds_1'); + expect(result.valid).toBe(false); + expect(result.errors[0]).toMatchObject({ target: 'data-source', kind: 'props' }); + }); + + test('dsl():汇总整份 DSL 的问题', async () => { + const dsl = createDsl(); + dsl.items[0].items!.push({ id: 'text_1', type: 'text' } as any, { id: 'text_2', type: 'text', text: '' } as any); + + const sdk = createSdk({ dsl, propsConfigs: { text: textConfig } }); + await sdk.ready; + + const result = await sdk.validate.dsl(); + expect(result.valid).toBe(false); + expect(result.errors.some((item) => item.message.includes('重复'))).toBe(true); + expect(result.errors.some((item) => item.kind === 'props')).toBe(true); + + expect((await createSdk({ dsl: null }).validate.dsl()).errors[0].target).toBe('dsl'); + expect((await createSdk().validate.dsl()).valid).toBe(true); + }); +}); + +describe('validateMode', () => { + test('off:不校验', async () => { + const sdk = createSdk({ validateMode: 'off' }); + await expect(sdk.editor.add({ type: 'not-exist' } as any)).resolves.toBeTruthy(); + expect(() => sdk.dataSource.add({ id: 'ds_z', type: 'unknown' } as any)).not.toThrow(); + await expect(sdk.editor.moveToContainer({ id: 'text_1' } as any, 'container_1')).resolves.toBeTruthy(); + }); + + test('record:不抛错,错误写进 invalidNodeIds', async () => { + const sdk = createSdk({ validateMode: 'record', propsConfigs: { text: textConfig } }); + await sdk.ready; + + await sdk.editor.update({ id: 'text_1', text: '' } as any); + expect(sdk.editor.getInvalidNodeInfo('text_1')?.props).toContain('必填'); + + await sdk.editor.update({ id: 'text_1', text: 'fixed' } as any); + expect(sdk.editor.getInvalidNodeInfo('text_1')).toBeUndefined(); + + // 结构问题同样只记录 + const added = (await sdk.editor.add({ type: 'text' } as any)) as any; + expect(added).toBeTruthy(); + expect(sdk.editor.getInvalidNodeInfo(added.id)?.props).toContain('必填'); + + // 数据源 / 移动只在 strict 模式下校验 + expect(() => sdk.dataSource.add({ id: 'ds_z', type: 'unknown' } as any)).not.toThrow(); + await expect(sdk.editor.moveToContainer({ id: 'text_1' } as any, 'container_1')).resolves.toBeTruthy(); + }); + + test('运行时切换 validateMode', async () => { + const sdk = createSdk(); + sdk.ctx.validateMode = 'off'; + await expect(sdk.editor.add({ type: 'not-exist' } as any)).resolves.toBeTruthy(); + }); +}); diff --git a/packages/editor/tests/unit/sdk/wiring.spec.ts b/packages/editor/tests/unit/sdk/wiring.spec.ts new file mode 100644 index 00000000..349b413d --- /dev/null +++ b/packages/editor/tests/unit/sdk/wiring.spec.ts @@ -0,0 +1,206 @@ +// @vitest-environment node +import { describe, expect, test } from 'vitest'; + +import { DepTargetType, NODE_CONDS_KEY } from '@tmagic/core'; + +import { classifyUpdatedNodes } from '@editor/sdk'; + +import { createDsl, createSdk } from './helpers'; + +describe('依赖收集连线', () => { + test('创建时收集数据源依赖并写回 DSL', () => { + const sdk = createSdk(); + const dsl = sdk.getDsl()!; + + expect(dsl.dataSourceDeps!.ds_1.text_1).toMatchObject({ keys: ['text'] }); + expect(dsl.dataSourceMethodDeps).toHaveProperty('ds_1'); + expect(dsl.dataSourceCondDeps).toHaveProperty('ds_1'); + }); + + test('没有 items 的 root 会清空依赖', async () => { + const sdk = createSdk(); + await sdk.setDsl({ id: 'app_2', type: 'app', dataSourceDeps: { x: {} } } as any); + + expect(sdk.getDsl()!.dataSourceDeps).toBeUndefined(); + expect(sdk.editor.get('nodes')[0].id).toBe('app_2'); + }); + + test('root 变化后保持原来的选中节点', async () => { + const sdk = createSdk(); + await sdk.editor.select('button_1'); + await sdk.setDsl(createDsl()); + + expect(sdk.editor.get('node')?.id).toBe('button_1'); + }); + + test('add:新增节点引用数据源时收集依赖', async () => { + const sdk = createSdk(); + const node = (await sdk.editor.add({ type: 'text', text: '${ds_1.name}' } as any)) as any; + + expect(sdk.getDsl()!.dataSourceDeps!.ds_1[node.id]).toBeDefined(); + }); + + test('update:修改绑定数据源的字段触发重新收集;改成普通文本后依赖被移除', async () => { + const sdk = createSdk(); + + await sdk.editor.update({ id: 'button_1', text: '${ds_1.name}' } as any, { + changeRecords: [{ propPath: 'text', value: '${ds_1.name}' }], + }); + expect(sdk.getDsl()!.dataSourceDeps!.ds_1.button_1).toBeDefined(); + + await sdk.editor.update({ id: 'button_1', text: 'plain' } as any, { + changeRecords: [{ propPath: 'text', value: 'plain' }], + }); + expect(sdk.getDsl()!.dataSourceDeps!.ds_1.button_1).toBeUndefined(); + }); + + test('update:没有 changeRecords 时按新旧节点是否包含数据源判断', async () => { + const sdk = createSdk(); + + await sdk.editor.update({ id: 'button_1', text: '${ds_1.name}' } as any); + expect(sdk.getDsl()!.dataSourceDeps!.ds_1.button_1).toBeDefined(); + }); + + test('update:普通字段变化只收集代码块 / 数据源方法依赖', async () => { + const sdk = createSdk(); + await sdk.editor.update({ id: 'button_1', text: 'new' } as any, { + changeRecords: [{ propPath: 'text', value: 'new' }], + }); + + expect(sdk.getDsl()!.dataSourceDeps!.ds_1.text_1).toBeDefined(); + }); + + test('update root 不触发收集', async () => { + const sdk = createSdk(); + await sdk.editor.update({ id: 'app_1', name: 'x' } as any); + + expect(sdk.getDsl()!.dataSourceDeps!.ds_1.text_1).toBeDefined(); + }); + + test('remove:删除节点时清理依赖', async () => { + const sdk = createSdk(); + await sdk.editor.remove({ id: 'text_1' } as any); + + expect(sdk.getDsl()!.dataSourceDeps!.ds_1.text_1).toBeUndefined(); + }); + + test('undo / redo 后依赖保持一致', async () => { + const sdk = createSdk(); + await sdk.editor.remove({ id: 'text_1' } as any); + await sdk.editor.undo(); + + expect(sdk.getDsl()!.dataSourceDeps!.ds_1.text_1).toBeDefined(); + + await sdk.editor.redo(); + expect(sdk.getDsl()!.dataSourceDeps!.ds_1.text_1).toBeUndefined(); + }); + + test('数据源:新增后引用它的节点生效;修改字段 / mock / 方法会重新收集;删除时清理', async () => { + const sdk = createSdk(); + + sdk.dataSource.add({ + id: 'ds_2', + type: 'base', + title: 'ds2', + fields: [{ name: 'a' }], + methods: [], + events: [], + } as any); + expect(sdk.dep.getTarget('ds_2', DepTargetType.DATA_SOURCE)).toBeTruthy(); + + await sdk.editor.update({ id: 'button_1', text: '${ds_2.a}' } as any); + expect(sdk.getDsl()!.dataSourceDeps!.ds_2.button_1).toBeDefined(); + + // 改字段 + sdk.dataSource.update( + { id: 'ds_2', type: 'base', title: 'ds2', fields: [{ name: 'b' }], methods: [], events: [] } as any, + { changeRecords: [{ propPath: 'fields', value: [{ name: 'b' }] }] }, + ); + expect(sdk.dep.getTarget('ds_2', DepTargetType.DATA_SOURCE)).toBeTruthy(); + + // 改 mock + sdk.dataSource.update( + { id: 'ds_2', type: 'base', title: 'ds2', fields: [{ name: 'b' }], methods: [], events: [], mocks: [] } as any, + { changeRecords: [{ propPath: 'mocks', value: [] }] }, + ); + expect(sdk.dep.getTarget('ds_2', DepTargetType.DATA_SOURCE)).toBeTruthy(); + + // 改方法 + sdk.dataSource.update( + { id: 'ds_2', type: 'base', title: 'ds2', fields: [{ name: 'b' }], methods: [], events: [] } as any, + { changeRecords: [{ propPath: 'methods', value: [] }] }, + ); + expect(sdk.dep.getTarget('ds_2', DepTargetType.DATA_SOURCE_METHOD)).toBeTruthy(); + + // 与依赖无关的字段 + sdk.dataSource.update( + { id: 'ds_2', type: 'base', title: 'renamed', fields: [{ name: 'b' }], methods: [], events: [] } as any, + { + changeRecords: [ + { propPath: 'title', value: 'renamed' }, + { propPath: '', value: '' }, + ], + }, + ); + + sdk.dataSource.remove('ds_2'); + expect(sdk.dep.getTarget('ds_2', DepTargetType.DATA_SOURCE)).toBeFalsy(); + expect(sdk.getDsl()!.dataSourceDeps!.ds_2).toBeUndefined(); + }); + + test('代码块:新增 / 更新 / 删除对应的依赖目标', async () => { + const sdk = createSdk(); + + expect(sdk.dep.hasTarget('code_1', DepTargetType.CODE_BLOCK)).toBe(true); + + await sdk.codeBlock.setCodeDslById('code_2', { name: 'two', content: '() => {}', params: [] }); + expect(sdk.dep.hasTarget('code_2', DepTargetType.CODE_BLOCK)).toBe(true); + + await sdk.codeBlock.setCodeDslById('code_2', { name: 'renamed', content: '() => {}', params: [] }); + expect(sdk.dep.getTarget('code_2', DepTargetType.CODE_BLOCK)?.name).toBe('renamed'); + + await sdk.codeBlock.deleteCodeDslByIds(['code_2']); + expect(sdk.dep.hasTarget('code_2', DepTargetType.CODE_BLOCK)).toBe(false); + }); + + test('unbind 之后不再响应', async () => { + const sdk = createSdk(); + const unbind = sdk.bindWiring(); + unbind(); + + await sdk.editor.add({ type: 'text', text: '${ds_1.name}' } as any); + // 创建时已自动绑定,这里 unbind 的是 bindWiring 返回的同一个句柄 + expect(Object.keys(sdk.getDsl()!.dataSourceDeps!.ds_1)).toContain('text_1'); + }); +}); + +describe('classifyUpdatedNodes', () => { + const sdk = createSdk(); + const make = (newNode: any, oldNode: any, changeRecords?: any[]) => ({ newNode, oldNode, changeRecords }); + + test('根节点归为普通节点', () => { + const { normalNodes } = classifyUpdatedNodes(sdk.ctx, [make({ id: 'a', type: 'app' }, { id: 'a', type: 'app' })]); + expect(normalNodes).toHaveLength(1); + }); + + test('没有 propPath、显示条件、绑定数据源的值都需要重新收集', () => { + const node = { id: 'x', type: 'text' }; + const { needRecollectNodes } = classifyUpdatedNodes(sdk.ctx, [ + make(node, node, [{ propPath: '', value: 1 }]), + make(node, node, [{ propPath: NODE_CONDS_KEY, value: 1 }]), + make(node, node, [{ propPath: `${NODE_CONDS_KEY}.0.cond`, value: 1 }]), + make(node, node, [{ propPath: 'a', value: '${ds_1.a}' }]), + ]); + expect(needRecollectNodes).toHaveLength(4); + }); + + test('修改的 key 已在依赖里时需要重新收集', () => { + const { needRecollectNodes, normalNodes } = classifyUpdatedNodes(sdk.ctx, [ + make({ id: 'text_1', type: 'text' }, { id: 'text_1', type: 'text' }, [{ propPath: 'text', value: 'a' }]), + make({ id: 'text_1', type: 'text' }, { id: 'text_1', type: 'text' }, [{ propPath: 'other', value: 'a' }]), + make({ id: 'zzz', type: 'text' }, { id: 'zzz', type: 'text' }, [{ propPath: 'text', value: 'a' }]), + ]); + expect(needRecollectNodes).toHaveLength(1); + expect(normalNodes).toHaveLength(2); + }); +}); diff --git a/packages/editor/tests/unit/services/BaseService.spec.ts b/packages/editor/tests/unit/services/BaseService.spec.ts index 0a855af5..20e2d097 100644 --- a/packages/editor/tests/unit/services/BaseService.spec.ts +++ b/packages/editor/tests/unit/services/BaseService.spec.ts @@ -5,7 +5,7 @@ */ import { describe, expect, test, vi } from 'vitest'; -import BaseService from '@editor/services/BaseService'; +import BaseService from '@editor/sdk/services/BaseService'; class SyncService extends BaseService { public count = 0; diff --git a/packages/editor/tests/unit/services/codeBlock.spec.ts b/packages/editor/tests/unit/services/codeBlock.spec.ts index 8bfe1965..7b135f95 100644 --- a/packages/editor/tests/unit/services/codeBlock.spec.ts +++ b/packages/editor/tests/unit/services/codeBlock.spec.ts @@ -5,18 +5,23 @@ */ import { afterEach, beforeAll, beforeEach, describe, expect, test, vi } from 'vitest'; -import codeBlockService from '@editor/services/codeBlock'; -import historyService from '@editor/services/history'; -import storageService, { Protocol } from '@editor/services/storage'; +import { codeBlockService, historyService, Protocol, storageService } from '@editor/services/sdk-instance'; 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/editor', () => ({ - default: { - getNodeById: vi.fn((id: string) => ({ id, code: 'code1' })), - }, -})); +vi.mock('@editor/services/sdk-instance', async (importOriginal) => { + const actual = await importOriginal(); + const modEditor = await (() => ({ + default: { + getNodeById: vi.fn((id: string) => ({ id, code: 'code1' })), + }, + }))(); + return { + ...actual, + editorService: modEditor.default, + }; +}); beforeAll(() => { setEditorConfig({ diff --git a/packages/editor/tests/unit/services/componentList.spec.ts b/packages/editor/tests/unit/services/componentList.spec.ts index f0c376df..0c94ff2b 100644 --- a/packages/editor/tests/unit/services/componentList.spec.ts +++ b/packages/editor/tests/unit/services/componentList.spec.ts @@ -5,7 +5,7 @@ */ import { afterEach, describe, expect, test, vi } from 'vitest'; -import componentList from '@editor/services/componentList'; +import { componentListService as componentList } from '@editor/services/sdk-instance'; afterEach(() => { componentList.resetState(); diff --git a/packages/editor/tests/unit/services/dataSource.spec.ts b/packages/editor/tests/unit/services/dataSource.spec.ts index 95c89c3f..9bcbd8ac 100644 --- a/packages/editor/tests/unit/services/dataSource.spec.ts +++ b/packages/editor/tests/unit/services/dataSource.spec.ts @@ -5,17 +5,27 @@ */ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; -import dataSource from '@editor/services/dataSource'; -import historyService from '@editor/services/history'; -import storageService, { Protocol } from '@editor/services/storage'; +import { + dataSourceService as dataSource, + historyService, + Protocol, + storageService, +} from '@editor/services/sdk-instance'; import { setEditorConfig } from '@editor/utils/config'; import { COPY_DS_STORAGE_KEY } from '@editor/utils/editor'; -vi.mock('@editor/services/editor', () => ({ - default: { - getNodeById: vi.fn((id: string) => ({ id, dsBinding: ['ds1'] })), - }, -})); +vi.mock('@editor/services/sdk-instance', async (importOriginal) => { + const actual = await importOriginal(); + const modEditor = await (() => ({ + default: { + getNodeById: vi.fn((id: string) => ({ id, dsBinding: ['ds1'] })), + }, + }))(); + return { + ...actual, + editorService: modEditor.default, + }; +}); setEditorConfig({ // eslint-disable-next-line no-new-func diff --git a/packages/editor/tests/unit/services/dep.spec.ts b/packages/editor/tests/unit/services/dep.spec.ts index c6c3e2cc..507fc948 100644 --- a/packages/editor/tests/unit/services/dep.spec.ts +++ b/packages/editor/tests/unit/services/dep.spec.ts @@ -8,7 +8,7 @@ import { reactive } from 'vue'; import { createDataSourceTarget, DepTargetType, Target } from '@tmagic/core'; -import depService from '@editor/services/dep'; +import { depService } from '@editor/services/sdk-instance'; // 全量收集({ id, dsl })与增量收集({ id, payload })共用一个常驻 worker vi.mock('@editor/utils/dep/worker.ts?worker&inline', () => ({ diff --git a/packages/editor/tests/unit/services/dragto_repro.spec.ts b/packages/editor/tests/unit/services/dragto_repro.spec.ts index b4d7029f..1aad8da5 100644 --- a/packages/editor/tests/unit/services/dragto_repro.spec.ts +++ b/packages/editor/tests/unit/services/dragto_repro.spec.ts @@ -4,8 +4,7 @@ import { cloneDeep } from 'lodash-es'; import type { MApp, MContainer } from '@tmagic/core'; import { NodeType } from '@tmagic/core'; -import editorService from '@editor/services/editor'; -import historyService from '@editor/services/history'; +import { editorService, historyService } from '@editor/services/sdk-instance'; import { setEditorConfig } from '@editor/utils'; setEditorConfig({ diff --git a/packages/editor/tests/unit/services/editor.spec.ts b/packages/editor/tests/unit/services/editor.spec.ts index 0cd89031..a39a7c69 100644 --- a/packages/editor/tests/unit/services/editor.spec.ts +++ b/packages/editor/tests/unit/services/editor.spec.ts @@ -23,9 +23,7 @@ import type { MApp, MContainer, MNode } from '@tmagic/core'; import { NodeType } from '@tmagic/core'; import { getNodePath } from '@tmagic/utils'; -import editorService from '@editor/services/editor'; -import historyService from '@editor/services/history'; -import storageService from '@editor/services/storage'; +import { editorService, historyService, storageService } from '@editor/services/sdk-instance'; import type { EditorChangeEvent } from '@editor/type'; import { COPY_STORAGE_KEY, setEditorConfig } from '@editor/utils'; diff --git a/packages/editor/tests/unit/services/events.spec.ts b/packages/editor/tests/unit/services/events.spec.ts index 6cbad396..5f75873a 100644 --- a/packages/editor/tests/unit/services/events.spec.ts +++ b/packages/editor/tests/unit/services/events.spec.ts @@ -19,7 +19,7 @@ import { beforeEach, describe, expect, test, vi } from 'vitest'; import { computed } from 'vue'; -import events from '@editor/services/events'; +import { eventsService as events } from '@editor/services/sdk-instance'; describe('events', () => { beforeEach(() => { diff --git a/packages/editor/tests/unit/services/history-persist.spec.ts b/packages/editor/tests/unit/services/history-persist.spec.ts index 9f466f67..9d709749 100644 --- a/packages/editor/tests/unit/services/history-persist.spec.ts +++ b/packages/editor/tests/unit/services/history-persist.spec.ts @@ -17,7 +17,7 @@ */ import { afterEach, beforeAll, beforeEach, describe, expect, test, vi } from 'vitest'; -import history from '@editor/services/history'; +import { historyService as history } from '@editor/services/sdk-instance'; import { setEditorConfig } from '@editor/utils/config'; import { createStackStep } from '@editor/utils/history'; import * as indexedDb from '@editor/utils/indexed-db'; diff --git a/packages/editor/tests/unit/services/history.spec.ts b/packages/editor/tests/unit/services/history.spec.ts index 63b7c582..0fc9ccee 100644 --- a/packages/editor/tests/unit/services/history.spec.ts +++ b/packages/editor/tests/unit/services/history.spec.ts @@ -5,7 +5,7 @@ */ import { afterEach, describe, expect, test, vi } from 'vitest'; -import history from '@editor/services/history'; +import { historyService as history } from '@editor/services/sdk-instance'; import { createStackStep } from '@editor/utils/history'; // pushCodeBlock / pushDataSource 已合入统一的 push(stepType, step, id)。 diff --git a/packages/editor/tests/unit/services/keybinding.spec.ts b/packages/editor/tests/unit/services/keybinding.spec.ts index 8ac6182b..791fd400 100644 --- a/packages/editor/tests/unit/services/keybinding.spec.ts +++ b/packages/editor/tests/unit/services/keybinding.spec.ts @@ -25,7 +25,14 @@ const uiService = vi.hoisted(() => ({ calcZoom: vi.fn(async () => 1.2), })); -vi.mock('@editor/services/editor', () => ({ default: editorService })); +vi.mock('@editor/services/sdk-instance', async (importOriginal) => { + const actual = await importOriginal(); + const modEditor = await (() => ({ default: editorService }))(); + return { + ...actual, + editorService: modEditor.default, + }; +}); vi.mock('@editor/services/ui', () => ({ default: uiService })); afterEach(() => { diff --git a/packages/editor/tests/unit/services/props.spec.ts b/packages/editor/tests/unit/services/props.spec.ts index 4f4a31e7..36fcfe33 100644 --- a/packages/editor/tests/unit/services/props.spec.ts +++ b/packages/editor/tests/unit/services/props.spec.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from 'vitest'; import { NodeType } from '@tmagic/core'; -import props from '@editor/services/props'; +import { propsService as props } from '@editor/services/sdk-instance'; test('createId', async () => { const id = await props.createId('text'); diff --git a/packages/editor/tests/unit/services/stageOverlay.spec.ts b/packages/editor/tests/unit/services/stageOverlay.spec.ts index fe33df21..52dca88e 100644 --- a/packages/editor/tests/unit/services/stageOverlay.spec.ts +++ b/packages/editor/tests/unit/services/stageOverlay.spec.ts @@ -28,26 +28,33 @@ vi.mock('@editor/hooks/use-stage', () => ({ })); const editorEvents: Record any)[]> = {}; -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('@editor/services/sdk-instance', async (importOriginal) => { + const actual = await importOriginal(); + 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('@tmagic/utils', async () => { const actual = await vi.importActual('@tmagic/utils'); @@ -192,8 +199,8 @@ describe('stageOverlay', () => { }); test('updateSelectStatus 多节点调用 multiSelect', async () => { - const editorMod = (await import('@editor/services/editor')) as any; - editorMod.default.get.mockImplementation((k: string) => { + const editorMod = (await import('@editor/services/sdk-instance')) as any; + editorMod.editorService.get.mockImplementation((k: string) => { if (k === 'nodes') return [{ id: 'n1' }, { id: 'n2' }]; return null; }); diff --git a/packages/editor/tests/unit/services/storage.spec.ts b/packages/editor/tests/unit/services/storage.spec.ts index a5867735..5babd5e1 100644 --- a/packages/editor/tests/unit/services/storage.spec.ts +++ b/packages/editor/tests/unit/services/storage.spec.ts @@ -5,7 +5,7 @@ */ import { afterEach, beforeEach, describe, expect, test } from 'vitest'; -import storage, { Protocol } from '@editor/services/storage'; +import { Protocol, storageService as storage } from '@editor/services/sdk-instance'; import { setEditorConfig } from '@editor/utils/config'; describe('storage service', () => { diff --git a/packages/editor/tests/unit/utils/event.spec.ts b/packages/editor/tests/unit/utils/event.spec.ts index 3d5b8298..7f9965f6 100644 --- a/packages/editor/tests/unit/utils/event.spec.ts +++ b/packages/editor/tests/unit/utils/event.spec.ts @@ -29,9 +29,14 @@ const { editorService, dataSourceService, eventsService } = vi.hoisted(() => ({ }, })); -vi.mock('@editor/services/editor', () => ({ default: editorService })); -vi.mock('@editor/services/dataSource', () => ({ default: dataSourceService })); -vi.mock('@editor/services/events', () => ({ default: eventsService })); +const services = { editorService, dataSourceService, eventsService } as any; + +// 不传 services 时使用默认实例(保持历史签名可用) +vi.mock('@editor/services/sdk-instance', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, editorService, dataSourceService, eventsService }; +}); + vi.mock('@editor/utils/data-source', async () => { const actual = await vi.importActual('@editor/utils/data-source'); return { ...actual, getCascaderOptionsFromFields: vi.fn(() => [{ label: 'f1', value: 'f1' }]) }; @@ -54,14 +59,14 @@ describe('event utils', () => { test('getCompActionOptions 普通组件返回 select options', () => { const node = { type: 'btn', id: '1' }; editorService.getNodeById.mockReturnValue(node); - expect(getCompActionOptions('1')).toEqual([{ text: 'open', value: 'open' }]); + expect(getCompActionOptions('1', services)).toEqual([{ text: 'open', value: 'open' }]); expect(eventsService.getMethod).toHaveBeenCalledWith('btn', { targetId: '1', node }); }); test('getCompActionOptions 无节点返回空', () => { editorService.getNodeById.mockReturnValue(null); - expect(getCompActionOptions('missing')).toEqual([]); - expect(getCompActionOptions(undefined)).toEqual([]); + expect(getCompActionOptions('missing', services)).toEqual([]); + expect(getCompActionOptions(undefined, services)).toEqual([]); }); test('getCompActionOptions 页面片容器返回 cascader options', () => { @@ -69,7 +74,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')).toEqual([ + expect(getCompActionOptions('1', services)).toEqual([ { label: 'b_c1', value: 'c1', @@ -81,25 +86,25 @@ describe('event utils', () => { test('getCompActionAllowedValues 枚举合法动作', () => { editorService.getNodeById.mockReturnValue({ type: 'btn', id: '1' }); - expect(getCompActionAllowedValues({ src: 'component' }, { to: '1' })).toEqual(new Set(['open'])); + expect(getCompActionAllowedValues({ src: 'component' }, { to: '1' }, services)).toEqual(new Set(['open'])); }); test('getCompActionAllowedValues 自定义 options 返回 null', () => { expect( - getCompActionAllowedValues({ src: 'component', compActionConfig: { options: () => [] } }, { to: '1' }), + getCompActionAllowedValues({ src: 'component', compActionConfig: { options: () => [] } }, { to: '1' }, services), ).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' })).toEqual(new Set(['c1.open'])); + expect(getCompActionAllowedValues({ src: 'component' }, { to: '1' }, services)).toEqual(new Set(['c1.open'])); }); test('getEventNameAllowedValues / collectEventNameOptionValues', () => { - expect(getEventNameAllowedValues({ src: 'component' }, { type: 'btn' })).toEqual(new Set(['click'])); + expect(getEventNameAllowedValues({ src: 'component' }, { type: 'btn' }, services)).toEqual(new Set(['click'])); expect( - getEventNameAllowedValues({ src: 'component', eventNameConfig: { options: [] } }, { type: 'btn' }), + getEventNameAllowedValues({ src: 'component', eventNameConfig: { options: [] } }, { type: 'btn' }, services), ).toBeNull(); expect(collectEventNameOptionValues([{ value: 'a', children: [{ value: 'b' }] }], false)).toEqual(new Set(['a.b'])); }); @@ -112,7 +117,7 @@ describe('event utils', () => { items: [{ id: 'pf1', name: '页面片', items: [child] }], }); - expect(getEventNameOptions('component', formValue)).toEqual([ + expect(getEventNameOptions('component', formValue, services)).toEqual([ { label: '页面片', value: 'pf1', @@ -129,7 +134,18 @@ describe('event utils', () => { test('getEventNameOptions datasource 追加数据变化', () => { dataSourceService.getDataSourceById.mockReturnValue({ fields: [{ name: 'f1' }] }); - const opts = getEventNameOptions('datasource', { type: 'ds', id: 'd1' }); + const opts = getEventNameOptions('datasource', { type: 'ds', id: 'd1' }, services); expect(opts.some((item: any) => item.value === 'ds-field-changed' || item.label === '数据变化')).toBe(true); }); + + 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()); + }); }); diff --git a/packages/editor/tests/unit/utils/operator.spec.ts b/packages/editor/tests/unit/utils/operator.spec.ts index 3e973eb6..f9f50823 100644 --- a/packages/editor/tests/unit/utils/operator.spec.ts +++ b/packages/editor/tests/unit/utils/operator.spec.ts @@ -7,8 +7,7 @@ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import { NodeType } from '@tmagic/core'; -import editorService from '@editor/services/editor'; -import propsService from '@editor/services/props'; +import { editorService, propsService } from '@editor/services/sdk-instance'; import { beforePaste, getAddParent, getDefaultConfig, getPositionInContainer } from '@editor/utils/operator'; const { editorState } = vi.hoisted(() => { @@ -16,20 +15,27 @@ const { editorState } = vi.hoisted(() => { return { editorState: state }; }); -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('@editor/services/sdk-instance', async (importOriginal) => { + const actual = await importOriginal(); + 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('@tmagic/utils', async () => { const actual = (await vi.importActual('@tmagic/utils')) as Record; diff --git a/packages/editor/tests/unit/utils/typeMatchRules.spec.ts b/packages/editor/tests/unit/utils/typeMatchRules.spec.ts index 0e52c7d5..0f9da894 100644 --- a/packages/editor/tests/unit/utils/typeMatchRules.spec.ts +++ b/packages/editor/tests/unit/utils/typeMatchRules.spec.ts @@ -33,37 +33,44 @@ const componentEventsState = vi.hoisted((): { value: Record ({ value: null as any })); const nodesState = vi.hoisted((): { value: Record } => ({ 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; +vi.mock('@editor/services/sdk-instance', async (importOriginal) => { + const actual = await importOriginal(); + const modCodeBlock = await (() => ({ + default: { + getCodeDsl: () => codeDslState.value, }, - 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 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, + }; +}); const ctx = (config: Record = {}, model: Record = {}, message?: string) => ({ fieldType: config.type || '', diff --git a/packages/form/src/utils/validateValues.ts b/packages/form/src/utils/validateValues.ts index 37d88acb..08a57ae5 100644 --- a/packages/form/src/utils/validateValues.ts +++ b/packages/form/src/utils/validateValues.ts @@ -17,7 +17,7 @@ */ import { reactive } from 'vue'; -import Schema from 'async-validator'; +import AsyncValidator from 'async-validator'; import type { FormConfig, FormContext, FormState, FormValue } from '../schema'; @@ -25,6 +25,10 @@ import { applyMountValueEffects, type CollectedField, collectValidatableFields } import { createFormStateBase, createFormStateProxy, initValue } from './form'; import { formatValidateError } from './validateError'; +// async-validator 是 CJS 包(`exports.default = Schema`)。原生 Node ESM 下默认导入拿到的是整个 `module.exports`, +// 需要再取一层 `.default`;打包器 / vitest 会自动处理互操作,此时 `.default` 不存在,直接用默认导入。 +const Schema: typeof AsyncValidator = (AsyncValidator as any).default ?? AsyncValidator; + // #region HeadlessFormStateOptions /** 构造无渲染 formState 所需的参数(与 MForm 的同名 props 对齐) */ export interface HeadlessFormStateOptions { diff --git a/playground/vite.config.ts b/playground/vite.config.ts index 34dfef22..0f58c297 100644 --- a/playground/vite.config.ts +++ b/playground/vite.config.ts @@ -84,6 +84,7 @@ export default defineConfig({ // `/headless` 必须在下方通用的 `^@tmagic/` 规则之前命中,否则会被改写成 // `.../src/index.ts/headless` 导致 Vite 解析失败。 { find: /^@tmagic\/editor\/headless$/, replacement: path.join(__dirname, '../packages/editor/src/headless.ts') }, + { find: /^@tmagic\/editor\/sdk$/, replacement: path.join(__dirname, '../packages/editor/src/sdk.ts') }, { find: /^@tmagic\/editor/, replacement: path.join(__dirname, '../packages/editor/src/index.ts') }, { find: /^@tmagic\/form-schema/, replacement: path.join(__dirname, '../packages/form-schema/src/index.ts') }, { find: /^@tmagic\/schema/, replacement: path.join(__dirname, '../packages/schema/src/index.ts') }, diff --git a/rolldown.dts.config.mjs b/rolldown.dts.config.mjs index f7f07820..66482b5c 100644 --- a/rolldown.dts.config.mjs +++ b/rolldown.dts.config.mjs @@ -70,8 +70,10 @@ function rolldownConfig(pkg, base, entry = 'index') { function packageDtsConfigs(pkg, base) { const configs = [rolldownConfig(pkg, base, 'index')]; - if (existsSync(`./temp/${base}/${pkg}/src/headless.d.ts`)) { - configs.push(rolldownConfig(pkg, base, 'headless')); + for (const entry of ['headless', 'sdk']) { + if (existsSync(`./temp/${base}/${pkg}/src/${entry}.d.ts`)) { + configs.push(rolldownConfig(pkg, base, entry)); + } } return configs; } diff --git a/scripts/build.mjs b/scripts/build.mjs index 1294dc02..d62993e6 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -11,6 +11,9 @@ import * as sass from 'sass-embedded'; const args = minimist(process.argv.slice(2)); +/** 除 index 外的子路径入口(`src/.ts`),存在才构建 */ +const subEntries = ['headless', 'sdk']; + const toPascalCase = (str) => str.replace(/(^\w|-\w)/g, (text) => text.replace(/-/, '').toUpperCase()); const dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -30,7 +33,9 @@ async function buildPackage({ packageName, packagesDir, requirePath }) { await build({ packageName, format: 'es', pkg, packagesDir }); await build({ packageName, format: 'umd', pkg, packagesDir }); - await buildHeadlessUmd({ packageName, pkg, packagesDir }); + for (const subEntry of subEntries) { + await buildSubEntryUmd({ packageName, pkg, packagesDir, subEntry }); + } buildThemes({ packageName, packagesDir }); } @@ -82,25 +87,32 @@ function fixUmdSymbolShadow() { }; } -function buildHeadlessUmd({ packageName, pkg, packagesDir }) { +function buildSubEntryUmd({ packageName, pkg, packagesDir, subEntry }) { const pkgRoot = path.resolve(packagesDir, `./${packageName}`); - if (!fs.existsSync(path.join(pkgRoot, 'src/headless.ts'))) return; + if (!fs.existsSync(path.join(pkgRoot, `src/${subEntry}.ts`))) return; return build({ packageName, format: 'umd', pkg, packagesDir, - entry: 'src/headless.ts', - name: `TMagic${toPascalCase(packageName)}Headless`, - fileName: `tmagic-${packageName}-headless`, - cssFileName: 'style-headless', + entry: `src/${subEntry}.ts`, + name: `TMagic${toPascalCase(packageName)}${toPascalCase(subEntry)}`, + fileName: `tmagic-${packageName}-${subEntry}`, + cssFileName: `style-${subEntry}`, }); } async function build({ packageName, format, pkg, packagesDir, entry, name, fileName, cssFileName = 'style' }) { const pkgRoot = path.resolve(packagesDir, `./${packageName}`); - const hasHeadless = !entry && format === 'es' && fs.existsSync(path.join(pkgRoot, 'src/headless.ts')); + const esEntries = + !entry && format === 'es' + ? Object.fromEntries( + subEntries + .filter((name) => fs.existsSync(path.join(pkgRoot, `src/${name}.ts`))) + .map((name) => [name, `src/${name}.ts`]), + ) + : {}; await buildVite({ root: pkgRoot, @@ -117,7 +129,7 @@ async function build({ packageName, format, pkg, packagesDir, entry, name, fileN target: 'esnext', lib: { - entry: entry ?? (hasHeadless ? { index: 'src/index.ts', headless: 'src/headless.ts' } : 'src/index.ts'), + entry: entry ?? (Object.keys(esEntries).length ? { index: 'src/index.ts', ...esEntries } : 'src/index.ts'), name: name ?? `TMagic${toPascalCase(packageName)}`, fileName: fileName ?? `tmagic-${packageName}`, formats: [format], diff --git a/scripts/check-headless-dist.mjs b/scripts/check-headless-dist.mjs index ba20d30b..3a3730e7 100644 --- a/scripts/check-headless-dist.mjs +++ b/scripts/check-headless-dist.mjs @@ -21,6 +21,7 @@ const targets = [ { specifier: '@tmagic/design/headless', expects: ['getDesignConfig', 'appendValidateSuggestion'] }, { specifier: '@tmagic/form/headless', expects: ['validateForm', 'submitForm', 'registerField', 'builtInFields'] }, { specifier: '@tmagic/editor/headless', expects: ['editorFields'] }, + { specifier: '@tmagic/editor/sdk', expects: ['createDslSdk', 'DslValidationError'] }, ]; /** @@ -86,6 +87,70 @@ for (const { specifier, expects } of targets) { } } +/** + * sdk 链路必须能在无 DOM 的 Node 里运行:沿着 `dist/es/sdk.js` 的相对 import 走一遍, + * 确认没有引入编辑器界面、画布、UI 库或 `.vue` 组件。 + * vue 本身不在禁用之列:`@tmagic/form/headless` 只用到它的响应式 API。 + */ +const SDK_FORBIDDEN = [ + /^@tmagic\/stage/, + /^@tmagic\/design$/, + /^@tmagic\/form$/, + /^@tmagic\/table/, + /^monaco-editor/, + /\.vue/, +]; + +const checkSdkImports = () => { + const dir = findPackageDir('@tmagic/editor/sdk'); + if (!dir) return; + + const esDir = path.join(dir, 'dist/es'); + const entry = path.join(esDir, 'sdk.js'); + if (!existsSync(entry)) { + console.error(`✗ sdk 产物不存在:${entry}`); + failed += 1; + return; + } + + const seen = new Set(); + const queue = [entry]; + const violations = []; + const importRe = /(?:import|export)\s[^'"]*?from\s*['"]([^'"]+)['"]|import\s*['"]([^'"]+)['"]/g; + + while (queue.length) { + const file = queue.pop(); + if (seen.has(file)) continue; + seen.add(file); + + const code = readFileSync(file, 'utf-8'); + for (const match of code.matchAll(importRe)) { + const specifier = match[1] || match[2]; + if (specifier.startsWith('.')) { + const resolved = path.resolve(path.dirname(file), specifier); + if (/\.vue(\.js)?$/.test(resolved)) { + violations.push(`${path.relative(esDir, file)} -> ${specifier}`); + } else if (existsSync(resolved) && resolved.endsWith('.js')) { + queue.push(resolved); + } + continue; + } + if (SDK_FORBIDDEN.some((re) => re.test(specifier))) { + violations.push(`${path.relative(esDir, file)} -> ${specifier}`); + } + } + } + + if (violations.length) { + failed += 1; + console.error(`✗ sdk 产物引入了禁用依赖:\n ${violations.join('\n ')}`); + } else { + console.log(`✓ sdk 依赖链干净(共 ${seen.size} 个模块)`); + } +}; + +checkSdkImports(); + if (failed) { console.error(`\n${failed} 个 headless 产物检查失败`); process.exit(1); diff --git a/tsconfig.json b/tsconfig.json index dd9ba865..700a5e80 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -22,6 +22,7 @@ "@tmagic/*": ["./packages/*/src"], "@tmagic/form/headless": ["./packages/form/src/headless.ts"], "@tmagic/editor/headless": ["./packages/editor/src/headless.ts"], + "@tmagic/editor/sdk": ["./packages/editor/src/sdk.ts"], "@tmagic/design/headless": ["./packages/design/src/headless.ts"], "@tmagic/tmagic-form-runtime": ["./runtime/tmagic-form/src"], "@tmagic/vue-runtime-help": ["./runtime/vue-runtime-help/src"], diff --git a/vitest.config.ts b/vitest.config.ts index d74c2a95..01f20f7d 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -12,6 +12,7 @@ const alias = { '@tmagic/form/headless': r('./packages/form/src/headless.ts'), '@tmagic/form': r('./packages/form/src'), '@tmagic/editor/headless': r('./packages/editor/src/headless.ts'), + '@tmagic/editor/sdk': r('./packages/editor/src/sdk.ts'), '@data-source': r('./packages/data-source/src'), }; @@ -45,6 +46,7 @@ export default defineConfig({ include: ['./packages/*/tests/**', './runtime/*/tests/**'], exclude: [ './packages/cli/tests/**', + './packages/editor/tests/unit/sdk/**', './packages/editor/tests/unit/hooks/use-stage.spec.ts', // 多份用例共用的脚手架模块,本身不含用例 './packages/*/tests/**/helpers/**', @@ -74,7 +76,9 @@ export default defineConfig({ resolve: { alias }, test: { name: 'node', - include: ['./packages/cli/tests/**', './packages/form/tests/node/**'], + include: ['./packages/cli/tests/**', './packages/form/tests/node/**', './packages/editor/tests/unit/sdk/**'], + // 多份用例共用的脚手架模块,本身不含用例 + exclude: ['./packages/*/tests/**/helpers/**'], environment: 'node', pool: 'forks', isolate: false,