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)
|
||||
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@tmagic/eslint-config",
|
||||
"version": "0.0.4",
|
||||
"version": "0.1.0",
|
||||
"main": "index.mjs",
|
||||
"type": "module",
|
||||
"repository": {
|
||||
@ -10,17 +10,17 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@typescript-eslint/parser": "^8.57.1",
|
||||
"@typescript-eslint/eslint-plugin": "^8.57.1",
|
||||
"@typescript-eslint/parser": "^8.58.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.58.0",
|
||||
"@stylistic/eslint-plugin": "^5.10.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"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",
|
||||
"vue-eslint-parser": "^10.3.0",
|
||||
"eslint-plugin-prettier": "^5.5.4",
|
||||
"vue-eslint-parser": "^10.4.0",
|
||||
"eslint-plugin-prettier": "^5.5.5",
|
||||
"globals": "^17.4.0",
|
||||
"typescript-eslint": "^8.57.1"
|
||||
"typescript-eslint": "^8.58.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"eslint": ">=10.0.0",
|
||||
|
||||
@ -16,6 +16,7 @@ export default defineConfig([
|
||||
'*/**/public/**/*',
|
||||
'*/**/types/**/*',
|
||||
'*/**/*.config.ts',
|
||||
'./tepm/**/*',
|
||||
'vite-env.d.ts',
|
||||
]),
|
||||
...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",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": "1.7.8-beta.3",
|
||||
"version": "1.7.10",
|
||||
"name": "@tmagic/cli",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/index.d.ts",
|
||||
|
||||
@ -2,8 +2,8 @@
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"moduleResolution": "Node",
|
||||
"module": "CommonJS",
|
||||
"moduleResolution": "node16",
|
||||
"module": "node16",
|
||||
"rootDir": "./src",
|
||||
"outDir": "./lib",
|
||||
"declaration": true,
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": "1.7.8-beta.3",
|
||||
"version": "1.7.10",
|
||||
"name": "@tmagic/core",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
|
||||
@ -297,7 +297,7 @@ class App extends EventEmitter {
|
||||
}
|
||||
|
||||
if (typeof dataSource[methodName] === 'function') {
|
||||
return await dataSource[methodName]();
|
||||
return await dataSource[methodName]({ params });
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (this.errorHandler) {
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": "1.7.8-beta.3",
|
||||
"version": "1.7.10",
|
||||
"name": "@tmagic/data-source",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
|
||||
@ -20,7 +20,7 @@ import EventEmitter from 'events';
|
||||
import { cloneDeep } from 'lodash-es';
|
||||
|
||||
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 { SimpleObservedData } from '@data-source/observed-data/SimpleObservedData';
|
||||
@ -51,6 +51,7 @@ export default class DataSource<T extends DataSourceSchema = DataSourceSchema> e
|
||||
super();
|
||||
|
||||
this.#id = options.schema.id;
|
||||
this.#type = options.schema.type;
|
||||
this.#schema = options.schema;
|
||||
|
||||
this.app = options.app;
|
||||
@ -58,6 +59,11 @@ export default class DataSource<T extends DataSourceSchema = DataSourceSchema> e
|
||||
this.setFields(options.schema.fields);
|
||||
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;
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
const ObservedDataClass = options.ObservedDataClass || SimpleObservedData;
|
||||
|
||||
@ -79,9 +79,9 @@ export default class HttpDataSource extends DataSource<HttpDataSourceSchema> {
|
||||
/** 请求函数 */
|
||||
#fetch?: RequestFunction;
|
||||
/** 请求前需要执行的函数队列 */
|
||||
#beforeRequest: ((...args: any[]) => any)[] = [];
|
||||
#beforeRequest: (Function | ((...args: any[]) => any))[] = [];
|
||||
/** 请求后需要执行的函数队列 */
|
||||
#afterRequest: ((...args: any[]) => any)[] = [];
|
||||
#afterRequest: (Function | ((...args: any[]) => any))[] = [];
|
||||
|
||||
#type = 'http';
|
||||
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": "1.7.8-beta.3",
|
||||
"version": "1.7.10",
|
||||
"name": "@tmagic/dep",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": "1.7.8-beta.3",
|
||||
"version": "1.7.10",
|
||||
"name": "@tmagic/design",
|
||||
"type": "module",
|
||||
"sideEffects": [
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": "1.7.8-beta.3",
|
||||
"version": "1.7.10",
|
||||
"name": "@tmagic/editor",
|
||||
"type": "module",
|
||||
"sideEffects": [
|
||||
|
||||
@ -207,6 +207,7 @@ const stageOptions: StageOptions = {
|
||||
renderType: props.renderType,
|
||||
guidesOptions: props.guidesOptions,
|
||||
disabledMultiSelect: props.disabledMultiSelect,
|
||||
beforeDblclick: props.beforeDblclick,
|
||||
};
|
||||
|
||||
stageOverlayService.set('stageOptions', stageOptions);
|
||||
|
||||
@ -88,7 +88,7 @@ const width = defineModel<number>('width', { default: 670 });
|
||||
const boxVisible = defineModel<boolean>('visible', { default: false });
|
||||
|
||||
const props = defineProps<{
|
||||
content: CodeBlockContent;
|
||||
content: Omit<CodeBlockContent, 'content'> & { content: string };
|
||||
disabled?: boolean;
|
||||
isDataSource?: boolean;
|
||||
dataSourceType?: string;
|
||||
|
||||
@ -46,13 +46,29 @@ const getFormConfig = (items: FormItemConfig[] = []) => [
|
||||
|
||||
const codeParamsConfig = computed(() =>
|
||||
getFormConfig(
|
||||
props.paramsConfig.map(({ name, text, extra, ...config }) => ({
|
||||
type: 'data-source-field-select',
|
||||
props.paramsConfig.map(({ name, text, extra, ...config }) => {
|
||||
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,
|
||||
text,
|
||||
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>;
|
||||
/** 用于自定义组件树与画布的右键菜单 */
|
||||
customContentMenu?: CustomContentMenuFunction;
|
||||
/** 画布双击前的钩子函数,返回 false 则阻止默认的双击行为 */
|
||||
beforeDblclick?: (event: MouseEvent) => Promise<boolean | void> | boolean | void;
|
||||
extendFormState?: (state: FormState) => Record<string, any> | Promise<Record<string, any>>;
|
||||
/** 页面顺序拖拽配置参数 */
|
||||
pageBarSortOptions?: PageBarSortOptions;
|
||||
|
||||
@ -1,6 +1,21 @@
|
||||
<template>
|
||||
<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
|
||||
:model-value="selectDataSourceId"
|
||||
clearable
|
||||
@ -92,6 +107,8 @@ const props = defineProps<{
|
||||
dataSourceFieldType?: DataSourceFieldType[];
|
||||
/** 是否可以编辑数据源,disable表示的是是否可以选择数据源 */
|
||||
notEditable?: boolean | FilterFunction;
|
||||
/** 指定数据源ID,限定只能选择该数据源的字段 */
|
||||
dataSourceId?: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
@ -106,7 +123,12 @@ const { dataSourceService, uiService } = useServices();
|
||||
const mForm = inject<FormState | undefined>('mForm');
|
||||
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 notEditable = computed(() => filterFunction(mForm, props.notEditable, props));
|
||||
@ -125,7 +147,13 @@ const selectFieldsId = ref<string[]>([]);
|
||||
watch(
|
||||
modelValue,
|
||||
(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;
|
||||
selectDataSourceId.value = dsId;
|
||||
selectFieldsId.value = fields;
|
||||
@ -140,7 +168,7 @@ watch(
|
||||
);
|
||||
|
||||
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 [];
|
||||
|
||||
@ -163,8 +191,13 @@ const dsChangeHandler = (v: string) => {
|
||||
};
|
||||
|
||||
const fieldChangeHandler = (v: string[] = []) => {
|
||||
if (props.dataSourceId) {
|
||||
modelValue.value = v;
|
||||
emit('change', v);
|
||||
} else {
|
||||
modelValue.value = [selectDataSourceId.value, ...v];
|
||||
emit('change', modelValue.value);
|
||||
}
|
||||
};
|
||||
|
||||
const onChangeHandler = (v: string[] = []) => {
|
||||
|
||||
@ -8,6 +8,7 @@
|
||||
:value="config.value"
|
||||
:checkStrictly="checkStrictly"
|
||||
:dataSourceFieldType="config.dataSourceFieldType"
|
||||
:dataSourceId="config.dataSourceId"
|
||||
@change="onChangeHandler"
|
||||
></FieldSelect>
|
||||
|
||||
|
||||
@ -52,12 +52,14 @@ import {
|
||||
type FormState,
|
||||
MCascader,
|
||||
} from '@tmagic/form';
|
||||
import { DATA_SOURCE_SET_DATA_METHOD_NAME } from '@tmagic/utils';
|
||||
|
||||
import CodeParams from '@editor/components/CodeParams.vue';
|
||||
import MIcon from '@editor/components/Icon.vue';
|
||||
import { useServices } from '@editor/hooks/use-services';
|
||||
import type { CodeParamStatement, EventBus } from '@editor/type';
|
||||
import { SideItemKey } from '@editor/type';
|
||||
import { getFieldType } from '@editor/utils';
|
||||
|
||||
defineOptions({
|
||||
name: 'MFieldsDataSourceMethodSelect',
|
||||
@ -92,6 +94,43 @@ const isCustomMethod = computed(() => {
|
||||
const getParamItemsConfig = ([dataSourceId, methodName]: [Id, string] = ['', '']): CodeParamStatement[] => {
|
||||
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
|
||||
?.find((item) => item.id === dataSourceId)
|
||||
?.methods?.find((item) => item.name === methodName)?.params;
|
||||
@ -108,12 +147,14 @@ const paramsConfig = ref<CodeParamStatement[]>(getParamItemsConfig(props.model[p
|
||||
|
||||
const methodsOptions = computed(
|
||||
() =>
|
||||
dataSources.value
|
||||
?.filter((ds) => ds.methods?.length || dataSourceService.getFormMethod(ds.type).length)
|
||||
?.map((ds) => ({
|
||||
dataSources.value?.map((ds) => ({
|
||||
label: ds.title || ds.id,
|
||||
value: ds.id,
|
||||
children: [
|
||||
{
|
||||
label: '设置数据',
|
||||
value: DATA_SOURCE_SET_DATA_METHOD_NAME,
|
||||
},
|
||||
...(dataSourceService?.getFormMethod(ds.type) || []),
|
||||
...(ds.methods || []).map((method) => ({
|
||||
label: method.name,
|
||||
|
||||
@ -42,7 +42,7 @@ const props = withDefaults(defineProps<FieldProps<DataSourceMethodsConfig>>(), {
|
||||
|
||||
const emit = defineEmits(['change']);
|
||||
|
||||
const codeConfig = ref<CodeBlockContent>();
|
||||
const codeConfig = ref<Omit<CodeBlockContent, 'content'> & { content: string }>();
|
||||
const codeBlockEditorRef = useTemplateRef<InstanceType<typeof CodeBlockEditor>>('codeBlockEditor');
|
||||
|
||||
let editIndex = -1;
|
||||
@ -72,10 +72,14 @@ const methodColumns: ColumnConfig[] = [
|
||||
{
|
||||
text: '编辑',
|
||||
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') {
|
||||
codeContent = codeContent.toString();
|
||||
if (method.content) {
|
||||
if (typeof method.content !== 'string') {
|
||||
codeContent = method.content.toString();
|
||||
} else {
|
||||
codeContent = method.content;
|
||||
}
|
||||
}
|
||||
|
||||
codeConfig.value = {
|
||||
|
||||
@ -26,7 +26,7 @@ import type { StyleSchema } from '@tmagic/schema';
|
||||
|
||||
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({
|
||||
name: 'MFieldsStyleSetter',
|
||||
@ -60,6 +60,10 @@ const list = [
|
||||
title: '边框与圆角',
|
||||
component: Border,
|
||||
},
|
||||
{
|
||||
title: '变形',
|
||||
component: Transform,
|
||||
},
|
||||
];
|
||||
|
||||
const collapseValue = shallowRef(
|
||||
|
||||
@ -39,7 +39,7 @@
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
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';
|
||||
|
||||
const direction = ref('');
|
||||
|
||||
@ -5,7 +5,7 @@
|
||||
<script lang="ts" setup>
|
||||
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 BackgroundPosition from '../components/BackgroundPosition.vue';
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
</template>
|
||||
|
||||
<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 Border from '../components/Border.vue';
|
||||
|
||||
@ -5,7 +5,7 @@
|
||||
<script lang="ts" setup>
|
||||
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 { AlignCenter, AlignLeft, AlignRight } from '../icons/text-align';
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
</template>
|
||||
|
||||
<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';
|
||||
|
||||
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 Position } from './Position.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';
|
||||
|
||||
export const useCodeBlockEdit = (codeBlockService: Services['codeBlockService']) => {
|
||||
const codeConfig = ref<CodeBlockContent>();
|
||||
const codeConfig = ref<Omit<CodeBlockContent, 'content'> & { content: string }>();
|
||||
const codeId = ref<string>();
|
||||
const codeBlockEditorRef = useTemplateRef<InstanceType<typeof CodeBlockEditor>>('codeBlockEditor');
|
||||
|
||||
@ -36,10 +36,14 @@ export const useCodeBlockEdit = (codeBlockService: Services['codeBlockService'])
|
||||
return;
|
||||
}
|
||||
|
||||
let codeContent = codeBlock.content;
|
||||
let codeContent = '';
|
||||
|
||||
if (typeof codeContent !== 'string') {
|
||||
codeContent = codeContent.toString();
|
||||
if (codeBlock.content) {
|
||||
if (typeof codeBlock.content !== 'string') {
|
||||
codeContent = codeBlock.content.toString();
|
||||
} else {
|
||||
codeContent = codeBlock.content;
|
||||
}
|
||||
}
|
||||
|
||||
codeConfig.value = {
|
||||
|
||||
@ -233,7 +233,7 @@ export const initServiceEvents = (
|
||||
((event: 'update:modelValue', value: MApp | null) => void),
|
||||
{ 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 stage = await getStage();
|
||||
@ -246,11 +246,11 @@ export const initServiceEvents = (
|
||||
return renderer.runtime.getApp?.();
|
||||
}
|
||||
|
||||
if (getTMagicAppPrimise) {
|
||||
return getTMagicAppPrimise;
|
||||
if (getTMagicAppPromise) {
|
||||
return getTMagicAppPromise;
|
||||
}
|
||||
|
||||
getTMagicAppPrimise = new Promise<TMagicCore | undefined>((resolve) => {
|
||||
getTMagicAppPromise = new Promise<TMagicCore | undefined>((resolve) => {
|
||||
// 设置 10s 超时
|
||||
const timeout = globalThis.setTimeout(() => {
|
||||
resolve(void 0);
|
||||
@ -264,7 +264,7 @@ export const initServiceEvents = (
|
||||
});
|
||||
});
|
||||
|
||||
return getTMagicAppPrimise;
|
||||
return getTMagicAppPromise;
|
||||
};
|
||||
|
||||
const updateStageNodes = (nodes: MComponent[]) => {
|
||||
@ -560,7 +560,7 @@ export const initServiceEvents = (
|
||||
depService.clear(nodes);
|
||||
};
|
||||
|
||||
// 由于历史记录变化是更新整个page,所以历史记录变化时,需要重新收集依赖
|
||||
// 历史记录变化时,需要重新收集依赖
|
||||
const historyChangeHandler = (page: MPage | MPageFragment) => {
|
||||
collectIdle([page], true).then(() => {
|
||||
updateStageNode(page);
|
||||
|
||||
@ -80,7 +80,7 @@ const props = withDefaults(
|
||||
let stage: StageCore | null = null;
|
||||
let runtime: Runtime | null = null;
|
||||
|
||||
const { editorService, uiService, keybindingService } = useServices();
|
||||
const { editorService, uiService, keybindingService, stageOverlayService } = useServices();
|
||||
|
||||
const stageLoading = computed(() => editorService.get('stageLoading'));
|
||||
|
||||
@ -97,6 +97,60 @@ const page = computed(() => editorService.get('page'));
|
||||
const zoom = computed(() => uiService.get('zoom'));
|
||||
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(() => {
|
||||
if (stage || !page.value) return;
|
||||
|
||||
@ -109,6 +163,40 @@ watchEffect(() => {
|
||||
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));
|
||||
|
||||
stage.mount(stageContainerEl.value);
|
||||
|
||||
@ -46,12 +46,7 @@ const style = computed(() => ({
|
||||
}));
|
||||
|
||||
watch(stage, (stage) => {
|
||||
if (stage) {
|
||||
stage.on('dblclick', async (event: MouseEvent) => {
|
||||
const el = (await stage.actionManager?.getElementFromPoint(event)) || null;
|
||||
stageOverlayService.openOverlay(el);
|
||||
});
|
||||
} else {
|
||||
if (!stage) {
|
||||
stageOverlayService.closeOverlay();
|
||||
}
|
||||
});
|
||||
|
||||
@ -17,23 +17,13 @@
|
||||
*/
|
||||
|
||||
import { reactive, toRaw } from 'vue';
|
||||
import { cloneDeep, get, isObject, mergeWith, uniq } from 'lodash-es';
|
||||
import type { Writable } from 'type-fest';
|
||||
import { cloneDeep, isObject, mergeWith, uniq } from 'lodash-es';
|
||||
|
||||
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 { isFixed } from '@tmagic/stage';
|
||||
import {
|
||||
calcValueByFontsize,
|
||||
getElById,
|
||||
getNodeInfo,
|
||||
getNodePath,
|
||||
isNumber,
|
||||
isPage,
|
||||
isPageFragment,
|
||||
isPop,
|
||||
} from '@tmagic/utils';
|
||||
import { getNodeInfo, getNodePath, isPage, isPageFragment } from '@tmagic/utils';
|
||||
|
||||
import BaseService from '@editor/services//BaseService';
|
||||
import propsService from '@editor/services//props';
|
||||
@ -42,69 +32,39 @@ import storageService, { Protocol } from '@editor/services/storage';
|
||||
import type {
|
||||
AddMNode,
|
||||
AsyncHookPlugin,
|
||||
AsyncMethodName,
|
||||
EditorEvents,
|
||||
EditorNodeInfo,
|
||||
HistoryOpType,
|
||||
PastePosition,
|
||||
StepValue,
|
||||
StoreState,
|
||||
StoreStateKey,
|
||||
} from '@editor/type';
|
||||
import { LayerOffset, Layout } from '@editor/type';
|
||||
import { canUsePluginMethods, LayerOffset, Layout } from '@editor/type';
|
||||
import {
|
||||
change2Fixed,
|
||||
calcAlignCenterStyle,
|
||||
calcLayerTargetIndex,
|
||||
calcMoveStyle,
|
||||
classifyDragSources,
|
||||
collectRelatedNodes,
|
||||
COPY_STORAGE_KEY,
|
||||
Fixed2Other,
|
||||
editorNodeMergeCustomizer,
|
||||
fixNodePosition,
|
||||
getInitPositionStyle,
|
||||
getNodeIndex,
|
||||
getPageFragmentList,
|
||||
getPageList,
|
||||
moveItemsInContainer,
|
||||
resolveSelectedNode,
|
||||
setChildrenLayout,
|
||||
setLayout,
|
||||
toggleFixedPosition,
|
||||
} 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';
|
||||
|
||||
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 {
|
||||
public state: StoreState = reactive({
|
||||
root: null,
|
||||
@ -121,6 +81,7 @@ class Editor extends BaseService {
|
||||
disabledMultiSelect: false,
|
||||
});
|
||||
private isHistoryStateChange = false;
|
||||
private selectionBeforeOp: Id[] | null = null;
|
||||
|
||||
constructor() {
|
||||
super(
|
||||
@ -390,6 +351,8 @@ class Editor extends BaseService {
|
||||
* @returns 添加后的节点
|
||||
*/
|
||||
public async add(addNode: AddMNode | MNode[], parent?: MContainer | null): Promise<MNode | MNode[]> {
|
||||
this.captureSelectionBeforeOp();
|
||||
|
||||
const stage = this.get('stage');
|
||||
|
||||
// 新增多个组件只存在于粘贴多个组件,粘贴的是一个完整的config,所以不再需要getPropsValue
|
||||
@ -435,7 +398,21 @@ class Editor extends BaseService {
|
||||
}
|
||||
|
||||
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);
|
||||
@ -498,13 +475,33 @@ class Editor extends BaseService {
|
||||
* @param {Object} node
|
||||
*/
|
||||
public async remove(nodeOrNodeList: MNode | MNode[]): Promise<void> {
|
||||
this.captureSelectionBeforeOp();
|
||||
|
||||
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)));
|
||||
|
||||
if (!(isPage(nodes[0]) || isPageFragment(nodes[0]))) {
|
||||
// 更新历史记录
|
||||
this.pushHistoryState();
|
||||
if (removedItems.length > 0 && pageForOp) {
|
||||
this.pushOpHistory('remove', { removedItems }, pageForOp);
|
||||
}
|
||||
|
||||
this.emit('remove', nodes);
|
||||
@ -525,21 +522,9 @@ class Editor extends BaseService {
|
||||
|
||||
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) => {
|
||||
if (typeof srcValue === 'undefined' && Object.hasOwn(source, key)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (isObject(srcValue) && Array.isArray(objValue)) {
|
||||
// 原来的配置是数组,新的配置是对象,则直接使用新的值
|
||||
return srcValue;
|
||||
}
|
||||
if (Array.isArray(srcValue)) {
|
||||
return srcValue;
|
||||
}
|
||||
});
|
||||
newConfig = mergeWith(cloneDeep(node), newConfig, editorNodeMergeCustomizer);
|
||||
|
||||
if (!newConfig.type) throw new Error('配置缺少type值');
|
||||
|
||||
@ -597,12 +582,28 @@ class Editor extends BaseService {
|
||||
config: MNode | MNode[],
|
||||
data: { changeRecords?: ChangeRecord[] } = {},
|
||||
): Promise<MNode | MNode[]> {
|
||||
this.captureSelectionBeforeOp();
|
||||
|
||||
const nodes = Array.isArray(config) ? config : [config];
|
||||
|
||||
const updateData = await Promise.all(nodes.map((node) => this.doUpdate(node, data)));
|
||||
|
||||
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);
|
||||
@ -616,6 +617,8 @@ class Editor extends BaseService {
|
||||
* @returns void
|
||||
*/
|
||||
public async sort(id1: Id, id2: Id): Promise<void> {
|
||||
this.captureSelectionBeforeOp();
|
||||
|
||||
const root = this.get('root');
|
||||
if (!root) throw new Error('root为空');
|
||||
|
||||
@ -640,9 +643,6 @@ class Editor extends BaseService {
|
||||
parentId: parent.id,
|
||||
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 {
|
||||
const copyNodes: MNode[] = Array.isArray(config) ? config : [config];
|
||||
|
||||
// 初始化复制组件相关的依赖收集器
|
||||
if (collectorOptions && typeof collectorOptions.isTarget === 'function') {
|
||||
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 = 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);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
collectRelatedNodes(copyNodes, collectorOptions, (id) => this.getNodeById(id));
|
||||
}
|
||||
|
||||
storageService.setItem(COPY_STORAGE_KEY, copyNodes, {
|
||||
@ -733,32 +710,16 @@ class Editor extends BaseService {
|
||||
|
||||
public async doAlignCenter(config: MNode): Promise<MNode> {
|
||||
const parent = this.getParentById(config.id);
|
||||
|
||||
if (!parent) throw new Error('找不到父节点');
|
||||
|
||||
const node = cloneDeep(toRaw(config));
|
||||
const layout = await this.getLayout(parent, node);
|
||||
if (layout === Layout.RELATIVE) {
|
||||
return config;
|
||||
}
|
||||
const doc = this.get('stage')?.renderer?.contentWindow?.document;
|
||||
const newStyle = calcAlignCenterStyle(node, parent, layout, doc);
|
||||
|
||||
if (!node.style) 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 = '';
|
||||
}
|
||||
if (!newStyle) return config;
|
||||
|
||||
node.style = newStyle;
|
||||
return node;
|
||||
}
|
||||
|
||||
@ -789,6 +750,8 @@ class Editor extends BaseService {
|
||||
* @param offset 偏移量
|
||||
*/
|
||||
public async moveLayer(offset: number | LayerOffset): Promise<void> {
|
||||
this.captureSelectionBeforeOp();
|
||||
|
||||
const root = this.get('root');
|
||||
if (!root) throw new Error('root为空');
|
||||
|
||||
@ -801,22 +764,16 @@ class Editor extends BaseService {
|
||||
const brothers: MNode[] = parent.items || [];
|
||||
const index = brothers.findIndex((item) => `${item.id}` === `${node?.id}`);
|
||||
|
||||
// 流式布局与绝对定位布局操作的相反的
|
||||
const layout = await this.getLayout(parent, node);
|
||||
const isRelative = layout === Layout.RELATIVE;
|
||||
|
||||
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);
|
||||
}
|
||||
const offsetIndex = calcLayerTargetIndex(index, offset, brothers.length, isRelative);
|
||||
|
||||
if ((offsetIndex > 0 && offsetIndex > brothers.length) || offsetIndex < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const oldParent = cloneDeep(toRaw(parent));
|
||||
|
||||
brothers.splice(index, 1);
|
||||
brothers.splice(offsetIndex, 0, node);
|
||||
|
||||
@ -829,7 +786,14 @@ class Editor extends BaseService {
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
@ -840,12 +804,17 @@ class Editor extends BaseService {
|
||||
* @param targetId 容器ID
|
||||
*/
|
||||
public async moveToContainer(config: MNode, targetId: Id): Promise<MNode | undefined> {
|
||||
this.captureSelectionBeforeOp();
|
||||
|
||||
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 stage = this.get('stage');
|
||||
if (root && node && parent && stage) {
|
||||
const oldSourceParent = cloneDeep(toRaw(parent));
|
||||
const oldTarget = cloneDeep(toRaw(target));
|
||||
|
||||
const index = getNodeIndex(node.id, parent);
|
||||
parent.items?.splice(index, 1);
|
||||
|
||||
@ -876,62 +845,60 @@ class Editor extends BaseService {
|
||||
|
||||
this.addModifiedNodeId(target.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;
|
||||
}
|
||||
}
|
||||
|
||||
public async dragTo(config: MNode | MNode[], targetParent: MContainer, targetIndex: number) {
|
||||
this.captureSelectionBeforeOp();
|
||||
|
||||
if (!targetParent || !Array.isArray(targetParent.items)) return;
|
||||
|
||||
const configs = Array.isArray(config) ? config : [config];
|
||||
|
||||
const sourceIndicesInTargetParent: number[] = [];
|
||||
const sourceOutTargetParent: MNode[] = [];
|
||||
const beforeSnapshots = new Map<string, MNode>();
|
||||
for (const cfg of configs) {
|
||||
const { parent } = this.getNodeInfo(cfg.id, false);
|
||||
if (parent && !beforeSnapshots.has(`${parent.id}`)) {
|
||||
beforeSnapshots.set(`${parent.id}`, cloneDeep(toRaw(parent)));
|
||||
}
|
||||
}
|
||||
if (!beforeSnapshots.has(`${targetParent.id}`)) {
|
||||
beforeSnapshots.set(`${targetParent.id}`, cloneDeep(toRaw(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
|
||||
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 {
|
||||
for (const { config: crossConfig, parent } of crossParentConfigs) {
|
||||
const layout = await this.getLayout(parent);
|
||||
|
||||
if (newLayout !== layout) {
|
||||
setLayout(config, newLayout);
|
||||
setLayout(crossConfig, newLayout);
|
||||
}
|
||||
|
||||
const index = getNodeIndex(crossConfig.id, parent);
|
||||
parent.items?.splice(index, 1);
|
||||
sourceOutTargetParent.push(config);
|
||||
this.addModifiedNodeId(parent.id);
|
||||
}
|
||||
}
|
||||
|
||||
moveItemsInContainer(sourceIndicesInTargetParent, targetParent, targetIndex);
|
||||
moveItemsInContainer(sameParentIndices, targetParent, targetIndex);
|
||||
|
||||
sourceOutTargetParent.forEach((config, index) => {
|
||||
targetParent.items?.splice(targetIndex + index, 0, config);
|
||||
this.addModifiedNodeId(config.id);
|
||||
crossParentConfigs.forEach(({ config: crossConfig }, index) => {
|
||||
targetParent.items?.splice(targetIndex + index, 0, crossConfig);
|
||||
this.addModifiedNodeId(crossConfig.id);
|
||||
});
|
||||
|
||||
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 });
|
||||
}
|
||||
|
||||
/**
|
||||
* 撤销当前操作
|
||||
* @returns 上一次数据
|
||||
* @returns 被撤销的操作
|
||||
*/
|
||||
public async undo(): Promise<StepValue | null> {
|
||||
const value = historyService.undo();
|
||||
await this.changeHistoryState(value);
|
||||
if (value) {
|
||||
await this.applyHistoryOp(value, true);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 恢复到下一步
|
||||
* @returns 下一步数据
|
||||
* @returns 被恢复的操作
|
||||
*/
|
||||
public async redo(): Promise<StepValue | null> {
|
||||
const value = historyService.redo();
|
||||
await this.changeHistoryState(value);
|
||||
if (value) {
|
||||
await this.applyHistoryOp(value, false);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
@ -975,47 +954,10 @@ class Editor extends BaseService {
|
||||
const node = toRaw(this.get('node'));
|
||||
if (!node || isPage(node)) return;
|
||||
|
||||
const { style, id, type } = node;
|
||||
if (!style || !['absolute', 'fixed'].includes(style.position)) return;
|
||||
const newStyle = calcMoveStyle(node.style || {}, left, top);
|
||||
if (!newStyle) return;
|
||||
|
||||
const update = (style: { [key: string]: any }) =>
|
||||
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: '',
|
||||
});
|
||||
}
|
||||
}
|
||||
await this.update({ id: node.id, type: node.type, style: newStyle });
|
||||
}
|
||||
|
||||
public resetState() {
|
||||
@ -1068,70 +1010,89 @@ class Editor extends BaseService {
|
||||
}
|
||||
}
|
||||
|
||||
private pushHistoryState() {
|
||||
const curNode = cloneDeep(toRaw(this.get('node')));
|
||||
const page = this.get('page');
|
||||
if (!this.isHistoryStateChange && curNode && page) {
|
||||
historyService.push({
|
||||
data: cloneDeep(toRaw(page)),
|
||||
modifiedNodeIds: this.get('modifiedNodeIds'),
|
||||
nodeId: curNode.id,
|
||||
});
|
||||
private captureSelectionBeforeOp() {
|
||||
if (this.isHistoryStateChange || this.selectionBeforeOp) return;
|
||||
this.selectionBeforeOp = this.get('nodes').map((n) => n.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;
|
||||
}
|
||||
|
||||
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;
|
||||
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(() => {
|
||||
if (!value.nodeId) return;
|
||||
this.select(value.nodeId).then(() => {
|
||||
this.get('stage')?.select(value.nodeId);
|
||||
});
|
||||
if (!selectIds.length) return;
|
||||
|
||||
if (selectIds.length > 1) {
|
||||
this.multiSelect(selectIds);
|
||||
stage?.multiSelect(selectIds);
|
||||
} else {
|
||||
this.select(selectIds[0])
|
||||
.then(() => stage?.select(selectIds[0]))
|
||||
.catch(() => {});
|
||||
}
|
||||
}, 0);
|
||||
this.emit('history-change', value.data);
|
||||
this.emit('history-change', page as MPage | MPageFragment);
|
||||
}
|
||||
|
||||
private async toggleFixedPosition(dist: MNode, src: MNode, root: MApp) {
|
||||
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;
|
||||
this.isHistoryStateChange = false;
|
||||
}
|
||||
|
||||
private selectedConfigExceptionHandler(config: MNode | Id): EditorNodeInfo {
|
||||
let id: 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,
|
||||
};
|
||||
return resolveSelectedNode(config, (id) => this.getNodeInfo(id), this.state.root?.id);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -56,15 +56,7 @@ class History extends BaseService {
|
||||
this.state.pageId = page.id;
|
||||
|
||||
if (!this.state.pageSteps[this.state.pageId]) {
|
||||
const undoRedo = new UndoRedo<StepValue>();
|
||||
|
||||
undoRedo.pushElement({
|
||||
data: page,
|
||||
modifiedNodeIds: new Map(),
|
||||
nodeId: page.id,
|
||||
});
|
||||
|
||||
this.state.pageSteps[this.state.pageId] = undoRedo;
|
||||
this.state.pageSteps[this.state.pageId] = new UndoRedo<StepValue>();
|
||||
}
|
||||
|
||||
this.setCanUndoRedo();
|
||||
|
||||
@ -20,10 +20,10 @@ import type { Component } from 'vue';
|
||||
import type EventEmitter from 'events';
|
||||
import type * as Monaco from 'monaco-editor';
|
||||
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 { FormConfig, TableColumnConfig } from '@tmagic/form';
|
||||
import type { ChangeRecord, FormConfig, TableColumnConfig, TypeFunction } from '@tmagic/form';
|
||||
import type StageCore from '@tmagic/stage';
|
||||
import type {
|
||||
ContainerHighlightType,
|
||||
@ -164,6 +164,8 @@ export interface StageOptions {
|
||||
disabledMultiSelect?: boolean;
|
||||
disabledRule?: boolean;
|
||||
zoom?: number;
|
||||
/** 画布双击前的钩子函数,返回 false 则阻止默认的双击行为 */
|
||||
beforeDblclick?: (event: MouseEvent) => Promise<boolean | void> | boolean | void;
|
||||
}
|
||||
|
||||
export interface StoreState {
|
||||
@ -541,14 +543,31 @@ export interface CodeParamStatement {
|
||||
/** 参数名称 */
|
||||
name: string;
|
||||
/** 参数类型 */
|
||||
type?: string;
|
||||
type?: string | TypeFunction<string>;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export type HistoryOpType = 'add' | 'remove' | 'update';
|
||||
|
||||
export interface StepValue {
|
||||
data: MPage | MPageFragment;
|
||||
/** 页面信息 */
|
||||
data: { name: string; id: Id };
|
||||
opType: HistoryOpType;
|
||||
/** 操作前选中的节点 ID,用于撤销后恢复选择状态 */
|
||||
selectedBefore: Id[];
|
||||
/** 操作后选中的节点 ID,用于重做后恢复选择状态 */
|
||||
selectedAfter: 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 {
|
||||
@ -712,3 +731,44 @@ export type CustomContentMenuFunction = (
|
||||
menus: (MenuButton | MenuComponent)[],
|
||||
type: 'layer' | 'data-source' | 'viewer' | 'code-block',
|
||||
) => (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 { isObject } from 'lodash-es';
|
||||
import { cloneDeep, get, isObject } from 'lodash-es';
|
||||
import serialize from 'serialize-javascript';
|
||||
|
||||
import type { Id, MApp, MContainer, MNode, MPage, MPageFragment } from '@tmagic/core';
|
||||
import { NODE_CONDS_KEY, NodeType } from '@tmagic/core';
|
||||
import type { Id, MApp, MContainer, MNode, MPage, MPageFragment, TargetOptions } from '@tmagic/core';
|
||||
import { NODE_CONDS_KEY, NodeType, Target, Watcher } from '@tmagic/core';
|
||||
import type StageCore from '@tmagic/stage';
|
||||
import { isFixed } from '@tmagic/stage';
|
||||
import {
|
||||
calcValueByFontsize,
|
||||
getElById,
|
||||
@ -34,7 +35,8 @@ import {
|
||||
isValueIncludeDataSource,
|
||||
} 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_CODE_STORAGE_KEY = '$MagicEditorCopyCode';
|
||||
@ -436,3 +438,246 @@ export const buildChangeRecords = (value: any, basePath: string) => {
|
||||
|
||||
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',
|
||||
],
|
||||
} as unknown as ChildConfig,
|
||||
{
|
||||
name: 'transform',
|
||||
defaultValue: () => ({}),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@ -23,7 +23,7 @@ export class UndoRedo<T = any> {
|
||||
private listCursor: number;
|
||||
private listMaxSize: number;
|
||||
|
||||
constructor(listMaxSize = 20) {
|
||||
constructor(listMaxSize = 100) {
|
||||
const minListMaxSize = 2;
|
||||
this.elementList = [];
|
||||
this.listCursor = 0;
|
||||
@ -42,29 +42,30 @@ export class UndoRedo<T = any> {
|
||||
}
|
||||
|
||||
public canUndo(): boolean {
|
||||
return this.listCursor > 1;
|
||||
return this.listCursor > 0;
|
||||
}
|
||||
|
||||
// 返回undo后的当前元素
|
||||
/** 返回被撤销的操作 */
|
||||
public undo(): T | null {
|
||||
if (!this.canUndo()) {
|
||||
return null;
|
||||
}
|
||||
this.listCursor -= 1;
|
||||
return this.getCurrentElement();
|
||||
return cloneDeep(this.elementList[this.listCursor]);
|
||||
}
|
||||
|
||||
public canRedo() {
|
||||
return this.elementList.length > this.listCursor;
|
||||
}
|
||||
|
||||
// 返回redo后的当前元素
|
||||
/** 返回被重做的操作 */
|
||||
public redo(): T | null {
|
||||
if (!this.canRedo()) {
|
||||
return null;
|
||||
}
|
||||
const element = cloneDeep(this.elementList[this.listCursor]);
|
||||
this.listCursor += 1;
|
||||
return this.getCurrentElement();
|
||||
return element;
|
||||
}
|
||||
|
||||
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 type { MApp, MContainer, MNode } 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';
|
||||
|
||||
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', () => {
|
||||
let undoRedo: UndoRedo;
|
||||
const element = { a: 1 };
|
||||
|
||||
beforeEach(() => {
|
||||
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.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 });
|
||||
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', () => {
|
||||
let undoRedo: UndoRedo;
|
||||
const element = { a: 1 };
|
||||
|
||||
beforeEach(() => {
|
||||
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.redo()).toBe(null);
|
||||
});
|
||||
|
||||
test('can no redo: no undo', () => {
|
||||
test('can not redo: no undo', () => {
|
||||
for (let i = 0; i < 5; i++) {
|
||||
undoRedo.pushElement(element);
|
||||
undoRedo.pushElement({ a: i });
|
||||
expect(undoRedo.canRedo()).toBe(false);
|
||||
expect(undoRedo.redo()).toBe(null);
|
||||
}
|
||||
});
|
||||
|
||||
test('can no redo: undo and push', () => {
|
||||
undoRedo.pushElement(element);
|
||||
test('can not redo: undo and push', () => {
|
||||
undoRedo.pushElement({ a: 1 });
|
||||
undoRedo.pushElement({ a: 2 });
|
||||
undoRedo.undo();
|
||||
undoRedo.pushElement(element);
|
||||
undoRedo.pushElement({ a: 3 });
|
||||
expect(undoRedo.canRedo()).toBe(false);
|
||||
expect(undoRedo.redo()).toEqual(null);
|
||||
});
|
||||
|
||||
test('can no redo: redo end', () => {
|
||||
const element1 = { a: 1 };
|
||||
const element2 = { a: 2 };
|
||||
undoRedo.pushElement(element1);
|
||||
undoRedo.pushElement(element2);
|
||||
test('can not redo: redo end', () => {
|
||||
undoRedo.pushElement({ a: 1 });
|
||||
undoRedo.pushElement({ a: 2 });
|
||||
undoRedo.undo();
|
||||
undoRedo.undo();
|
||||
undoRedo.redo();
|
||||
@ -85,23 +91,20 @@ describe('redo', () => {
|
||||
});
|
||||
|
||||
test('can redo', () => {
|
||||
const element1 = { a: 1 };
|
||||
const element2 = { a: 2 };
|
||||
undoRedo.pushElement(element1);
|
||||
undoRedo.pushElement(element2);
|
||||
undoRedo.pushElement({ a: 1 });
|
||||
undoRedo.pushElement({ a: 2 });
|
||||
undoRedo.undo();
|
||||
undoRedo.undo();
|
||||
|
||||
expect(undoRedo.canRedo()).toBe(true);
|
||||
expect(undoRedo.redo()).toEqual(element1);
|
||||
expect(undoRedo.redo()).toEqual({ a: 1 });
|
||||
expect(undoRedo.canRedo()).toBe(true);
|
||||
expect(undoRedo.redo()).toEqual(element2);
|
||||
expect(undoRedo.redo()).toEqual({ a: 2 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('get current element', () => {
|
||||
let undoRedo: UndoRedo;
|
||||
const element = { a: 1 };
|
||||
|
||||
beforeEach(() => {
|
||||
undoRedo = new UndoRedo();
|
||||
@ -112,44 +115,38 @@ describe('get current element', () => {
|
||||
});
|
||||
|
||||
test('has element', () => {
|
||||
undoRedo.pushElement(element);
|
||||
expect(undoRedo.getCurrentElement()).toEqual(element);
|
||||
undoRedo.pushElement({ a: 1 });
|
||||
expect(undoRedo.getCurrentElement()).toEqual({ a: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('list max size', () => {
|
||||
let undoRedo: UndoRedo;
|
||||
const listMaxSize = 100;
|
||||
const element = { a: 1 };
|
||||
|
||||
beforeEach(() => {
|
||||
undoRedo = new UndoRedo(listMaxSize);
|
||||
undoRedo.pushElement(element);
|
||||
});
|
||||
|
||||
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: listMaxSize }); // 这个元素使得list达到maxSize,触发数据删除
|
||||
|
||||
expect(undoRedo.getCurrentElement()).toEqual({ a: listMaxSize });
|
||||
expect(undoRedo.canRedo()).toBe(false);
|
||||
expect(undoRedo.canUndo()).toBe(true);
|
||||
});
|
||||
|
||||
test('reach max size, then undo', () => {
|
||||
for (let i = 0; i < listMaxSize + 1; i++) {
|
||||
test('reach max size, then undo all', () => {
|
||||
for (let i = 0; i <= listMaxSize; i++) {
|
||||
undoRedo.pushElement({ a: i });
|
||||
}
|
||||
for (let i = 0; i < listMaxSize - 1; i++) {
|
||||
for (let i = 0; i < listMaxSize; i++) {
|
||||
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.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",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": "1.7.8-beta.3",
|
||||
"version": "1.7.10",
|
||||
"name": "@tmagic/form-schema",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
|
||||
@ -348,7 +348,7 @@ export interface HtmlField extends FormItem {
|
||||
export interface DisplayConfig extends FormItem {
|
||||
type: 'display';
|
||||
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 {
|
||||
type: 'component';
|
||||
id: string;
|
||||
extend: any;
|
||||
display: any;
|
||||
component: any;
|
||||
extend?: any;
|
||||
display?: any;
|
||||
component?: any;
|
||||
}
|
||||
|
||||
export interface FlexLayoutConfig<T = never> extends FormItem, ContainerCommonConfig<T> {
|
||||
|
||||
@ -29,6 +29,8 @@ export interface DataSourceFieldSelectConfig<T = never> extends FormItem {
|
||||
fieldConfig?: FormItemConfig<T>;
|
||||
/** 是否可以编辑数据源,disable表示的是是否可以选择数据源 */
|
||||
notEditable?: boolean | FilterFunction;
|
||||
|
||||
dataSourceId?: string;
|
||||
}
|
||||
|
||||
export interface CodeConfig extends FormItem {
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": "1.7.8-beta.3",
|
||||
"version": "1.7.10",
|
||||
"name": "@tmagic/form",
|
||||
"type": "module",
|
||||
"sideEffects": [
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<TMagicCol v-show="display && 'type' in config && config.type !== 'hidden'" :span="span">
|
||||
<TMagicCol v-show="display && type !== 'hidden'" :span="span">
|
||||
<Container
|
||||
:model="model"
|
||||
:lastValues="lastValues"
|
||||
@ -52,4 +52,6 @@ const mForm = inject<FormState | undefined>('mForm');
|
||||
const display = computed(() => displayFunction(mForm, props.config.display, props));
|
||||
const changeHandler = (v: any, eventData: ContainerChangeEventData) => emit('change', v, eventData);
|
||||
const onAddDiffCount = () => emit('addDiffCount');
|
||||
|
||||
const type = computed(() => (props.config as any).type);
|
||||
</script>
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": "1.7.8-beta.3",
|
||||
"version": "1.7.10",
|
||||
"name": "@tmagic/schema",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
|
||||
@ -186,7 +186,7 @@ export interface CodeBlockContent {
|
||||
/** 代码块名称 */
|
||||
name: string;
|
||||
/** 代码块内容 */
|
||||
content: ((...args: any[]) => any) | string;
|
||||
content: ((...args: any[]) => any) | Function;
|
||||
/** 参数定义 */
|
||||
params: CodeParam[] | [];
|
||||
/** 注释 */
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": "1.7.8-beta.3",
|
||||
"version": "1.7.10",
|
||||
"name": "@tmagic/stage",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
@ -31,6 +31,7 @@
|
||||
"dependencies": {
|
||||
"@scena/guides": "^0.29.2",
|
||||
"events": "^3.3.0",
|
||||
"@zumer/snapdom": "^2.8.0",
|
||||
"keycon": "^1.4.0",
|
||||
"lodash-es": "^4.17.21",
|
||||
"moveable": "^0.53.0",
|
||||
|
||||
@ -236,6 +236,30 @@ export default class ActionManager extends EventEmitter {
|
||||
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 被判断的元素
|
||||
|
||||
@ -124,6 +124,7 @@ export default class Rule extends EventEmitter {
|
||||
this.hGuides?.off('changeGuides', this.hGuidesChangeGuidesHandler);
|
||||
this.vGuides?.off('changeGuides', this.vGuidesChangeGuidesHandler);
|
||||
this.containerResizeObserver?.disconnect();
|
||||
this.container = undefined;
|
||||
this.removeAllListeners();
|
||||
}
|
||||
|
||||
@ -137,7 +138,6 @@ export default class Rule extends EventEmitter {
|
||||
|
||||
this.hGuides = undefined;
|
||||
this.vGuides = undefined;
|
||||
this.container = undefined;
|
||||
}
|
||||
|
||||
private getGuidesStyle = (type: GuidesType) => ({
|
||||
|
||||
@ -18,6 +18,7 @@
|
||||
|
||||
import { EventEmitter } from 'events';
|
||||
|
||||
import { SnapdomOptions } from '@zumer/snapdom';
|
||||
import type { MoveableOptions, OnDragStart } from 'moveable';
|
||||
|
||||
import type { Id } from '@tmagic/core';
|
||||
@ -88,7 +89,38 @@ export default class StageCore extends EventEmitter {
|
||||
* @param id 选中的id
|
||||
*/
|
||||
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;
|
||||
|
||||
await this.renderer?.select([id]);
|
||||
@ -241,6 +273,21 @@ export default class StageCore extends EventEmitter {
|
||||
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 { snapdom, SnapdomOptions } from '@zumer/snapdom';
|
||||
|
||||
import type { Id } 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将指定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() {
|
||||
this.contentWindow = this.iframe?.contentWindow as RuntimeWindow;
|
||||
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": "1.7.8-beta.3",
|
||||
"version": "1.7.10",
|
||||
"name": "@tmagic/table",
|
||||
"type": "module",
|
||||
"sideEffects": [
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": "1.7.8-beta.3",
|
||||
"version": "1.7.10",
|
||||
"name": "@tmagic/tdesign-vue-next-adapter",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": "1.7.8-beta.3",
|
||||
"version": "1.7.10",
|
||||
"name": "@tmagic/utils",
|
||||
"type": "module",
|
||||
"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 { DATA_SOURCE_FIELDS_SELECT_VALUE_PREFIX } from './const';
|
||||
|
||||
export * from './dom';
|
||||
|
||||
export * from './const';
|
||||
|
||||
// for typeof global checks without @types/node
|
||||
declare let global: {};
|
||||
|
||||
@ -542,10 +546,6 @@ export const getDefaultValueFromFields = (fields: DataSchema[]) => {
|
||||
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 calculatePercentage = (value: number, percentageStr: string) => {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "tmagic-playground",
|
||||
"version": "1.7.8-beta.3",
|
||||
"version": "1.7.10",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
@ -12,11 +12,11 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@element-plus/icons-vue": "^2.3.2",
|
||||
"@tmagic/core": "1.7.8-beta.3",
|
||||
"@tmagic/design": "1.7.8-beta.3",
|
||||
"@tmagic/editor": "1.7.8-beta.3",
|
||||
"@tmagic/element-plus-adapter": "1.7.8-beta.3",
|
||||
"@tmagic/tdesign-vue-next-adapter": "1.7.8-beta.3",
|
||||
"@tmagic/core": "1.7.10",
|
||||
"@tmagic/design": "1.7.10",
|
||||
"@tmagic/editor": "1.7.10",
|
||||
"@tmagic/element-plus-adapter": "1.7.10",
|
||||
"@tmagic/tdesign-vue-next-adapter": "1.7.10",
|
||||
"@tmagic/tmagic-form-runtime": "1.1.3",
|
||||
"element-plus": "^2.11.8",
|
||||
"lodash-es": "^4.17.21",
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"baseUrl": "..",
|
||||
},
|
||||
"exclude": [
|
||||
"**/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/*'
|
||||
- 'playground'
|
||||
- 'runtime/*'
|
||||
- 'vue-components/*'
|
||||
- 'react-components/*'
|
||||
- 'eslint-config'
|
||||
- "packages/*"
|
||||
- "playground"
|
||||
- "runtime/*"
|
||||
- "vue-components/*"
|
||||
- "react-components/*"
|
||||
- "eslint-config"
|
||||
|
||||
catalog:
|
||||
vue: ^3.5.24
|
||||
'@vue/compiler-sfc': ^3.5.24
|
||||
vite: ^8.0.0
|
||||
typescript: "^5.9.3"
|
||||
|
||||
"@vue/compiler-sfc": ^3.5.24
|
||||
vite: ^8.0.8
|
||||
typescript: "^6.0.2"
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { cloneDeep } from 'lodash-es';
|
||||
|
||||
import type { ChangeEvent, MNode } from '@tmagic/core';
|
||||
import type TMagicApp from '@tmagic/core';
|
||||
import type { ChangeEvent, MNode } from '@tmagic/core';
|
||||
import { isPage, replaceChildNode } from '@tmagic/core';
|
||||
|
||||
export const useDsl = (app: TMagicApp | undefined) => {
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { cloneDeep } from 'lodash-es';
|
||||
|
||||
import type { Id, MApp } from '@tmagic/core';
|
||||
import type TMagicApp from '@tmagic/core';
|
||||
import type { Id, MApp } from '@tmagic/core';
|
||||
import { getElById, replaceChildNode } from '@tmagic/core';
|
||||
import type { Magic, RemoveData, SortEventData, UpdateData } from '@tmagic/stage';
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "runtime-react",
|
||||
"version": "1.7.8-beta.3",
|
||||
"version": "1.7.10",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"engines": {
|
||||
@ -16,16 +16,16 @@
|
||||
"build:playground": "node scripts/build.mjs --type=playground"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tmagic/core": "1.7.8-beta.3",
|
||||
"@tmagic/core": "1.7.10",
|
||||
"@tmagic/react-runtime-help": "0.2.2",
|
||||
"@tmagic/stage": "1.7.8-beta.3",
|
||||
"@tmagic/stage": "1.7.10",
|
||||
"axios": "^1.13.2",
|
||||
"qrcode": "^1.5.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tmagic/cli": "1.7.8-beta.3",
|
||||
"@tmagic/cli": "1.7.10",
|
||||
"@types/fs-extra": "^11.0.4",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"baseUrl": "./",
|
||||
"jsx": "react",
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"types": ["node"],
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "runtime-vue",
|
||||
"version": "1.7.8-beta.3",
|
||||
"version": "1.7.10",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"engines": {
|
||||
@ -16,14 +16,14 @@
|
||||
"build:playground": "node scripts/build.mjs --type=playground"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tmagic/core": "1.7.8-beta.3",
|
||||
"@tmagic/stage": "1.7.8-beta.3",
|
||||
"@tmagic/core": "1.7.10",
|
||||
"@tmagic/stage": "1.7.10",
|
||||
"@tmagic/vue-runtime-help": "^2.0.0",
|
||||
"axios": "^1.13.2",
|
||||
"vue": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tmagic/cli": "1.7.8-beta.3",
|
||||
"@tmagic/cli": "1.7.10",
|
||||
"@types/fs-extra": "^11.0.4",
|
||||
"@types/node": "^24.0.10",
|
||||
"@vitejs/plugin-legacy": "^8.0.0",
|
||||
|
||||
@ -20,9 +20,10 @@ if (args.package) {
|
||||
const pkgRoot = path.resolve(packagesDir, args.package);
|
||||
if (fs.statSync(pkgRoot).isDirectory()) {
|
||||
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: 'umd' });
|
||||
build({ packageName: args.package, format: 'es', pkg, packagesDir });
|
||||
build({ packageName: args.package, format: 'umd', pkg, packagesDir });
|
||||
}
|
||||
} else {
|
||||
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 }) {
|
||||
await buildVite({
|
||||
root: path.resolve(packagesDir, `./${packageName}`),
|
||||
@ -69,6 +94,7 @@ async function build({ packageName, format, pkg, packagesDir }) {
|
||||
},
|
||||
|
||||
rolldownOptions: {
|
||||
plugins: [fixUmdSymbolShadow()],
|
||||
// 确保外部化处理那些你不想打包进库的依赖
|
||||
external(id) {
|
||||
if (format === 'umd' && id === 'lodash-es') {
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"outDir": "temp",
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user