mirror of
https://github.com/Tencent/tmagic-editor.git
synced 2026-04-23 10:18:55 +00:00
Compare commits
30 Commits
v1.7.8-bet
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9bf42f9007 | ||
|
|
3875ccde33 | ||
|
|
62a488ac66 | ||
|
|
27bb886054 | ||
|
|
fa09ab0b30 | ||
|
|
31f4d2b4e2 | ||
|
|
b2888962df | ||
|
|
b3f4e42716 | ||
|
|
6e07d5762b | ||
|
|
cfd5998242 | ||
|
|
26dc70d70c | ||
|
|
99c8274a1e | ||
|
|
334569e2d7 | ||
|
|
f583c7daec | ||
|
|
172a7a1c92 | ||
|
|
73c676931f | ||
|
|
df2d635682 | ||
|
|
0c2f2fd2b5 | ||
|
|
a7274198bf | ||
|
|
6f2e8d8d74 | ||
|
|
637a5bb69a | ||
|
|
42e7ac1b2e | ||
|
|
a3cdad9d91 | ||
|
|
711af79d72 | ||
|
|
728fbc035c | ||
|
|
01795455e9 | ||
|
|
9b56223359 | ||
|
|
984cea7ca3 | ||
|
|
9921ed8a2d | ||
|
|
e36d8d7cf8 |
56
AGENTS.md
Normal file
56
AGENTS.md
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
# AGENTS.md — TMagic编辑器
|
||||||
|
|
||||||
|
> 魔方平台可视化编辑器核心,提供拖拽式活动页面编辑能力。
|
||||||
|
> 负责人:roymondchen | 创建:2026-04-03
|
||||||
|
|
||||||
|
## 项目概述
|
||||||
|
|
||||||
|
TMagic Editor 是魔方平台的可视化编辑器核心库,提供拖拽式组件编辑、配置面板、预览发布等能力。支持 Vue 和 React 双框架 Runtime,采用 pnpm monorepo 管理多个核心包。开源项目,同时支持内部业务定制。
|
||||||
|
|
||||||
|
**技术栈:** Vue 3, Element Plus, TypeScript, Vite, vitest, VitePress
|
||||||
|
**主仓库:** `https://git.woa.com/vft-magic/tmagic-editor.git`
|
||||||
|
**开源仓库:** `https://github.com/Tencent/tmagic-editor.git`
|
||||||
|
|
||||||
|
## 架构地图
|
||||||
|
|
||||||
|
关键目录:
|
||||||
|
- `packages/` — 核心编辑器包(202 *.vue, 194 *.ts)
|
||||||
|
- `runtime/` — Vue/React Runtime 实现
|
||||||
|
- `vue-components/` — Vue 组件封装
|
||||||
|
- `react-components/` — React 组件封装
|
||||||
|
- `playground/` — 演示 playground
|
||||||
|
- `docs/` — VitePress 文档(100 *.md)
|
||||||
|
- `scripts/` — 构建和发布脚本
|
||||||
|
- `eslint-config/` — 共享 ESLint 配置
|
||||||
|
|
||||||
|
## 开发约定
|
||||||
|
|
||||||
|
**分支策略:** dev=dev, test/prod=master
|
||||||
|
**提交规范:** commitlint + husky,`type: 描述`
|
||||||
|
|
||||||
|
**禁止事项:**
|
||||||
|
- 禁止在核心包中引入腾讯内部专有依赖(开源项目)
|
||||||
|
- 禁止直接修改 CHANGELOG.md,应通过 `pnpm changelog` 生成
|
||||||
|
|
||||||
|
## 常用命令
|
||||||
|
|
||||||
|
pnpm bootstrap # 安装依赖并构建
|
||||||
|
pnpm pg # 启动 Vue playground
|
||||||
|
pnpm pg:react # 启动 React playground
|
||||||
|
pnpm build # 完整构建(DTS + 包)
|
||||||
|
pnpm test # 运行测试
|
||||||
|
pnpm lint-fix # ESLint 修复
|
||||||
|
pnpm docs:dev # 启动文档开发
|
||||||
|
pnpm release # 发版
|
||||||
|
|
||||||
|
## 当前状态
|
||||||
|
|
||||||
|
**当前里程碑:** {待人工填写}
|
||||||
|
|
||||||
|
## 深入阅读
|
||||||
|
|
||||||
|
| 文档 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| docs/ | VitePress 文档站 |
|
||||||
|
| CONTRIBUTING.md | 贡献指南 |
|
||||||
|
| CHANGELOG.md | 变更日志 |
|
||||||
50
CHANGELOG.md
50
CHANGELOG.md
@ -1,3 +1,53 @@
|
|||||||
|
## [1.7.10](https://github.com/Tencent/tmagic-editor/compare/v1.7.9...v1.7.10) (2026-04-13)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **editor:** 优化 StageOverlay 双击行为,仅在元素被滚动容器裁剪时打开 overlay ([df2d635](https://github.com/Tencent/tmagic-editor/commit/df2d635682034f2f478c575543ed63f94b7a291c))
|
||||||
|
* **editor:** 修复 getTMagicAppPrimise 变量名拼写错误 ([99c8274](https://github.com/Tencent/tmagic-editor/commit/99c8274a1ee25c8e155498e43520c5d7ba99c4b1))
|
||||||
|
* **editor:** 历史记录信息中添加页面信息 ([26dc70d](https://github.com/Tencent/tmagic-editor/commit/26dc70d70c329d9a83a671d92bbfaf77d7abbcef))
|
||||||
|
* **editor:** 数据源方法选择器展示所有数据源并支持字段非叶子节点选择 ([31f4d2b](https://github.com/Tencent/tmagic-editor/commit/31f4d2b4e26f5a941041c41c0843828d70ed3cd8))
|
||||||
|
* **stage:** 修复隐藏标尺后无法显示问题 ([6f2e8d8](https://github.com/Tencent/tmagic-editor/commit/6f2e8d8d74ed0d8a7ef1eefd0b39b08a90abc6e7))
|
||||||
|
* **stage:** 新增组件后等待渲染完后选中 ([cfd5998](https://github.com/Tencent/tmagic-editor/commit/cfd5998242031f393bc1003c40b55cde35f3c515))
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* **editor,data-source:** 数据源支持内置"设置数据"方法 ([f583c7d](https://github.com/Tencent/tmagic-editor/commit/f583c7daec76518bbb2e7044740337e0c15997e1))
|
||||||
|
* **editor,stage:** 支持双击穿透选中鼠标下方的下一个可选中元素 ([172a7a1](https://github.com/Tencent/tmagic-editor/commit/172a7a1c92e85266114c628c0fc0534b6b76aca3))
|
||||||
|
* **editor:** 样式配置添加变形项 ([fa09ab0](https://github.com/Tencent/tmagic-editor/commit/fa09ab0b301d9bc9f6a9462fc022418bbf21cee3))
|
||||||
|
* **editor:** 添加 stage beforeDblclick 钩子,支持拦截默认双击行为 ([334569e](https://github.com/Tencent/tmagic-editor/commit/334569e2d784d708f4fde7e9f37f5cac50b2e396))
|
||||||
|
* **stage:** 支持将指定id的dom生成图片 ([b3f4e42](https://github.com/Tencent/tmagic-editor/commit/b3f4e42716fe1d998d093e7556897bdb2e9037a9))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## [1.7.9](https://github.com/Tencent/tmagic-editor/compare/v1.7.8...v1.7.9) (2026-03-23)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **form:** row容器中如果配置没有type显示异常 ([711af79](https://github.com/Tencent/tmagic-editor/commit/711af79d72c202a7bc961b0639ce62c318952ee6))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## [1.7.8](https://github.com/Tencent/tmagic-editor/compare/v1.7.8-beta.4...v1.7.8) (2026-03-20)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **editor:** 组件配置样式显示出错 ([9b56223](https://github.com/Tencent/tmagic-editor/commit/9b56223359f4ce826597f8f5d440626869e8b650))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## [1.7.8-beta.4](https://github.com/Tencent/tmagic-editor/compare/v1.7.8-beta.3...v1.7.8-beta.4) (2026-03-20)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **form-schema:** 表单 schema 中 display 与 component 部分字段改为可选 ([e36d8d7](https://github.com/Tencent/tmagic-editor/commit/e36d8d7cf874fe5e307eee5f3906499a86337b85))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## [1.7.8-beta.3](https://github.com/Tencent/tmagic-editor/compare/v1.7.8-beta.2...v1.7.8-beta.3) (2026-03-20)
|
## [1.7.8-beta.3](https://github.com/Tencent/tmagic-editor/compare/v1.7.8-beta.2...v1.7.8-beta.3) (2026-03-20)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -16,9 +16,9 @@ https://tencent.github.io/tmagic-editor/playground/index.html
|
|||||||
|
|
||||||
## 环境准备
|
## 环境准备
|
||||||
|
|
||||||
node.js >= 18
|
node.js ^20.19.0 || >=22.12.0
|
||||||
|
|
||||||
pnpm >= 9
|
pnpm >= 10
|
||||||
|
|
||||||
先安装 pnpm
|
先安装 pnpm
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@tmagic/eslint-config",
|
"name": "@tmagic/eslint-config",
|
||||||
"version": "0.0.4",
|
"version": "0.1.0",
|
||||||
"main": "index.mjs",
|
"main": "index.mjs",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"repository": {
|
"repository": {
|
||||||
@ -10,17 +10,17 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
"@typescript-eslint/parser": "^8.57.1",
|
"@typescript-eslint/parser": "^8.58.0",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.57.1",
|
"@typescript-eslint/eslint-plugin": "^8.58.0",
|
||||||
"@stylistic/eslint-plugin": "^5.10.0",
|
"@stylistic/eslint-plugin": "^5.10.0",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"eslint-plugin-import": "^2.32.0",
|
"eslint-plugin-import": "^2.32.0",
|
||||||
"eslint-plugin-simple-import-sort": "^12.1.1",
|
"eslint-plugin-simple-import-sort": "^13.0.0",
|
||||||
"eslint-plugin-vue": "^10.8.0",
|
"eslint-plugin-vue": "^10.8.0",
|
||||||
"vue-eslint-parser": "^10.3.0",
|
"vue-eslint-parser": "^10.4.0",
|
||||||
"eslint-plugin-prettier": "^5.5.4",
|
"eslint-plugin-prettier": "^5.5.5",
|
||||||
"globals": "^17.4.0",
|
"globals": "^17.4.0",
|
||||||
"typescript-eslint": "^8.57.1"
|
"typescript-eslint": "^8.58.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"eslint": ">=10.0.0",
|
"eslint": ">=10.0.0",
|
||||||
|
|||||||
@ -16,6 +16,7 @@ export default defineConfig([
|
|||||||
'*/**/public/**/*',
|
'*/**/public/**/*',
|
||||||
'*/**/types/**/*',
|
'*/**/types/**/*',
|
||||||
'*/**/*.config.ts',
|
'*/**/*.config.ts',
|
||||||
|
'./tepm/**/*',
|
||||||
'vite-env.d.ts',
|
'vite-env.d.ts',
|
||||||
]),
|
]),
|
||||||
...eslintConfig(path.join(path.dirname(fileURLToPath(import.meta.url)), 'tsconfig.json')),
|
...eslintConfig(path.join(path.dirname(fileURLToPath(import.meta.url)), 'tsconfig.json')),
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"version": "1.7.8-beta.3",
|
"version": "1.7.10",
|
||||||
"name": "tmagic",
|
"name": "tmagic",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"version": "1.7.8-beta.3",
|
"version": "1.7.10",
|
||||||
"name": "@tmagic/cli",
|
"name": "@tmagic/cli",
|
||||||
"main": "lib/index.js",
|
"main": "lib/index.js",
|
||||||
"types": "lib/index.d.ts",
|
"types": "lib/index.d.ts",
|
||||||
|
|||||||
@ -2,8 +2,8 @@
|
|||||||
"extends": "../../tsconfig.json",
|
"extends": "../../tsconfig.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
"moduleResolution": "Node",
|
"moduleResolution": "node16",
|
||||||
"module": "CommonJS",
|
"module": "node16",
|
||||||
"rootDir": "./src",
|
"rootDir": "./src",
|
||||||
"outDir": "./lib",
|
"outDir": "./lib",
|
||||||
"declaration": true,
|
"declaration": true,
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"version": "1.7.8-beta.3",
|
"version": "1.7.10",
|
||||||
"name": "@tmagic/core",
|
"name": "@tmagic/core",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
|
|||||||
@ -297,7 +297,7 @@ class App extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (typeof dataSource[methodName] === 'function') {
|
if (typeof dataSource[methodName] === 'function') {
|
||||||
return await dataSource[methodName]();
|
return await dataSource[methodName]({ params });
|
||||||
}
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
if (this.errorHandler) {
|
if (this.errorHandler) {
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"version": "1.7.8-beta.3",
|
"version": "1.7.10",
|
||||||
"name": "@tmagic/data-source",
|
"name": "@tmagic/data-source",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
|
|||||||
@ -20,7 +20,7 @@ import EventEmitter from 'events';
|
|||||||
import { cloneDeep } from 'lodash-es';
|
import { cloneDeep } from 'lodash-es';
|
||||||
|
|
||||||
import type { CodeBlockContent, DataSchema, DataSourceSchema, default as TMagicApp } from '@tmagic/core';
|
import type { CodeBlockContent, DataSchema, DataSourceSchema, default as TMagicApp } from '@tmagic/core';
|
||||||
import { getDefaultValueFromFields } from '@tmagic/core';
|
import { DATA_SOURCE_SET_DATA_METHOD_NAME, getDefaultValueFromFields } from '@tmagic/core';
|
||||||
|
|
||||||
import { ObservedData } from '@data-source/observed-data/ObservedData';
|
import { ObservedData } from '@data-source/observed-data/ObservedData';
|
||||||
import { SimpleObservedData } from '@data-source/observed-data/SimpleObservedData';
|
import { SimpleObservedData } from '@data-source/observed-data/SimpleObservedData';
|
||||||
@ -51,6 +51,7 @@ export default class DataSource<T extends DataSourceSchema = DataSourceSchema> e
|
|||||||
super();
|
super();
|
||||||
|
|
||||||
this.#id = options.schema.id;
|
this.#id = options.schema.id;
|
||||||
|
this.#type = options.schema.type;
|
||||||
this.#schema = options.schema;
|
this.#schema = options.schema;
|
||||||
|
|
||||||
this.app = options.app;
|
this.app = options.app;
|
||||||
@ -58,6 +59,11 @@ export default class DataSource<T extends DataSourceSchema = DataSourceSchema> e
|
|||||||
this.setFields(options.schema.fields);
|
this.setFields(options.schema.fields);
|
||||||
this.setMethods(options.schema.methods || []);
|
this.setMethods(options.schema.methods || []);
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
this[DATA_SOURCE_SET_DATA_METHOD_NAME] = ({ params }: { params: { field?: string[]; data: any } }) => {
|
||||||
|
this.setData(params.data, params.field?.join('.'));
|
||||||
|
};
|
||||||
|
|
||||||
let data = options.initialData;
|
let data = options.initialData;
|
||||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||||
const ObservedDataClass = options.ObservedDataClass || SimpleObservedData;
|
const ObservedDataClass = options.ObservedDataClass || SimpleObservedData;
|
||||||
|
|||||||
@ -79,9 +79,9 @@ export default class HttpDataSource extends DataSource<HttpDataSourceSchema> {
|
|||||||
/** 请求函数 */
|
/** 请求函数 */
|
||||||
#fetch?: RequestFunction;
|
#fetch?: RequestFunction;
|
||||||
/** 请求前需要执行的函数队列 */
|
/** 请求前需要执行的函数队列 */
|
||||||
#beforeRequest: ((...args: any[]) => any)[] = [];
|
#beforeRequest: (Function | ((...args: any[]) => any))[] = [];
|
||||||
/** 请求后需要执行的函数队列 */
|
/** 请求后需要执行的函数队列 */
|
||||||
#afterRequest: ((...args: any[]) => any)[] = [];
|
#afterRequest: (Function | ((...args: any[]) => any))[] = [];
|
||||||
|
|
||||||
#type = 'http';
|
#type = 'http';
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"version": "1.7.8-beta.3",
|
"version": "1.7.10",
|
||||||
"name": "@tmagic/dep",
|
"name": "@tmagic/dep",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"version": "1.7.8-beta.3",
|
"version": "1.7.10",
|
||||||
"name": "@tmagic/design",
|
"name": "@tmagic/design",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"sideEffects": [
|
"sideEffects": [
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"version": "1.7.8-beta.3",
|
"version": "1.7.10",
|
||||||
"name": "@tmagic/editor",
|
"name": "@tmagic/editor",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"sideEffects": [
|
"sideEffects": [
|
||||||
|
|||||||
@ -207,6 +207,7 @@ const stageOptions: StageOptions = {
|
|||||||
renderType: props.renderType,
|
renderType: props.renderType,
|
||||||
guidesOptions: props.guidesOptions,
|
guidesOptions: props.guidesOptions,
|
||||||
disabledMultiSelect: props.disabledMultiSelect,
|
disabledMultiSelect: props.disabledMultiSelect,
|
||||||
|
beforeDblclick: props.beforeDblclick,
|
||||||
};
|
};
|
||||||
|
|
||||||
stageOverlayService.set('stageOptions', stageOptions);
|
stageOverlayService.set('stageOptions', stageOptions);
|
||||||
|
|||||||
@ -88,7 +88,7 @@ const width = defineModel<number>('width', { default: 670 });
|
|||||||
const boxVisible = defineModel<boolean>('visible', { default: false });
|
const boxVisible = defineModel<boolean>('visible', { default: false });
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
content: CodeBlockContent;
|
content: Omit<CodeBlockContent, 'content'> & { content: string };
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
isDataSource?: boolean;
|
isDataSource?: boolean;
|
||||||
dataSourceType?: string;
|
dataSourceType?: string;
|
||||||
|
|||||||
@ -46,13 +46,29 @@ const getFormConfig = (items: FormItemConfig[] = []) => [
|
|||||||
|
|
||||||
const codeParamsConfig = computed(() =>
|
const codeParamsConfig = computed(() =>
|
||||||
getFormConfig(
|
getFormConfig(
|
||||||
props.paramsConfig.map(({ name, text, extra, ...config }) => ({
|
props.paramsConfig.map(({ name, text, extra, ...config }) => {
|
||||||
type: 'data-source-field-select',
|
let { type } = config;
|
||||||
|
if (typeof type === 'function') {
|
||||||
|
type = type(undefined, {
|
||||||
|
model: props.model[props.name],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (type && ['data-source-field-select', 'vs-code'].includes(type)) {
|
||||||
|
return {
|
||||||
|
...config,
|
||||||
name,
|
name,
|
||||||
text,
|
text,
|
||||||
extra,
|
extra,
|
||||||
fieldConfig: config as FormItemConfig,
|
};
|
||||||
})),
|
}
|
||||||
|
return {
|
||||||
|
type: 'data-source-field-select' as const,
|
||||||
|
name,
|
||||||
|
text,
|
||||||
|
extra,
|
||||||
|
fieldConfig: config,
|
||||||
|
};
|
||||||
|
}) as FormItemConfig[],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -98,6 +98,8 @@ export interface EditorProps {
|
|||||||
isContainer?: (el: HTMLElement) => boolean | Promise<boolean>;
|
isContainer?: (el: HTMLElement) => boolean | Promise<boolean>;
|
||||||
/** 用于自定义组件树与画布的右键菜单 */
|
/** 用于自定义组件树与画布的右键菜单 */
|
||||||
customContentMenu?: CustomContentMenuFunction;
|
customContentMenu?: CustomContentMenuFunction;
|
||||||
|
/** 画布双击前的钩子函数,返回 false 则阻止默认的双击行为 */
|
||||||
|
beforeDblclick?: (event: MouseEvent) => Promise<boolean | void> | boolean | void;
|
||||||
extendFormState?: (state: FormState) => Record<string, any> | Promise<Record<string, any>>;
|
extendFormState?: (state: FormState) => Record<string, any> | Promise<Record<string, any>>;
|
||||||
/** 页面顺序拖拽配置参数 */
|
/** 页面顺序拖拽配置参数 */
|
||||||
pageBarSortOptions?: PageBarSortOptions;
|
pageBarSortOptions?: PageBarSortOptions;
|
||||||
|
|||||||
@ -1,6 +1,21 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="m-editor-data-source-field-select">
|
<div class="m-editor-data-source-field-select">
|
||||||
<template v-if="checkStrictly">
|
<template v-if="dataSourceId">
|
||||||
|
<TMagicCascader
|
||||||
|
:model-value="selectFieldsId"
|
||||||
|
clearable
|
||||||
|
filterable
|
||||||
|
:size="size"
|
||||||
|
:disabled="disabled"
|
||||||
|
:options="fieldsOptions"
|
||||||
|
:props="{
|
||||||
|
checkStrictly,
|
||||||
|
}"
|
||||||
|
@change="fieldChangeHandler"
|
||||||
|
></TMagicCascader>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="checkStrictly">
|
||||||
<TMagicSelect
|
<TMagicSelect
|
||||||
:model-value="selectDataSourceId"
|
:model-value="selectDataSourceId"
|
||||||
clearable
|
clearable
|
||||||
@ -92,6 +107,8 @@ const props = defineProps<{
|
|||||||
dataSourceFieldType?: DataSourceFieldType[];
|
dataSourceFieldType?: DataSourceFieldType[];
|
||||||
/** 是否可以编辑数据源,disable表示的是是否可以选择数据源 */
|
/** 是否可以编辑数据源,disable表示的是是否可以选择数据源 */
|
||||||
notEditable?: boolean | FilterFunction;
|
notEditable?: boolean | FilterFunction;
|
||||||
|
/** 指定数据源ID,限定只能选择该数据源的字段 */
|
||||||
|
dataSourceId?: string;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@ -106,7 +123,12 @@ const { dataSourceService, uiService } = useServices();
|
|||||||
const mForm = inject<FormState | undefined>('mForm');
|
const mForm = inject<FormState | undefined>('mForm');
|
||||||
const eventBus = inject<EventBus>('eventBus');
|
const eventBus = inject<EventBus>('eventBus');
|
||||||
|
|
||||||
const dataSources = computed(() => dataSourceService.get('dataSources') || []);
|
const allDataSources = computed(() => dataSourceService.get('dataSources') || []);
|
||||||
|
|
||||||
|
const dataSources = computed(() => {
|
||||||
|
if (!props.dataSourceId) return allDataSources.value;
|
||||||
|
return allDataSources.value.filter((ds) => ds.id === props.dataSourceId);
|
||||||
|
});
|
||||||
|
|
||||||
const valueIsKey = computed(() => props.value === 'key');
|
const valueIsKey = computed(() => props.value === 'key');
|
||||||
const notEditable = computed(() => filterFunction(mForm, props.notEditable, props));
|
const notEditable = computed(() => filterFunction(mForm, props.notEditable, props));
|
||||||
@ -125,7 +147,13 @@ const selectFieldsId = ref<string[]>([]);
|
|||||||
watch(
|
watch(
|
||||||
modelValue,
|
modelValue,
|
||||||
(value) => {
|
(value) => {
|
||||||
if (Array.isArray(value)) {
|
if (props.dataSourceId) {
|
||||||
|
const dsIdValue = valueIsKey.value
|
||||||
|
? props.dataSourceId
|
||||||
|
: `${DATA_SOURCE_FIELDS_SELECT_VALUE_PREFIX}${props.dataSourceId}`;
|
||||||
|
selectDataSourceId.value = dsIdValue;
|
||||||
|
selectFieldsId.value = Array.isArray(value) ? value : [];
|
||||||
|
} else if (Array.isArray(value) && value.length) {
|
||||||
const [dsId, ...fields] = value;
|
const [dsId, ...fields] = value;
|
||||||
selectDataSourceId.value = dsId;
|
selectDataSourceId.value = dsId;
|
||||||
selectFieldsId.value = fields;
|
selectFieldsId.value = fields;
|
||||||
@ -140,7 +168,7 @@ watch(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const fieldsOptions = computed(() => {
|
const fieldsOptions = computed(() => {
|
||||||
const ds = dataSources.value.find((ds) => ds.id === removeDataSourceFieldPrefix(selectDataSourceId.value));
|
const ds = allDataSources.value.find((ds) => ds.id === removeDataSourceFieldPrefix(selectDataSourceId.value));
|
||||||
|
|
||||||
if (!ds) return [];
|
if (!ds) return [];
|
||||||
|
|
||||||
@ -163,8 +191,13 @@ const dsChangeHandler = (v: string) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const fieldChangeHandler = (v: string[] = []) => {
|
const fieldChangeHandler = (v: string[] = []) => {
|
||||||
|
if (props.dataSourceId) {
|
||||||
|
modelValue.value = v;
|
||||||
|
emit('change', v);
|
||||||
|
} else {
|
||||||
modelValue.value = [selectDataSourceId.value, ...v];
|
modelValue.value = [selectDataSourceId.value, ...v];
|
||||||
emit('change', modelValue.value);
|
emit('change', modelValue.value);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onChangeHandler = (v: string[] = []) => {
|
const onChangeHandler = (v: string[] = []) => {
|
||||||
|
|||||||
@ -8,6 +8,7 @@
|
|||||||
:value="config.value"
|
:value="config.value"
|
||||||
:checkStrictly="checkStrictly"
|
:checkStrictly="checkStrictly"
|
||||||
:dataSourceFieldType="config.dataSourceFieldType"
|
:dataSourceFieldType="config.dataSourceFieldType"
|
||||||
|
:dataSourceId="config.dataSourceId"
|
||||||
@change="onChangeHandler"
|
@change="onChangeHandler"
|
||||||
></FieldSelect>
|
></FieldSelect>
|
||||||
|
|
||||||
|
|||||||
@ -52,12 +52,14 @@ import {
|
|||||||
type FormState,
|
type FormState,
|
||||||
MCascader,
|
MCascader,
|
||||||
} from '@tmagic/form';
|
} from '@tmagic/form';
|
||||||
|
import { DATA_SOURCE_SET_DATA_METHOD_NAME } from '@tmagic/utils';
|
||||||
|
|
||||||
import CodeParams from '@editor/components/CodeParams.vue';
|
import CodeParams from '@editor/components/CodeParams.vue';
|
||||||
import MIcon from '@editor/components/Icon.vue';
|
import MIcon from '@editor/components/Icon.vue';
|
||||||
import { useServices } from '@editor/hooks/use-services';
|
import { useServices } from '@editor/hooks/use-services';
|
||||||
import type { CodeParamStatement, EventBus } from '@editor/type';
|
import type { CodeParamStatement, EventBus } from '@editor/type';
|
||||||
import { SideItemKey } from '@editor/type';
|
import { SideItemKey } from '@editor/type';
|
||||||
|
import { getFieldType } from '@editor/utils';
|
||||||
|
|
||||||
defineOptions({
|
defineOptions({
|
||||||
name: 'MFieldsDataSourceMethodSelect',
|
name: 'MFieldsDataSourceMethodSelect',
|
||||||
@ -92,6 +94,43 @@ const isCustomMethod = computed(() => {
|
|||||||
const getParamItemsConfig = ([dataSourceId, methodName]: [Id, string] = ['', '']): CodeParamStatement[] => {
|
const getParamItemsConfig = ([dataSourceId, methodName]: [Id, string] = ['', '']): CodeParamStatement[] => {
|
||||||
if (!dataSourceId) return [];
|
if (!dataSourceId) return [];
|
||||||
|
|
||||||
|
if (methodName === DATA_SOURCE_SET_DATA_METHOD_NAME) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
name: 'field',
|
||||||
|
text: '字段',
|
||||||
|
type: 'data-source-field-select',
|
||||||
|
dataSourceId,
|
||||||
|
checkStrictly: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'data',
|
||||||
|
text: '数据',
|
||||||
|
type: (_formState, { model }) => {
|
||||||
|
const fieldType = getFieldType(dataSourceService.getDataSourceById(`${dataSourceId}`), model.field);
|
||||||
|
|
||||||
|
let type = 'vs-code';
|
||||||
|
|
||||||
|
if (fieldType === 'number') {
|
||||||
|
type = 'number';
|
||||||
|
} else if (fieldType === 'string') {
|
||||||
|
type = 'text';
|
||||||
|
} else if (fieldType === 'boolean') {
|
||||||
|
type = 'switch';
|
||||||
|
}
|
||||||
|
|
||||||
|
return type;
|
||||||
|
},
|
||||||
|
language: 'javascript',
|
||||||
|
options: inject('codeOptions', {}),
|
||||||
|
autosize: {
|
||||||
|
minRows: 1,
|
||||||
|
maxRows: 10,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
const paramStatements = dataSources.value
|
const paramStatements = dataSources.value
|
||||||
?.find((item) => item.id === dataSourceId)
|
?.find((item) => item.id === dataSourceId)
|
||||||
?.methods?.find((item) => item.name === methodName)?.params;
|
?.methods?.find((item) => item.name === methodName)?.params;
|
||||||
@ -108,12 +147,14 @@ const paramsConfig = ref<CodeParamStatement[]>(getParamItemsConfig(props.model[p
|
|||||||
|
|
||||||
const methodsOptions = computed(
|
const methodsOptions = computed(
|
||||||
() =>
|
() =>
|
||||||
dataSources.value
|
dataSources.value?.map((ds) => ({
|
||||||
?.filter((ds) => ds.methods?.length || dataSourceService.getFormMethod(ds.type).length)
|
|
||||||
?.map((ds) => ({
|
|
||||||
label: ds.title || ds.id,
|
label: ds.title || ds.id,
|
||||||
value: ds.id,
|
value: ds.id,
|
||||||
children: [
|
children: [
|
||||||
|
{
|
||||||
|
label: '设置数据',
|
||||||
|
value: DATA_SOURCE_SET_DATA_METHOD_NAME,
|
||||||
|
},
|
||||||
...(dataSourceService?.getFormMethod(ds.type) || []),
|
...(dataSourceService?.getFormMethod(ds.type) || []),
|
||||||
...(ds.methods || []).map((method) => ({
|
...(ds.methods || []).map((method) => ({
|
||||||
label: method.name,
|
label: method.name,
|
||||||
|
|||||||
@ -42,7 +42,7 @@ const props = withDefaults(defineProps<FieldProps<DataSourceMethodsConfig>>(), {
|
|||||||
|
|
||||||
const emit = defineEmits(['change']);
|
const emit = defineEmits(['change']);
|
||||||
|
|
||||||
const codeConfig = ref<CodeBlockContent>();
|
const codeConfig = ref<Omit<CodeBlockContent, 'content'> & { content: string }>();
|
||||||
const codeBlockEditorRef = useTemplateRef<InstanceType<typeof CodeBlockEditor>>('codeBlockEditor');
|
const codeBlockEditorRef = useTemplateRef<InstanceType<typeof CodeBlockEditor>>('codeBlockEditor');
|
||||||
|
|
||||||
let editIndex = -1;
|
let editIndex = -1;
|
||||||
@ -72,10 +72,14 @@ const methodColumns: ColumnConfig[] = [
|
|||||||
{
|
{
|
||||||
text: '编辑',
|
text: '编辑',
|
||||||
handler: (method: CodeBlockContent, index: number) => {
|
handler: (method: CodeBlockContent, index: number) => {
|
||||||
let codeContent = method.content || '({ params, dataSource, app }) => {\n // place your code here\n}';
|
let codeContent: string = '({ params, dataSource, app }) => {\n // place your code here\n}';
|
||||||
|
|
||||||
if (typeof codeContent !== 'string') {
|
if (method.content) {
|
||||||
codeContent = codeContent.toString();
|
if (typeof method.content !== 'string') {
|
||||||
|
codeContent = method.content.toString();
|
||||||
|
} else {
|
||||||
|
codeContent = method.content;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
codeConfig.value = {
|
codeConfig.value = {
|
||||||
|
|||||||
@ -26,7 +26,7 @@ import type { StyleSchema } from '@tmagic/schema';
|
|||||||
|
|
||||||
import MIcon from '@editor/components/Icon.vue';
|
import MIcon from '@editor/components/Icon.vue';
|
||||||
|
|
||||||
import { Background, Border, Font, Layout, Position } from './pro/';
|
import { Background, Border, Font, Layout, Position, Transform } from './pro/';
|
||||||
|
|
||||||
defineOptions({
|
defineOptions({
|
||||||
name: 'MFieldsStyleSetter',
|
name: 'MFieldsStyleSetter',
|
||||||
@ -60,6 +60,10 @@ const list = [
|
|||||||
title: '边框与圆角',
|
title: '边框与圆角',
|
||||||
component: Border,
|
component: Border,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: '变形',
|
||||||
|
component: Transform,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const collapseValue = shallowRef(
|
const collapseValue = shallowRef(
|
||||||
|
|||||||
@ -39,7 +39,7 @@
|
|||||||
import { computed, ref } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
|
|
||||||
import type { ContainerChangeEventData, FormValue } from '@tmagic/form';
|
import type { ContainerChangeEventData, FormValue } from '@tmagic/form';
|
||||||
import { defineFormItem, type MContainer } from '@tmagic/form';
|
import { defineFormItem, MContainer } from '@tmagic/form';
|
||||||
import type { StyleSchema } from '@tmagic/schema';
|
import type { StyleSchema } from '@tmagic/schema';
|
||||||
|
|
||||||
const direction = ref('');
|
const direction = ref('');
|
||||||
|
|||||||
@ -5,7 +5,7 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { markRaw } from 'vue';
|
import { markRaw } from 'vue';
|
||||||
|
|
||||||
import { type ContainerChangeEventData, defineFormItem, type MContainer } from '@tmagic/form';
|
import { type ContainerChangeEventData, defineFormItem, MContainer } from '@tmagic/form';
|
||||||
import type { StyleSchema } from '@tmagic/schema';
|
import type { StyleSchema } from '@tmagic/schema';
|
||||||
|
|
||||||
import BackgroundPosition from '../components/BackgroundPosition.vue';
|
import BackgroundPosition from '../components/BackgroundPosition.vue';
|
||||||
|
|||||||
@ -4,7 +4,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { type ContainerChangeEventData, defineFormItem, type MContainer } from '@tmagic/form';
|
import { type ContainerChangeEventData, defineFormItem, MContainer } from '@tmagic/form';
|
||||||
import type { StyleSchema } from '@tmagic/schema';
|
import type { StyleSchema } from '@tmagic/schema';
|
||||||
|
|
||||||
import Border from '../components/Border.vue';
|
import Border from '../components/Border.vue';
|
||||||
|
|||||||
@ -5,7 +5,7 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { markRaw } from 'vue';
|
import { markRaw } from 'vue';
|
||||||
|
|
||||||
import { type ContainerChangeEventData, defineFormItem, type MContainer } from '@tmagic/form';
|
import { type ContainerChangeEventData, defineFormItem, MContainer } from '@tmagic/form';
|
||||||
import type { StyleSchema } from '@tmagic/schema';
|
import type { StyleSchema } from '@tmagic/schema';
|
||||||
|
|
||||||
import { AlignCenter, AlignLeft, AlignRight } from '../icons/text-align';
|
import { AlignCenter, AlignLeft, AlignRight } from '../icons/text-align';
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { type ContainerChangeEventData, defineFormItem, type MContainer } from '@tmagic/form';
|
import { type ContainerChangeEventData, defineFormItem, MContainer } from '@tmagic/form';
|
||||||
import type { StyleSchema } from '@tmagic/schema';
|
import type { StyleSchema } from '@tmagic/schema';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
|
|||||||
54
packages/editor/src/fields/StyleSetter/pro/Transform.vue
Normal file
54
packages/editor/src/fields/StyleSetter/pro/Transform.vue
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
<template>
|
||||||
|
<MContainer :config="config" :model="values" :size="size" :disabled="disabled" @change="change"></MContainer>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { type ContainerChangeEventData, defineFormItem, MContainer } from '@tmagic/form';
|
||||||
|
import type { StyleSchema } from '@tmagic/schema';
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
values: Partial<StyleSchema>;
|
||||||
|
disabled?: boolean;
|
||||||
|
size?: 'large' | 'default' | 'small';
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
change: [v: StyleSchema, eventData: ContainerChangeEventData];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const config = defineFormItem({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
name: 'transform',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
name: 'rotate',
|
||||||
|
text: '旋转角度',
|
||||||
|
labelWidth: '68px',
|
||||||
|
type: 'data-source-field-select',
|
||||||
|
checkStrictly: false,
|
||||||
|
dataSourceFieldType: ['string', 'number'],
|
||||||
|
fieldConfig: {
|
||||||
|
type: 'text',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'scale',
|
||||||
|
text: '缩放',
|
||||||
|
labelWidth: '68px',
|
||||||
|
type: 'data-source-field-select',
|
||||||
|
checkStrictly: false,
|
||||||
|
dataSourceFieldType: ['string', 'number'],
|
||||||
|
fieldConfig: {
|
||||||
|
type: 'text',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const change = (value: StyleSchema, eventData: ContainerChangeEventData) => {
|
||||||
|
emit('change', value, eventData);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
@ -3,3 +3,4 @@ export { default as Font } from './Font.vue';
|
|||||||
export { default as Layout } from './Layout.vue';
|
export { default as Layout } from './Layout.vue';
|
||||||
export { default as Position } from './Position.vue';
|
export { default as Position } from './Position.vue';
|
||||||
export { default as Border } from './Border.vue';
|
export { default as Border } from './Border.vue';
|
||||||
|
export { default as Transform } from './Transform.vue';
|
||||||
|
|||||||
@ -8,7 +8,7 @@ import CodeBlockEditor from '@editor/components/CodeBlockEditor.vue';
|
|||||||
import type { Services } from '@editor/type';
|
import type { Services } from '@editor/type';
|
||||||
|
|
||||||
export const useCodeBlockEdit = (codeBlockService: Services['codeBlockService']) => {
|
export const useCodeBlockEdit = (codeBlockService: Services['codeBlockService']) => {
|
||||||
const codeConfig = ref<CodeBlockContent>();
|
const codeConfig = ref<Omit<CodeBlockContent, 'content'> & { content: string }>();
|
||||||
const codeId = ref<string>();
|
const codeId = ref<string>();
|
||||||
const codeBlockEditorRef = useTemplateRef<InstanceType<typeof CodeBlockEditor>>('codeBlockEditor');
|
const codeBlockEditorRef = useTemplateRef<InstanceType<typeof CodeBlockEditor>>('codeBlockEditor');
|
||||||
|
|
||||||
@ -36,10 +36,14 @@ export const useCodeBlockEdit = (codeBlockService: Services['codeBlockService'])
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let codeContent = codeBlock.content;
|
let codeContent = '';
|
||||||
|
|
||||||
if (typeof codeContent !== 'string') {
|
if (codeBlock.content) {
|
||||||
codeContent = codeContent.toString();
|
if (typeof codeBlock.content !== 'string') {
|
||||||
|
codeContent = codeBlock.content.toString();
|
||||||
|
} else {
|
||||||
|
codeContent = codeBlock.content;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
codeConfig.value = {
|
codeConfig.value = {
|
||||||
|
|||||||
@ -233,7 +233,7 @@ export const initServiceEvents = (
|
|||||||
((event: 'update:modelValue', value: MApp | null) => void),
|
((event: 'update:modelValue', value: MApp | null) => void),
|
||||||
{ editorService, codeBlockService, dataSourceService, depService }: Services,
|
{ editorService, codeBlockService, dataSourceService, depService }: Services,
|
||||||
) => {
|
) => {
|
||||||
let getTMagicAppPrimise: Promise<TMagicCore | undefined> | null = null;
|
let getTMagicAppPromise: Promise<TMagicCore | undefined> | null = null;
|
||||||
|
|
||||||
const getTMagicApp = async (): Promise<TMagicCore | undefined> => {
|
const getTMagicApp = async (): Promise<TMagicCore | undefined> => {
|
||||||
const stage = await getStage();
|
const stage = await getStage();
|
||||||
@ -246,11 +246,11 @@ export const initServiceEvents = (
|
|||||||
return renderer.runtime.getApp?.();
|
return renderer.runtime.getApp?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (getTMagicAppPrimise) {
|
if (getTMagicAppPromise) {
|
||||||
return getTMagicAppPrimise;
|
return getTMagicAppPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
getTMagicAppPrimise = new Promise<TMagicCore | undefined>((resolve) => {
|
getTMagicAppPromise = new Promise<TMagicCore | undefined>((resolve) => {
|
||||||
// 设置 10s 超时
|
// 设置 10s 超时
|
||||||
const timeout = globalThis.setTimeout(() => {
|
const timeout = globalThis.setTimeout(() => {
|
||||||
resolve(void 0);
|
resolve(void 0);
|
||||||
@ -264,7 +264,7 @@ export const initServiceEvents = (
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
return getTMagicAppPrimise;
|
return getTMagicAppPromise;
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateStageNodes = (nodes: MComponent[]) => {
|
const updateStageNodes = (nodes: MComponent[]) => {
|
||||||
@ -560,7 +560,7 @@ export const initServiceEvents = (
|
|||||||
depService.clear(nodes);
|
depService.clear(nodes);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 由于历史记录变化是更新整个page,所以历史记录变化时,需要重新收集依赖
|
// 历史记录变化时,需要重新收集依赖
|
||||||
const historyChangeHandler = (page: MPage | MPageFragment) => {
|
const historyChangeHandler = (page: MPage | MPageFragment) => {
|
||||||
collectIdle([page], true).then(() => {
|
collectIdle([page], true).then(() => {
|
||||||
updateStageNode(page);
|
updateStageNode(page);
|
||||||
|
|||||||
@ -80,7 +80,7 @@ const props = withDefaults(
|
|||||||
let stage: StageCore | null = null;
|
let stage: StageCore | null = null;
|
||||||
let runtime: Runtime | null = null;
|
let runtime: Runtime | null = null;
|
||||||
|
|
||||||
const { editorService, uiService, keybindingService } = useServices();
|
const { editorService, uiService, keybindingService, stageOverlayService } = useServices();
|
||||||
|
|
||||||
const stageLoading = computed(() => editorService.get('stageLoading'));
|
const stageLoading = computed(() => editorService.get('stageLoading'));
|
||||||
|
|
||||||
@ -97,6 +97,60 @@ const page = computed(() => editorService.get('page'));
|
|||||||
const zoom = computed(() => uiService.get('zoom'));
|
const zoom = computed(() => uiService.get('zoom'));
|
||||||
const node = computed(() => editorService.get('node'));
|
const node = computed(() => editorService.get('node'));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断元素是否被非页面级的滚动容器裁剪(未完整显示)
|
||||||
|
*
|
||||||
|
* 从元素向上遍历祖先节点,跳过页面/页面片容器,
|
||||||
|
* 检查是否存在设置了 overflow 的滚动容器将该元素裁剪,
|
||||||
|
* 只有元素未被完整显示时才需要打开 overlay 以展示完整内容
|
||||||
|
*/
|
||||||
|
const isClippedByScrollContainer = (el: HTMLElement): boolean => {
|
||||||
|
const win = el.ownerDocument.defaultView;
|
||||||
|
if (!win) return false;
|
||||||
|
|
||||||
|
// 收集所有页面和页面片的 id
|
||||||
|
const root = editorService.get('root');
|
||||||
|
const pageIds = new Set(root?.items?.map((item) => `${item.id}`) ?? []);
|
||||||
|
|
||||||
|
// el 本身就是页面或页面片,无需判断
|
||||||
|
const elId = getIdFromEl()(el);
|
||||||
|
if (elId && pageIds.has(elId)) return false;
|
||||||
|
|
||||||
|
let parent = el.parentElement;
|
||||||
|
|
||||||
|
while (parent && parent !== el.ownerDocument.documentElement) {
|
||||||
|
const parentId = getIdFromEl()(parent);
|
||||||
|
|
||||||
|
// 到达页面或页面片层级,不再继续向上查找
|
||||||
|
if (parentId && pageIds.has(parentId)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { overflowX, overflowY } = win.getComputedStyle(parent);
|
||||||
|
|
||||||
|
if (
|
||||||
|
['auto', 'scroll', 'hidden'].includes(overflowX) ||
|
||||||
|
['auto', 'scroll', 'hidden'].includes(overflowY) ||
|
||||||
|
parent.scrollWidth > parent.clientWidth ||
|
||||||
|
parent.scrollHeight > parent.clientHeight
|
||||||
|
) {
|
||||||
|
// 比较元素与容器的可视区域,判断元素是否被裁剪
|
||||||
|
const elRect = el.getBoundingClientRect();
|
||||||
|
const containerRect = parent.getBoundingClientRect();
|
||||||
|
if (
|
||||||
|
elRect.top < containerRect.top ||
|
||||||
|
elRect.left < containerRect.left ||
|
||||||
|
elRect.bottom > containerRect.bottom ||
|
||||||
|
elRect.right > containerRect.right
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
parent = parent.parentElement;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
watchEffect(() => {
|
watchEffect(() => {
|
||||||
if (stage || !page.value) return;
|
if (stage || !page.value) return;
|
||||||
|
|
||||||
@ -109,6 +163,40 @@ watchEffect(() => {
|
|||||||
stageWrapRef.value?.container?.focus();
|
stageWrapRef.value?.container?.focus();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
stage.on('dblclick', async (event: MouseEvent) => {
|
||||||
|
if (props.stageOptions.beforeDblclick) {
|
||||||
|
const result = await props.stageOptions.beforeDblclick(event);
|
||||||
|
if (result === false) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const el = (await stage?.actionManager?.getElementFromPoint(event)) || null;
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
const id = getIdFromEl()(el);
|
||||||
|
|
||||||
|
if (id) {
|
||||||
|
const node = editorService.getNodeById(id);
|
||||||
|
if (node?.type === 'page-fragment-container' && node.pageFragmentId) {
|
||||||
|
await editorService.select(node.pageFragmentId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!props.disabledStageOverlay && isClippedByScrollContainer(el)) {
|
||||||
|
stageOverlayService.openOverlay(el);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextEl = (await stage?.actionManager?.getNextElementFromPoint(event)) || null;
|
||||||
|
if (nextEl) {
|
||||||
|
const nextId = getIdFromEl()(nextEl);
|
||||||
|
if (nextId) {
|
||||||
|
await editorService.select(nextId);
|
||||||
|
editorService.get('stage')?.select(nextId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
editorService.set('stage', markRaw(stage));
|
editorService.set('stage', markRaw(stage));
|
||||||
|
|
||||||
stage.mount(stageContainerEl.value);
|
stage.mount(stageContainerEl.value);
|
||||||
|
|||||||
@ -46,12 +46,7 @@ const style = computed(() => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
watch(stage, (stage) => {
|
watch(stage, (stage) => {
|
||||||
if (stage) {
|
if (!stage) {
|
||||||
stage.on('dblclick', async (event: MouseEvent) => {
|
|
||||||
const el = (await stage.actionManager?.getElementFromPoint(event)) || null;
|
|
||||||
stageOverlayService.openOverlay(el);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
stageOverlayService.closeOverlay();
|
stageOverlayService.closeOverlay();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@ -17,23 +17,13 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { reactive, toRaw } from 'vue';
|
import { reactive, toRaw } from 'vue';
|
||||||
import { cloneDeep, get, isObject, mergeWith, uniq } from 'lodash-es';
|
import { cloneDeep, isObject, mergeWith, uniq } from 'lodash-es';
|
||||||
import type { Writable } from 'type-fest';
|
|
||||||
|
|
||||||
import type { Id, MApp, MContainer, MNode, MPage, MPageFragment, TargetOptions } from '@tmagic/core';
|
import type { Id, MApp, MContainer, MNode, MPage, MPageFragment, TargetOptions } from '@tmagic/core';
|
||||||
import { NodeType, Target, Watcher } from '@tmagic/core';
|
import { NodeType } from '@tmagic/core';
|
||||||
import type { ChangeRecord } from '@tmagic/form';
|
import type { ChangeRecord } from '@tmagic/form';
|
||||||
import { isFixed } from '@tmagic/stage';
|
import { isFixed } from '@tmagic/stage';
|
||||||
import {
|
import { getNodeInfo, getNodePath, isPage, isPageFragment } from '@tmagic/utils';
|
||||||
calcValueByFontsize,
|
|
||||||
getElById,
|
|
||||||
getNodeInfo,
|
|
||||||
getNodePath,
|
|
||||||
isNumber,
|
|
||||||
isPage,
|
|
||||||
isPageFragment,
|
|
||||||
isPop,
|
|
||||||
} from '@tmagic/utils';
|
|
||||||
|
|
||||||
import BaseService from '@editor/services//BaseService';
|
import BaseService from '@editor/services//BaseService';
|
||||||
import propsService from '@editor/services//props';
|
import propsService from '@editor/services//props';
|
||||||
@ -42,69 +32,39 @@ import storageService, { Protocol } from '@editor/services/storage';
|
|||||||
import type {
|
import type {
|
||||||
AddMNode,
|
AddMNode,
|
||||||
AsyncHookPlugin,
|
AsyncHookPlugin,
|
||||||
|
AsyncMethodName,
|
||||||
|
EditorEvents,
|
||||||
EditorNodeInfo,
|
EditorNodeInfo,
|
||||||
|
HistoryOpType,
|
||||||
PastePosition,
|
PastePosition,
|
||||||
StepValue,
|
StepValue,
|
||||||
StoreState,
|
StoreState,
|
||||||
StoreStateKey,
|
StoreStateKey,
|
||||||
} from '@editor/type';
|
} from '@editor/type';
|
||||||
import { LayerOffset, Layout } from '@editor/type';
|
import { canUsePluginMethods, LayerOffset, Layout } from '@editor/type';
|
||||||
import {
|
import {
|
||||||
change2Fixed,
|
calcAlignCenterStyle,
|
||||||
|
calcLayerTargetIndex,
|
||||||
|
calcMoveStyle,
|
||||||
|
classifyDragSources,
|
||||||
|
collectRelatedNodes,
|
||||||
COPY_STORAGE_KEY,
|
COPY_STORAGE_KEY,
|
||||||
Fixed2Other,
|
editorNodeMergeCustomizer,
|
||||||
fixNodePosition,
|
fixNodePosition,
|
||||||
getInitPositionStyle,
|
getInitPositionStyle,
|
||||||
getNodeIndex,
|
getNodeIndex,
|
||||||
getPageFragmentList,
|
getPageFragmentList,
|
||||||
getPageList,
|
getPageList,
|
||||||
moveItemsInContainer,
|
moveItemsInContainer,
|
||||||
|
resolveSelectedNode,
|
||||||
setChildrenLayout,
|
setChildrenLayout,
|
||||||
setLayout,
|
setLayout,
|
||||||
|
toggleFixedPosition,
|
||||||
} from '@editor/utils/editor';
|
} from '@editor/utils/editor';
|
||||||
|
import type { HistoryOpContext } from '@editor/utils/editor-history';
|
||||||
|
import { applyHistoryAddOp, applyHistoryRemoveOp, applyHistoryUpdateOp } from '@editor/utils/editor-history';
|
||||||
import { beforePaste, getAddParent } from '@editor/utils/operator';
|
import { beforePaste, getAddParent } from '@editor/utils/operator';
|
||||||
|
|
||||||
export interface EditorEvents {
|
|
||||||
'root-change': [value: StoreState['root'], preValue?: StoreState['root']];
|
|
||||||
select: [node: MNode | null];
|
|
||||||
add: [nodes: MNode[]];
|
|
||||||
remove: [nodes: MNode[]];
|
|
||||||
update: [nodes: { newNode: MNode; oldNode: MNode; changeRecords?: ChangeRecord[] }[]];
|
|
||||||
'move-layer': [offset: number | LayerOffset];
|
|
||||||
'drag-to': [data: { targetIndex: number; configs: MNode | MNode[]; targetParent: MContainer }];
|
|
||||||
'history-change': [data: MPage | MPageFragment];
|
|
||||||
}
|
|
||||||
|
|
||||||
const canUsePluginMethods = {
|
|
||||||
async: [
|
|
||||||
'getLayout',
|
|
||||||
'highlight',
|
|
||||||
'select',
|
|
||||||
'multiSelect',
|
|
||||||
'doAdd',
|
|
||||||
'add',
|
|
||||||
'doRemove',
|
|
||||||
'remove',
|
|
||||||
'doUpdate',
|
|
||||||
'update',
|
|
||||||
'sort',
|
|
||||||
'copy',
|
|
||||||
'paste',
|
|
||||||
'doPaste',
|
|
||||||
'doAlignCenter',
|
|
||||||
'alignCenter',
|
|
||||||
'moveLayer',
|
|
||||||
'moveToContainer',
|
|
||||||
'dragTo',
|
|
||||||
'undo',
|
|
||||||
'redo',
|
|
||||||
'move',
|
|
||||||
] as const,
|
|
||||||
sync: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
type AsyncMethodName = Writable<(typeof canUsePluginMethods)['async']>;
|
|
||||||
|
|
||||||
class Editor extends BaseService {
|
class Editor extends BaseService {
|
||||||
public state: StoreState = reactive({
|
public state: StoreState = reactive({
|
||||||
root: null,
|
root: null,
|
||||||
@ -121,6 +81,7 @@ class Editor extends BaseService {
|
|||||||
disabledMultiSelect: false,
|
disabledMultiSelect: false,
|
||||||
});
|
});
|
||||||
private isHistoryStateChange = false;
|
private isHistoryStateChange = false;
|
||||||
|
private selectionBeforeOp: Id[] | null = null;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super(
|
super(
|
||||||
@ -390,6 +351,8 @@ class Editor extends BaseService {
|
|||||||
* @returns 添加后的节点
|
* @returns 添加后的节点
|
||||||
*/
|
*/
|
||||||
public async add(addNode: AddMNode | MNode[], parent?: MContainer | null): Promise<MNode | MNode[]> {
|
public async add(addNode: AddMNode | MNode[], parent?: MContainer | null): Promise<MNode | MNode[]> {
|
||||||
|
this.captureSelectionBeforeOp();
|
||||||
|
|
||||||
const stage = this.get('stage');
|
const stage = this.get('stage');
|
||||||
|
|
||||||
// 新增多个组件只存在于粘贴多个组件,粘贴的是一个完整的config,所以不再需要getPropsValue
|
// 新增多个组件只存在于粘贴多个组件,粘贴的是一个完整的config,所以不再需要getPropsValue
|
||||||
@ -435,7 +398,21 @@ class Editor extends BaseService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!(isPage(newNodes[0]) || isPageFragment(newNodes[0]))) {
|
if (!(isPage(newNodes[0]) || isPageFragment(newNodes[0]))) {
|
||||||
this.pushHistoryState();
|
const pageForOp = this.getNodeInfo(newNodes[0].id, false).page;
|
||||||
|
this.pushOpHistory(
|
||||||
|
'add',
|
||||||
|
{
|
||||||
|
nodes: newNodes.map((n) => cloneDeep(toRaw(n))),
|
||||||
|
parentId: (this.getParentById(newNodes[0].id, false) ?? this.get('root'))!.id,
|
||||||
|
indexMap: Object.fromEntries(
|
||||||
|
newNodes.map((n) => {
|
||||||
|
const p = this.getParentById(n.id, false) as MContainer;
|
||||||
|
return [n.id, p ? getNodeIndex(n.id, p) : -1];
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ name: pageForOp?.name || '', id: pageForOp!.id },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.emit('add', newNodes);
|
this.emit('add', newNodes);
|
||||||
@ -498,13 +475,33 @@ class Editor extends BaseService {
|
|||||||
* @param {Object} node
|
* @param {Object} node
|
||||||
*/
|
*/
|
||||||
public async remove(nodeOrNodeList: MNode | MNode[]): Promise<void> {
|
public async remove(nodeOrNodeList: MNode | MNode[]): Promise<void> {
|
||||||
|
this.captureSelectionBeforeOp();
|
||||||
|
|
||||||
const nodes = Array.isArray(nodeOrNodeList) ? nodeOrNodeList : [nodeOrNodeList];
|
const nodes = Array.isArray(nodeOrNodeList) ? nodeOrNodeList : [nodeOrNodeList];
|
||||||
|
|
||||||
|
const removedItems: { node: MNode; parentId: Id; index: number }[] = [];
|
||||||
|
let pageForOp: { name: string; id: Id } | null = null;
|
||||||
|
if (!(isPage(nodes[0]) || isPageFragment(nodes[0]))) {
|
||||||
|
for (const n of nodes) {
|
||||||
|
const { parent, node: curNode, page } = this.getNodeInfo(n.id, false);
|
||||||
|
if (parent && curNode) {
|
||||||
|
if (!pageForOp && page) {
|
||||||
|
pageForOp = { name: page.name || '', id: page.id };
|
||||||
|
}
|
||||||
|
const idx = getNodeIndex(curNode.id, parent);
|
||||||
|
removedItems.push({
|
||||||
|
node: cloneDeep(toRaw(curNode)),
|
||||||
|
parentId: parent.id,
|
||||||
|
index: typeof idx === 'number' ? idx : -1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await Promise.all(nodes.map((node) => this.doRemove(node)));
|
await Promise.all(nodes.map((node) => this.doRemove(node)));
|
||||||
|
|
||||||
if (!(isPage(nodes[0]) || isPageFragment(nodes[0]))) {
|
if (removedItems.length > 0 && pageForOp) {
|
||||||
// 更新历史记录
|
this.pushOpHistory('remove', { removedItems }, pageForOp);
|
||||||
this.pushHistoryState();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.emit('remove', nodes);
|
this.emit('remove', nodes);
|
||||||
@ -525,21 +522,9 @@ class Editor extends BaseService {
|
|||||||
|
|
||||||
const node = toRaw(info.node);
|
const node = toRaw(info.node);
|
||||||
|
|
||||||
let newConfig = await this.toggleFixedPosition(toRaw(config), node, root);
|
let newConfig = await toggleFixedPosition(toRaw(config), node, root, this.getLayout);
|
||||||
|
|
||||||
newConfig = mergeWith(cloneDeep(node), newConfig, (objValue, srcValue, key, object: any, source: any) => {
|
newConfig = mergeWith(cloneDeep(node), newConfig, editorNodeMergeCustomizer);
|
||||||
if (typeof srcValue === 'undefined' && Object.hasOwn(source, key)) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isObject(srcValue) && Array.isArray(objValue)) {
|
|
||||||
// 原来的配置是数组,新的配置是对象,则直接使用新的值
|
|
||||||
return srcValue;
|
|
||||||
}
|
|
||||||
if (Array.isArray(srcValue)) {
|
|
||||||
return srcValue;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!newConfig.type) throw new Error('配置缺少type值');
|
if (!newConfig.type) throw new Error('配置缺少type值');
|
||||||
|
|
||||||
@ -597,12 +582,28 @@ class Editor extends BaseService {
|
|||||||
config: MNode | MNode[],
|
config: MNode | MNode[],
|
||||||
data: { changeRecords?: ChangeRecord[] } = {},
|
data: { changeRecords?: ChangeRecord[] } = {},
|
||||||
): Promise<MNode | MNode[]> {
|
): Promise<MNode | MNode[]> {
|
||||||
|
this.captureSelectionBeforeOp();
|
||||||
|
|
||||||
const nodes = Array.isArray(config) ? config : [config];
|
const nodes = Array.isArray(config) ? config : [config];
|
||||||
|
|
||||||
const updateData = await Promise.all(nodes.map((node) => this.doUpdate(node, data)));
|
const updateData = await Promise.all(nodes.map((node) => this.doUpdate(node, data)));
|
||||||
|
|
||||||
if (updateData[0].oldNode?.type !== NodeType.ROOT) {
|
if (updateData[0].oldNode?.type !== NodeType.ROOT) {
|
||||||
this.pushHistoryState();
|
const curNodes = this.get('nodes');
|
||||||
|
if (!this.isHistoryStateChange && curNodes.length) {
|
||||||
|
const pageForOp = this.getNodeInfo(nodes[0].id, false).page;
|
||||||
|
this.pushOpHistory(
|
||||||
|
'update',
|
||||||
|
{
|
||||||
|
updatedItems: updateData.map((d) => ({
|
||||||
|
oldNode: cloneDeep(d.oldNode),
|
||||||
|
newNode: cloneDeep(toRaw(d.newNode)),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
{ name: pageForOp?.name || '', id: pageForOp!.id },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
this.isHistoryStateChange = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.emit('update', updateData);
|
this.emit('update', updateData);
|
||||||
@ -616,6 +617,8 @@ class Editor extends BaseService {
|
|||||||
* @returns void
|
* @returns void
|
||||||
*/
|
*/
|
||||||
public async sort(id1: Id, id2: Id): Promise<void> {
|
public async sort(id1: Id, id2: Id): Promise<void> {
|
||||||
|
this.captureSelectionBeforeOp();
|
||||||
|
|
||||||
const root = this.get('root');
|
const root = this.get('root');
|
||||||
if (!root) throw new Error('root为空');
|
if (!root) throw new Error('root为空');
|
||||||
|
|
||||||
@ -640,9 +643,6 @@ class Editor extends BaseService {
|
|||||||
parentId: parent.id,
|
parentId: parent.id,
|
||||||
root: cloneDeep(root),
|
root: cloneDeep(root),
|
||||||
});
|
});
|
||||||
|
|
||||||
this.addModifiedNodeId(parent.id);
|
|
||||||
this.pushHistoryState();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -664,31 +664,8 @@ class Editor extends BaseService {
|
|||||||
public copyWithRelated(config: MNode | MNode[], collectorOptions?: TargetOptions): void {
|
public copyWithRelated(config: MNode | MNode[], collectorOptions?: TargetOptions): void {
|
||||||
const copyNodes: MNode[] = Array.isArray(config) ? config : [config];
|
const copyNodes: MNode[] = Array.isArray(config) ? config : [config];
|
||||||
|
|
||||||
// 初始化复制组件相关的依赖收集器
|
|
||||||
if (collectorOptions && typeof collectorOptions.isTarget === 'function') {
|
if (collectorOptions && typeof collectorOptions.isTarget === 'function') {
|
||||||
const customTarget = new Target({
|
collectRelatedNodes(copyNodes, collectorOptions, (id) => this.getNodeById(id));
|
||||||
...collectorOptions,
|
|
||||||
});
|
|
||||||
|
|
||||||
const coperWatcher = new Watcher();
|
|
||||||
|
|
||||||
coperWatcher.addTarget(customTarget);
|
|
||||||
|
|
||||||
coperWatcher.collect(copyNodes, {}, true, collectorOptions.type);
|
|
||||||
Object.keys(customTarget.deps).forEach((nodeId: Id) => {
|
|
||||||
const node = this.getNodeById(nodeId);
|
|
||||||
if (!node) return;
|
|
||||||
customTarget!.deps[nodeId].keys.forEach((key) => {
|
|
||||||
const relateNodeId = get(node, key);
|
|
||||||
const isExist = copyNodes.find((node) => node.id === relateNodeId);
|
|
||||||
if (!isExist) {
|
|
||||||
const relateNode = this.getNodeById(relateNodeId);
|
|
||||||
if (relateNode) {
|
|
||||||
copyNodes.push(relateNode);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
storageService.setItem(COPY_STORAGE_KEY, copyNodes, {
|
storageService.setItem(COPY_STORAGE_KEY, copyNodes, {
|
||||||
@ -733,32 +710,16 @@ class Editor extends BaseService {
|
|||||||
|
|
||||||
public async doAlignCenter(config: MNode): Promise<MNode> {
|
public async doAlignCenter(config: MNode): Promise<MNode> {
|
||||||
const parent = this.getParentById(config.id);
|
const parent = this.getParentById(config.id);
|
||||||
|
|
||||||
if (!parent) throw new Error('找不到父节点');
|
if (!parent) throw new Error('找不到父节点');
|
||||||
|
|
||||||
const node = cloneDeep(toRaw(config));
|
const node = cloneDeep(toRaw(config));
|
||||||
const layout = await this.getLayout(parent, node);
|
const layout = await this.getLayout(parent, node);
|
||||||
if (layout === Layout.RELATIVE) {
|
const doc = this.get('stage')?.renderer?.contentWindow?.document;
|
||||||
return config;
|
const newStyle = calcAlignCenterStyle(node, parent, layout, doc);
|
||||||
}
|
|
||||||
|
|
||||||
if (!node.style) return config;
|
if (!newStyle) return config;
|
||||||
|
|
||||||
const stage = this.get('stage');
|
|
||||||
const doc = stage?.renderer?.contentWindow?.document;
|
|
||||||
|
|
||||||
if (doc) {
|
|
||||||
const el = getElById()(doc, node.id);
|
|
||||||
const parentEl = layout === Layout.FIXED ? doc.body : el?.offsetParent;
|
|
||||||
if (parentEl && el) {
|
|
||||||
node.style.left = calcValueByFontsize(doc, (parentEl.clientWidth - el.clientWidth) / 2);
|
|
||||||
node.style.right = '';
|
|
||||||
}
|
|
||||||
} else if (parent.style && isNumber(parent.style?.width) && isNumber(node.style?.width)) {
|
|
||||||
node.style.left = (parent.style.width - node.style.width) / 2;
|
|
||||||
node.style.right = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
|
node.style = newStyle;
|
||||||
return node;
|
return node;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -789,6 +750,8 @@ class Editor extends BaseService {
|
|||||||
* @param offset 偏移量
|
* @param offset 偏移量
|
||||||
*/
|
*/
|
||||||
public async moveLayer(offset: number | LayerOffset): Promise<void> {
|
public async moveLayer(offset: number | LayerOffset): Promise<void> {
|
||||||
|
this.captureSelectionBeforeOp();
|
||||||
|
|
||||||
const root = this.get('root');
|
const root = this.get('root');
|
||||||
if (!root) throw new Error('root为空');
|
if (!root) throw new Error('root为空');
|
||||||
|
|
||||||
@ -801,22 +764,16 @@ class Editor extends BaseService {
|
|||||||
const brothers: MNode[] = parent.items || [];
|
const brothers: MNode[] = parent.items || [];
|
||||||
const index = brothers.findIndex((item) => `${item.id}` === `${node?.id}`);
|
const index = brothers.findIndex((item) => `${item.id}` === `${node?.id}`);
|
||||||
|
|
||||||
// 流式布局与绝对定位布局操作的相反的
|
|
||||||
const layout = await this.getLayout(parent, node);
|
const layout = await this.getLayout(parent, node);
|
||||||
const isRelative = layout === Layout.RELATIVE;
|
const isRelative = layout === Layout.RELATIVE;
|
||||||
|
const offsetIndex = calcLayerTargetIndex(index, offset, brothers.length, isRelative);
|
||||||
let offsetIndex: number;
|
|
||||||
if (offset === LayerOffset.TOP) {
|
|
||||||
offsetIndex = isRelative ? 0 : brothers.length;
|
|
||||||
} else if (offset === LayerOffset.BOTTOM) {
|
|
||||||
offsetIndex = isRelative ? brothers.length : 0;
|
|
||||||
} else {
|
|
||||||
offsetIndex = index + (isRelative ? -offset : offset);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((offsetIndex > 0 && offsetIndex > brothers.length) || offsetIndex < 0) {
|
if ((offsetIndex > 0 && offsetIndex > brothers.length) || offsetIndex < 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const oldParent = cloneDeep(toRaw(parent));
|
||||||
|
|
||||||
brothers.splice(index, 1);
|
brothers.splice(index, 1);
|
||||||
brothers.splice(offsetIndex, 0, node);
|
brothers.splice(offsetIndex, 0, node);
|
||||||
|
|
||||||
@ -829,7 +786,14 @@ class Editor extends BaseService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
this.addModifiedNodeId(parent.id);
|
this.addModifiedNodeId(parent.id);
|
||||||
this.pushHistoryState();
|
const pageForOp = this.getNodeInfo(node.id, false).page;
|
||||||
|
this.pushOpHistory(
|
||||||
|
'update',
|
||||||
|
{
|
||||||
|
updatedItems: [{ oldNode: oldParent, newNode: cloneDeep(toRaw(parent)) }],
|
||||||
|
},
|
||||||
|
{ name: pageForOp?.name || '', id: pageForOp!.id },
|
||||||
|
);
|
||||||
|
|
||||||
this.emit('move-layer', offset);
|
this.emit('move-layer', offset);
|
||||||
}
|
}
|
||||||
@ -840,12 +804,17 @@ class Editor extends BaseService {
|
|||||||
* @param targetId 容器ID
|
* @param targetId 容器ID
|
||||||
*/
|
*/
|
||||||
public async moveToContainer(config: MNode, targetId: Id): Promise<MNode | undefined> {
|
public async moveToContainer(config: MNode, targetId: Id): Promise<MNode | undefined> {
|
||||||
|
this.captureSelectionBeforeOp();
|
||||||
|
|
||||||
const root = this.get('root');
|
const root = this.get('root');
|
||||||
const { node, parent } = this.getNodeInfo(config.id, false);
|
const { node, parent, page: pageForOp } = this.getNodeInfo(config.id, false);
|
||||||
const target = this.getNodeById(targetId, false) as MContainer;
|
const target = this.getNodeById(targetId, false) as MContainer;
|
||||||
|
|
||||||
const stage = this.get('stage');
|
const stage = this.get('stage');
|
||||||
if (root && node && parent && stage) {
|
if (root && node && parent && stage) {
|
||||||
|
const oldSourceParent = cloneDeep(toRaw(parent));
|
||||||
|
const oldTarget = cloneDeep(toRaw(target));
|
||||||
|
|
||||||
const index = getNodeIndex(node.id, parent);
|
const index = getNodeIndex(node.id, parent);
|
||||||
parent.items?.splice(index, 1);
|
parent.items?.splice(index, 1);
|
||||||
|
|
||||||
@ -876,62 +845,60 @@ class Editor extends BaseService {
|
|||||||
|
|
||||||
this.addModifiedNodeId(target.id);
|
this.addModifiedNodeId(target.id);
|
||||||
this.addModifiedNodeId(parent.id);
|
this.addModifiedNodeId(parent.id);
|
||||||
this.pushHistoryState();
|
this.pushOpHistory(
|
||||||
|
'update',
|
||||||
|
{
|
||||||
|
updatedItems: [
|
||||||
|
{ oldNode: oldSourceParent, newNode: cloneDeep(toRaw(parent)) },
|
||||||
|
{ oldNode: oldTarget, newNode: cloneDeep(toRaw(target)) },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ name: pageForOp?.name || '', id: pageForOp!.id },
|
||||||
|
);
|
||||||
|
|
||||||
return newConfig;
|
return newConfig;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async dragTo(config: MNode | MNode[], targetParent: MContainer, targetIndex: number) {
|
public async dragTo(config: MNode | MNode[], targetParent: MContainer, targetIndex: number) {
|
||||||
|
this.captureSelectionBeforeOp();
|
||||||
|
|
||||||
if (!targetParent || !Array.isArray(targetParent.items)) return;
|
if (!targetParent || !Array.isArray(targetParent.items)) return;
|
||||||
|
|
||||||
const configs = Array.isArray(config) ? config : [config];
|
const configs = Array.isArray(config) ? config : [config];
|
||||||
|
|
||||||
const sourceIndicesInTargetParent: number[] = [];
|
const beforeSnapshots = new Map<string, MNode>();
|
||||||
const sourceOutTargetParent: MNode[] = [];
|
for (const cfg of configs) {
|
||||||
|
const { parent } = this.getNodeInfo(cfg.id, false);
|
||||||
|
if (parent && !beforeSnapshots.has(`${parent.id}`)) {
|
||||||
|
beforeSnapshots.set(`${parent.id}`, cloneDeep(toRaw(parent)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!beforeSnapshots.has(`${targetParent.id}`)) {
|
||||||
|
beforeSnapshots.set(`${targetParent.id}`, cloneDeep(toRaw(targetParent)));
|
||||||
|
}
|
||||||
|
|
||||||
const newLayout = await this.getLayout(targetParent);
|
const newLayout = await this.getLayout(targetParent);
|
||||||
|
const { sameParentIndices, crossParentConfigs, aborted } = classifyDragSources(configs, targetParent, (id, raw) =>
|
||||||
|
this.getNodeInfo(id, raw),
|
||||||
|
);
|
||||||
|
if (aborted) return;
|
||||||
|
|
||||||
// eslint-disable-next-line no-restricted-syntax
|
for (const { config: crossConfig, parent } of crossParentConfigs) {
|
||||||
forConfigs: for (const config of configs) {
|
|
||||||
const { parent, node: curNode } = this.getNodeInfo(config.id, false);
|
|
||||||
if (!parent || !curNode) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const path = getNodePath(curNode.id, parent.items);
|
|
||||||
|
|
||||||
for (const node of path) {
|
|
||||||
if (targetParent.id === node.id) {
|
|
||||||
continue forConfigs;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const index = getNodeIndex(curNode.id, parent);
|
|
||||||
|
|
||||||
if (parent.id === targetParent.id) {
|
|
||||||
if (typeof index !== 'number' || index === -1) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
sourceIndicesInTargetParent.push(index);
|
|
||||||
} else {
|
|
||||||
const layout = await this.getLayout(parent);
|
const layout = await this.getLayout(parent);
|
||||||
|
|
||||||
if (newLayout !== layout) {
|
if (newLayout !== layout) {
|
||||||
setLayout(config, newLayout);
|
setLayout(crossConfig, newLayout);
|
||||||
}
|
}
|
||||||
|
const index = getNodeIndex(crossConfig.id, parent);
|
||||||
parent.items?.splice(index, 1);
|
parent.items?.splice(index, 1);
|
||||||
sourceOutTargetParent.push(config);
|
|
||||||
this.addModifiedNodeId(parent.id);
|
this.addModifiedNodeId(parent.id);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
moveItemsInContainer(sourceIndicesInTargetParent, targetParent, targetIndex);
|
moveItemsInContainer(sameParentIndices, targetParent, targetIndex);
|
||||||
|
|
||||||
sourceOutTargetParent.forEach((config, index) => {
|
crossParentConfigs.forEach(({ config: crossConfig }, index) => {
|
||||||
targetParent.items?.splice(targetIndex + index, 0, config);
|
targetParent.items?.splice(targetIndex + index, 0, crossConfig);
|
||||||
this.addModifiedNodeId(config.id);
|
this.addModifiedNodeId(crossConfig.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
const page = this.get('page');
|
const page = this.get('page');
|
||||||
@ -946,28 +913,40 @@ class Editor extends BaseService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
this.pushHistoryState();
|
const updatedItems: { oldNode: MNode; newNode: MNode }[] = [];
|
||||||
|
for (const oldNode of beforeSnapshots.values()) {
|
||||||
|
const newNode = this.getNodeById(oldNode.id, false);
|
||||||
|
if (newNode) {
|
||||||
|
updatedItems.push({ oldNode, newNode: cloneDeep(toRaw(newNode)) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const pageForOp = this.getNodeInfo(configs[0].id, false).page;
|
||||||
|
this.pushOpHistory('update', { updatedItems }, { name: pageForOp?.name || '', id: pageForOp!.id });
|
||||||
|
|
||||||
this.emit('drag-to', { targetIndex, configs, targetParent });
|
this.emit('drag-to', { targetIndex, configs, targetParent });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 撤销当前操作
|
* 撤销当前操作
|
||||||
* @returns 上一次数据
|
* @returns 被撤销的操作
|
||||||
*/
|
*/
|
||||||
public async undo(): Promise<StepValue | null> {
|
public async undo(): Promise<StepValue | null> {
|
||||||
const value = historyService.undo();
|
const value = historyService.undo();
|
||||||
await this.changeHistoryState(value);
|
if (value) {
|
||||||
|
await this.applyHistoryOp(value, true);
|
||||||
|
}
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 恢复到下一步
|
* 恢复到下一步
|
||||||
* @returns 下一步数据
|
* @returns 被恢复的操作
|
||||||
*/
|
*/
|
||||||
public async redo(): Promise<StepValue | null> {
|
public async redo(): Promise<StepValue | null> {
|
||||||
const value = historyService.redo();
|
const value = historyService.redo();
|
||||||
await this.changeHistoryState(value);
|
if (value) {
|
||||||
|
await this.applyHistoryOp(value, false);
|
||||||
|
}
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -975,47 +954,10 @@ class Editor extends BaseService {
|
|||||||
const node = toRaw(this.get('node'));
|
const node = toRaw(this.get('node'));
|
||||||
if (!node || isPage(node)) return;
|
if (!node || isPage(node)) return;
|
||||||
|
|
||||||
const { style, id, type } = node;
|
const newStyle = calcMoveStyle(node.style || {}, left, top);
|
||||||
if (!style || !['absolute', 'fixed'].includes(style.position)) return;
|
if (!newStyle) return;
|
||||||
|
|
||||||
const update = (style: { [key: string]: any }) =>
|
await this.update({ id: node.id, type: node.type, style: newStyle });
|
||||||
this.update({
|
|
||||||
id,
|
|
||||||
type,
|
|
||||||
style,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (top) {
|
|
||||||
if (isNumber(style.top)) {
|
|
||||||
update({
|
|
||||||
...style,
|
|
||||||
top: Number(style.top) + Number(top),
|
|
||||||
bottom: '',
|
|
||||||
});
|
|
||||||
} else if (isNumber(style.bottom)) {
|
|
||||||
update({
|
|
||||||
...style,
|
|
||||||
bottom: Number(style.bottom) - Number(top),
|
|
||||||
top: '',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (left) {
|
|
||||||
if (isNumber(style.left)) {
|
|
||||||
update({
|
|
||||||
...style,
|
|
||||||
left: Number(style.left) + Number(left),
|
|
||||||
right: '',
|
|
||||||
});
|
|
||||||
} else if (isNumber(style.right)) {
|
|
||||||
update({
|
|
||||||
...style,
|
|
||||||
right: Number(style.right) - Number(left),
|
|
||||||
left: '',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public resetState() {
|
public resetState() {
|
||||||
@ -1068,70 +1010,89 @@ class Editor extends BaseService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private pushHistoryState() {
|
private captureSelectionBeforeOp() {
|
||||||
const curNode = cloneDeep(toRaw(this.get('node')));
|
if (this.isHistoryStateChange || this.selectionBeforeOp) return;
|
||||||
const page = this.get('page');
|
this.selectionBeforeOp = this.get('nodes').map((n) => n.id);
|
||||||
if (!this.isHistoryStateChange && curNode && page) {
|
|
||||||
historyService.push({
|
|
||||||
data: cloneDeep(toRaw(page)),
|
|
||||||
modifiedNodeIds: this.get('modifiedNodeIds'),
|
|
||||||
nodeId: curNode.id,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private pushOpHistory(opType: HistoryOpType, extra: Partial<StepValue>, pageData: { name: string; id: Id }) {
|
||||||
|
if (this.isHistoryStateChange) {
|
||||||
|
this.selectionBeforeOp = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const step: StepValue = {
|
||||||
|
data: pageData,
|
||||||
|
opType,
|
||||||
|
selectedBefore: this.selectionBeforeOp ?? [],
|
||||||
|
selectedAfter: this.get('nodes').map((n) => n.id),
|
||||||
|
modifiedNodeIds: new Map(this.get('modifiedNodeIds')),
|
||||||
|
...extra,
|
||||||
|
};
|
||||||
|
historyService.push(step);
|
||||||
|
this.selectionBeforeOp = null;
|
||||||
this.isHistoryStateChange = false;
|
this.isHistoryStateChange = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async changeHistoryState(value: StepValue | null) {
|
/**
|
||||||
if (!value) return;
|
* 应用历史操作(撤销 / 重做)
|
||||||
|
* @param step 操作记录
|
||||||
|
* @param reverse true = 撤销,false = 重做
|
||||||
|
*/
|
||||||
|
private async applyHistoryOp(step: StepValue, reverse: boolean) {
|
||||||
this.isHistoryStateChange = true;
|
this.isHistoryStateChange = true;
|
||||||
await this.update(value.data);
|
|
||||||
this.set('modifiedNodeIds', value.modifiedNodeIds);
|
const root = this.get('root');
|
||||||
|
const stage = this.get('stage');
|
||||||
|
if (!root) return;
|
||||||
|
|
||||||
|
const ctx: HistoryOpContext = {
|
||||||
|
root,
|
||||||
|
stage,
|
||||||
|
getNodeById: (id, raw) => this.getNodeById(id, raw),
|
||||||
|
getNodeInfo: (id, raw) => this.getNodeInfo(id, raw),
|
||||||
|
setRoot: (r) => this.set('root', r),
|
||||||
|
setPage: (p) => this.set('page', p),
|
||||||
|
getPage: () => this.get('page'),
|
||||||
|
};
|
||||||
|
|
||||||
|
switch (step.opType) {
|
||||||
|
case 'add':
|
||||||
|
await applyHistoryAddOp(step, reverse, ctx);
|
||||||
|
break;
|
||||||
|
case 'remove':
|
||||||
|
await applyHistoryRemoveOp(step, reverse, ctx);
|
||||||
|
break;
|
||||||
|
case 'update':
|
||||||
|
await applyHistoryUpdateOp(step, reverse, ctx);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.set('modifiedNodeIds', step.modifiedNodeIds);
|
||||||
|
|
||||||
|
const page = toRaw(this.get('page'));
|
||||||
|
if (page) {
|
||||||
|
const selectIds = reverse ? step.selectedBefore : step.selectedAfter;
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (!value.nodeId) return;
|
if (!selectIds.length) return;
|
||||||
this.select(value.nodeId).then(() => {
|
|
||||||
this.get('stage')?.select(value.nodeId);
|
if (selectIds.length > 1) {
|
||||||
});
|
this.multiSelect(selectIds);
|
||||||
|
stage?.multiSelect(selectIds);
|
||||||
|
} else {
|
||||||
|
this.select(selectIds[0])
|
||||||
|
.then(() => stage?.select(selectIds[0]))
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
}, 0);
|
}, 0);
|
||||||
this.emit('history-change', value.data);
|
this.emit('history-change', page as MPage | MPageFragment);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async toggleFixedPosition(dist: MNode, src: MNode, root: MApp) {
|
this.isHistoryStateChange = false;
|
||||||
const newConfig = cloneDeep(dist);
|
|
||||||
|
|
||||||
if (!isPop(src) && newConfig.style?.position) {
|
|
||||||
if (isFixed(newConfig.style) && !isFixed(src.style || {})) {
|
|
||||||
newConfig.style = change2Fixed(newConfig, root);
|
|
||||||
} else if (!isFixed(newConfig.style) && isFixed(src.style || {})) {
|
|
||||||
newConfig.style = await Fixed2Other(newConfig, root, this.getLayout);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return newConfig;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private selectedConfigExceptionHandler(config: MNode | Id): EditorNodeInfo {
|
private selectedConfigExceptionHandler(config: MNode | Id): EditorNodeInfo {
|
||||||
let id: Id;
|
return resolveSelectedNode(config, (id) => this.getNodeInfo(id), this.state.root?.id);
|
||||||
if (typeof config === 'string' || typeof config === 'number') {
|
|
||||||
id = config;
|
|
||||||
} else {
|
|
||||||
id = config.id;
|
|
||||||
}
|
|
||||||
if (!id) {
|
|
||||||
throw new Error('没有ID,无法选中');
|
|
||||||
}
|
|
||||||
|
|
||||||
const { node, parent, page } = this.getNodeInfo(id);
|
|
||||||
if (!node) throw new Error('获取不到组件信息');
|
|
||||||
|
|
||||||
if (node.id === this.state.root?.id) {
|
|
||||||
throw new Error('不能选根节点');
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
node,
|
|
||||||
parent,
|
|
||||||
page,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -56,15 +56,7 @@ class History extends BaseService {
|
|||||||
this.state.pageId = page.id;
|
this.state.pageId = page.id;
|
||||||
|
|
||||||
if (!this.state.pageSteps[this.state.pageId]) {
|
if (!this.state.pageSteps[this.state.pageId]) {
|
||||||
const undoRedo = new UndoRedo<StepValue>();
|
this.state.pageSteps[this.state.pageId] = new UndoRedo<StepValue>();
|
||||||
|
|
||||||
undoRedo.pushElement({
|
|
||||||
data: page,
|
|
||||||
modifiedNodeIds: new Map(),
|
|
||||||
nodeId: page.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
this.state.pageSteps[this.state.pageId] = undoRedo;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.setCanUndoRedo();
|
this.setCanUndoRedo();
|
||||||
|
|||||||
@ -20,10 +20,10 @@ import type { Component } from 'vue';
|
|||||||
import type EventEmitter from 'events';
|
import type EventEmitter from 'events';
|
||||||
import type * as Monaco from 'monaco-editor';
|
import type * as Monaco from 'monaco-editor';
|
||||||
import type { default as Sortable, Options, SortableEvent } from 'sortablejs';
|
import type { default as Sortable, Options, SortableEvent } from 'sortablejs';
|
||||||
import type { PascalCasedProperties } from 'type-fest';
|
import type { PascalCasedProperties, Writable } from 'type-fest';
|
||||||
|
|
||||||
import type { CodeBlockContent, CodeBlockDSL, Id, MApp, MContainer, MNode, MPage, MPageFragment } from '@tmagic/core';
|
import type { CodeBlockContent, CodeBlockDSL, Id, MApp, MContainer, MNode, MPage, MPageFragment } from '@tmagic/core';
|
||||||
import type { FormConfig, TableColumnConfig } from '@tmagic/form';
|
import type { ChangeRecord, FormConfig, TableColumnConfig, TypeFunction } from '@tmagic/form';
|
||||||
import type StageCore from '@tmagic/stage';
|
import type StageCore from '@tmagic/stage';
|
||||||
import type {
|
import type {
|
||||||
ContainerHighlightType,
|
ContainerHighlightType,
|
||||||
@ -164,6 +164,8 @@ export interface StageOptions {
|
|||||||
disabledMultiSelect?: boolean;
|
disabledMultiSelect?: boolean;
|
||||||
disabledRule?: boolean;
|
disabledRule?: boolean;
|
||||||
zoom?: number;
|
zoom?: number;
|
||||||
|
/** 画布双击前的钩子函数,返回 false 则阻止默认的双击行为 */
|
||||||
|
beforeDblclick?: (event: MouseEvent) => Promise<boolean | void> | boolean | void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StoreState {
|
export interface StoreState {
|
||||||
@ -541,14 +543,31 @@ export interface CodeParamStatement {
|
|||||||
/** 参数名称 */
|
/** 参数名称 */
|
||||||
name: string;
|
name: string;
|
||||||
/** 参数类型 */
|
/** 参数类型 */
|
||||||
type?: string;
|
type?: string | TypeFunction<string>;
|
||||||
[key: string]: any;
|
[key: string]: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type HistoryOpType = 'add' | 'remove' | 'update';
|
||||||
|
|
||||||
export interface StepValue {
|
export interface StepValue {
|
||||||
data: MPage | MPageFragment;
|
/** 页面信息 */
|
||||||
|
data: { name: string; id: Id };
|
||||||
|
opType: HistoryOpType;
|
||||||
|
/** 操作前选中的节点 ID,用于撤销后恢复选择状态 */
|
||||||
|
selectedBefore: Id[];
|
||||||
|
/** 操作后选中的节点 ID,用于重做后恢复选择状态 */
|
||||||
|
selectedAfter: Id[];
|
||||||
modifiedNodeIds: Map<Id, Id>;
|
modifiedNodeIds: Map<Id, Id>;
|
||||||
nodeId: Id;
|
/** opType 'add': 新增的节点 */
|
||||||
|
nodes?: MNode[];
|
||||||
|
/** opType 'add': 父节点 ID */
|
||||||
|
parentId?: Id;
|
||||||
|
/** opType 'add': 每个新增节点在父节点 items 中的索引 */
|
||||||
|
indexMap?: Record<string, number>;
|
||||||
|
/** opType 'remove': 被删除的节点及其位置信息 */
|
||||||
|
removedItems?: { node: MNode; parentId: Id; index: number }[];
|
||||||
|
/** opType 'update': 变更前后的节点快照 */
|
||||||
|
updatedItems?: { oldNode: MNode; newNode: MNode }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HistoryState {
|
export interface HistoryState {
|
||||||
@ -712,3 +731,44 @@ export type CustomContentMenuFunction = (
|
|||||||
menus: (MenuButton | MenuComponent)[],
|
menus: (MenuButton | MenuComponent)[],
|
||||||
type: 'layer' | 'data-source' | 'viewer' | 'code-block',
|
type: 'layer' | 'data-source' | 'viewer' | 'code-block',
|
||||||
) => (MenuButton | MenuComponent)[];
|
) => (MenuButton | MenuComponent)[];
|
||||||
|
|
||||||
|
export interface EditorEvents {
|
||||||
|
'root-change': [value: StoreState['root'], preValue?: StoreState['root']];
|
||||||
|
select: [node: MNode | null];
|
||||||
|
add: [nodes: MNode[]];
|
||||||
|
remove: [nodes: MNode[]];
|
||||||
|
update: [nodes: { newNode: MNode; oldNode: MNode; changeRecords?: ChangeRecord[] }[]];
|
||||||
|
'move-layer': [offset: number | LayerOffset];
|
||||||
|
'drag-to': [data: { targetIndex: number; configs: MNode | MNode[]; targetParent: MContainer }];
|
||||||
|
'history-change': [data: MPage | MPageFragment];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const canUsePluginMethods = {
|
||||||
|
async: [
|
||||||
|
'getLayout',
|
||||||
|
'highlight',
|
||||||
|
'select',
|
||||||
|
'multiSelect',
|
||||||
|
'doAdd',
|
||||||
|
'add',
|
||||||
|
'doRemove',
|
||||||
|
'remove',
|
||||||
|
'doUpdate',
|
||||||
|
'update',
|
||||||
|
'sort',
|
||||||
|
'copy',
|
||||||
|
'paste',
|
||||||
|
'doPaste',
|
||||||
|
'doAlignCenter',
|
||||||
|
'alignCenter',
|
||||||
|
'moveLayer',
|
||||||
|
'moveToContainer',
|
||||||
|
'dragTo',
|
||||||
|
'undo',
|
||||||
|
'redo',
|
||||||
|
'move',
|
||||||
|
] as const,
|
||||||
|
sync: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AsyncMethodName = Writable<(typeof canUsePluginMethods)['async']>;
|
||||||
|
|||||||
138
packages/editor/src/utils/editor-history.ts
Normal file
138
packages/editor/src/utils/editor-history.ts
Normal file
@ -0,0 +1,138 @@
|
|||||||
|
/*
|
||||||
|
* Tencent is pleased to support the open source community by making TMagicEditor available.
|
||||||
|
*
|
||||||
|
* Copyright (C) 2025 Tencent. All rights reserved.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { toRaw } from 'vue';
|
||||||
|
import { cloneDeep } from 'lodash-es';
|
||||||
|
|
||||||
|
import type { Id, MApp, MContainer, MNode, MPage, MPageFragment } from '@tmagic/core';
|
||||||
|
import { NodeType } from '@tmagic/core';
|
||||||
|
import type StageCore from '@tmagic/stage';
|
||||||
|
import { isPage, isPageFragment } from '@tmagic/utils';
|
||||||
|
|
||||||
|
import type { EditorNodeInfo, StepValue } from '@editor/type';
|
||||||
|
import { getNodeIndex } from '@editor/utils/editor';
|
||||||
|
|
||||||
|
export interface HistoryOpContext {
|
||||||
|
root: MApp;
|
||||||
|
stage: StageCore | null;
|
||||||
|
getNodeById(id: Id, raw?: boolean): MNode | null;
|
||||||
|
getNodeInfo(id: Id, raw?: boolean): EditorNodeInfo;
|
||||||
|
setRoot(root: MApp): void;
|
||||||
|
setPage(page: MPage | MPageFragment): void;
|
||||||
|
getPage(): MPage | MPageFragment | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 应用 add 类型的历史操作
|
||||||
|
* reverse=true(撤销):从父节点中移除已添加的节点
|
||||||
|
* reverse=false(重做):重新添加节点到父节点中
|
||||||
|
*/
|
||||||
|
export async function applyHistoryAddOp(step: StepValue, reverse: boolean, ctx: HistoryOpContext): Promise<void> {
|
||||||
|
const { root, stage } = ctx;
|
||||||
|
|
||||||
|
if (reverse) {
|
||||||
|
for (const node of step.nodes ?? []) {
|
||||||
|
const parent = ctx.getNodeById(step.parentId!, false) as MContainer;
|
||||||
|
if (!parent?.items) continue;
|
||||||
|
const idx = getNodeIndex(node.id, parent);
|
||||||
|
if (typeof idx === 'number' && idx !== -1) {
|
||||||
|
parent.items.splice(idx, 1);
|
||||||
|
}
|
||||||
|
await stage?.remove({ id: node.id, parentId: parent.id, root: cloneDeep(root) });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const parent = ctx.getNodeById(step.parentId!, false) as MContainer;
|
||||||
|
if (parent?.items) {
|
||||||
|
for (const node of step.nodes ?? []) {
|
||||||
|
const idx = step.indexMap?.[node.id] ?? parent.items.length;
|
||||||
|
parent.items.splice(idx, 0, cloneDeep(node));
|
||||||
|
await stage?.add({
|
||||||
|
config: cloneDeep(node),
|
||||||
|
parent: cloneDeep(parent),
|
||||||
|
parentId: parent.id,
|
||||||
|
root: cloneDeep(root),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 应用 remove 类型的历史操作
|
||||||
|
* reverse=true(撤销):将已删除的节点按原位置重新插入
|
||||||
|
* reverse=false(重做):再次删除节点
|
||||||
|
*/
|
||||||
|
export async function applyHistoryRemoveOp(step: StepValue, reverse: boolean, ctx: HistoryOpContext): Promise<void> {
|
||||||
|
const { root, stage } = ctx;
|
||||||
|
|
||||||
|
if (reverse) {
|
||||||
|
const sorted = [...(step.removedItems ?? [])].sort((a, b) => a.index - b.index);
|
||||||
|
for (const { node, parentId, index } of sorted) {
|
||||||
|
const parent = ctx.getNodeById(parentId, false) as MContainer;
|
||||||
|
if (!parent?.items) continue;
|
||||||
|
parent.items.splice(index, 0, cloneDeep(node));
|
||||||
|
await stage?.add({ config: cloneDeep(node), parent: cloneDeep(parent), parentId, root: cloneDeep(root) });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (const { node, parentId } of step.removedItems ?? []) {
|
||||||
|
const parent = ctx.getNodeById(parentId, false) as MContainer;
|
||||||
|
if (!parent?.items) continue;
|
||||||
|
const idx = getNodeIndex(node.id, parent);
|
||||||
|
if (typeof idx === 'number' && idx !== -1) {
|
||||||
|
parent.items.splice(idx, 1);
|
||||||
|
}
|
||||||
|
await stage?.remove({ id: node.id, parentId, root: cloneDeep(root) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 应用 update 类型的历史操作
|
||||||
|
* reverse=true(撤销):将节点恢复为 oldNode
|
||||||
|
* reverse=false(重做):将节点更新为 newNode
|
||||||
|
*/
|
||||||
|
export async function applyHistoryUpdateOp(step: StepValue, reverse: boolean, ctx: HistoryOpContext): Promise<void> {
|
||||||
|
const { root, stage } = ctx;
|
||||||
|
const items = step.updatedItems ?? [];
|
||||||
|
|
||||||
|
for (const { oldNode, newNode } of items) {
|
||||||
|
const config = reverse ? oldNode : newNode;
|
||||||
|
if (config.type === NodeType.ROOT) {
|
||||||
|
ctx.setRoot(cloneDeep(config) as MApp);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const info = ctx.getNodeInfo(config.id, false);
|
||||||
|
if (!info.parent) continue;
|
||||||
|
const idx = getNodeIndex(config.id, info.parent);
|
||||||
|
if (typeof idx !== 'number' || idx === -1) continue;
|
||||||
|
info.parent.items![idx] = cloneDeep(config);
|
||||||
|
|
||||||
|
if (isPage(config) || isPageFragment(config)) {
|
||||||
|
ctx.setPage(config as MPage | MPageFragment);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const curPage = ctx.getPage();
|
||||||
|
if (stage && curPage) {
|
||||||
|
await stage.update({
|
||||||
|
config: cloneDeep(toRaw(curPage)),
|
||||||
|
parentId: root.id,
|
||||||
|
root: cloneDeep(toRaw(root)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -17,12 +17,13 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { detailedDiff } from 'deep-object-diff';
|
import { detailedDiff } from 'deep-object-diff';
|
||||||
import { isObject } from 'lodash-es';
|
import { cloneDeep, get, isObject } from 'lodash-es';
|
||||||
import serialize from 'serialize-javascript';
|
import serialize from 'serialize-javascript';
|
||||||
|
|
||||||
import type { Id, MApp, MContainer, MNode, MPage, MPageFragment } from '@tmagic/core';
|
import type { Id, MApp, MContainer, MNode, MPage, MPageFragment, TargetOptions } from '@tmagic/core';
|
||||||
import { NODE_CONDS_KEY, NodeType } from '@tmagic/core';
|
import { NODE_CONDS_KEY, NodeType, Target, Watcher } from '@tmagic/core';
|
||||||
import type StageCore from '@tmagic/stage';
|
import type StageCore from '@tmagic/stage';
|
||||||
|
import { isFixed } from '@tmagic/stage';
|
||||||
import {
|
import {
|
||||||
calcValueByFontsize,
|
calcValueByFontsize,
|
||||||
getElById,
|
getElById,
|
||||||
@ -34,7 +35,8 @@ import {
|
|||||||
isValueIncludeDataSource,
|
isValueIncludeDataSource,
|
||||||
} from '@tmagic/utils';
|
} from '@tmagic/utils';
|
||||||
|
|
||||||
import { Layout } from '@editor/type';
|
import type { EditorNodeInfo } from '@editor/type';
|
||||||
|
import { LayerOffset, Layout } from '@editor/type';
|
||||||
|
|
||||||
export const COPY_STORAGE_KEY = '$MagicEditorCopyData';
|
export const COPY_STORAGE_KEY = '$MagicEditorCopyData';
|
||||||
export const COPY_CODE_STORAGE_KEY = '$MagicEditorCopyCode';
|
export const COPY_CODE_STORAGE_KEY = '$MagicEditorCopyCode';
|
||||||
@ -436,3 +438,246 @@ export const buildChangeRecords = (value: any, basePath: string) => {
|
|||||||
|
|
||||||
return changeRecords;
|
return changeRecords;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据节点配置或ID解析出选中节点信息,并进行合法性校验
|
||||||
|
* @param config 节点配置或节点ID
|
||||||
|
* @param getNodeInfoFn 获取节点信息的回调函数
|
||||||
|
* @param rootId 根节点ID,用于排除根节点被选中
|
||||||
|
* @returns 选中节点的完整信息(node、parent、page)
|
||||||
|
*/
|
||||||
|
export const resolveSelectedNode = (
|
||||||
|
config: MNode | Id,
|
||||||
|
getNodeInfoFn: (id: Id) => EditorNodeInfo,
|
||||||
|
rootId?: Id,
|
||||||
|
): EditorNodeInfo => {
|
||||||
|
const id: Id = typeof config === 'string' || typeof config === 'number' ? config : config.id;
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
throw new Error('没有ID,无法选中');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { node, parent, page } = getNodeInfoFn(id);
|
||||||
|
if (!node) throw new Error('获取不到组件信息');
|
||||||
|
if (node.id === rootId) throw new Error('不能选根节点');
|
||||||
|
|
||||||
|
return { node, parent, page };
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理节点在 fixed 定位与其他定位之间的切换
|
||||||
|
* 当节点从非 fixed 变为 fixed 时,根据节点路径累加偏移量;反之则还原偏移量
|
||||||
|
* @param dist 更新后的节点配置(目标状态)
|
||||||
|
* @param src 更新前的节点配置(原始状态)
|
||||||
|
* @param root 根节点配置,用于计算节点路径上的偏移量
|
||||||
|
* @param getLayoutFn 获取父节点布局方式的回调函数
|
||||||
|
* @returns 处理后的节点配置(深拷贝)
|
||||||
|
*/
|
||||||
|
export const toggleFixedPosition = async (
|
||||||
|
dist: MNode,
|
||||||
|
src: MNode,
|
||||||
|
root: MApp,
|
||||||
|
getLayoutFn: (parent: MNode, node?: MNode | null) => Promise<Layout>,
|
||||||
|
): Promise<MNode> => {
|
||||||
|
const newConfig = cloneDeep(dist);
|
||||||
|
|
||||||
|
if (!isPop(src) && newConfig.style?.position) {
|
||||||
|
if (isFixed(newConfig.style) && !isFixed(src.style || {})) {
|
||||||
|
newConfig.style = change2Fixed(newConfig, root);
|
||||||
|
} else if (!isFixed(newConfig.style) && isFixed(src.style || {})) {
|
||||||
|
newConfig.style = await Fixed2Other(newConfig, root, getLayoutFn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return newConfig;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据键盘移动的偏移量计算节点的新样式
|
||||||
|
* 仅对 absolute 或 fixed 定位的节点生效;优先修改 top/left,若未设置则修改 bottom/right
|
||||||
|
* @param style 节点当前样式
|
||||||
|
* @param left 水平方向偏移量(正值向右,负值向左)
|
||||||
|
* @param top 垂直方向偏移量(正值向下,负值向上)
|
||||||
|
* @returns 计算后的新样式对象,若节点不支持移动则返回 null
|
||||||
|
*/
|
||||||
|
export const calcMoveStyle = (style: Record<string, any>, left: number, top: number): Record<string, any> | null => {
|
||||||
|
if (!style || !['absolute', 'fixed'].includes(style.position)) return null;
|
||||||
|
|
||||||
|
const newStyle: Record<string, any> = { ...style };
|
||||||
|
|
||||||
|
if (top) {
|
||||||
|
if (isNumber(style.top)) {
|
||||||
|
newStyle.top = Number(style.top) + Number(top);
|
||||||
|
newStyle.bottom = '';
|
||||||
|
} else if (isNumber(style.bottom)) {
|
||||||
|
newStyle.bottom = Number(style.bottom) - Number(top);
|
||||||
|
newStyle.top = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (left) {
|
||||||
|
if (isNumber(style.left)) {
|
||||||
|
newStyle.left = Number(style.left) + Number(left);
|
||||||
|
newStyle.right = '';
|
||||||
|
} else if (isNumber(style.right)) {
|
||||||
|
newStyle.right = Number(style.right) - Number(left);
|
||||||
|
newStyle.left = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return newStyle;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算节点水平居中对齐后的样式
|
||||||
|
* 流式布局(relative)下不做处理;优先通过 DOM 元素实际宽度计算,回退到配置中的 width 值
|
||||||
|
* @param node 需要居中的节点配置
|
||||||
|
* @param parent 父容器节点配置
|
||||||
|
* @param layout 当前布局方式
|
||||||
|
* @param doc 画布 document 对象,用于获取 DOM 元素实际宽度
|
||||||
|
* @returns 计算后的新样式对象,若不支持居中则返回 null
|
||||||
|
*/
|
||||||
|
export const calcAlignCenterStyle = (
|
||||||
|
node: MNode,
|
||||||
|
parent: MContainer,
|
||||||
|
layout: Layout,
|
||||||
|
doc?: Document,
|
||||||
|
): Record<string, any> | null => {
|
||||||
|
if (layout === Layout.RELATIVE || !node.style) return null;
|
||||||
|
|
||||||
|
const style = { ...node.style };
|
||||||
|
|
||||||
|
if (doc) {
|
||||||
|
const el = getElById()(doc, node.id);
|
||||||
|
const parentEl = layout === Layout.FIXED ? doc.body : el?.offsetParent;
|
||||||
|
if (parentEl && el) {
|
||||||
|
style.left = calcValueByFontsize(doc, (parentEl.clientWidth - el.clientWidth) / 2);
|
||||||
|
style.right = '';
|
||||||
|
}
|
||||||
|
} else if (parent.style && isNumber(parent.style?.width) && isNumber(node.style?.width)) {
|
||||||
|
style.left = (parent.style.width - node.style.width) / 2;
|
||||||
|
style.right = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return style;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算图层移动后的目标索引
|
||||||
|
* 流式布局与绝对定位布局的移动方向相反:流式布局中"上移"对应索引减小,绝对定位中"上移"对应索引增大
|
||||||
|
* @param currentIndex 节点当前在兄弟列表中的索引
|
||||||
|
* @param offset 移动偏移量,支持数值或 LayerOffset.TOP / LayerOffset.BOTTOM
|
||||||
|
* @param brothersLength 兄弟节点总数
|
||||||
|
* @param isRelative 是否为流式布局
|
||||||
|
* @returns 目标索引位置
|
||||||
|
*/
|
||||||
|
export const calcLayerTargetIndex = (
|
||||||
|
currentIndex: number,
|
||||||
|
offset: number | LayerOffset,
|
||||||
|
brothersLength: number,
|
||||||
|
isRelative: boolean,
|
||||||
|
): number => {
|
||||||
|
if (offset === LayerOffset.TOP) {
|
||||||
|
return isRelative ? 0 : brothersLength;
|
||||||
|
}
|
||||||
|
if (offset === LayerOffset.BOTTOM) {
|
||||||
|
return isRelative ? brothersLength : 0;
|
||||||
|
}
|
||||||
|
return currentIndex + (isRelative ? -(offset as number) : (offset as number));
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 节点配置合并策略:用于 mergeWith 的自定义回调
|
||||||
|
* - undefined 且 source 拥有该 key 时返回空字符串
|
||||||
|
* - 原来是数组而新值是对象时,使用新值
|
||||||
|
* - 新值是数组时,直接替换而非逐元素合并
|
||||||
|
*/
|
||||||
|
export const editorNodeMergeCustomizer = (objValue: any, srcValue: any, key: string, _object: any, source: any) => {
|
||||||
|
if (typeof srcValue === 'undefined' && Object.hasOwn(source, key)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isObject(srcValue) && Array.isArray(objValue)) {
|
||||||
|
return srcValue;
|
||||||
|
}
|
||||||
|
if (Array.isArray(srcValue)) {
|
||||||
|
return srcValue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 收集复制节点关联的依赖节点,将关联节点追加到 copyNodes 数组中
|
||||||
|
* @param copyNodes 待复制的节点列表(会被就地修改)
|
||||||
|
* @param collectorOptions 依赖收集器配置
|
||||||
|
* @param getNodeById 根据 ID 获取节点的回调函数
|
||||||
|
*/
|
||||||
|
export const collectRelatedNodes = (
|
||||||
|
copyNodes: MNode[],
|
||||||
|
collectorOptions: TargetOptions,
|
||||||
|
getNodeById: (id: Id) => MNode | null,
|
||||||
|
): void => {
|
||||||
|
const customTarget = new Target({ ...collectorOptions });
|
||||||
|
const coperWatcher = new Watcher();
|
||||||
|
|
||||||
|
coperWatcher.addTarget(customTarget);
|
||||||
|
coperWatcher.collect(copyNodes, {}, true, collectorOptions.type);
|
||||||
|
|
||||||
|
Object.keys(customTarget.deps).forEach((nodeId: Id) => {
|
||||||
|
const node = getNodeById(nodeId);
|
||||||
|
if (!node) return;
|
||||||
|
customTarget.deps[nodeId].keys.forEach((key) => {
|
||||||
|
const relateNodeId = get(node, key);
|
||||||
|
const isExist = copyNodes.find((n) => n.id === relateNodeId);
|
||||||
|
if (!isExist) {
|
||||||
|
const relateNode = getNodeById(relateNodeId);
|
||||||
|
if (relateNode) {
|
||||||
|
copyNodes.push(relateNode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface DragClassification {
|
||||||
|
sameParentIndices: number[];
|
||||||
|
crossParentConfigs: { config: MNode; parent: MContainer }[];
|
||||||
|
/** 当同父容器节点索引异常时置为 true,调用方应中止拖拽操作 */
|
||||||
|
aborted: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 对拖拽的节点进行分类:同父容器内移动 vs 跨容器移动
|
||||||
|
* @param configs 被拖拽的节点列表
|
||||||
|
* @param targetParent 目标父容器
|
||||||
|
* @param getNodeInfo 获取节点信息的回调
|
||||||
|
* @returns 分类结果,包含同容器索引列表和跨容器节点列表
|
||||||
|
*/
|
||||||
|
export const classifyDragSources = (
|
||||||
|
configs: MNode[],
|
||||||
|
targetParent: MContainer,
|
||||||
|
getNodeInfo: (id: Id, raw?: boolean) => EditorNodeInfo,
|
||||||
|
): DragClassification => {
|
||||||
|
const sameParentIndices: number[] = [];
|
||||||
|
const crossParentConfigs: { config: MNode; parent: MContainer }[] = [];
|
||||||
|
|
||||||
|
for (const config of configs) {
|
||||||
|
const { parent, node: curNode } = getNodeInfo(config.id, false);
|
||||||
|
if (!parent || !curNode) continue;
|
||||||
|
|
||||||
|
const path = getNodePath(curNode.id, parent.items);
|
||||||
|
if (path.some((node) => `${targetParent.id}` === `${node.id}`)) continue;
|
||||||
|
|
||||||
|
const index = getNodeIndex(curNode.id, parent);
|
||||||
|
|
||||||
|
if (`${parent.id}` === `${targetParent.id}`) {
|
||||||
|
if (typeof index !== 'number' || index === -1) {
|
||||||
|
return { sameParentIndices, crossParentConfigs, aborted: true };
|
||||||
|
}
|
||||||
|
sameParentIndices.push(index);
|
||||||
|
} else {
|
||||||
|
crossParentConfigs.push({ config, parent });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { sameParentIndices, crossParentConfigs, aborted: false };
|
||||||
|
};
|
||||||
|
|||||||
@ -108,6 +108,10 @@ export const styleTabConfig: TabPaneConfig = {
|
|||||||
'borderColor',
|
'borderColor',
|
||||||
],
|
],
|
||||||
} as unknown as ChildConfig,
|
} as unknown as ChildConfig,
|
||||||
|
{
|
||||||
|
name: 'transform',
|
||||||
|
defaultValue: () => ({}),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@ -23,7 +23,7 @@ export class UndoRedo<T = any> {
|
|||||||
private listCursor: number;
|
private listCursor: number;
|
||||||
private listMaxSize: number;
|
private listMaxSize: number;
|
||||||
|
|
||||||
constructor(listMaxSize = 20) {
|
constructor(listMaxSize = 100) {
|
||||||
const minListMaxSize = 2;
|
const minListMaxSize = 2;
|
||||||
this.elementList = [];
|
this.elementList = [];
|
||||||
this.listCursor = 0;
|
this.listCursor = 0;
|
||||||
@ -42,29 +42,30 @@ export class UndoRedo<T = any> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public canUndo(): boolean {
|
public canUndo(): boolean {
|
||||||
return this.listCursor > 1;
|
return this.listCursor > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 返回undo后的当前元素
|
/** 返回被撤销的操作 */
|
||||||
public undo(): T | null {
|
public undo(): T | null {
|
||||||
if (!this.canUndo()) {
|
if (!this.canUndo()) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
this.listCursor -= 1;
|
this.listCursor -= 1;
|
||||||
return this.getCurrentElement();
|
return cloneDeep(this.elementList[this.listCursor]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public canRedo() {
|
public canRedo() {
|
||||||
return this.elementList.length > this.listCursor;
|
return this.elementList.length > this.listCursor;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 返回redo后的当前元素
|
/** 返回被重做的操作 */
|
||||||
public redo(): T | null {
|
public redo(): T | null {
|
||||||
if (!this.canRedo()) {
|
if (!this.canRedo()) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
const element = cloneDeep(this.elementList[this.listCursor]);
|
||||||
this.listCursor += 1;
|
this.listCursor += 1;
|
||||||
return this.getCurrentElement();
|
return element;
|
||||||
}
|
}
|
||||||
|
|
||||||
public getCurrentElement(): T | null {
|
public getCurrentElement(): T | null {
|
||||||
|
|||||||
245
packages/editor/tests/unit/utils/editor-history.spec.ts
Normal file
245
packages/editor/tests/unit/utils/editor-history.spec.ts
Normal file
@ -0,0 +1,245 @@
|
|||||||
|
/*
|
||||||
|
* Tencent is pleased to support the open source community by making TMagicEditor available.
|
||||||
|
*
|
||||||
|
* Copyright (C) 2025 Tencent. All rights reserved.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, expect, test, vi } from 'vitest';
|
||||||
|
|
||||||
|
import type { MApp, MContainer, MNode } from '@tmagic/core';
|
||||||
|
import { NodeType } from '@tmagic/core';
|
||||||
|
|
||||||
|
import type { StepValue } from '@editor/type';
|
||||||
|
import type { HistoryOpContext } from '@editor/utils/editor-history';
|
||||||
|
import { applyHistoryAddOp, applyHistoryRemoveOp, applyHistoryUpdateOp } from '@editor/utils/editor-history';
|
||||||
|
|
||||||
|
const makePage = (): MContainer => ({
|
||||||
|
id: 'page_1',
|
||||||
|
type: NodeType.PAGE,
|
||||||
|
items: [
|
||||||
|
{ id: 'n1', type: 'text' },
|
||||||
|
{ id: 'n2', type: 'button' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const makeRoot = (page: MContainer): MApp => ({
|
||||||
|
id: 'app_1',
|
||||||
|
type: NodeType.ROOT,
|
||||||
|
items: [page],
|
||||||
|
});
|
||||||
|
|
||||||
|
const makeCtx = (root: MApp): HistoryOpContext => {
|
||||||
|
const page = root.items[0] as MContainer;
|
||||||
|
return {
|
||||||
|
root,
|
||||||
|
stage: {
|
||||||
|
add: vi.fn(),
|
||||||
|
remove: vi.fn(),
|
||||||
|
update: vi.fn(),
|
||||||
|
} as any,
|
||||||
|
getNodeById: (id: any) => {
|
||||||
|
if (`${id}` === `${root.id}`) return root as unknown as MNode;
|
||||||
|
if (`${id}` === `${page.id}`) return page as unknown as MNode;
|
||||||
|
return page.items.find((n) => `${n.id}` === `${id}`) ?? null;
|
||||||
|
},
|
||||||
|
getNodeInfo: (id: any) => {
|
||||||
|
if (`${id}` === `${page.id}`) {
|
||||||
|
return { node: page as unknown as MNode, parent: root as unknown as MContainer, page: page as any };
|
||||||
|
}
|
||||||
|
const node = page.items.find((n) => `${n.id}` === `${id}`);
|
||||||
|
return { node: node ?? null, parent: node ? page : null, page: page as any };
|
||||||
|
},
|
||||||
|
setRoot: vi.fn(),
|
||||||
|
setPage: vi.fn(),
|
||||||
|
getPage: () => page as any,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('applyHistoryAddOp', () => {
|
||||||
|
test('撤销 add:从父节点移除已添加的节点', async () => {
|
||||||
|
const page = makePage();
|
||||||
|
const root = makeRoot(page);
|
||||||
|
const ctx = makeCtx(root);
|
||||||
|
|
||||||
|
const step: StepValue = {
|
||||||
|
opType: 'add',
|
||||||
|
selectedBefore: [],
|
||||||
|
selectedAfter: ['n1'],
|
||||||
|
modifiedNodeIds: new Map(),
|
||||||
|
nodes: [{ id: 'n1', type: 'text' }],
|
||||||
|
parentId: 'page_1',
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(page.items).toHaveLength(2);
|
||||||
|
await applyHistoryAddOp(step, true, ctx);
|
||||||
|
expect(page.items).toHaveLength(1);
|
||||||
|
expect(page.items[0].id).toBe('n2');
|
||||||
|
expect(ctx.stage!.remove).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('重做 add:重新添加节点到父节点', async () => {
|
||||||
|
const page: MContainer = { id: 'page_1', type: NodeType.PAGE, items: [] };
|
||||||
|
const root = makeRoot(page);
|
||||||
|
const ctx = makeCtx(root);
|
||||||
|
|
||||||
|
const step: StepValue = {
|
||||||
|
opType: 'add',
|
||||||
|
selectedBefore: [],
|
||||||
|
selectedAfter: ['new1'],
|
||||||
|
modifiedNodeIds: new Map(),
|
||||||
|
nodes: [{ id: 'new1', type: 'text' }],
|
||||||
|
parentId: 'page_1',
|
||||||
|
indexMap: { new1: 0 },
|
||||||
|
};
|
||||||
|
|
||||||
|
await applyHistoryAddOp(step, false, ctx);
|
||||||
|
expect(page.items).toHaveLength(1);
|
||||||
|
expect(page.items[0].id).toBe('new1');
|
||||||
|
expect(ctx.stage!.add).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('applyHistoryRemoveOp', () => {
|
||||||
|
test('撤销 remove:将已删除节点按原位置重新插入', async () => {
|
||||||
|
const page: MContainer = { id: 'page_1', type: NodeType.PAGE, items: [{ id: 'n2', type: 'button' }] };
|
||||||
|
const root = makeRoot(page);
|
||||||
|
const ctx = makeCtx(root);
|
||||||
|
|
||||||
|
const step: StepValue = {
|
||||||
|
opType: 'remove',
|
||||||
|
selectedBefore: ['n1'],
|
||||||
|
selectedAfter: [],
|
||||||
|
modifiedNodeIds: new Map(),
|
||||||
|
removedItems: [{ node: { id: 'n1', type: 'text' }, parentId: 'page_1', index: 0 }],
|
||||||
|
};
|
||||||
|
|
||||||
|
await applyHistoryRemoveOp(step, true, ctx);
|
||||||
|
expect(page.items).toHaveLength(2);
|
||||||
|
expect(page.items[0].id).toBe('n1');
|
||||||
|
expect(ctx.stage!.add).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('重做 remove:再次删除节点', async () => {
|
||||||
|
const page = makePage();
|
||||||
|
const root = makeRoot(page);
|
||||||
|
const ctx = makeCtx(root);
|
||||||
|
|
||||||
|
const step: StepValue = {
|
||||||
|
opType: 'remove',
|
||||||
|
selectedBefore: [],
|
||||||
|
selectedAfter: [],
|
||||||
|
modifiedNodeIds: new Map(),
|
||||||
|
removedItems: [{ node: { id: 'n1', type: 'text' }, parentId: 'page_1', index: 0 }],
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(page.items).toHaveLength(2);
|
||||||
|
await applyHistoryRemoveOp(step, false, ctx);
|
||||||
|
expect(page.items).toHaveLength(1);
|
||||||
|
expect(page.items[0].id).toBe('n2');
|
||||||
|
expect(ctx.stage!.remove).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('applyHistoryUpdateOp', () => {
|
||||||
|
test('撤销 update:将节点恢复为 oldNode', async () => {
|
||||||
|
const page = makePage();
|
||||||
|
const root = makeRoot(page);
|
||||||
|
const ctx = makeCtx(root);
|
||||||
|
|
||||||
|
const step: StepValue = {
|
||||||
|
opType: 'update',
|
||||||
|
selectedBefore: [],
|
||||||
|
selectedAfter: [],
|
||||||
|
modifiedNodeIds: new Map(),
|
||||||
|
updatedItems: [
|
||||||
|
{
|
||||||
|
oldNode: { id: 'n1', type: 'text', text: 'before' },
|
||||||
|
newNode: { id: 'n1', type: 'text', text: 'after' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
await applyHistoryUpdateOp(step, true, ctx);
|
||||||
|
expect(page.items[0].text).toBe('before');
|
||||||
|
expect(ctx.stage!.update).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('重做 update:将节点更新为 newNode', async () => {
|
||||||
|
const page = makePage();
|
||||||
|
const root = makeRoot(page);
|
||||||
|
const ctx = makeCtx(root);
|
||||||
|
|
||||||
|
const step: StepValue = {
|
||||||
|
opType: 'update',
|
||||||
|
selectedBefore: [],
|
||||||
|
selectedAfter: [],
|
||||||
|
modifiedNodeIds: new Map(),
|
||||||
|
updatedItems: [
|
||||||
|
{
|
||||||
|
oldNode: { id: 'n1', type: 'text', text: 'before' },
|
||||||
|
newNode: { id: 'n1', type: 'text', text: 'after' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
await applyHistoryUpdateOp(step, false, ctx);
|
||||||
|
expect(page.items[0].text).toBe('after');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('update ROOT 类型调用 setRoot', async () => {
|
||||||
|
const page = makePage();
|
||||||
|
const root = makeRoot(page);
|
||||||
|
const ctx = makeCtx(root);
|
||||||
|
|
||||||
|
const step: StepValue = {
|
||||||
|
opType: 'update',
|
||||||
|
selectedBefore: [],
|
||||||
|
selectedAfter: [],
|
||||||
|
modifiedNodeIds: new Map(),
|
||||||
|
updatedItems: [
|
||||||
|
{
|
||||||
|
oldNode: { id: 'app_1', type: NodeType.ROOT, items: [] } as any,
|
||||||
|
newNode: { id: 'app_1', type: NodeType.ROOT, items: [page] } as any,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
await applyHistoryUpdateOp(step, true, ctx);
|
||||||
|
expect(ctx.setRoot).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('update 页面节点调用 setPage', async () => {
|
||||||
|
const page = makePage();
|
||||||
|
const root = makeRoot(page);
|
||||||
|
const ctx = makeCtx(root);
|
||||||
|
|
||||||
|
const updatedPage = { ...page, name: 'renamed' };
|
||||||
|
const step: StepValue = {
|
||||||
|
opType: 'update',
|
||||||
|
selectedBefore: [],
|
||||||
|
selectedAfter: [],
|
||||||
|
modifiedNodeIds: new Map(),
|
||||||
|
updatedItems: [
|
||||||
|
{
|
||||||
|
oldNode: page as any,
|
||||||
|
newNode: updatedPage as any,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
await applyHistoryUpdateOp(step, false, ctx);
|
||||||
|
expect(ctx.setPage).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -17,8 +17,11 @@
|
|||||||
*/
|
*/
|
||||||
import { describe, expect, test } from 'vitest';
|
import { describe, expect, test } from 'vitest';
|
||||||
|
|
||||||
|
import type { MApp, MContainer, MNode } from '@tmagic/core';
|
||||||
import { NodeType } from '@tmagic/core';
|
import { NodeType } from '@tmagic/core';
|
||||||
|
|
||||||
|
import type { EditorNodeInfo } from '@editor/type';
|
||||||
|
import { LayerOffset, Layout } from '@editor/type';
|
||||||
import * as editor from '@editor/utils/editor';
|
import * as editor from '@editor/utils/editor';
|
||||||
|
|
||||||
describe('util form', () => {
|
describe('util form', () => {
|
||||||
@ -305,3 +308,452 @@ describe('buildChangeRecords', () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ===== 以下为新提取的工具函数测试 =====
|
||||||
|
|
||||||
|
const mockRoot: MApp = {
|
||||||
|
id: 'app_1',
|
||||||
|
type: NodeType.ROOT,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: 'page_1',
|
||||||
|
type: NodeType.PAGE,
|
||||||
|
name: 'index',
|
||||||
|
style: { position: 'relative', width: 375 },
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: 'node_1',
|
||||||
|
type: 'text',
|
||||||
|
style: { position: 'absolute', top: 10, left: 20, width: 100 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'node_2',
|
||||||
|
type: 'button',
|
||||||
|
style: { position: 'absolute', bottom: 50, right: 30 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'node_3',
|
||||||
|
type: 'image',
|
||||||
|
style: { position: 'relative', top: 0, left: 0 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockGetNodeInfo = (id: string | number): EditorNodeInfo => {
|
||||||
|
const page = mockRoot.items[0];
|
||||||
|
if (`${id}` === `${mockRoot.id}`) {
|
||||||
|
return { node: mockRoot as unknown as MNode, parent: null, page: null };
|
||||||
|
}
|
||||||
|
if (`${id}` === `${page.id}`) {
|
||||||
|
return { node: page, parent: mockRoot as unknown as MContainer, page: page as any };
|
||||||
|
}
|
||||||
|
const items = (page as MContainer).items || [];
|
||||||
|
const node = items.find((n: MNode) => `${n.id}` === `${id}`);
|
||||||
|
if (node) {
|
||||||
|
return { node, parent: page as MContainer, page: page as any };
|
||||||
|
}
|
||||||
|
return { node: null, parent: null, page: null };
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('resolveSelectedNode', () => {
|
||||||
|
test('传入数字ID,正常返回节点信息', () => {
|
||||||
|
const result = editor.resolveSelectedNode('node_1', mockGetNodeInfo, mockRoot.id);
|
||||||
|
expect(result.node?.id).toBe('node_1');
|
||||||
|
expect(result.parent?.id).toBe('page_1');
|
||||||
|
expect(result.page?.id).toBe('page_1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('传入节点配置对象,正常返回节点信息', () => {
|
||||||
|
const config: MNode = { id: 'node_2', type: 'button' };
|
||||||
|
const result = editor.resolveSelectedNode(config, mockGetNodeInfo, mockRoot.id);
|
||||||
|
expect(result.node?.id).toBe('node_2');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('传入页面ID,正常返回页面信息', () => {
|
||||||
|
const result = editor.resolveSelectedNode('page_1', mockGetNodeInfo, mockRoot.id);
|
||||||
|
expect(result.node?.id).toBe('page_1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('传入空ID,抛出错误', () => {
|
||||||
|
expect(() => editor.resolveSelectedNode({ id: '', type: 'text' }, mockGetNodeInfo)).toThrow('没有ID,无法选中');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('传入不存在的ID,抛出错误', () => {
|
||||||
|
expect(() => editor.resolveSelectedNode('not_exist', mockGetNodeInfo)).toThrow('获取不到组件信息');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('传入根节点ID,抛出错误', () => {
|
||||||
|
expect(() => editor.resolveSelectedNode('app_1', mockGetNodeInfo, mockRoot.id)).toThrow('不能选根节点');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('不传rootId时,不校验根节点', () => {
|
||||||
|
const result = editor.resolveSelectedNode('app_1', mockGetNodeInfo);
|
||||||
|
expect(result.node?.id).toBe('app_1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('toggleFixedPosition', () => {
|
||||||
|
const getLayoutFn = async () => Layout.ABSOLUTE;
|
||||||
|
|
||||||
|
test('非fixed变为fixed,调用change2Fixed', async () => {
|
||||||
|
const src: MNode = { id: 'node_1', type: 'text', style: { position: 'absolute', top: 10, left: 20 } };
|
||||||
|
const dist: MNode = { id: 'node_1', type: 'text', style: { position: 'fixed', top: 10, left: 20 } };
|
||||||
|
|
||||||
|
const result = await editor.toggleFixedPosition(dist, src, mockRoot, getLayoutFn);
|
||||||
|
expect(result.style?.position).toBe('fixed');
|
||||||
|
expect(result).not.toBe(dist);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fixed变为非fixed,调用Fixed2Other', async () => {
|
||||||
|
const src: MNode = { id: 'node_1', type: 'text', style: { position: 'fixed', top: 10, left: 20 } };
|
||||||
|
const dist: MNode = { id: 'node_1', type: 'text', style: { position: 'absolute', top: 10, left: 20 } };
|
||||||
|
|
||||||
|
const result = await editor.toggleFixedPosition(dist, src, mockRoot, getLayoutFn);
|
||||||
|
expect(result.style?.position).toBe('absolute');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('定位未变化,不修改样式', async () => {
|
||||||
|
const src: MNode = { id: 'node_1', type: 'text', style: { position: 'absolute', top: 10, left: 20 } };
|
||||||
|
const dist: MNode = { id: 'node_1', type: 'text', style: { position: 'absolute', top: 30, left: 40 } };
|
||||||
|
|
||||||
|
const result = await editor.toggleFixedPosition(dist, src, mockRoot, getLayoutFn);
|
||||||
|
expect(result.style?.top).toBe(30);
|
||||||
|
expect(result.style?.left).toBe(40);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pop类型节点不做处理', async () => {
|
||||||
|
const src: MNode = {
|
||||||
|
id: 'node_1',
|
||||||
|
type: 'pop',
|
||||||
|
style: { position: 'absolute', top: 10, left: 20 },
|
||||||
|
name: 'pop',
|
||||||
|
};
|
||||||
|
const dist: MNode = { id: 'node_1', type: 'pop', style: { position: 'fixed', top: 10, left: 20 }, name: 'pop' };
|
||||||
|
|
||||||
|
const result = await editor.toggleFixedPosition(dist, src, mockRoot, getLayoutFn);
|
||||||
|
expect(result.style?.position).toBe('fixed');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('目标节点无position属性,不做处理', async () => {
|
||||||
|
const src: MNode = { id: 'node_1', type: 'text', style: { position: 'absolute' } };
|
||||||
|
const dist: MNode = { id: 'node_1', type: 'text', style: { width: 100 } };
|
||||||
|
|
||||||
|
const result = await editor.toggleFixedPosition(dist, src, mockRoot, getLayoutFn);
|
||||||
|
expect(result.style?.position).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('返回深拷贝,不修改原对象', async () => {
|
||||||
|
const src: MNode = { id: 'node_1', type: 'text', style: { position: 'absolute', top: 10 } };
|
||||||
|
const dist: MNode = { id: 'node_1', type: 'text', style: { position: 'absolute', top: 20 } };
|
||||||
|
|
||||||
|
const result = await editor.toggleFixedPosition(dist, src, mockRoot, getLayoutFn);
|
||||||
|
expect(result).not.toBe(dist);
|
||||||
|
expect(dist.style?.top).toBe(20);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('calcMoveStyle', () => {
|
||||||
|
test('absolute定位,向下移动', () => {
|
||||||
|
const style = { position: 'absolute', top: 10, left: 20 };
|
||||||
|
const result = editor.calcMoveStyle(style, 0, 5);
|
||||||
|
expect(result).toEqual({ position: 'absolute', top: 15, left: 20, bottom: '' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('absolute定位,向右移动', () => {
|
||||||
|
const style = { position: 'absolute', top: 10, left: 20 };
|
||||||
|
const result = editor.calcMoveStyle(style, 5, 0);
|
||||||
|
expect(result).toEqual({ position: 'absolute', top: 10, left: 25, right: '' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('absolute定位,同时向下和向右移动', () => {
|
||||||
|
const style = { position: 'absolute', top: 10, left: 20 };
|
||||||
|
const result = editor.calcMoveStyle(style, 3, 7);
|
||||||
|
expect(result).toEqual({ position: 'absolute', top: 17, left: 23, bottom: '', right: '' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fixed定位,正常移动', () => {
|
||||||
|
const style = { position: 'fixed', top: 100, left: 200 };
|
||||||
|
const result = editor.calcMoveStyle(style, -10, -20);
|
||||||
|
expect(result).toEqual({ position: 'fixed', top: 80, left: 190, bottom: '', right: '' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('使用bottom定位时,向下移动减小bottom', () => {
|
||||||
|
const style = { position: 'absolute', bottom: 50, left: 20 };
|
||||||
|
const result = editor.calcMoveStyle(style, 0, 10);
|
||||||
|
expect(result?.bottom).toBe(40);
|
||||||
|
expect(result?.top).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('使用right定位时,向右移动减小right', () => {
|
||||||
|
const style = { position: 'absolute', top: 10, right: 30 };
|
||||||
|
const result = editor.calcMoveStyle(style, 10, 0);
|
||||||
|
expect(result?.right).toBe(20);
|
||||||
|
expect(result?.left).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('relative定位,返回null', () => {
|
||||||
|
const style = { position: 'relative', top: 0, left: 0 };
|
||||||
|
const result = editor.calcMoveStyle(style, 10, 10);
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('无position属性,返回null', () => {
|
||||||
|
const style = { width: 100 };
|
||||||
|
const result = editor.calcMoveStyle(style, 10, 10);
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('空样式对象,返回null', () => {
|
||||||
|
const result = editor.calcMoveStyle({}, 10, 10);
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('偏移量为0,不修改样式', () => {
|
||||||
|
const style = { position: 'absolute', top: 10, left: 20 };
|
||||||
|
const result = editor.calcMoveStyle(style, 0, 0);
|
||||||
|
expect(result).toEqual({ position: 'absolute', top: 10, left: 20 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('不修改原对象', () => {
|
||||||
|
const style = { position: 'absolute', top: 10, left: 20 };
|
||||||
|
editor.calcMoveStyle(style, 5, 5);
|
||||||
|
expect(style.top).toBe(10);
|
||||||
|
expect(style.left).toBe(20);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('calcAlignCenterStyle', () => {
|
||||||
|
test('absolute布局,通过配置中的width计算居中', () => {
|
||||||
|
const node: MNode = { id: 'n1', type: 'text', style: { width: 100, left: 0 } };
|
||||||
|
const parent = { id: 'p1', type: NodeType.PAGE, style: { width: 375 }, items: [] } as unknown as MContainer;
|
||||||
|
const result = editor.calcAlignCenterStyle(node, parent, Layout.ABSOLUTE);
|
||||||
|
expect(result?.left).toBe(137.5);
|
||||||
|
expect(result?.right).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('relative布局,返回null', () => {
|
||||||
|
const node: MNode = { id: 'n1', type: 'text', style: { width: 100 } };
|
||||||
|
const parent = { id: 'p1', type: NodeType.PAGE, style: { width: 375 }, items: [] } as unknown as MContainer;
|
||||||
|
const result = editor.calcAlignCenterStyle(node, parent, Layout.RELATIVE);
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('节点无style,返回null', () => {
|
||||||
|
const node: MNode = { id: 'n1', type: 'text' };
|
||||||
|
const parent = { id: 'p1', type: NodeType.PAGE, style: { width: 375 }, items: [] } as unknown as MContainer;
|
||||||
|
const result = editor.calcAlignCenterStyle(node, parent, Layout.ABSOLUTE);
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('父节点无style,不修改', () => {
|
||||||
|
const node: MNode = { id: 'n1', type: 'text', style: { width: 100, left: 10 } };
|
||||||
|
const parent = { id: 'p1', type: NodeType.PAGE, items: [] } as unknown as MContainer;
|
||||||
|
const result = editor.calcAlignCenterStyle(node, parent, Layout.ABSOLUTE);
|
||||||
|
expect(result?.left).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('父节点width非数字,不修改left', () => {
|
||||||
|
const node: MNode = { id: 'n1', type: 'text', style: { width: 100, left: 10 } };
|
||||||
|
const parent = {
|
||||||
|
id: 'p1',
|
||||||
|
type: NodeType.PAGE,
|
||||||
|
style: { width: '100%' },
|
||||||
|
items: [],
|
||||||
|
} as unknown as MContainer;
|
||||||
|
const result = editor.calcAlignCenterStyle(node, parent, Layout.ABSOLUTE);
|
||||||
|
expect(result?.left).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('不修改原节点style', () => {
|
||||||
|
const node: MNode = { id: 'n1', type: 'text', style: { width: 100, left: 0 } };
|
||||||
|
const parent = { id: 'p1', type: NodeType.PAGE, style: { width: 375 }, items: [] } as unknown as MContainer;
|
||||||
|
editor.calcAlignCenterStyle(node, parent, Layout.ABSOLUTE);
|
||||||
|
expect(node.style?.left).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('calcLayerTargetIndex', () => {
|
||||||
|
test('绝对定位,向上移动1层', () => {
|
||||||
|
const result = editor.calcLayerTargetIndex(2, 1, 5, false);
|
||||||
|
expect(result).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('绝对定位,向下移动1层', () => {
|
||||||
|
const result = editor.calcLayerTargetIndex(2, -1, 5, false);
|
||||||
|
expect(result).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('流式布局,向上移动1层(索引减小)', () => {
|
||||||
|
const result = editor.calcLayerTargetIndex(2, 1, 5, true);
|
||||||
|
expect(result).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('流式布局,向下移动1层(索引增大)', () => {
|
||||||
|
const result = editor.calcLayerTargetIndex(2, -1, 5, true);
|
||||||
|
expect(result).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('绝对定位,置顶', () => {
|
||||||
|
const result = editor.calcLayerTargetIndex(2, LayerOffset.TOP, 5, false);
|
||||||
|
expect(result).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('绝对定位,置底', () => {
|
||||||
|
const result = editor.calcLayerTargetIndex(2, LayerOffset.BOTTOM, 5, false);
|
||||||
|
expect(result).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('流式布局,置顶(索引最小)', () => {
|
||||||
|
const result = editor.calcLayerTargetIndex(3, LayerOffset.TOP, 5, true);
|
||||||
|
expect(result).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('流式布局,置底(索引最大)', () => {
|
||||||
|
const result = editor.calcLayerTargetIndex(1, LayerOffset.BOTTOM, 5, true);
|
||||||
|
expect(result).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('偏移量为0,索引不变', () => {
|
||||||
|
const result = editor.calcLayerTargetIndex(2, 0, 5, false);
|
||||||
|
expect(result).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('editorNodeMergeCustomizer', () => {
|
||||||
|
test('undefined 且 source 拥有该 key 时返回空字符串', () => {
|
||||||
|
const source = { name: undefined };
|
||||||
|
const result = editor.editorNodeMergeCustomizer('old', undefined, 'name', {}, source);
|
||||||
|
expect(result).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('source 不拥有该 key 时返回 undefined(使用默认合并)', () => {
|
||||||
|
const result = editor.editorNodeMergeCustomizer('old', undefined, 'name', {}, {});
|
||||||
|
expect(result).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('原来是数组,新值是对象,使用新值', () => {
|
||||||
|
const srcValue = { a: 1 };
|
||||||
|
const result = editor.editorNodeMergeCustomizer([1, 2], srcValue, 'key', {}, {});
|
||||||
|
expect(result).toBe(srcValue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('新值是数组,直接替换', () => {
|
||||||
|
const srcValue = [3, 4];
|
||||||
|
const result = editor.editorNodeMergeCustomizer([1, 2], srcValue, 'key', {}, {});
|
||||||
|
expect(result).toBe(srcValue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('都是普通值,返回 undefined(使用默认合并)', () => {
|
||||||
|
const result = editor.editorNodeMergeCustomizer('old', 'new', 'key', {}, {});
|
||||||
|
expect(result).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('classifyDragSources', () => {
|
||||||
|
const makeTree = (): { root: MApp; getNodeInfo: (id: any, raw?: boolean) => EditorNodeInfo } => {
|
||||||
|
const child1: MNode = { id: 'c1', type: 'text' };
|
||||||
|
const child2: MNode = { id: 'c2', type: 'text' };
|
||||||
|
const child3: MNode = { id: 'c3', type: 'text' };
|
||||||
|
const container1: MContainer = {
|
||||||
|
id: 'cont1',
|
||||||
|
type: NodeType.CONTAINER,
|
||||||
|
items: [child1, child2],
|
||||||
|
};
|
||||||
|
const container2: MContainer = {
|
||||||
|
id: 'cont2',
|
||||||
|
type: NodeType.CONTAINER,
|
||||||
|
items: [child3],
|
||||||
|
};
|
||||||
|
const page: any = {
|
||||||
|
id: 'page_1',
|
||||||
|
type: NodeType.PAGE,
|
||||||
|
items: [container1, container2],
|
||||||
|
};
|
||||||
|
const root: MApp = { id: 'app', type: NodeType.ROOT, items: [page] };
|
||||||
|
|
||||||
|
const getNodeInfo = (id: any): EditorNodeInfo => {
|
||||||
|
if (`${id}` === 'c1' || `${id}` === 'c2') {
|
||||||
|
return {
|
||||||
|
node: container1.items.find((n) => `${n.id}` === `${id}`) ?? null,
|
||||||
|
parent: container1,
|
||||||
|
page,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (`${id}` === 'c3') {
|
||||||
|
return { node: child3, parent: container2, page };
|
||||||
|
}
|
||||||
|
if (`${id}` === 'cont1') {
|
||||||
|
return { node: container1, parent: page, page };
|
||||||
|
}
|
||||||
|
if (`${id}` === 'cont2') {
|
||||||
|
return { node: container2, parent: page, page };
|
||||||
|
}
|
||||||
|
return { node: null, parent: null, page: null };
|
||||||
|
};
|
||||||
|
|
||||||
|
return { root, getNodeInfo };
|
||||||
|
};
|
||||||
|
|
||||||
|
test('同父容器内拖拽,返回 sameParentIndices', () => {
|
||||||
|
const { getNodeInfo } = makeTree();
|
||||||
|
const targetParent = getNodeInfo('cont1').node as MContainer;
|
||||||
|
const result = editor.classifyDragSources([{ id: 'c1', type: 'text' }], targetParent, getNodeInfo);
|
||||||
|
expect(result.aborted).toBe(false);
|
||||||
|
expect(result.sameParentIndices).toEqual([0]);
|
||||||
|
expect(result.crossParentConfigs).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('跨容器拖拽,返回 crossParentConfigs', () => {
|
||||||
|
const { getNodeInfo } = makeTree();
|
||||||
|
const targetParent = getNodeInfo('cont1').node as MContainer;
|
||||||
|
const result = editor.classifyDragSources([{ id: 'c3', type: 'text' }], targetParent, getNodeInfo);
|
||||||
|
expect(result.aborted).toBe(false);
|
||||||
|
expect(result.sameParentIndices).toHaveLength(0);
|
||||||
|
expect(result.crossParentConfigs).toHaveLength(1);
|
||||||
|
expect(result.crossParentConfigs[0].config.id).toBe('c3');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('混合拖拽:同容器+跨容器', () => {
|
||||||
|
const { getNodeInfo } = makeTree();
|
||||||
|
const targetParent = getNodeInfo('cont1').node as MContainer;
|
||||||
|
const result = editor.classifyDragSources(
|
||||||
|
[
|
||||||
|
{ id: 'c1', type: 'text' },
|
||||||
|
{ id: 'c3', type: 'text' },
|
||||||
|
],
|
||||||
|
targetParent,
|
||||||
|
getNodeInfo,
|
||||||
|
);
|
||||||
|
expect(result.aborted).toBe(false);
|
||||||
|
expect(result.sameParentIndices).toEqual([0]);
|
||||||
|
expect(result.crossParentConfigs).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('节点不存在时跳过', () => {
|
||||||
|
const { getNodeInfo } = makeTree();
|
||||||
|
const targetParent = getNodeInfo('cont1').node as MContainer;
|
||||||
|
const result = editor.classifyDragSources([{ id: 'nonexistent', type: 'text' }], targetParent, getNodeInfo);
|
||||||
|
expect(result.aborted).toBe(false);
|
||||||
|
expect(result.sameParentIndices).toHaveLength(0);
|
||||||
|
expect(result.crossParentConfigs).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('目标容器在节点路径上时跳过(防止循环嵌套)', () => {
|
||||||
|
const { getNodeInfo } = makeTree();
|
||||||
|
const targetParent = getNodeInfo('cont1').node as MContainer;
|
||||||
|
const result = editor.classifyDragSources([{ id: 'c1', type: 'text' }], targetParent, (id: any) => {
|
||||||
|
if (`${id}` === 'c1') {
|
||||||
|
return {
|
||||||
|
node: { id: 'c1', type: 'text' },
|
||||||
|
parent: targetParent,
|
||||||
|
page: { id: 'page_1', type: NodeType.PAGE, items: [] } as any,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { node: null, parent: null, page: null };
|
||||||
|
});
|
||||||
|
expect(result.sameParentIndices).toEqual([0]);
|
||||||
|
expect(result.crossParentConfigs).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -21,60 +21,66 @@ import { UndoRedo } from '@editor/utils/undo-redo';
|
|||||||
|
|
||||||
describe('undo', () => {
|
describe('undo', () => {
|
||||||
let undoRedo: UndoRedo;
|
let undoRedo: UndoRedo;
|
||||||
const element = { a: 1 };
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
undoRedo = new UndoRedo();
|
undoRedo = new UndoRedo();
|
||||||
undoRedo.pushElement(element);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('can no undo: empty list', () => {
|
test('can not undo: empty list', () => {
|
||||||
expect(undoRedo.canUndo()).toBe(false);
|
expect(undoRedo.canUndo()).toBe(false);
|
||||||
expect(undoRedo.undo()).toEqual(null);
|
expect(undoRedo.undo()).toEqual(null);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('can undo', () => {
|
test('can undo after one push', () => {
|
||||||
|
undoRedo.pushElement({ a: 1 });
|
||||||
|
expect(undoRedo.canUndo()).toBe(true);
|
||||||
|
expect(undoRedo.undo()).toEqual({ a: 1 });
|
||||||
|
expect(undoRedo.canUndo()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('can undo returns the operation being undone', () => {
|
||||||
|
undoRedo.pushElement({ a: 1 });
|
||||||
undoRedo.pushElement({ a: 2 });
|
undoRedo.pushElement({ a: 2 });
|
||||||
expect(undoRedo.canUndo()).toBe(true);
|
expect(undoRedo.canUndo()).toBe(true);
|
||||||
expect(undoRedo.undo()).toEqual(element);
|
expect(undoRedo.undo()).toEqual({ a: 2 });
|
||||||
|
expect(undoRedo.canUndo()).toBe(true);
|
||||||
|
expect(undoRedo.undo()).toEqual({ a: 1 });
|
||||||
|
expect(undoRedo.canUndo()).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('redo', () => {
|
describe('redo', () => {
|
||||||
let undoRedo: UndoRedo;
|
let undoRedo: UndoRedo;
|
||||||
const element = { a: 1 };
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
undoRedo = new UndoRedo();
|
undoRedo = new UndoRedo();
|
||||||
undoRedo.pushElement(element);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('can no redo: empty list', () => {
|
test('can not redo: empty list', () => {
|
||||||
expect(undoRedo.canRedo()).toBe(false);
|
expect(undoRedo.canRedo()).toBe(false);
|
||||||
expect(undoRedo.redo()).toBe(null);
|
expect(undoRedo.redo()).toBe(null);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('can no redo: no undo', () => {
|
test('can not redo: no undo', () => {
|
||||||
for (let i = 0; i < 5; i++) {
|
for (let i = 0; i < 5; i++) {
|
||||||
undoRedo.pushElement(element);
|
undoRedo.pushElement({ a: i });
|
||||||
expect(undoRedo.canRedo()).toBe(false);
|
expect(undoRedo.canRedo()).toBe(false);
|
||||||
expect(undoRedo.redo()).toBe(null);
|
expect(undoRedo.redo()).toBe(null);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('can no redo: undo and push', () => {
|
test('can not redo: undo and push', () => {
|
||||||
undoRedo.pushElement(element);
|
undoRedo.pushElement({ a: 1 });
|
||||||
|
undoRedo.pushElement({ a: 2 });
|
||||||
undoRedo.undo();
|
undoRedo.undo();
|
||||||
undoRedo.pushElement(element);
|
undoRedo.pushElement({ a: 3 });
|
||||||
expect(undoRedo.canRedo()).toBe(false);
|
expect(undoRedo.canRedo()).toBe(false);
|
||||||
expect(undoRedo.redo()).toEqual(null);
|
expect(undoRedo.redo()).toEqual(null);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('can no redo: redo end', () => {
|
test('can not redo: redo end', () => {
|
||||||
const element1 = { a: 1 };
|
undoRedo.pushElement({ a: 1 });
|
||||||
const element2 = { a: 2 };
|
undoRedo.pushElement({ a: 2 });
|
||||||
undoRedo.pushElement(element1);
|
|
||||||
undoRedo.pushElement(element2);
|
|
||||||
undoRedo.undo();
|
undoRedo.undo();
|
||||||
undoRedo.undo();
|
undoRedo.undo();
|
||||||
undoRedo.redo();
|
undoRedo.redo();
|
||||||
@ -85,23 +91,20 @@ describe('redo', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('can redo', () => {
|
test('can redo', () => {
|
||||||
const element1 = { a: 1 };
|
undoRedo.pushElement({ a: 1 });
|
||||||
const element2 = { a: 2 };
|
undoRedo.pushElement({ a: 2 });
|
||||||
undoRedo.pushElement(element1);
|
|
||||||
undoRedo.pushElement(element2);
|
|
||||||
undoRedo.undo();
|
undoRedo.undo();
|
||||||
undoRedo.undo();
|
undoRedo.undo();
|
||||||
|
|
||||||
expect(undoRedo.canRedo()).toBe(true);
|
expect(undoRedo.canRedo()).toBe(true);
|
||||||
expect(undoRedo.redo()).toEqual(element1);
|
expect(undoRedo.redo()).toEqual({ a: 1 });
|
||||||
expect(undoRedo.canRedo()).toBe(true);
|
expect(undoRedo.canRedo()).toBe(true);
|
||||||
expect(undoRedo.redo()).toEqual(element2);
|
expect(undoRedo.redo()).toEqual({ a: 2 });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('get current element', () => {
|
describe('get current element', () => {
|
||||||
let undoRedo: UndoRedo;
|
let undoRedo: UndoRedo;
|
||||||
const element = { a: 1 };
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
undoRedo = new UndoRedo();
|
undoRedo = new UndoRedo();
|
||||||
@ -112,44 +115,38 @@ describe('get current element', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('has element', () => {
|
test('has element', () => {
|
||||||
undoRedo.pushElement(element);
|
undoRedo.pushElement({ a: 1 });
|
||||||
expect(undoRedo.getCurrentElement()).toEqual(element);
|
expect(undoRedo.getCurrentElement()).toEqual({ a: 1 });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('list max size', () => {
|
describe('list max size', () => {
|
||||||
let undoRedo: UndoRedo;
|
let undoRedo: UndoRedo;
|
||||||
const listMaxSize = 100;
|
const listMaxSize = 100;
|
||||||
const element = { a: 1 };
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
undoRedo = new UndoRedo(listMaxSize);
|
undoRedo = new UndoRedo(listMaxSize);
|
||||||
undoRedo.pushElement(element);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('reach max size', () => {
|
test('reach max size', () => {
|
||||||
for (let i = 0; i < listMaxSize; i++) {
|
for (let i = 0; i <= listMaxSize; i++) {
|
||||||
undoRedo.pushElement({ a: i });
|
undoRedo.pushElement({ a: i });
|
||||||
}
|
}
|
||||||
undoRedo.pushElement({ a: listMaxSize }); // 这个元素使得list达到maxSize,触发数据删除
|
|
||||||
|
|
||||||
expect(undoRedo.getCurrentElement()).toEqual({ a: listMaxSize });
|
expect(undoRedo.getCurrentElement()).toEqual({ a: listMaxSize });
|
||||||
expect(undoRedo.canRedo()).toBe(false);
|
expect(undoRedo.canRedo()).toBe(false);
|
||||||
expect(undoRedo.canUndo()).toBe(true);
|
expect(undoRedo.canUndo()).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('reach max size, then undo', () => {
|
test('reach max size, then undo all', () => {
|
||||||
for (let i = 0; i < listMaxSize + 1; i++) {
|
for (let i = 0; i <= listMaxSize; i++) {
|
||||||
undoRedo.pushElement({ a: i });
|
undoRedo.pushElement({ a: i });
|
||||||
}
|
}
|
||||||
for (let i = 0; i < listMaxSize - 1; i++) {
|
for (let i = 0; i < listMaxSize; i++) {
|
||||||
undoRedo.undo();
|
undoRedo.undo();
|
||||||
}
|
}
|
||||||
const ele = undoRedo.getCurrentElement();
|
|
||||||
undoRedo.undo();
|
|
||||||
|
|
||||||
expect(ele?.a).toBe(1); // 经过超过maxSize被删元素之后,原本a值为0的第一个元素已经被删除,现在第一个元素a值为1
|
|
||||||
expect(undoRedo.canUndo()).toBe(false);
|
expect(undoRedo.canUndo()).toBe(false);
|
||||||
expect(undoRedo.getCurrentElement()).toEqual(element);
|
expect(undoRedo.getCurrentElement()).toEqual(null);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"version": "1.7.8-beta.3",
|
"version": "1.7.10",
|
||||||
"name": "@tmagic/element-plus-adapter",
|
"name": "@tmagic/element-plus-adapter",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"version": "1.7.8-beta.3",
|
"version": "1.7.10",
|
||||||
"name": "@tmagic/form-schema",
|
"name": "@tmagic/form-schema",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
|
|||||||
@ -348,7 +348,7 @@ export interface HtmlField extends FormItem {
|
|||||||
export interface DisplayConfig extends FormItem {
|
export interface DisplayConfig extends FormItem {
|
||||||
type: 'display';
|
type: 'display';
|
||||||
initValue?: string | number | boolean;
|
initValue?: string | number | boolean;
|
||||||
displayText: FilterFunction<string> | string;
|
displayText?: FilterFunction<string> | string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 文本输入框 */
|
/** 文本输入框 */
|
||||||
@ -820,9 +820,9 @@ export interface StepConfig<T = never> extends FormItem {
|
|||||||
export interface ComponentConfig extends FormItem {
|
export interface ComponentConfig extends FormItem {
|
||||||
type: 'component';
|
type: 'component';
|
||||||
id: string;
|
id: string;
|
||||||
extend: any;
|
extend?: any;
|
||||||
display: any;
|
display?: any;
|
||||||
component: any;
|
component?: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FlexLayoutConfig<T = never> extends FormItem, ContainerCommonConfig<T> {
|
export interface FlexLayoutConfig<T = never> extends FormItem, ContainerCommonConfig<T> {
|
||||||
|
|||||||
@ -29,6 +29,8 @@ export interface DataSourceFieldSelectConfig<T = never> extends FormItem {
|
|||||||
fieldConfig?: FormItemConfig<T>;
|
fieldConfig?: FormItemConfig<T>;
|
||||||
/** 是否可以编辑数据源,disable表示的是是否可以选择数据源 */
|
/** 是否可以编辑数据源,disable表示的是是否可以选择数据源 */
|
||||||
notEditable?: boolean | FilterFunction;
|
notEditable?: boolean | FilterFunction;
|
||||||
|
|
||||||
|
dataSourceId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CodeConfig extends FormItem {
|
export interface CodeConfig extends FormItem {
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"version": "1.7.8-beta.3",
|
"version": "1.7.10",
|
||||||
"name": "@tmagic/form",
|
"name": "@tmagic/form",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"sideEffects": [
|
"sideEffects": [
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<TMagicCol v-show="display && 'type' in config && config.type !== 'hidden'" :span="span">
|
<TMagicCol v-show="display && type !== 'hidden'" :span="span">
|
||||||
<Container
|
<Container
|
||||||
:model="model"
|
:model="model"
|
||||||
:lastValues="lastValues"
|
:lastValues="lastValues"
|
||||||
@ -52,4 +52,6 @@ const mForm = inject<FormState | undefined>('mForm');
|
|||||||
const display = computed(() => displayFunction(mForm, props.config.display, props));
|
const display = computed(() => displayFunction(mForm, props.config.display, props));
|
||||||
const changeHandler = (v: any, eventData: ContainerChangeEventData) => emit('change', v, eventData);
|
const changeHandler = (v: any, eventData: ContainerChangeEventData) => emit('change', v, eventData);
|
||||||
const onAddDiffCount = () => emit('addDiffCount');
|
const onAddDiffCount = () => emit('addDiffCount');
|
||||||
|
|
||||||
|
const type = computed(() => (props.config as any).type);
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"version": "1.7.8-beta.3",
|
"version": "1.7.10",
|
||||||
"name": "@tmagic/schema",
|
"name": "@tmagic/schema",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
|
|||||||
@ -186,7 +186,7 @@ export interface CodeBlockContent {
|
|||||||
/** 代码块名称 */
|
/** 代码块名称 */
|
||||||
name: string;
|
name: string;
|
||||||
/** 代码块内容 */
|
/** 代码块内容 */
|
||||||
content: ((...args: any[]) => any) | string;
|
content: ((...args: any[]) => any) | Function;
|
||||||
/** 参数定义 */
|
/** 参数定义 */
|
||||||
params: CodeParam[] | [];
|
params: CodeParam[] | [];
|
||||||
/** 注释 */
|
/** 注释 */
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"version": "1.7.8-beta.3",
|
"version": "1.7.10",
|
||||||
"name": "@tmagic/stage",
|
"name": "@tmagic/stage",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
@ -31,6 +31,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@scena/guides": "^0.29.2",
|
"@scena/guides": "^0.29.2",
|
||||||
"events": "^3.3.0",
|
"events": "^3.3.0",
|
||||||
|
"@zumer/snapdom": "^2.8.0",
|
||||||
"keycon": "^1.4.0",
|
"keycon": "^1.4.0",
|
||||||
"lodash-es": "^4.17.21",
|
"lodash-es": "^4.17.21",
|
||||||
"moveable": "^0.53.0",
|
"moveable": "^0.53.0",
|
||||||
|
|||||||
@ -236,6 +236,30 @@ export default class ActionManager extends EventEmitter {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取鼠标下方第二个可选中元素(跳过最上层),用于双击穿透选中下方元素
|
||||||
|
* @param event 鼠标事件
|
||||||
|
* @returns 鼠标下方第二个可选中元素,不存在时返回 null
|
||||||
|
*/
|
||||||
|
public async getNextElementFromPoint(event: MouseEvent): Promise<HTMLElement | null> {
|
||||||
|
const els = this.getElementsFromPoint(event as Point);
|
||||||
|
|
||||||
|
let stopped = false;
|
||||||
|
const stop = () => (stopped = true);
|
||||||
|
let skippedFirst = false;
|
||||||
|
for (const el of els) {
|
||||||
|
if (!getIdFromEl()(el)?.startsWith(GHOST_EL_ID_PREFIX) && (await this.isElCanSelect(el, event, stop))) {
|
||||||
|
if (stopped) break;
|
||||||
|
if (!skippedFirst) {
|
||||||
|
skippedFirst = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 判断一个元素能否在当前场景被选中
|
* 判断一个元素能否在当前场景被选中
|
||||||
* @param el 被判断的元素
|
* @param el 被判断的元素
|
||||||
|
|||||||
@ -124,6 +124,7 @@ export default class Rule extends EventEmitter {
|
|||||||
this.hGuides?.off('changeGuides', this.hGuidesChangeGuidesHandler);
|
this.hGuides?.off('changeGuides', this.hGuidesChangeGuidesHandler);
|
||||||
this.vGuides?.off('changeGuides', this.vGuidesChangeGuidesHandler);
|
this.vGuides?.off('changeGuides', this.vGuidesChangeGuidesHandler);
|
||||||
this.containerResizeObserver?.disconnect();
|
this.containerResizeObserver?.disconnect();
|
||||||
|
this.container = undefined;
|
||||||
this.removeAllListeners();
|
this.removeAllListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -137,7 +138,6 @@ export default class Rule extends EventEmitter {
|
|||||||
|
|
||||||
this.hGuides = undefined;
|
this.hGuides = undefined;
|
||||||
this.vGuides = undefined;
|
this.vGuides = undefined;
|
||||||
this.container = undefined;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private getGuidesStyle = (type: GuidesType) => ({
|
private getGuidesStyle = (type: GuidesType) => ({
|
||||||
|
|||||||
@ -18,6 +18,7 @@
|
|||||||
|
|
||||||
import { EventEmitter } from 'events';
|
import { EventEmitter } from 'events';
|
||||||
|
|
||||||
|
import { SnapdomOptions } from '@zumer/snapdom';
|
||||||
import type { MoveableOptions, OnDragStart } from 'moveable';
|
import type { MoveableOptions, OnDragStart } from 'moveable';
|
||||||
|
|
||||||
import type { Id } from '@tmagic/core';
|
import type { Id } from '@tmagic/core';
|
||||||
@ -88,7 +89,38 @@ export default class StageCore extends EventEmitter {
|
|||||||
* @param id 选中的id
|
* @param id 选中的id
|
||||||
*/
|
*/
|
||||||
public async select(id: Id, event?: MouseEvent): Promise<void> {
|
public async select(id: Id, event?: MouseEvent): Promise<void> {
|
||||||
const el = this.renderer?.getTargetElement(id) || null;
|
if (!this.renderer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let el = this.renderer.getTargetElement(id) || null;
|
||||||
|
|
||||||
|
if (!el) {
|
||||||
|
el = await new Promise<HTMLElement | null>((resolve) => {
|
||||||
|
const observer = new MutationObserver(() => {
|
||||||
|
const target = this.renderer?.getTargetElement(id);
|
||||||
|
if (target) {
|
||||||
|
observer.disconnect();
|
||||||
|
clearTimeout(timer);
|
||||||
|
resolve(target);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const body = this.renderer?.getDocument()?.body;
|
||||||
|
if (!body) {
|
||||||
|
resolve(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
observer.observe(body, { childList: true, subtree: true });
|
||||||
|
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
observer.disconnect();
|
||||||
|
resolve(this.renderer?.getTargetElement(id) || null);
|
||||||
|
}, 1000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (el === this.actionManager?.getSelectedEl()) return;
|
if (el === this.actionManager?.getSelectedEl()) return;
|
||||||
|
|
||||||
await this.renderer?.select([id]);
|
await this.renderer?.select([id]);
|
||||||
@ -241,6 +273,21 @@ export default class StageCore extends EventEmitter {
|
|||||||
this.renderer?.reloadIframe(url);
|
this.renderer?.reloadIframe(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将指定id的dom元素生成为图片
|
||||||
|
*/
|
||||||
|
public async getElementImage(
|
||||||
|
id: Id,
|
||||||
|
type: 'download' | 'raw' | 'svg' | 'canvas' | 'png' | 'jpeg' | 'webp' | 'blob' = 'png',
|
||||||
|
options: SnapdomOptions = {},
|
||||||
|
) {
|
||||||
|
if (!this.renderer) {
|
||||||
|
throw new Error('Renderer is not initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.renderer.getElementImage(id, type, options);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 销毁实例
|
* 销毁实例
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -18,6 +18,8 @@
|
|||||||
|
|
||||||
import { EventEmitter } from 'events';
|
import { EventEmitter } from 'events';
|
||||||
|
|
||||||
|
import { snapdom, SnapdomOptions } from '@zumer/snapdom';
|
||||||
|
|
||||||
import type { Id } from '@tmagic/core';
|
import type { Id } from '@tmagic/core';
|
||||||
import { getElById, getHost, guid, injectStyle, isSameDomain } from '@tmagic/core';
|
import { getElById, getHost, guid, injectStyle, isSameDomain } from '@tmagic/core';
|
||||||
|
|
||||||
@ -162,6 +164,35 @@ export default class StageRender extends EventEmitter {
|
|||||||
return getElById()(this.getDocument(), id);
|
return getElById()(this.getDocument(), id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将指定id的dom元素生成为图片
|
||||||
|
* @param id 目标元素的id
|
||||||
|
* @param options 图片生成选项
|
||||||
|
* @returns data URL 格式的图片数据
|
||||||
|
*/
|
||||||
|
public async getElementImage(
|
||||||
|
id: Id,
|
||||||
|
type: 'download' | 'raw' | 'svg' | 'canvas' | 'png' | 'jpeg' | 'webp' | 'blob' = 'png',
|
||||||
|
options: SnapdomOptions = {},
|
||||||
|
) {
|
||||||
|
const el = this.getTargetElement(id);
|
||||||
|
if (!el) {
|
||||||
|
throw new Error(`Element with id "${id}" not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
el.scrollIntoView();
|
||||||
|
|
||||||
|
const toFunc = `to${type.charAt(0).toUpperCase() + type.slice(1)}`;
|
||||||
|
|
||||||
|
const result = await snapdom(el, options);
|
||||||
|
|
||||||
|
if (toFunc in result) {
|
||||||
|
return result[toFunc]();
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Invalid type: ${type}`);
|
||||||
|
}
|
||||||
|
|
||||||
public postTmagicRuntimeReady() {
|
public postTmagicRuntimeReady() {
|
||||||
this.contentWindow = this.iframe?.contentWindow as RuntimeWindow;
|
this.contentWindow = this.iframe?.contentWindow as RuntimeWindow;
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"version": "1.7.8-beta.3",
|
"version": "1.7.10",
|
||||||
"name": "@tmagic/table",
|
"name": "@tmagic/table",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"sideEffects": [
|
"sideEffects": [
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"version": "1.7.8-beta.3",
|
"version": "1.7.10",
|
||||||
"name": "@tmagic/tdesign-vue-next-adapter",
|
"name": "@tmagic/tdesign-vue-next-adapter",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"version": "1.7.8-beta.3",
|
"version": "1.7.10",
|
||||||
"name": "@tmagic/utils",
|
"name": "@tmagic/utils",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
|
|||||||
5
packages/utils/src/const.ts
Normal file
5
packages/utils/src/const.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
export const DATA_SOURCE_FIELDS_SELECT_VALUE_PREFIX = 'ds-field::';
|
||||||
|
|
||||||
|
export const DATA_SOURCE_FIELDS_CHANGE_EVENT_PREFIX = 'ds-field-changed';
|
||||||
|
|
||||||
|
export const DATA_SOURCE_SET_DATA_METHOD_NAME = 'setDataFromEvent';
|
||||||
@ -35,8 +35,12 @@ import { NodeType } from '@tmagic/schema';
|
|||||||
|
|
||||||
import type { EditorNodeInfo } from '@editor/type';
|
import type { EditorNodeInfo } from '@editor/type';
|
||||||
|
|
||||||
|
import { DATA_SOURCE_FIELDS_SELECT_VALUE_PREFIX } from './const';
|
||||||
|
|
||||||
export * from './dom';
|
export * from './dom';
|
||||||
|
|
||||||
|
export * from './const';
|
||||||
|
|
||||||
// for typeof global checks without @types/node
|
// for typeof global checks without @types/node
|
||||||
declare let global: {};
|
declare let global: {};
|
||||||
|
|
||||||
@ -542,10 +546,6 @@ export const getDefaultValueFromFields = (fields: DataSchema[]) => {
|
|||||||
return data;
|
return data;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DATA_SOURCE_FIELDS_SELECT_VALUE_PREFIX = 'ds-field::';
|
|
||||||
|
|
||||||
export const DATA_SOURCE_FIELDS_CHANGE_EVENT_PREFIX = 'ds-field-changed';
|
|
||||||
|
|
||||||
export const getKeys = Object.keys as <T extends object>(obj: T) => Array<keyof T>;
|
export const getKeys = Object.keys as <T extends object>(obj: T) => Array<keyof T>;
|
||||||
|
|
||||||
export const calculatePercentage = (value: number, percentageStr: string) => {
|
export const calculatePercentage = (value: number, percentageStr: string) => {
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "tmagic-playground",
|
"name": "tmagic-playground",
|
||||||
"version": "1.7.8-beta.3",
|
"version": "1.7.10",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@ -12,11 +12,11 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@element-plus/icons-vue": "^2.3.2",
|
"@element-plus/icons-vue": "^2.3.2",
|
||||||
"@tmagic/core": "1.7.8-beta.3",
|
"@tmagic/core": "1.7.10",
|
||||||
"@tmagic/design": "1.7.8-beta.3",
|
"@tmagic/design": "1.7.10",
|
||||||
"@tmagic/editor": "1.7.8-beta.3",
|
"@tmagic/editor": "1.7.10",
|
||||||
"@tmagic/element-plus-adapter": "1.7.8-beta.3",
|
"@tmagic/element-plus-adapter": "1.7.10",
|
||||||
"@tmagic/tdesign-vue-next-adapter": "1.7.8-beta.3",
|
"@tmagic/tdesign-vue-next-adapter": "1.7.10",
|
||||||
"@tmagic/tmagic-form-runtime": "1.1.3",
|
"@tmagic/tmagic-form-runtime": "1.1.3",
|
||||||
"element-plus": "^2.11.8",
|
"element-plus": "^2.11.8",
|
||||||
"lodash-es": "^4.17.21",
|
"lodash-es": "^4.17.21",
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
{
|
{
|
||||||
"extends": "../tsconfig.json",
|
"extends": "../tsconfig.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"baseUrl": "..",
|
|
||||||
},
|
},
|
||||||
"exclude": [
|
"exclude": [
|
||||||
"**/dist/**/*"
|
"**/dist/**/*"
|
||||||
|
|||||||
1424
pnpm-lock.yaml
generated
1424
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -1,14 +1,13 @@
|
|||||||
packages:
|
packages:
|
||||||
- 'packages/*'
|
- "packages/*"
|
||||||
- 'playground'
|
- "playground"
|
||||||
- 'runtime/*'
|
- "runtime/*"
|
||||||
- 'vue-components/*'
|
- "vue-components/*"
|
||||||
- 'react-components/*'
|
- "react-components/*"
|
||||||
- 'eslint-config'
|
- "eslint-config"
|
||||||
|
|
||||||
catalog:
|
catalog:
|
||||||
vue: ^3.5.24
|
vue: ^3.5.24
|
||||||
'@vue/compiler-sfc': ^3.5.24
|
"@vue/compiler-sfc": ^3.5.24
|
||||||
vite: ^8.0.0
|
vite: ^8.0.8
|
||||||
typescript: "^5.9.3"
|
typescript: "^6.0.2"
|
||||||
|
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { cloneDeep } from 'lodash-es';
|
import { cloneDeep } from 'lodash-es';
|
||||||
|
|
||||||
import type { ChangeEvent, MNode } from '@tmagic/core';
|
|
||||||
import type TMagicApp from '@tmagic/core';
|
import type TMagicApp from '@tmagic/core';
|
||||||
|
import type { ChangeEvent, MNode } from '@tmagic/core';
|
||||||
import { isPage, replaceChildNode } from '@tmagic/core';
|
import { isPage, replaceChildNode } from '@tmagic/core';
|
||||||
|
|
||||||
export const useDsl = (app: TMagicApp | undefined) => {
|
export const useDsl = (app: TMagicApp | undefined) => {
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { cloneDeep } from 'lodash-es';
|
import { cloneDeep } from 'lodash-es';
|
||||||
|
|
||||||
import type { Id, MApp } from '@tmagic/core';
|
|
||||||
import type TMagicApp from '@tmagic/core';
|
import type TMagicApp from '@tmagic/core';
|
||||||
|
import type { Id, MApp } from '@tmagic/core';
|
||||||
import { getElById, replaceChildNode } from '@tmagic/core';
|
import { getElById, replaceChildNode } from '@tmagic/core';
|
||||||
import type { Magic, RemoveData, SortEventData, UpdateData } from '@tmagic/stage';
|
import type { Magic, RemoveData, SortEventData, UpdateData } from '@tmagic/stage';
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "runtime-react",
|
"name": "runtime-react",
|
||||||
"version": "1.7.8-beta.3",
|
"version": "1.7.10",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"private": true,
|
"private": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
@ -16,16 +16,16 @@
|
|||||||
"build:playground": "node scripts/build.mjs --type=playground"
|
"build:playground": "node scripts/build.mjs --type=playground"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tmagic/core": "1.7.8-beta.3",
|
"@tmagic/core": "1.7.10",
|
||||||
"@tmagic/react-runtime-help": "0.2.2",
|
"@tmagic/react-runtime-help": "0.2.2",
|
||||||
"@tmagic/stage": "1.7.8-beta.3",
|
"@tmagic/stage": "1.7.10",
|
||||||
"axios": "^1.13.2",
|
"axios": "^1.13.2",
|
||||||
"qrcode": "^1.5.0",
|
"qrcode": "^1.5.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1"
|
"react-dom": "^18.3.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tmagic/cli": "1.7.8-beta.3",
|
"@tmagic/cli": "1.7.10",
|
||||||
"@types/fs-extra": "^11.0.4",
|
"@types/fs-extra": "^11.0.4",
|
||||||
"@types/react": "^18.3.3",
|
"@types/react": "^18.3.3",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "^18.3.0",
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
{
|
{
|
||||||
"extends": "../../tsconfig.json",
|
"extends": "../../tsconfig.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"baseUrl": "./",
|
|
||||||
"jsx": "react",
|
"jsx": "react",
|
||||||
"forceConsistentCasingInFileNames": true,
|
"forceConsistentCasingInFileNames": true,
|
||||||
"types": ["node"],
|
"types": ["node"],
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "runtime-vue",
|
"name": "runtime-vue",
|
||||||
"version": "1.7.8-beta.3",
|
"version": "1.7.10",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"private": true,
|
"private": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
@ -16,14 +16,14 @@
|
|||||||
"build:playground": "node scripts/build.mjs --type=playground"
|
"build:playground": "node scripts/build.mjs --type=playground"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tmagic/core": "1.7.8-beta.3",
|
"@tmagic/core": "1.7.10",
|
||||||
"@tmagic/stage": "1.7.8-beta.3",
|
"@tmagic/stage": "1.7.10",
|
||||||
"@tmagic/vue-runtime-help": "^2.0.0",
|
"@tmagic/vue-runtime-help": "^2.0.0",
|
||||||
"axios": "^1.13.2",
|
"axios": "^1.13.2",
|
||||||
"vue": "catalog:"
|
"vue": "catalog:"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tmagic/cli": "1.7.8-beta.3",
|
"@tmagic/cli": "1.7.10",
|
||||||
"@types/fs-extra": "^11.0.4",
|
"@types/fs-extra": "^11.0.4",
|
||||||
"@types/node": "^24.0.10",
|
"@types/node": "^24.0.10",
|
||||||
"@vitejs/plugin-legacy": "^8.0.0",
|
"@vitejs/plugin-legacy": "^8.0.0",
|
||||||
|
|||||||
@ -20,9 +20,10 @@ if (args.package) {
|
|||||||
const pkgRoot = path.resolve(packagesDir, args.package);
|
const pkgRoot = path.resolve(packagesDir, args.package);
|
||||||
if (fs.statSync(pkgRoot).isDirectory()) {
|
if (fs.statSync(pkgRoot).isDirectory()) {
|
||||||
rimraf.sync(path.resolve(packagesDir, `./${args.package}/dist`));
|
rimraf.sync(path.resolve(packagesDir, `./${args.package}/dist`));
|
||||||
|
const pkg = createRequire(import.meta.url)(`../packages/${args.package}/package.json`);
|
||||||
|
|
||||||
build({ packageName: args.package, format: 'es' });
|
build({ packageName: args.package, format: 'es', pkg, packagesDir });
|
||||||
build({ packageName: args.package, format: 'umd' });
|
build({ packageName: args.package, format: 'umd', pkg, packagesDir });
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const packages = getPackageNames(packagesDir);
|
const packages = getPackageNames(packagesDir);
|
||||||
@ -45,6 +46,30 @@ if (args.package) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// rolldown 在 UMD 输出顶部会注入
|
||||||
|
// Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
||||||
|
// 当内联的依赖(如 lodash-es 的 _Symbol.js)声明 `var Symbol = root.Symbol;`
|
||||||
|
// 时,由于 var hoisting,该局部 `Symbol` 会把上面一行引用到的全局 `Symbol`
|
||||||
|
// 遮蔽掉(此时局部变量还未赋值),运行时抛出
|
||||||
|
// TypeError: Cannot read properties of undefined (reading 'toStringTag')
|
||||||
|
// 这里通过后处理把该引用改为 `globalThis.Symbol.toStringTag`,绕开被 hoist
|
||||||
|
// 的局部绑定。rolldown 修好前先用此 workaround。
|
||||||
|
function fixUmdSymbolShadow() {
|
||||||
|
return {
|
||||||
|
name: 'tmagic:fix-umd-symbol-shadow',
|
||||||
|
generateBundle(outputOptions, bundle) {
|
||||||
|
if (outputOptions.format !== 'umd') return;
|
||||||
|
for (const file of Object.values(bundle)) {
|
||||||
|
if (file.type !== 'chunk' || typeof file.code !== 'string') continue;
|
||||||
|
file.code = file.code.replace(
|
||||||
|
/Object\.defineProperty\(exports,\s*Symbol\.toStringTag,/g,
|
||||||
|
'Object.defineProperty(exports, globalThis.Symbol.toStringTag,',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async function build({ packageName, format, pkg, packagesDir }) {
|
async function build({ packageName, format, pkg, packagesDir }) {
|
||||||
await buildVite({
|
await buildVite({
|
||||||
root: path.resolve(packagesDir, `./${packageName}`),
|
root: path.resolve(packagesDir, `./${packageName}`),
|
||||||
@ -69,6 +94,7 @@ async function build({ packageName, format, pkg, packagesDir }) {
|
|||||||
},
|
},
|
||||||
|
|
||||||
rolldownOptions: {
|
rolldownOptions: {
|
||||||
|
plugins: [fixUmdSymbolShadow()],
|
||||||
// 确保外部化处理那些你不想打包进库的依赖
|
// 确保外部化处理那些你不想打包进库的依赖
|
||||||
external(id) {
|
external(id) {
|
||||||
if (format === 'umd' && id === 'lodash-es') {
|
if (format === 'umd' && id === 'lodash-es') {
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"baseUrl": ".",
|
|
||||||
"outDir": "temp",
|
"outDir": "temp",
|
||||||
"target": "ESNext",
|
"target": "ESNext",
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user