diff --git a/.eslintrc.js b/.eslintrc.js index d43a7b389d..972882ce73 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -1,5 +1,9 @@ module.exports = { extends: 'eslint-config-ali/typescript/react', + parserOptions: { + project: [], // for lint performance + createDefaultProgram: false, // for lint performance + }, rules: { 'react/no-multi-comp': 0, 'no-unused-expressions': 0, @@ -11,12 +15,11 @@ module.exports = { 'no-prototype-builtins': 1, 'no-useless-constructor': 1, 'no-empty-function': 1, - '@typescript-eslint/member-ordering': 0, 'lines-between-class-members': 0, 'no-await-in-loop': 0, 'no-plusplus': 0, '@typescript-eslint/no-parameter-properties': 0, - '@typescript-eslint/no-unused-vars': 1, + 'no-restricted-exports': ['error'], 'no-multi-assign': 1, 'no-dupe-class-members': 1, 'react/no-deprecated': 1, @@ -31,8 +34,26 @@ module.exports = { '@typescript-eslint/indent': 0, 'import/no-cycle': 0, '@typescript-eslint/no-shadow': 0, - "@typescript-eslint/method-signature-style": 0, - "@typescript-eslint/consistent-type-assertions": 0, - "@typescript-eslint/no-useless-constructor": 0, - } + '@typescript-eslint/method-signature-style': 0, + '@typescript-eslint/consistent-type-assertions': 0, + '@typescript-eslint/no-useless-constructor': 0, + '@typescript-eslint/dot-notation': 0, // for lint performance + '@typescript-eslint/restrict-plus-operands': 0, // for lint performance + 'no-unexpected-multiline': 1, + 'no-multiple-empty-lines': ['error', { max: 1 }], + 'lines-around-comment': ['error', { + beforeBlockComment: true, + afterBlockComment: false, + afterLineComment: false, + allowBlockStart: true, + }], + 'comma-dangle': ['error', 'always-multiline'], + '@typescript-eslint/member-ordering': [ + 'error', + { default: ['signature', 'field', 'constructor', 'method'] } + ], + '@typescript-eslint/no-unused-vars': ['error'], + 'no-redeclare': 0, + '@typescript-eslint/no-redeclare': 1, + }, }; diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index bc889c3079..670db7dfbd 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -2,13 +2,7 @@ # These owners will be the default owners for everything in # the repo. Unless a later match takes precedence -* @leoyuan @JackLian +* @liujuping @1ncounter /modules/material-parser @akirakai -/modules/code-generator @Clarence-pan - -/packages/renderer-core/ @liujuping -/packages/react-renderer/ @liujuping -/packages/react-simulator-renderer/ @liujuping -/packages/rax-renderer/ @liujuping -/packages/rax-simulator-renderer/ @liujuping \ No newline at end of file +/modules/code-generator @qingniaotonghua diff --git a/.github/ISSUE_TEMPLATE/bug-report.md b/.github/ISSUE_TEMPLATE/bug-report.md index f2292f413e..c72471ee28 100644 --- a/.github/ISSUE_TEMPLATE/bug-report.md +++ b/.github/ISSUE_TEMPLATE/bug-report.md @@ -1,6 +1,6 @@ --- name: Bug report / 提交 bug -about: Create a report to help us improve / 提交一个好的 bug 帮助我们优化引擎 +about: Create a report to help us improve / 提交一个好的 issue 帮助我们优化引擎,[引擎的 issue 说明](https://lowcode-engine.cn/site/community/issue) title: '' labels: '' assignees: '' diff --git a/.github/workflows/check base branch.yml b/.github/workflows/check base branch.yml new file mode 100644 index 0000000000..cef996c75a --- /dev/null +++ b/.github/workflows/check base branch.yml @@ -0,0 +1,33 @@ +name: Check Base Branch + +on: + pull_request: + types: [opened] + +jobs: + code-review: + name: Check + runs-on: ubuntu-latest + + steps: + # 判断用户是否有写仓库权限 + - name: 'Check User Permission' + uses: 'lannonbr/repo-permission-check-action@2.0.0' + with: + permission: 'write' + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: 'Check base branch name is develop or not' + if: github.event.pull_request.base.ref != 'develop' # check the target branch if it's master + uses: actions-cool/issues-helper@v2 + with: + actions: 'create-comment' + token: ${{ secrets.GITHUB_TOKEN }} + issue-number: ${{ github.event.pull_request.number }} + body: | + 感谢你的 PR,根据引擎的 [研发协作流程](https://lowcode-engine.cn/site/docs/participate/flow),请将目标合入分支设置为 **develop**。 + + Thanks in advance, according to the [Contribution Guideline](https://lowcode-engine.cn/site/docs/participate/flow), please set the base branch to **develop**. + + @${{ github.event.pull_request.user.login }} \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000000..4636a1bdd1 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,116 @@ +name: Node CI + +on: + push: + branches: + - main + pull_request: + branches: + - main + +jobs: + upload-designer-codecov: + runs-on: ubuntu-latest + # if: ${{ github.event.pull_request.head.repo.full_name == 'alibaba/lowcode-engine' }} + steps: + - name: checkout + uses: actions/checkout@v2 + + - uses: actions/setup-node@v2 + with: + node-version: '14' + + - name: install + run: npm i && npm run setup:skip-build + + - name: test designer + run: cd packages/designer && npm run test:cov && cd ../.. + + - name: Upload designer coverage to Codecov + uses: codecov/codecov-action@v3 + with: + # working-directory: packages/designer + directory: ./packages/designer/coverage + token: ${{ secrets.CODECOV_TOKEN }} + name: designer + fail_ci_if_error: true + verbose: true + + upload-renderer-core: + runs-on: ubuntu-latest + steps: + - name: checkout + uses: actions/checkout@v2 + + - uses: actions/setup-node@v2 + with: + node-version: '14' + + - name: install + run: npm i && npm run setup:skip-build + + - name: test renderer-core + run: cd packages/renderer-core && npm run test:cov && cd ../.. + + - name: Upload renderer-core coverage to Codecov + uses: codecov/codecov-action@v3 + with: + # working-directory: packages/designer + directory: ./packages/renderer-core/coverage + token: ${{ secrets.CODECOV_TOKEN }} + name: renderer-core + fail_ci_if_error: true + verbose: true + + upload-react-simulator-renderer: + runs-on: ubuntu-latest + steps: + - name: checkout + uses: actions/checkout@v2 + + - uses: actions/setup-node@v2 + with: + node-version: '14' + + - name: install + run: npm i && npm run setup:skip-build + + - name: test react-simulator-renderer + run: cd packages/react-simulator-renderer && npm run test:cov && cd ../.. + + - name: Upload react-simulator-renderer coverage to Codecov + uses: codecov/codecov-action@v3 + with: + # working-directory: packages/designer + directory: ./packages/react-simulator-renderer/coverage + token: ${{ secrets.CODECOV_TOKEN }} + name: react-simulator-renderer + fail_ci_if_error: true + verbose: true + + upload-code-generator: + runs-on: ubuntu-latest + # if: ${{ github.event.pull_request.head.repo.full_name == 'alibaba/lowcode-engine' }} + steps: + - name: checkout + uses: actions/checkout@v2 + + - uses: actions/setup-node@v2 + with: + node-version: '14' + + - name: install + run: npm i && npm run setup:skip-build + + - name: test code-generator + run: cd modules/code-generator && npm i && npm run build && npm run test:cov && cd ../.. + + - name: Upload code-generator coverage to Codecov + uses: codecov/codecov-action@v3 + with: + # working-directory: packages/designer + directory: ./modules/code-generator/coverage + token: ${{ secrets.CODECOV_TOKEN }} + name: code-generator + fail_ci_if_error: true + verbose: true \ No newline at end of file diff --git a/.github/workflows/cov packages.yml b/.github/workflows/cov packages.yml new file mode 100644 index 0000000000..7f92e1009c --- /dev/null +++ b/.github/workflows/cov packages.yml @@ -0,0 +1,96 @@ +name: coverage + +on: + pull_request: + paths: + - 'packages/**' + - '!packages/**.md' + +jobs: + cov-designer: + runs-on: ubuntu-latest + # skip fork's PR, otherwise it fails while making a comment + if: ${{ github.event.pull_request.head.repo.full_name == 'alibaba/lowcode-engine' }} + steps: + - name: checkout + uses: actions/checkout@v2 + + - uses: actions/setup-node@v2 + with: + node-version: '14' + + - name: install + run: npm i && npm run setup:skip-build + + - uses: ArtiomTr/jest-coverage-report-action@v2 + with: + working-directory: packages/designer + test-script: npm test -- --jest-ci --jest-json --jest-coverage --jest-testLocationInResults --jest-outputFile=report.json + package-manager: yarn + annotations: none + + cov-renderer-core: + runs-on: ubuntu-latest + # skip fork's PR, otherwise it fails while making a comment + if: ${{ github.event.pull_request.head.repo.full_name == 'alibaba/lowcode-engine' }} + steps: + - name: checkout + uses: actions/checkout@v2 + + - uses: actions/setup-node@v2 + with: + node-version: '14' + + - name: install + run: npm i && npm run setup:skip-build + + - uses: ArtiomTr/jest-coverage-report-action@v2 + with: + working-directory: packages/renderer-core + test-script: npm test -- --jest-ci --jest-json --jest-coverage --jest-testLocationInResults --jest-outputFile=report.json + package-manager: yarn + annotations: none + + cov-react-simulator-renderer: + runs-on: ubuntu-latest + # skip fork's PR, otherwise it fails while making a comment + if: ${{ github.event.pull_request.head.repo.full_name == 'alibaba/lowcode-engine' }} + steps: + - name: checkout + uses: actions/checkout@v2 + + - uses: actions/setup-node@v2 + with: + node-version: '14' + + - name: install + run: npm i && npm run setup:skip-build + + - uses: ArtiomTr/jest-coverage-report-action@v2 + with: + working-directory: packages/react-simulator-renderer + test-script: npm test -- --jest-ci --jest-json --jest-coverage --jest-testLocationInResults --jest-outputFile=report.json + package-manager: yarn + annotations: none + + cov-utils: + runs-on: ubuntu-latest + # skip fork's PR, otherwise it fails while making a comment + if: ${{ github.event.pull_request.head.repo.full_name == 'alibaba/lowcode-engine' }} + steps: + - name: checkout + uses: actions/checkout@v2 + + - uses: actions/setup-node@v2 + with: + node-version: '14' + + - name: install + run: npm i && npm run setup:skip-build + + - uses: ArtiomTr/jest-coverage-report-action@v2 + with: + working-directory: packages/utils + test-script: npm test -- --jest-ci --jest-json --jest-coverage --jest-testLocationInResults --jest-outputFile=report.json + package-manager: yarn + annotations: none \ No newline at end of file diff --git a/.github/workflows/help wanted.yml b/.github/workflows/help wanted.yml index 94927ad28f..619d08b936 100644 --- a/.github/workflows/help wanted.yml +++ b/.github/workflows/help wanted.yml @@ -1,4 +1,4 @@ -name: Issue Reply +name: Help Wanted on: issues: diff --git a/.github/workflows/insufficient information.yml b/.github/workflows/insufficient information.yml index 33c1d39067..c49e133f16 100644 --- a/.github/workflows/insufficient information.yml +++ b/.github/workflows/insufficient information.yml @@ -1,4 +1,4 @@ -name: Issue Reply +name: Insufficient Info on: issues: @@ -16,4 +16,4 @@ jobs: token: ${{ secrets.GITHUB_TOKEN }} issue-number: ${{ github.event.issue.number }} body: | - 你好 @${{ github.event.issue.user.login }},由于缺乏必要的信息(如 bug 重现步骤、引擎版本信息 等),无法定位问题,请按照 [issue bug 模板](https://github.com/alibaba/lowcode-engine/blob/main/.github/ISSUE_TEMPLATE/bug-report.md) 补全信息。 + 你好 @${{ github.event.issue.user.login }},由于缺乏必要的信息(如 bug 重现步骤、引擎版本信息 等),无法定位问题,请按照 [issue bug 模板](https://github.com/alibaba/lowcode-engine/blob/main/.github/ISSUE_TEMPLATE/bug-report.md) 补全信息,也可以通过阅读 [引擎的 issue 说明](https://lowcode-engine.cn/site/community/issue) 了解什么类型的 issue 可以获得更好、更快的支持。 diff --git a/.github/workflows/pr comment by chatgpt.yml b/.github/workflows/pr comment by chatgpt.yml new file mode 100644 index 0000000000..52585c4778 --- /dev/null +++ b/.github/workflows/pr comment by chatgpt.yml @@ -0,0 +1,23 @@ +name: Pull Request Review By ChatGPT + +on: + pull_request: + types: [opened, synchronize, reopened] + +jobs: + code-review: + name: Code Review + runs-on: ubuntu-latest + + steps: + # 判断用户是否有写仓库权限 + - name: 'Check User Permission' + uses: 'lannonbr/repo-permission-check-action@2.0.0' + with: + permission: 'write' + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - uses: opensumi/actions/.github/actions/code-review@main + env: + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} \ No newline at end of file diff --git a/.github/workflows/pre build.yml b/.github/workflows/pre build.yml new file mode 100644 index 0000000000..e6f7d6479d --- /dev/null +++ b/.github/workflows/pre build.yml @@ -0,0 +1,34 @@ +name: Pre Build + +on: + push: + paths: + - 'packages/**' + - '!packages/**.md' + pull_request: + paths: + - 'packages/**' + - '!packages/**.md' + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v2 + + - name: Install dependencies and setup + run: npm install && npm run setup + + - name: Build + run: npm run build + + - name: Check build status + run: | + if [ $? -eq 0 ]; then + echo "Build succeeded!" + else + echo "Build failed!" + exit 1 + fi diff --git a/.github/workflows/publish docs.yml b/.github/workflows/publish docs.yml new file mode 100644 index 0000000000..139b70239f --- /dev/null +++ b/.github/workflows/publish docs.yml @@ -0,0 +1,53 @@ +name: Update and Publish Docs + +on: + push: + branches: + - develop + paths: + - 'docs/docs/**' + workflow_dispatch: + +jobs: + publish-docs: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Setup Node.js + uses: actions/setup-node@v2 + with: + ref: 'develop' + node-version: '16' + registry-url: 'https://registry.npmjs.org' + - run: cd docs && npm install + - run: | + cd docs + npm version patch + git config --local user.email "action@github.com" + git config --local user.name "GitHub Action" + git add package.json + git commit -m "chore(docs): publish documentation" + git push + - run: cd docs && npm run build && npm publish + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + - name: Get version + id: get_version + run: echo "version=$(node -p "require('./docs/package.json').version")" >> $GITHUB_OUTPUT + + comment-pr: + needs: publish-docs + runs-on: ubuntu-latest + steps: + - name: Comment on PR + if: github.event_name == 'pull_request' && github.event.action == 'closed' && github.event.pull_request.merged == true + uses: actions/github-script@v4 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + github.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body: '🚀 New version has been released: ' + '${{ needs.publish-docs.outputs.version }}' + }) diff --git a/.github/workflows/publish engine beta.yml b/.github/workflows/publish engine beta.yml new file mode 100644 index 0000000000..ed4c374756 --- /dev/null +++ b/.github/workflows/publish engine beta.yml @@ -0,0 +1,30 @@ +name: Publish Engine Beta + +on: + push: + branches: + - 'release/[0-9]+.[0-9]+.[0-9]+-beta' + paths: + - 'packages/**' + +jobs: + publish-engine: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Setup Node.js + uses: actions/setup-node@v2 + with: + node-version: '14' + registry-url: 'https://registry.npmjs.org' + - run: npm install && npm run setup + - run: | + npm run build + git config --local user.email "action@github.com" + git config --local user.name "GitHub Action" + - run: npm run pub:prerelease + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + - name: Get version + id: get_version + run: echo "version=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT diff --git a/.github/workflows/publish engine.yml b/.github/workflows/publish engine.yml new file mode 100644 index 0000000000..ddbefcde55 --- /dev/null +++ b/.github/workflows/publish engine.yml @@ -0,0 +1,33 @@ +name: Publish Engine + +on: + workflow_dispatch: + inputs: + publishCommand: + description: 'publish command' + required: true + +jobs: + publish-engine: + runs-on: ubuntu-latest + if: >- + contains(github.ref, 'refs/heads/release/') && + (github.actor == '1ncounter' || github.actor == 'liujuping') + steps: + - uses: actions/checkout@v2 + - name: Setup Node.js + uses: actions/setup-node@v2 + with: + node-version: '16' + registry-url: 'https://registry.npmjs.org' + - run: npm install && npm run setup + - run: | + npm run build + git config --local user.email "action@github.com" + git config --local user.name "GitHub Action" + - run: npm run ${{ github.event.inputs.publishCommand }} + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + - name: Get version + id: get_version + run: echo "version=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index aa9b14b045..6fa710ec4d 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -14,9 +14,10 @@ jobs: close-issue-message: 'This issue was closed because it has been stalled for 10 days with no activity.' close-pr-message: 'This PR was closed because it has been stalled for 10 days with no activity.' days-before-issue-stale: 10 - days-before-issue-close: 2 + days-before-issue-close: 10 days-before-pr-stale: 10 - days-before-pr-close: 2 - exempt-issue-labels: 'bug,enhancement,good first issue,help wanted,WIP' + days-before-pr-close: 10 + exempt-issue-labels: 'bug,enhancement,good first issue,help wanted,WIP,discussion,documentation,later,material' stale-issue-label: 'stale' - stale-pr-label: 'stale' \ No newline at end of file + stale-pr-label: 'stale' + exempt-all-assignee: true \ No newline at end of file diff --git a/.github/workflows/test modules.yml b/.github/workflows/test modules.yml new file mode 100644 index 0000000000..b2464cc40c --- /dev/null +++ b/.github/workflows/test modules.yml @@ -0,0 +1,44 @@ +name: Lint & Test (Mods) + +on: + push: + paths: + - 'modules/**' + - '!modules/**.md' + pull_request: + paths: + - 'modules/**' + - '!modules/**.md' + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - name: checkout + uses: actions/checkout@v2 + + - uses: actions/setup-node@v2 + with: + node-version: '14' + + - name: install + run: npm i + + - name: lint + run: npm run lint:modules + + test-code-generator: + runs-on: ubuntu-latest + steps: + - name: checkout + uses: actions/checkout@v2 + + - uses: actions/setup-node@v2 + with: + node-version: '14' + + - name: install + run: npm i && npm run setup:skip-build + + - name: test + run: cd modules/code-generator && npm i && npm run build && npm test \ No newline at end of file diff --git a/.github/workflows/test packages.yml b/.github/workflows/test packages.yml new file mode 100644 index 0000000000..45fa665465 --- /dev/null +++ b/.github/workflows/test packages.yml @@ -0,0 +1,140 @@ +name: Lint & Test (Pkgs) + +on: + push: + paths: + - 'packages/**' + - '!packages/**.md' + pull_request: + paths: + - 'packages/**' + - '!packages/**.md' + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - name: checkout + uses: actions/checkout@v2 + + - uses: actions/setup-node@v2 + with: + node-version: '14' + + - name: install + run: npm i && npm run setup:skip-build + + - name: lint + run: npm run lint + + test-designer: + runs-on: ubuntu-latest + steps: + - name: checkout + uses: actions/checkout@v2 + + - uses: actions/setup-node@v2 + with: + node-version: '14' + + - name: install + run: npm i && npm run setup:skip-build + + - name: test + run: cd packages/designer && npm test + + test-editor-skeleton: + runs-on: ubuntu-latest + steps: + - name: checkout + uses: actions/checkout@v2 + + - uses: actions/setup-node@v2 + with: + node-version: '14' + + - name: install + run: npm i && npm run setup:skip-build + + - name: test + run: cd packages/editor-skeleton && npm test + + test-renderer-core: + runs-on: ubuntu-latest + steps: + - name: checkout + uses: actions/checkout@v2 + + - uses: actions/setup-node@v2 + with: + node-version: '14' + + - name: install + run: npm i && npm run setup:skip-build + + - name: test + run: cd packages/renderer-core && npm test + + test-react-simulator-renderer: + runs-on: ubuntu-latest + steps: + - name: checkout + uses: actions/checkout@v2 + + - uses: actions/setup-node@v2 + with: + node-version: '14' + + - name: install + run: npm i && npm run setup:skip-build + + - name: test + run: cd packages/react-simulator-renderer && npm test + + test-utils: + runs-on: ubuntu-latest + steps: + - name: checkout + uses: actions/checkout@v2 + + - uses: actions/setup-node@v2 + with: + node-version: '14' + + - name: install + run: npm i && npm run setup:skip-build + + - name: test + run: cd packages/utils && npm test + + test-editor-core: + runs-on: ubuntu-latest + steps: + - name: checkout + uses: actions/checkout@v2 + + - uses: actions/setup-node@v2 + with: + node-version: '14' + + - name: install + run: npm i && npm run setup:skip-build + + - name: test + run: cd packages/editor-core && npm test + + test-plugin-command: + runs-on: ubuntu-latest + steps: + - name: checkout + uses: actions/checkout@v2 + + - uses: actions/setup-node@v2 + with: + node-version: '14' + + - name: install + run: npm i && npm run setup:skip-build + + - name: test + run: cd packages/plugin-command && npm test \ No newline at end of file diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml deleted file mode 100644 index aec4d9a5d6..0000000000 --- a/.github/workflows/test.yml +++ /dev/null @@ -1,36 +0,0 @@ -name: lint & test - -on: [push, pull_request] - -jobs: - lint: - runs-on: ubuntu-latest - steps: - - name: checkout - uses: actions/checkout@v2 - - - uses: actions/setup-node@v2 - with: - node-version: '14' - - - name: install - run: npm i && npm run setup:skip-build - - - name: lint - run: npm run lint - - test-designer: - runs-on: ubuntu-latest - steps: - - name: checkout - uses: actions/checkout@v2 - - - uses: actions/setup-node@v2 - with: - node-version: '14' - - - name: install - run: npm i && npm run setup:skip-build - - - name: test - run: cd packages/designer && npm test \ No newline at end of file diff --git a/.gitignore b/.gitignore index 2f87724cb9..6a19ae3e0c 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ packages/*/output/ packages/demo/ package-lock.json yarn.lock +pnpm-lock.yaml deploy-space/packages deploy-space/.env @@ -36,6 +37,7 @@ lib-cov # Coverage directory used by tools like istanbul coverage +coverage-all # nyc test coverage .nyc_output @@ -106,3 +108,5 @@ typings/ # codealike codealike.json .node + +.must.config.js \ No newline at end of file diff --git a/CONTRIBUTOR.md b/CONTRIBUTOR.md index c15f58af95..11d50baade 100644 --- a/CONTRIBUTOR.md +++ b/CONTRIBUTOR.md @@ -16,6 +16,7 @@ - [leoyuan](https://github.com/leoyuan) - [liujuping](https://github.com/liujuping) - [lqy978599280](https://github.com/lqy978599280) +- [markyun](https://github.com/markyun) - [mark-ck](https://github.com/mark-ck) - [mochen666](https://github.com/mochen666) - [tsy77](https://github.com/tsy77) @@ -23,5 +24,6 @@ - [Ychangqing](https://github.com/Ychangqing) - [yize](https://github.com/yize) - [youluna](https://github.com/youluna) +- [ibreathebsb](https://github.com/ibreathebsb) 如果您贡献过低代码引擎,但是没有看到您的名字,为我们的疏忽感到抱歉。欢迎您通过 PR 补充上自己的名字。 diff --git a/babel.config.js b/babel.config.js new file mode 100644 index 0000000000..a089167a78 --- /dev/null +++ b/babel.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: [ + ['@babel/plugin-proposal-decorators', { legacy: true }], + [require.resolve('@babel/plugin-proposal-class-properties'), { loose: true }], + ], +}; \ No newline at end of file diff --git a/deploy-space/static/index.html b/deploy-space/static/index.html index 3b4cbddc29..e7ff4ba730 100644 --- a/deploy-space/static/index.html +++ b/deploy-space/static/index.html @@ -21,7 +21,7 @@ + + + + + + + + + + + + + + + +``` +> 注:如果 unpkg 的服务比较缓慢,您可以使用官方 CDN 来获得确定版本的低代码引擎,如对于引擎的 1.0.18 版本,可用以下官方 CDN 替代 +> - [https://uipaas-assets.com/prod/npm/@alilc/lowcode-engine/1.0.18/dist/js/engine-core.js](https://uipaas-assets.com/prod/npm/@alilc/lowcode-engine/1.0.18/dist/js/engine-core.js) + + +### 配置打包 + +因为这些资源已经通过 UMD 方式引入,所以在 webpack 等构建工具中需要配置它们为 external,不再重复打包: + +```javascript +{ + "externals": { + "react": "var window.React", + "react-dom": "var window.ReactDOM", + "prop-types": "var window.PropTypes", + "@alifd/next": "var window.Next", + "@alilc/lowcode-engine": "var window.AliLowCodeEngine", + "@alilc/lowcode-engine-ext": "var window.AliLowCodeEngineExt", + "moment": "var window.moment", + "lodash": "var window._" + } +} +``` + +### 初始化低代码编辑器 + +正确引入后,我们可以直接通过 window 上的变量进行引用,如 `window.AliLowCodeEngine.init`。您可以直接通过此方式初始化低代码引擎: + +```javascript +// 确保在执行此命令前,在 中已有一个 id 为 lce-container 的
+window.AliLowCodeEngine.init(document.getElementById('lce-container'), { + enableCondition: true, + enableCanvasLock: true, +}); +``` + +如果您的项目中使用了 TypeScript,您可以通过如下 devDependencies 引入相关包,并获得对应的类型推断。 +```javascript +// package.json +{ + "devDependencies": { + "@alilc/lowcode-engine": "^1.0.0" + } +} +``` +```javascript +// src/index.tsx +import { init } from '@alilc/lowcode-engine'; + +init(document.getElementById('lce-container'), { + enableCondition: true, + enableCanvasLock: true, +}); +``` + +init 的功能包括但不限于: + +1. 传递 options 并设置 config 对象; +2. 传递 preference 并设置 plugins 入参; +3. 初始化 Workbench; + +> 本节中的低代码编辑器例子可以在 demo 中找到:[https://github.com/alibaba/lowcode-demo/blob/main/demo-general/src/index.ts](https://github.com/alibaba/lowcode-demo/blob/main/demo-general/src/index.ts) + +## 配置低代码编辑器 +详见[低代码扩展简述](/site/docs/guide/expand/editor/summary)章节。 diff --git a/docs/docs/guide/create/useRenderer.md b/docs/docs/guide/create/useRenderer.md new file mode 100644 index 0000000000..a9fc79909e --- /dev/null +++ b/docs/docs/guide/create/useRenderer.md @@ -0,0 +1,106 @@ +--- +title: 接入运行时 +sidebar_position: 1 +--- + +低代码引擎的编辑器将产出两份数据: + +- 资产包数据 assets:包含物料名称、包名及其获取方式,对应协议中的[《低代码引擎资产包协议规范》](/site/docs/specs/assets-spec) +- 页面数据 schema:包含页面结构信息、生命周期和代码信息,对应协议中的[《低代码引擎搭建协议规范》](/site/docs/specs/lowcode-spec) + +经过上述两份数据,可以直接交由渲染模块或者出码模块来运行,二者的区别在于: + +- 渲染模块:使用资产包数据、页面数据和低代码运行时,并且允许维护者在低代码编辑器中用 `低代码(LowCode)`的方式继续维护; +- 出码模块:不依赖低代码运行时和页面数据,直接生成可直接运行的代码,并且允许维护者用 `源码(ProCode)` 的方式继续维护,但无法再利用低代码编辑器; + +> 渲染和出码的详细阐述可参考此文:[低代码技术在研发团队的应用模式探讨](https://mp.weixin.qq.com/s/Ynk_wjJbmNw7fEG6UtGZbQ) + +## 渲染模块 + +[在 Demo 中](https://lowcode-engine.cn/demo/demo-general/index.html),右上角有渲染模块的示例使用方式: +![Mar-13-2022 16-52-49.gif](https://img.alicdn.com/imgextra/i2/O1CN01PRsEl61o7Zct5fJML_!!6000000005178-1-tps-1534-514.gif) + +基于官方提供的渲染模块 [@alifd/lowcode-react-renderer](https://github.com/alibaba/lowcode-engine/tree/main/packages/react-renderer),你可以在 React 上下文渲染低代码编辑器产出的页面。 + +### 构造渲染模块所需数据 + +渲染模块所需要的数据需要通过编辑器产出的数据进行一定的转换,规则如下: + +- schema:从编辑器产出的 projectSchema 中拿到 componentsTree 中的首项,即 `projectSchema.componentsTree[0]`; +- components:需要根据编辑器产出的资产包 assets 中,根据页面 projectSchema 中声明依赖的 componentsMap,来加载所有依赖的资产包,最后获取资产包的实例并生成物料 - 资产包的键值对 components。 + +这个过程可以参考 demo 项目中的 `src/preview.tsx`: + +```typescript +async function getSchemaAndComponents() { + const packages = JSON.parse(window.localStorage.getItem('packages') || ''); + const projectSchema = JSON.parse(window.localStorage.getItem('projectSchema') || ''); + const { componentsMap: componentsMapArray, componentsTree } = projectSchema; + const componentsMap: any = {}; + componentsMapArray.forEach((component: any) => { + componentsMap[component.componentName] = component; + }); + const schema = componentsTree[0]; + + const libraryMap = {}; + const libraryAsset = []; + packages.forEach(({ package: _package, library, urls, renderUrls }) => { + libraryMap[_package] = library; + if (renderUrls) { + libraryAsset.push(renderUrls); + } else if (urls) { + libraryAsset.push(urls); + } + }); + + const vendors = [assetBundle(libraryAsset, AssetLevel.Library)]; + + const assetLoader = new AssetLoader(); + await assetLoader.load(libraryAsset); + const components = await injectComponents(buildComponents(libraryMap, componentsMap)); + + return { + schema, + components, + }; +} +``` + +### 进行渲染 + +拿到 schema 和 components 以后,您可以借由资产包数据和页面数据来完成页面的渲染: +```tsx +import React from 'react'; +import ReactRenderer from '@alilc/lowcode-react-renderer'; + +const SamplePreview = () => { + return ( + + ); +} +``` + +> 注 1:您可以注意到,此处是依赖了 React 进行渲染的,对于 Vue 形态的渲染或编辑器支持,详见[对应公告](https://github.com/alibaba/lowcode-engine/issues/236)。 +> +> 注 2:本节示例可在 Demo 代码里找到更完整的版本:[https://github.com/alibaba/lowcode-demo/blob/main/demo-general/src/preview.tsx](https://github.com/alibaba/lowcode-demo/blob/main/demo-general/src/preview.tsx) + + +## 出码模块 + +[在 Demo 中](https://lowcode-engine.cn/demo/demo-general/index.html),右上角有出码模块的示例使用方式: + +![Mar-13-2022 16-55-56.gif](https://img.alicdn.com/imgextra/i3/O1CN017CVeka27p3vwrGI1D_!!6000000007845-1-tps-1536-514.gif) + +> 本节示例可在出码插件里找到:[https://github.com/alibaba/lowcode-code-generator-demo](https://github.com/alibaba/lowcode-code-generator-demo) + + +## 低代码的生产和消费流程总览 + +经过“接入编辑器” - “接入运行时”这两节的介绍,我们已经可以了解到低代码所构建的生产和消费流程了,梳理如下图: + +![image.png](https://img.alicdn.com/imgextra/i3/O1CN01yiFiUc1rT32o9HpnW_!!6000000005631-2-tps-3206-1786.png) + +如上述流程所示,您一般需要一个后端项目来保存页面数据信息,如果资产包信息是动态的,也需要保存资产包信息。 diff --git a/docs/docs/guide/design/_category_.json b/docs/docs/guide/design/_category_.json new file mode 100644 index 0000000000..1868732be3 --- /dev/null +++ b/docs/docs/guide/design/_category_.json @@ -0,0 +1,6 @@ +{ + "label": "引擎设计原理", + "position": 3, + "collapsed": false, + "collapsible": true +} diff --git a/docs/docs/guide/design/datasourceEngine.md b/docs/docs/guide/design/datasourceEngine.md new file mode 100644 index 0000000000..33c7adb082 --- /dev/null +++ b/docs/docs/guide/design/datasourceEngine.md @@ -0,0 +1,152 @@ +--- +title: 数据源引擎设计 +sidebar_position: 7 +--- +## 核心原理 + +考虑之后的扩展性和兼容性,核心分为了 2 类包,一个是 **datasource-engine** ,另一个是 **datasource-engine-x-handler** ,x 的意思其实是对应数据源的 type,比如说 **datasource-engine-mtop-handler**,也就是说我们会将真正的请求工具放在 handler 里面去处理,engine 在使用的时候由使用方自身来决定需要注册哪些 handler,这样的目的有 2 个,一个是如果将所有的 handler 都放到一个包,对于端上来说这个包过大,有一些浪费资源和损耗性能的问题,另一个是如果有新的类型的数据源出现,只需要按照既定的格式去新增一个对应的 handler 处理器即可,达到了高扩展性的目的; + +![](https://img.alicdn.com/imgextra/i3/O1CN011ep9No2ACzrgzgtk0_!!6000000008168-2-tps-720-370.png) + +### DataSourceEngine + +- engine:engine 主要分 2 类,一类是面向 render 引擎的,可以从 engine/interpret 引入,一类是面向出码或者说直接单纯使用数据源引擎的场景,可以从 engine/runtime 引入,代码如下 + +```typescript +import { createInterpret, createRuntime } from '@alilc/lowcode-datasource-engine'; +``` + +create 方法定义如下 + +```typescript +interface IDataSourceEngineFactory { + create(dataSource: DataSource, context: Omit, extraConfig?: { + requestHandlersMap: RequestHandlersMap; + [key: string]: any; + }): IDataSourceEngine; +} +``` + +create 接收三个参数,第一个是 DataSource,对于运行时渲染和出码来说,DataSource 的定义分别如下: + +```typescript +/** + * 数据源对象--运行时渲染 + */ +export interface DataSource { + list: DataSourceConfig[]; + dataHandler?: JSFunction; +} + +/** + * 数据源对象 + */ +export interface DataSourceConfig { + id: string; + isInit: boolean | JSExpression; + type: string; + requestHandler?: JSFunction; + dataHandler?: JSFunction; + options?: { + uri: string | JSExpression; + params?: JSONObject | JSExpression; + method?: string | JSExpression; + isCors?: boolean | JSExpression; + timeout?: number | JSExpression; + headers?: JSONObject | JSExpression; + [option: string]: CompositeValue; + }; + [otherKey: string]: CompositeValue; +} +``` + +但是对于出码来说,create 和 DataSource 定义如下: + +```typescript +export interface IRuntimeDataSourceEngineFactory { + create(dataSource: RuntimeDataSource, context: Omit, extraConfig?: { + requestHandlersMap: RequestHandlersMap; + [key: string]: any; + }): IDataSourceEngine; +} + +export interface RuntimeOptionsConfig { + uri: string; + params?: Record; + method?: string; + isCors?: boolean; + timeout?: number; + headers?: Record; + shouldFetch?: () => boolean; + [option: string]: unknown; +} +export declare type RuntimeOptions = () => RuntimeOptionsConfig; // 考虑需要动态获取值的情况,这里在运行时会真正的转为一个 function + +export interface RuntimeDataSourceConfig { + id: string; + isInit: boolean; + type: string; + requestHandler?: () => {}; + dataHandler: (data: unknown, err?: Error) => {}; + options?: RuntimeOptions; + [otherKey: string]: unknown; +} + +/** + * 数据源对象 + */ +export interface RuntimeDataSource { + list: RuntimeDataSourceConfig[]; + dataHandler?: (dataMap: DataSourceMap) => void; +} +``` + +2 者的区别还是比较明显的,一个是带 js 表达式一类的字符串,另一个是真正转为直接可以运行的 js 代码,对于出码来说,转为可执行的 js 代码的过程是出码自身负责的,对于渲染引擎来说,它只能接受到初始的 schema json 所以需要数据源引擎来做转化 + +- context:数据源引擎内部有一些使用了 this 的表达式,这些表达式需要求值的时候依赖上下文,因此需要将当前的上下文丢给数据源引擎,另外在 handler 里面去赋值的时候,也会用到诸如 setState 这种上下文里面的 api,当然,这个是可选的,我们后面再说。 + +```typescript +/** + * 运行时上下文--暂时是参考 react,当然可以自己构建,完全没问题 + */ +export interface IRuntimeContext> { + /** 当前容器的状态 */ + readonly state: TState; + /** 设置状态 (浅合并) */ + setState(state: Partial): void; + /** 自定义的方法 */ + [customMethod: string]: any; + /** 数据源,key 是数据源的 ID */ + dataSourceMap: Record; + /** 重新加载所有的数据源 */ + reloadDataSource(): Promise; + /** 页面容器 */ + readonly page: IRuntimeContext & { + readonly props: Record; + }; + /** 低代码业务组件容器 */ + readonly component: IRuntimeContext & { + readonly props: Record; + }; +} +``` + +- extraConfig:这个字段是为了留着扩展用的,除了一个必填的字段 **requestHandlersMap** + +```typescript +export declare type RequestHandler = (ds: RuntimeDataSourceConfig, context: IRuntimeContext) => Promise>; +export declare type RequestHandlersMap = Record; +``` + +RequestHandlersMap 是一个把数据源以及对应的数据源 handler 关联起来的桥梁,它的 key 对应的是数据源 DataSourceConfig 的 type,比如 mtop/http/jsonp ... ,每个类型的数据源在真正使用的时候会调用对应的 type-handler,并将当前的参数和上下文带给对应的 handler。 + +create 调用结束后,可以获取到一个 DataSourceEngine 实例 + +```typescript +export interface IDataSourceEngine { + /** 数据源,key 是数据源的 ID */ + dataSourceMap: Record; + /** 重新加载所有的数据源 */ + reloadDataSource(): Promise; +} +``` diff --git a/docs/docs/guide/design/editor.md b/docs/docs/guide/design/editor.md new file mode 100644 index 0000000000..0614d9c332 --- /dev/null +++ b/docs/docs/guide/design/editor.md @@ -0,0 +1,368 @@ +--- +title: 编排模块设计 +sidebar_position: 3 +--- +本篇重点介绍如何从零开始设计编排模块,设计思路是什么?思考编排的本质是什么?围绕着本质,如何设计并实现对应的功能模块。 + +![image.png](https://img.alicdn.com/imgextra/i4/O1CN01fGzyI41bqpl6AavNp_!!6000000003517-2-tps-1920-1080.png) + +## 编排是什么 + +所谓编排,即将设计器中的所有物料,进行布局设置、组件设置、交互设置(JS 编写/逻辑编排)后,形成符合业务诉求的 schema 描述。 +## 编排的本质 + +首先,思考编排的本质是什么? + +编排的本质是生产符合《阿里巴巴中后台前端搭建协议规范》的数据**,**在这个场景里,协议是通过 JSON 来承载的。如: + +```json +{ + "componentName": "Page", + "props": { + "layout": "wide" + }, + "children": [ + { + "componentName": "Button", + "props": { + "size": "large" + } + } + ] +} +``` + +可是在真实场景,节点数可能有成百上千,每个节点都具有新增、删除、修改、移动、插入子节点等操作,同时还有若干约束,JSON 结构操作起来不是很便利,于是我们仿 DOM 设计了 **节点模型 & 属性模型,**用更具可编程性的方式来编排,这是**编排系统的基石**。 + +其次,每次编排动作后(CRUD),都需要实时的渲染出视图。广义的视图应该包括各种平台上的展现,浏览器、Rax、小程序、Flutter 等等,所以使用何种渲染器去渲染 JSON 结构应该可以由用户去扩展,我们定义一种机制去衔接设计态和渲染态。 + +至此,我们已经完成了**编排模块最基础的功能**,接下来,就是完善细节,逐步丰满功能。比如: +1. 编排面板的整体功能区划分设计; +2. 节点属性设计;节点删除、移动等操作设计;容器节点设计; +3. 节点拖拽功能、拖拽定位设计和实现; +4. 节点在画布上的辅助功能,比如 hover、选中、选中时的操作项、resize、拖拽占位符等; +5. 设计态和渲染态的坐标系转换,滚动监听等; +6. 快捷键机制; +7. 历史功能,撤销和重做; +8. 结构化的插件扩展机制; +9. 原地编辑功能; + +有非常多模块,但只要记住一点,这些功能的目的都是辅助用户在画布上有更好的编排体验、扩展能力而逐个增加设计的。 + +## 编排功能模块 +### 模型设计 + +编排实际上操作 schema,但是实际代码运行的过程中,我们将 schema 分成了很多层,每一层有各自的职责,他们所负责的功能是明确清晰的。这就是低代码引擎中的模型设计。 + +我们通过将 schema 和常用的操作等结合起来,最终将低代码引擎的模型分为节点模型、属性模型、文档模型和项目模型。 + +#### 项目模型(`Project`) + +项目模型提供项目管理能力。通常一个引擎启动会默认创建一个 `Project` 实例,有且只有一个。项目模型实例下可以持有多个文档模型的实例,而当前处于设计器设计状态的文档模型,我们将其添加 active 标识,也将其称为 `currentDocument`,可以通过 `project.currentDocument` 获得。 + +一个 `Project` 包含若干个 `DocumentModel` 实例,即项目模型和文档模型的关系是 1 对 n,如下图所示: + +![image.png](https://img.alicdn.com/imgextra/i3/O1CN01G28BKC1RvHRvhhiDf_!!6000000002173-2-tps-1226-1648.png) + +#### 文档模型(`DocumentModel`) + +文档模型提供文档管理的能力,每一个页面即一个文档流,对应一个文档模型。文档模型包含了一组 Node 组成的一颗树,类似于 DOM。我们可以通过文档模型来操作 `Node` 树,来达到管理文档模型的能力。每一个文档模型对应多个 `Node`,但是根 `Node` 只有一个,即 `rootNode` 和 `nodes`。 + +文档模型可以通过 `Node` 树,通过 `doc.schema` 来导出文档的 `schema`,并使用其进行渲染。 + +他们的关系如下图: + +![image.png](https://img.alicdn.com/imgextra/i3/O1CN01NYVhN61nab6hsw5ZK_!!6000000005106-2-tps-960-1490.png) + +#### 节点模型(`Node`) + +我们先看一下一个 `Node` 在 `schema` 中对应的示例: + +```javascript +{ + componentName: 'Text', + id: 'node_k1ow3cbf', + props: { + showTitle: false, + behavior: 'NORMAL', + content: { + use: 'zh_CN', + en_US: 'Title', + zh_CN: '个人信息', + type: 'i18n', + }, + fieldId: 'text_k1ow3h1j', + maxLine: 0, + }, + condition: true, +} +``` + +上面的示例是一个 `Text` 的 `Node` 节点,而我们的 `Node` 节点模型就是负责这一层级的 `Schema` 管理。它的功能聚焦于单层级的 schema 相关操作。我们可以看一下节点模型的一些方法,了解其功能。 + +```typescript +declare class Node { + // Props + props: Props; + get propsData(): PropsMap | PropsList | null; + getProp(path: string, stash?: boolean): Prop | null; + getPropValue(path: string): any; + setPropValue(path: string, value: any): void; + clearPropValue(path: string): void; + mergeProps(props: PropsMap): void; + setProps(props?: PropsMap | PropsList | Props | null): void; + + // Node + get parent(): ParentalNode | null; + get children(): NodeChildren | null; + get nextSibling(): Node | null; + get prevSibling(): Node | null; + remove(useMutator?: boolean, purge?: boolean): void; + select(): void; + hover(flag?: boolean): void; + replaceChild(node: Node, data: any): Node; + mergeChildren(remover: () => any, adder: (children: Node[]) => NodeData[] | null, sorter: () => any): void; + removeChild(node: Node): void; + insert(node: Node, ref?: Node, useMutator?: boolean): void; + insertBefore(node: any, ref?: Node, useMutator?: boolean): void; + insertAfter(node: any, ref?: Node, useMutator?: boolean): void; + + // Schema + get schema(): Schema; + set schema(data: Schema); + export(stage?: TransformStage): Schema; + replaceWith(schema: Schema, migrate?: boolean): any; +} +``` + +这里没有展示全部的方法,但是我们可以发现,`Node` 节点模型核心功能点有三个: + +1. `Props` 管理:通过 `Props` 实例管理所有的 `Prop`,包括新增、设置、删除等 `Prop` 相关操作。 +2. `Node` 管理:管理 `Node` 树的关系,修改当前 `Node` 节点或者 `Node` 子节点等。 +3. `Schema` 管理:可以通过 `Node` 获取当前层级的 `Schema` 描述协议内容,并且也可以修改它。 + +通过 `Node` 这一层级,对 `Props`、`Node` 树和 `Schema` 的管理粒度控制到最低,这样扩展性也就更强。 + +#### 属性模型(Prop) + +一个 `Props` 对应多个 `Prop`,每一个 `Prop` 对应 schema 的 `props` 下的一个字段。 + +`Props` 管理的是 `Node` 节点模型中的 `props` 字段下的内容。而 `Prop` 管理的是 `props` 下的每一个 `key` 的内容,例如下面的示例中,一个 `Props` 管理至少 6 个 `Prop`,而其中一个 `Prop` 管理的是 `showTitle` 的结果。 + +```javascript +{ + props: { + showTitle: false, + behavior: 'NORMAL', + content: { + use: 'zh_CN', + en_US: 'Title', + zh_CN: '个人信息', + type: 'i18n', + }, + fieldId: 'text_k1ow3h1j', + maxLine: 0, + }, +} +``` +#### 组件描述模型(ComponentMeta) + +编排已经等价于直接操作节点 & 属性了,而一个节点和一组对应的属性相当于一个真实的组件,而真实的组件一定是有约束的,比如组件名、组件类型、支持哪些属性以及属性类型、组件能否拖动、支持哪些扩展操作、组件是否是容器型组件、A 组件中能否放入 B 组件等等。 + +于是,我们设计了一份协议专门负责组件描述,即《中后台搭建组件描述协议》,而编排模块中也有负责解析和使用符合描述协议规范的模块。 + +每一个组件对应一个 `ComponentMeta` 的实例,其属性和方法就是描述协议中的所有字段,所有 `ComponentMeta` 都由设计器器的 `designer` 模块进行创建和管理,其他模块通过 `designer` 来获取指定的 `ComponentMeta` 实例,尤其是每个 `Node` 实例上都会挂载对应的 `ComponentMeta` 实例。 + +![image.png](https://img.alicdn.com/imgextra/i2/O1CN01NSh0LI1b150RUzOUc_!!6000000003404-2-tps-998-756.png) + +组件描述模型是后续编排辅助的基础,包括设置面板、拖拽定位机制等。 +#### 项目、文档、节点和属性模型关系 + +整体来看,一个 Project 包含若干个 DocumentModel 实例,每个 DocumentModel 包含一组 Node 构成一颗树(类似 DOM 树),每个 Node 通过 Props 实例管理所有 Prop。整体的关系图如下。 + +![image.png](https://img.alicdn.com/imgextra/i4/O1CN01mufxpY1qCGvDTSdw9_!!6000000005459-2-tps-1694-1356.png) + +节点 & 属性模型是引擎基石,几乎贯穿所有模块,相信从上面的类图已经能看出几个基础类的职责以及依赖关系。 + +节点 & 属性模型等价于 JSON 数据结构,而编排的本质是产出 JSON 数据结构,现在可以重新表述为编排的本质是操作节点 & 属性模型了。 + +```typescript +// 一段编排的示例代码 +rootNode.insertAfter({ componentName: 'Button', props: { size: 'medium' } }); +rootNode.insertAfter({ componentName: 'Button', props: { size: 'medium' } }); +rootNode.children.get(1).getProp('size').setValue('large'); +rootNode.children.get(2).remove(); +rootNode.export(); +// => 产出 schema +``` + +### 画布渲染 + +画布渲染使用了设计态与渲染态的双层架构。 + +![image.png](https://img.alicdn.com/imgextra/i1/O1CN01cZ6Q32260qtiDofwi_!!6000000007600-2-tps-1416-710.png) + +如上图,设计器和渲染器其实处在不同的 Frame 下,渲染器以单独的 `iframe` 嵌入。这样做的好处,一是为了给渲染器一个更纯净的运行环境,更贴近生产环境,二是扩展性考虑,让用户基于接口约束自定义自己的渲染器。 + +#### xxx-renderer + +xxx-renderer 是一个纯 renderer,即一个渲染器,通过给定输入 schema、依赖组件和配置参数之后完成渲染。 + +#### xxx-simulator-renderer + +xxx-simulator-renderer 通过和 host 进行通信来和设计器打交道,提供了 `DocumentModel` 获取 schema 和组件。将其传入 xxx-renderer 来完成渲染。 + +另外其提供了一些必要的接口,来帮助设计器完成交互,比如点击渲染画布任意一个位置,需要能计算出点击的组件实例,继而找到设计器对应的 Node 实例,以及组件实例的位置/尺寸信息,让设计器完成辅助 UI 的绘制,如节点选中。 + +#### react-simulator-renderer + +以官方提供的 react-simulator-renderer 为例,我们看一下点击一个 DOM 节点后编排模块是如何处理的。 + +首先在初始化的时候,renderer 渲染的时候会给每一个元素添加 ref,通过 ref 机制在组件创建时将其存储起来。在存储的时候我们给实例添加 `Symbol('_LCNodeId')` 的属性。 + +当点击之后,会去根据 `__reactInternalInstance$` 查找相应的 fiberNode,通过递归查找到对应的 React 组件实例。找到一个挂载着 `Symbol('_LCNodeId')` 的实例,也就是上面我们初始化添加的属性。 + +通过 `Symbol('_LCNodeId')` 属性,我们可以获取 Node 的 id,这样我们就可以找到 Node 实例。 + +通过 `getBoundingClientRect` 我们可以获取到 Node 渲染出来的 DOM 的相关信息,包括 `x`、`y`、`width`、`height` 等。 + +通过 DOM 信息,我们将 focus 节点所需的标志渲染到对应的地方。hover、拖拽占位符、resize handler 等辅助 UI 都是类似逻辑。 + +#### 通信机制 + +既然设计器和渲染器处于两个 Frame,它们之间的事件通信、方法调用是通过各自的代理对象进行的,不允许其他方式,避免代码耦合。 + +![image.png](https://img.alicdn.com/imgextra/i2/O1CN01hxtg7X1M3AZsAdt83_!!6000000001378-2-tps-1290-648.png) + +##### host +host 可以访问设计器的所有模块,由于 renderer 层不负责与设计器相关的交互。所以增加了一层 host,作为通信的中间层。host 可以访问到设计器中所有模块,并提供相关方法供 simulator-renderer 层调用。例如 schema 的获取、组件获取等。 + +simulator-renderer 通过调用 host 的方法,将 schema、components 等参数传给 renderer,让 renderer 进行渲染。 + +##### xxx-simulator-renderer + +为了完成双向交互,simulator-renderer 也需要提供一些方法来供 host 层调用,之后当设计器和用户有交互,例如上述提到的节点选中。这里需要提供的方法有: + +- getClientRects +- getClosestNodeInstance +- findDOMNodes +- getComponent +- setNativeSelection +- setDraggingState +- setCopyState +- clearState + +这样,host 和 simulator-renderer 之间便通过相关方法实现了双向通信,能在隔离设计器的基础上完成设计器到画布和画布到设计器的通信流程。 + +### 编排辅助的核心 +#### 设置面板与设置器 +当在渲染画布上点击一个 DOM 节点,我们可以通过 xxx-simulator-renderer 获取 `Node` 节点,我们在 `Node` 上挂载了 `ComponentMeta` 实例。通过 `ComponentMeta` 我们获取到当前组件的描述模型。通过描述模型,我们即可获得组件、即当前 Node 支持的所有属性配置。 + +![image.png](https://img.alicdn.com/imgextra/i4/O1CN01c7nkoo1OXyRhVAFlK_!!6000000001716-2-tps-1500-985.png) + +##### 设置面板 + +设置面板对于配置项的呈现结构是通过 `ComponentMeta.configure` 来确定的。 + +```json +{ + "component": { + "isContainer": true + }, + "props": { + "isExtends": true, + "override": [ + { + "name": "count", + "title": { + "label": "展示的数字", + "tip": "count|大于 overflowCount 时显示为 ${overflowCount}+,为 0 时默认隐藏", + "docUrl": "https://fusion.alibaba-inc.com/pc/component/basic/badge" + }, + "setter": { + "componentName": "MixedSetter", + "props": { + "setters": [ + "StringSetter", + "ExpressionSetter" + ] + } + } + } + ] + } +} +``` + +上述的 `component.isContainer` 描述了这个组件是否是一个容器组件。而 props 下的属性就是我们在设置面板中展示的属性,包含了这个属性的名称、使用的设置器、配置之后影响的是哪个属性等。 + +而这只是描述,编排模块的 `SettingTopEntry` 便是管理设置面板的实现模块。 + +`SettingTopEntry` 包含了 n 个 `SettingField`,每一个 `SettingField` 就对应下面要将的设置器。即 `SettingTopEntry` 负责管理多个 `SettingField`。 + +##### 设置器 +选中节点可供配置的属性都有相应的设置器配置,比如文本、数字、颜色、JSON、Choice、I18N、表达式 等等,或者混合多种。 + +设置器本质上是一个 React 组件,但是设置面板在渲染时会传入当前配置项对应的 `SettingField` 实例,`SettingField` 本质上就是包裹了 `Prop` 实例,设置器内部的行为以及 UI 变化都由设置器自己把控,但当属性值发生变化时需要通过 `SettingField` 下的 `Prop` 来修改值,因为修改 `Prop` 实例就相当于修改了 schema。一方面这样的设置器设置之后,保存的 schema 才是正确的,另外一方面,只有 schema 变化了,才能触发渲染画布重新渲染。 + +#### 拖拽引擎 & 拖拽定位机制 + +![](https://img.alicdn.com/imgextra/i4/O1CN01G8zyBw1OkL8m0FG4J_!!6000000001743-1-tps-1425-917.gif) + +拖拽引擎(`Dragon`)核心完成的工作是将被拖拽对象拖拽到目标位置,涉及到几个概念: + +- 被拖拽对象 - `DragObject` +- 拖拽到的目标位置 - `DropLocation` +- 拖拽感应区 - `IPublicModelSensor` +- 定位事件 - `LocateEvent` + +##### Sensor + +在引擎初始化的时候,我们监听 `document` 和 iframe `contentDocument` 的 `mouse`、`keyboard`、`drag` 事件来感知拖拽的发生。而这些监听的区域我们又称为拖拽感应区,也就是 `Sensor`。`Sensor` 会有多个,因为感应器有多个,默认设置器和设置面板是没有 `Sensor`,但是他们是可以注册 `Sensor` 来增加感应区域,例如大纲树就注册了自己的 `Sensor`。 + +`Sensor` 有两个关键职责: +1. 用于事件对象转换,比如坐标系换算。 +2. 根据拖拽过程中提供的位置信息,结合每一层 `Node` 也就是组件包含的描述信息,知道其是否能作为容器等限制条件,来进行进一步的定位,最后计算出精准信息来进行视图渲染。 + +**拖拽流程** +1. 在引擎初始化的时候,初始化多个 `Sensor`。 +2. 当拖拽开始的时候,开启 `mousemove`、`mouseleave`、`mouseover` 等事件的监听。 +3. 拖拽过程中根据 `mousemove` 的 `MouseEvent` 对象封装出 `LocateEvent` 对象,继而交给相应 `sensor` 做进一步定位处理。 +4. 拖拽结束时,根据拖拽的结果进行 schema 变更和视图渲染。 +5. 最后关闭拖拽开始时的事件监听。 + +##### 拖拽方式 +根据拖拽的对象不同,我们将拖拽分为几种方式: +1. **画布内拖拽:**此时 sensor 是 simulatorHost,拖拽完成之后,会根据拖拽的位置来完成节点的精确插入。 +2. **从组件面板拖拽到画布**:此时的 sensor 还是 simulatorHost,因为拖拽结束的目标还是画布。 +3. **大纲树面板拖拽到画布中**:此时有两个 sensor,一个是大纲树,当我们拖拽到画布区域时,画布区域内的 simulatorHost 开始接管。 +4. **画布拖拽到大纲树中**:从画布中开始拖拽时,最新生效的是 simulatorHost,当离开画布到大纲树时,大纲树 sensor 开始接管生效。当拖拽到大纲树的某一个节点下时,大纲树会将大纲树中的信息转化为 schema,然后渲染到画布中。 +### 其他 + +引擎的编排能力远远不止上述所描述的功能,这里只描述了其核心和关键的功能。在整个引擎的迭代和设计过程中还有很多细节来使我们的引擎更好用、更容易扩展。 + +#### schema 处理的管道机制 + +通过 PropsReducer 的管道机制,用户可以定制自己需要的逻辑,来修改 Schema。 + +#### 组件 metadata 处理的管道机制 + +组件的描述信息都收拢在各自的 ComponentMeta 实例内,涉及到的消费方几乎遍及整个编排过程,包括但不限于 组件拖拽、拖拽辅助 UI、设置区、原地编辑、大纲树 等等。 + +在用户需要自定义的场景,开放 ComponentMeta 的修改能力至关重要,因此我们设计了 metadata 初始化/修改的管道机制。 + +#### hotkey & builtin-hotkey + +快捷键的实现,以及引擎内核默认绑定的快捷键行为。 + +#### drag resize 引擎 + +对于布局等类型的组件,支持拖拽改变大小。resize 拖拽引擎根据组件 ComponentMeta 声明来开启,拖拽后,触发组件的钩子函数(`onResizeStart` / `onResize` / `onResizeEnd`),完成 resize 过程。 + +#### OffsetObserver + +设计态的辅助 UI 需要根据渲染态的视图变化而变化,比如渲染容器滚动了,此时通过 OffsetObserver 做一个动态的监听。 + +#### 插件机制 + +我们希望保持引擎内核足够小,但拥有足够强的扩展能力,所有扩展功能都通过插件机制来承载。 diff --git a/docs/docs/guide/design/generator.md b/docs/docs/guide/design/generator.md new file mode 100644 index 0000000000..2310cb7a5f --- /dev/null +++ b/docs/docs/guide/design/generator.md @@ -0,0 +1,118 @@ +--- +title: 出码模块设计 +sidebar_position: 5 +--- + +本篇主要讲解了出码模块实现的基本思路与一些概念。如需接入出码和定制出码方案,可以参考《[使用出码功能](/site/docs/guide/expand/runtime/codeGeneration)》一节。 + +## npm 包与仓库信息 + +| **NPM 包** | **代码仓库** | **说明** | +| --- | --- | --- | +| [@alilc/lowcode-code-generator](https://www.npmjs.com/package/@alilc/lowcode-code-generator) | [alibaba/lowcode-engine](https://github.com/alibaba/lowcode-engine)(子目录:modules/code-generator)| 出码模块核心库,支持在 node 环境下运行,也提供了浏览器下运行的 standalone 模式 | +| [@alilc/lowcode-plugin-code-generator](https://www.npmjs.com/package/@alilc/lowcode-plugin-code-generator) | [alibaba/lowcode-code-generator-demo](https://github.com/alibaba/lowcode-code-generator-demo) | 出码示例 -- 浏览器端出码插件 | + +## 出码模块原理 + +出码模块的输入和输出很简单: +![](https://img.alicdn.com/imgextra/i3/O1CN01OkDmKq1xMX6Xxv6co_!!6000000006429-0-tps-1262-346.jpg) + +这里有几个概念: + +- schema: 搭建协议内容,指符合《阿里巴巴中后台前端搭建协议规范》的 schema +- solution:出码方案,指具体的项目框架(如 Rax,Ice.js) +- Source Codes:生成的源代码,以目录树的形式进行描述 + +可以看出,这是一个与用户基本没有交互,通过既定的流程完成整个功能链路的模块。其核心暴露的是一个将搭建协议 schema 按既定的 solution 转换为代码的函数。对于使用者来说就是一个输入输出都确定的黑盒系统。 + +### 出码流程概述 + +出码模块和编译器很类似,都是将代码的一种表现形式转换成另一种表现形式,如: + +#### 编译器流程 +![image.png](https://img.alicdn.com/imgextra/i3/O1CN019F21Lb1bsCwvNcWRq_!!6000000003520-2-tps-3228-492.png) + +#### 出码模块流程 +![image.png](https://img.alicdn.com/imgextra/i3/O1CN01SEcVta1uLD72W0URZ_!!6000000006020-2-tps-1536-182.png) + +### 出码流程详解 +#### 协议解析 + +协议解析主要是将输入的 schema 解析成更适合出码模块内部使用的数据结构的过程。这样在后面的代码生成过程中就可以直接用这些数据,不必重复解析了。 + +![](https://img.alicdn.com/imgextra/i3/O1CN016EeitG1giCNCNTLVF_!!6000000004175-0-tps-1282-515.jpg) + +主要步骤如下: + +- 解析三方组件依赖 +- 分析 ref API 的使用情况 +- 建立容器之间的依赖关系索引 +- 分析容器内的组件依赖关系 +- 分析路由配置 +- 分析 utils 和 NPM 包依赖关系 +- 其他兼容处理 + +#### 前置优化 + +前置优化是计划基于策略对 schema 做一些优化。 + +主要逻辑分为分析、规则和优化三个部分,组合为一个支持通过配置进行一定程度定制化的策略包。每个策略包会先执行分析器,对输入进行特征提取,然后通过规则对特征进行判断,决定是否执行优化动作: + +![](https://img.alicdn.com/imgextra/i4/O1CN01P0Lw7v1lfyWtfQTuR_!!6000000004847-2-tps-994-278.png) + +#### 代码生成 +代码生成的流程如下: +![](https://img.alicdn.com/imgextra/i1/O1CN01lhcWBg1RG3nsoSoY2_!!6000000002083-2-tps-1468-464.png) + +如果简单粗暴地拼字符串生成源代码将难以扩展和维护,因此出码模块在代码生成过程中将代码进行了一些抽象化。 + +日常开发中,我们常常是基于某一个特定的项目框架,将一些配置、UI 代码、逻辑代码放到他们应该在的地方,最终形成一套可以 run 起来的业务系统。那么其实对于出码这件事,我们也可以层层拆解,**项目 -> 插槽 -> 模块 -> 文件 -> 代码块**(代码片段)。这样就能将复杂的项目产出问题,拆分为一个个相对专注且单一的代码块产出问题,同时也支持组合复用。 + +![image.png](https://img.alicdn.com/imgextra/i4/O1CN01vOGmBT1JaegccXDt8_!!6000000001045-2-tps-892-454.png) + +注:中间表达结构即为对 Schema 解析后的结构化产物 + +##### 插槽 + +首先来看下插槽,插槽描述了对应模块在项目中相对路径,并且可以对模块做固定的命名。每个插槽都有一系列插件来完成代码产出工作。生成的一个或多个文件,最终会依照插槽的描述放入项目中。 + +```typescript +// 项目模版 +export interface IProjectTemplate { + slots: Record; +} + +// 插槽 +interface IProjectSlot { + path: string[]; + fileName?: string; +} + +// 插槽出码插件配置 +interface IProjectPlugins { + [slotName: string]: BuilderComponentPlugin[]; +} +``` +##### 代码块 + +代码块是出码产物的最小单元,由出码模块插件产出,多个代码块最后会被组装为代码文件。每个代码块通过 name 描述自己,再通过 linkAfter 描述应该跟在哪些 name 的代码块后面。 + +```typescript +interface ICodeChunk { + type: ChunkType; // 处理类型 ast | string | json + fileType: string; // 文件类型 js | css | ts ... + name: string; // 代码块名称,与 linkAfter 相关 + subModule?: string; // 模块内文件名,默认是 index + content: ChunkContent; // 代码块内容,数据格式与 type 相关 + linkAfter: string[]; +} +``` + +#### 后置优化 + +后置优化分为文件级别和项目级别两种: + +- 文件级别:在生成完一个文件后进行处理 +- 项目级别:在所有文件都生成完了之后进行处理 + +文件级别的后置优化目前主要是有 prettier 这个代码格式化工具。 diff --git a/docs/docs/guide/design/materialParser.md b/docs/docs/guide/design/materialParser.md new file mode 100644 index 0000000000..78936011fd --- /dev/null +++ b/docs/docs/guide/design/materialParser.md @@ -0,0 +1,80 @@ +--- +title: 入料模块设计 +sidebar_position: 2 +--- +## 介绍 +入料模块负责物料接入,通过自动扫描、解析源码组件,产出一份符合《中后台低代码组件描述协议》的** **JSON Schema。这份 Schema 包含基础信息和属性描述信息部分,低代码引擎会基于它们在运行时自动生成一份 configure 配置,用作设置面板展示。 + +## npm 包与仓库信息 + +- npm 包:@alilc/lowcode-material-parser +- 仓库:[https://github.com/alibaba/lowcode-engine](https://github.com/alibaba/lowcode-engine) 下的 modules/material-parser + +## 原理 +入料模块使用动静态分析结合的方案,动态胜在真实,静态胜在细致,不过全都依赖源码中定义的属性,若未定义,或者定义错误,则无法正确入料。 + +### 整体流程 +大体分为本地化、扫描、解析、转换、校验 5 部分,如下图所示。 +![image.png](https://img.alicdn.com/imgextra/i2/O1CN01sXf5fL1E5RcRxAlM1_!!6000000000300-2-tps-2116-206.png) + +### 静态解析 +在静态分析时,分为 JS 和 TS 两种情况。 + +#### 静态解析 JS +在 JS 情况下,基于 react-docgen 进行扩展,自定义了 resolver 及 handler,前者用于寻找组件定义,后者用于解析 propTypes、defaultProps 等信息,整体流程图如下: + +![image.png](https://img.alicdn.com/imgextra/i1/O1CN01VrhkEb1R6tsntvGhV_!!6000000002063-2-tps-2176-478.png) + +react-docgen 使用 babel 生成语法树,再使用 ast-types 进行遍历去寻找组件节点及其属性类型定义。原本的 react-docgen 只能解析单文件,且不能解析 IIFE、逗号表达式等语法结构 (一般出现在转码后的代码中)。笔者对其进行改造,使之可以递归解析多文件去查找组件定义,且能够解开 IIFE,以及对逗号表达式进行转换,以方便后续的组件解析。另外,还增加了子组件解析的功能,即类似 `Button.Group = Group` 这种定义。 + +#### 静态解析 TS +在 TS 情况下,还要再细分为 TS 源码和 TS 编译后的代码。 +TS 源码中,React 组件具有类型签名;TS 编译后的代码中,dts 文件 (如有) 包含全部的 class / interface / type 类型信息。可以从这些类型信息中获取组件属性描述。整体流程图如下: + +![image.png](https://img.alicdn.com/imgextra/i1/O1CN014lOIIy1FUvGW6wcYZ_!!6000000000491-2-tps-2280-240.png) + +react-docgen 内置了 TypeScript 的 babel 插件,所以也具备解析 interface 的能力,可惜能力有限,babel 只能解析 TS 代码,但没法做类型检查,类型处理是由 react-docgen 实现的,它对于 extends/implements/utility 的情况处理不好,并且没有类型推断,虽然可以对其功能进行完善,不过这种情况下,应该借助 TypeScript Compiler 的能力,而非自己造轮子。通过调研,发现市面上有 typescript-react-docgen 这个项目。它在底层依赖了 TypeScript,且产出的数据格式与 react-docgen 一致,所以我们选择基于它进行解析。 + +TypeScript Compiler 会递归解析某个文件中出现及引用的全部类型,当然,前提是已经定义或安装了相应的类型声明。typescript-react-docgen 会调用 TypeScript Compiler 的 API,获取每个文件输出的类型,判断其是否为 React 组件。满足下列条件之一的,会被判定为 React 组件: + +1. 获取其函数签名,如果只有一个入参,或者第一个入参名称为 props,会被判定为函数式组件; +2. 获取其 `constructor` 方法,如果其返回值包含 props 属性,会被判定为有状态组件。 + +然后,遍历组件的 props 类型,获取每个属性的类型签名字符串,比如 `(a: string) => void`。typescript-react-docgen 可以克服 react-docgen 解析 TypeScirpt 类型的问题,但是每个类型都以字符串的形式来呈现,不利于后续的解析。所以,笔者对其进行了扩展,递归解析每一层的属性值。此外,在函数式组件的判定上,笔者做了完善,会看函数的返回值是否为 `ReactElement` ,若是,才为函数式组件。 + +下面讲对于一些特殊情况的处理。 + +**循环定义** + +TypeScript 类型可以循环定义,比如下面的 JSON 类型: + +```typescript +interface Json { + [x: string]: string | number | boolean | Json | JsonArray; +} +type JsonArray = Array; +``` + +因为低代码组件描述协议中没有引用功能,而且也不方便在界面上展示出来,所以这种循环定义无需完全解析,入料模块会在检测到循环定义的时候,把类型简化为 `object` 。对于特殊的类型,如 JSON,可以用相应的 Setter 来编辑。 + +**复杂类型** +TypeScript Compiler 会将合成类型的所有属性展开,比如 `boolean | string`,会被展开为 `true | false | string`,这带来了不必要的精确,我们需要的只是 `boolean | string` 而已。当然,对于这个例子,我们很容易把它还原回 `boolean | string`,然而,对于诸如 `React.ButtonHTMLAttributes & {'data-name': string}` 这种类型,它会把 `ButtonHTMLAttributes` 中众多的属性和 `data-name` 混杂在一起,完全无法分辨,只能以展开的形式提供。这 100 多个属性,如果都放在设置面板,绝对是使用者的噩梦,所以,其结果会被简化为 `object` 。当然,即使没有 `{'data-name': string}`,`ButtonHTMLAttributes` 也是没有单独的 Setter 的,同样会被简化为 `object` 。 + +### 动态解析 + +当一个组件,使用静态解析无法入料时,会使用动态解析。 + +整体流程图如下: + +![image.png](https://img.alicdn.com/imgextra/i2/O1CN01dJ62Dm1u5de8GihG6_!!6000000005986-2-tps-2516-449.png) + +基本思想很简单,require 组件进来,然后读取其组件类上定义的 propTypes 和 defaultProps 属性。这里使用了 parse-prop-types 库,使用它的时候必须在组件之前引用,因为它会先对 prop-types 库进行修改,在每个 PropTypes 透出的函数上挂上类型,比如 string, number 等等,然后再去遍历。动态解析可以解析出全部的类型信息,因为 PropTypes 有可能引入依赖组件的一些类型定义,这在静态解析中很难做到,或者成本较高,而对于动态解析来说,都由运行时完成了。 + +##### 技术细节 + +值得注意的是,有些 js 文件里还会引入 css 文件,而且从笔者了解的情况来看,这种情况在集团内部不在少数。这种组件不配合 webpack 使用,肯定会报错,但是使用 webpack 会明显拖慢速度,所以笔者采用了 sandbox 的方式,对 require 进来的类 css 文件进行 mock。这里,笔者使用了 vm2 这个库,它对 node 自带的 vm 进行了封装,可以劫持文件中的 require 方法。因为 parse-prop-types 的修改在沙箱中会失效,所以笔者也 mock 了组件中的 prop-types 库。 + +### 整体大图 +把上述的静态解析和动态解析流程结合起来,可以得到以下大图。 + +![image.png](https://img.alicdn.com/imgextra/i1/O1CN01TA9lQp27QmwVT7WUC_!!6000000007792-2-tps-2658-1072.png) diff --git a/docs/docs/guide/design/renderer.md b/docs/docs/guide/design/renderer.md new file mode 100644 index 0000000000..4a8c43f329 --- /dev/null +++ b/docs/docs/guide/design/renderer.md @@ -0,0 +1,215 @@ +--- +title: 渲染模块设计 +sidebar_position: 4 +--- +## 低代码渲染介绍 + + + +基于 Schema 和物料组件,如何渲染出我们的页面?这一节描述的就是这个。 + +## npm 包与仓库信息 + +- React 框架渲染 npm 包:@alilc/lowcode-react-renderer +- 仓库:[https://github.com/alibaba/lowcode-engine](https://github.com/alibaba/lowcode-engine) 下的 + - packages/renderer-core + - packages/react-renderer + - packages/react-simulator-renderer + +## 渲染框架原理 +### 整体架构 + +![image.png](https://img.alicdn.com/imgextra/i1/O1CN01i4IiSR1cMtUFXaWQq_!!6000000003587-2-tps-1686-1062.png) + +- 协议层:基于[《低代码引擎搭建协议规范》](/site/docs/specs/lowcode-spec) 产出的 Schema 作为我们的规范协议。 +- 能力层:提供组件、区块、页面等渲染所需的核心能力,包括 Props 解析、样式注入、条件渲染等。 +- 适配层:由于我们使用的运行时框架不是统一的,所以统一使用适配层将不同运行框架的差异部分,通过接口对外,让渲染层注册/适配对应所需的方法。能保障渲染层和能力层直接通过适配层连接起来,能起到独立可扩展的作用。 +- 渲染层:提供核心的渲染方法,由于不同运行时框架提供的渲染方法是不同的,所以其通过适配层进行注入,只需要提供适配层所需的接口,即可实现渲染。 +- 应用层:根据渲染层所提供的方法,可以应用到项目中,根据使用的方法和规模即可实现应用、页面、区块的渲染。 + +### 核心解析 + +这里主要解析一下刚刚提到的架构中的适配层和渲染层。 + +#### 适配层 +适配层提供的是各个框架之间的差异项。比如 `React.createElement` 和 `Rax.createElement` 方法是不同的。所以需要在适配层对 API 进行抹平。 + +##### React +```typescript +import { createElement } from 'react'; +import { + adapter, +} from '@ali/lowcode-renderer-core'; + +adapter.setRuntime({ + createElement, +}); +``` +##### Rax +```typescript +import { createElement } from 'rax'; +import { + adapter, +} from '@ali/lowcode-renderer-core'; + +adapter.setRuntime({ + createElement, +}); +``` +这时,在核心层使用的 `createElement` 会基于使用不同的 renderer 而使用不同的方法,自动适配框架所需的运行时方法。 + +所需的方法包括: + +- `setRuntime`:设置运行时相关方法 + - `Component`:组件类,参考 React 的 `Component`。 + - `PureComponent`:组件类,参考 React 的 `PureComponent`。 + - `createContext`:创建一个 `Context` 对象的方法。例如,当 React 渲染一个订阅了这个 `Context` 对象的组件,这个组件会从组件树中离自身最近的那个匹配的 `Provider` 中读取到当前的 `context` 值。 + - `createElement`:创建 `Component` 元素,例如在 React 中即为创建 React 元素。 + - `forwardRef`:ref 转发的方法。Ref 转发是一个可选特性,其允许某些组件接收 ref,并将其向下传递(换句话说,“转发”它)给子组件。 + - `findDOMNode`:是一个访问底层 DOM 节点的方法。如果组件已经被挂载到 DOM 上,此方法会返回浏览器中相应的原生 DOM 元素。 +- `setRenderers` + - `PageRenderer`:页面渲染的方法。可以定制页面渲染的生命周期,定制导航,定制路由等。 + - `ComponentRenderer`:组件渲染的方法。 + - `BlockRenderer`:区块渲染的方法。 + +#### 渲染层 +##### React Renderer +内部的技术栈统一都是 React,大多数适配层的 API 都是按照 React 来设计的,所以对于 React Renderer 来说,需要做的不多。 + +React Renderer 的代码量很少,主要是将 React API 注册到适配层中。 + +```typescript +import React, { Component, PureComponent, createElement, createContext, forwardRef, ReactInstance, ContextType } from 'react'; +import ReactDOM from 'react-dom'; +import { + adapter, + pageRendererFactory, + componentRendererFactory, + blockRendererFactory, + addonRendererFactory, + tempRendererFactory, + rendererFactory, + types, +} from '@ali/lowcode-renderer-core'; +import ConfigProvider from '@alifd/next/lib/config-provider'; + +window.React = React; +(window as any).ReactDom = ReactDOM; + +adapter.setRuntime({ + Component, + PureComponent, + createContext, + createElement, + forwardRef, + findDOMNode: ReactDOM.findDOMNode, +}); + +adapter.setRenderers({ + PageRenderer: pageRendererFactory(), + ComponentRenderer: componentRendererFactory(), + BlockRenderer: blockRendererFactory(), + AddonRenderer: addonRendererFactory(), + TempRenderer: tempRendererFactory(), + DivRenderer: blockRendererFactory(), +}); + +adapter.setConfigProvider(ConfigProvider); +``` + +##### Rax Renderer +Rax 的大多数 API 和 React 基本也是一致的,差异点在于重写了一些方法。 +```typescript +import { Component, PureComponent, createElement, createContext, forwardRef } from 'rax'; +import findDOMNode from 'rax-find-dom-node'; +import { + adapter, + addonRendererFactory, + tempRendererFactory, + rendererFactory, +} from '@ali/lowcode-renderer-core'; +import pageRendererFactory from './renderer/page'; +import componentRendererFactory from './renderer/component'; +import blockRendererFactory from './renderer/block'; +import CompFactory from './hoc/compFactory'; + +adapter.setRuntime({ + Component, + PureComponent, + createContext, + createElement, + forwardRef, + findDOMNode, +}); + +adapter.setRenderers({ + PageRenderer: pageRendererFactory(), + ComponentRenderer: componentRendererFactory(), + BlockRenderer: blockRendererFactory(), + AddonRenderer: addonRendererFactory(), + TempRenderer: tempRendererFactory(), +}); +``` + +### 多模式渲染 +#### 预览模式渲染 +预览模式的渲染,主要是通过 Schema、components 即可完成上述的页面渲染能力。 +```typescript +import ReactRenderer from '@ali/lowcode-react-renderer'; +import ReactDOM from 'react-dom'; +import { Button } from '@alifd/next'; + +const schema = { + componentName: 'Page', + props: {}, + children: [ + { + componentName: 'Button', + props: { + type: 'primary', + style: { + color: '#2077ff' + }, + }, + children: '确定', + }, + ], +}; + +const components = { + Button, +}; + +ReactDOM.render(( + +), document.getElementById('root')); +``` + +#### 设计模式渲染(Simulator) +设计模式渲染就是将编排生成的《搭建协议》渲染成视图的过程,视图是可以交互的,所以必须要处理好内部数据流、生命周期、事件绑定、国际化等等。也称为画布的渲染,画布是 UI 编排的核心,它一般融合了页面的渲染以及组件/区块的拖拽、选择、快捷配置。 +画布的渲染和预览模式的渲染的区别在于,画布的渲染和设计器之间是有交互的。所以在这里我们新增了一层 `Simulator` 作为设计器和渲染的连接器。 +`Simulator` 是将设计器传入的 `DocumentModel` 和组件/库描述转成相应的 Schema 和 组件类。再调用 Render 层完成渲染。我们这里介绍一下它提供的能力。 +##### 整体架构 +![image.png](https://img.alicdn.com/imgextra/i2/O1CN017cYBAp1hvJKPUVLbx_!!6000000004339-2-tps-1500-864.png) + +- `Project`:位于顶层的 Project,保留了对所有文档模型的引用,用于管理应用级 Schema 的导入与导出。 +- `Document`:文档模型包括 Simulator 与数据模型两部分。Simulator 通过一份 Simulator Host 协议与数据模型层通信,达到画布上的 UI 操作驱动数据模型变化。通过多文档的设计及多 Tab 交互方式,能够实现同时设计多个页面,以及在一个浏览器标签里进行搭建与配置应用属性。 +- `Simulator`:模拟器主要承载特定运行时环境的页面渲染及与模型层的通信。 +- `Node`:节点模型是对可视化组件/区块的抽象,保留了组件属性集合 Props 的引用,封装了一系列针对组件的 API,比如修改、编辑、保存、拖拽、复制等。 +- `Props`:描述了当前组件所维系的所有可以「设计」的属性,提供一系列操作、遍历和修改属性的方法。同时保持对单个属性 Prop 的引用。 +- `Prop`:属性模型 Prop 与当前可视化组件/区块的某一具体属性想映射,提供了一系列操作属性变更的 API。 +- `Settings`:`SettingField` 的集合。 +- `SettingField`:它连接属性设置器 `Setter` 与属性模型 `Prop`,它是实现多节点属性批处理的关键。 +- 通用交互模型:内置了拖拽、活跃追踪、悬停探测、剪贴板、滚动、快捷键绑定。 + +##### 模拟器介绍 +![image.png](https://img.alicdn.com/imgextra/i2/O1CN01GF1PMj288kxovvnK8_!!6000000007888-2-tps-1500-740.png) + +- 运行时环境:从运行时环境来看,目前我们有 React 生态、Rax 生态。而在对外的历程中,我们也会拥有 Vue 生态、Angular 生态等。 +- 布局模式:不同于 C 端营销页的搭建,中后台场景大多是表单、表格,流式布局是主流的选择。对于设计师、产品来说,是需要绝对布局的方式来进行页面研发的。 +- 研发场景:从研发场景来看,低代码搭建不仅有页面编排,还有诸如逻辑编排、业务编排的场景。 + +基于以上思考,我们通过基于沙箱隔离的模拟器技术来实现了多运行时环境(如 React、Rax、小程序、Vue)、多模式(如流式布局、自由布局)、多场景(如页面编排、关系图编排)的 UI 编排。通过注册不同的运行时环境的渲染模块,能够实现编辑器从 React 页面搭建到 Rax 页面搭建的迁移。通过注册不同的模拟器画布,你可以基于 G6 或者 mxgraph 来做关系图编排。你可以定制一个流式布局的画布,也可以定制一个自由布局的画布。 diff --git a/docs/docs/guide/design/setter.md b/docs/docs/guide/design/setter.md new file mode 100644 index 0000000000..7afbbf034f --- /dev/null +++ b/docs/docs/guide/design/setter.md @@ -0,0 +1,92 @@ +--- +title: 设置器设计 +sidebar_position: 6 +--- + +设置器,又称为 Setter,是作为物料属性和用户交互的重要途径,在编辑器日常使用中有着非常重要的作用,本文重点介绍 Setter 的设计原理和使用方式,帮助用户更好的理解 Setter。 + +在编辑器的右边区域,Setter 的区块就展现在这里,如下图: + +![image.png](https://img.alicdn.com/imgextra/i4/O1CN01qEjjoQ24QNkD42wzl_!!6000000007385-2-tps-3836-1730.png) + +其中包含 属性、样式、事件、高级: + +- 属性:展示该物料常规的属性; +- 样式:展示该物料样式的属性; +- 事件:如果该物料有声明事件,则会出现事件面板,用于绑定事件; +- 高级:两个逻辑相关的属性,**条件渲染**和**循环。** +## npm 包与仓库信息 + +- npm 包:@alilc/lowcode-engine-ext +- 仓库:[https://github.com/alibaba/lowcode-engine-ext](https://github.com/alibaba/lowcode-engine-ext) + +## 设置器模块原理 + +![image.png](https://img.alicdn.com/imgextra/i2/O1CN01EAmitQ1U5TUws63AV_!!6000000002466-2-tps-1534-964.png) + +设置面板依赖于以下三块抽象 + +- 编辑器上下文 `editor`,主要包含:消息通知、插件引用等 +- 设置对象 `settingTarget`,主要包含:选中的节点、是否同一值、值的储存等 +- 设置列 `settingField`,主要和当前设置视图相关,包含视图的 `ref`、以及设置对象 `settingTarget` + +### SettingTarget 抽象 + +如果不是多选,可以直接暴露 `Node` 给到这,但涉及多选编辑的时候,大家的值通常是不一样的,设置的时候需要批量设置进去,这里主要封装这些逻辑,把多选编辑的复杂性屏蔽掉。 + +所选节点所构成的**设置对象**抽象如下: + +```typescript +interface SettingTarget { + // 所设置的节点集,至少一个 + readonly nodes: Node[]; + // 所有属性值数据 + readonly props: object; + // 设置属性值 + setPropValue(propName: string, value: any): void; + // 获取属性值 + getPropValue(propName: string): any; + // 设置多个属性值,替换原有值 + setProps(data: object): void; + // 设置多个属性值,和原有值合并 + mergeProps(data: object): void; + // 绑定属性值发生变化时 + onPropsChange(fn: () => void): () => void; +} +``` + +基于设置对象所派生的**设置目标属性**抽象如下: + +```typescript +interface SettingTargetProp extends SettingTarget { + // 当前属性名称 + readonly propName: string; + // 当前属性值 + value: any; + // 是否设置对象的值一致 + isSameValue(): boolean; + // 是否是空值 + isEmpty(): boolean; + // 设置属性值 + setValue(value: any): void; + // 移除当前设置 + remove(): void; +} +``` + +### SettingField 抽象 +![image.png](https://img.alicdn.com/imgextra/i2/O1CN01D855j01j8sg9GdtJr_!!6000000004504-2-tps-2022-402.png) + +```typescript +interface SettingField extends SettingTarget { + // 当前 Field 设置的目标属性,为 group 时此值为空 + readonly prop?: SettingTargetProp; + + // 当前设置项的 ref 引用 + readonly ref?: ReactInstance; + + // 属性配置描述传入的配置 + readonly config: SettingConfig; + // others.... +} +``` diff --git a/docs/docs/guide/design/specs.md b/docs/docs/guide/design/specs.md new file mode 100644 index 0000000000..2e8e4c195c --- /dev/null +++ b/docs/docs/guide/design/specs.md @@ -0,0 +1,89 @@ +--- +title: 协议栈简介 +sidebar_position: 1 +--- +## 什么是低代码协议 +低代码引擎体系基于三份协议来构建,分别是 [《低代码引擎搭建协议规范》](/site/docs/specs/lowcode-spec)、[《低代码引擎物料协议规范》](/site/docs/specs/material-spec)和[《低代码引擎资产包协议规范》](/site/docs/specs/assets-spec), 它们保障了低代码领域的标准化,成为了生态建设和流通的基石。 + +![image.png](https://img.alicdn.com/imgextra/i3/O1CN01axsOyW1s01YgXnT8z_!!6000000005703-2-tps-1888-1000.png) + +## 为什么需要协议 + +首先,我们做一个不恰当的类比,我们将低代码引擎和 JavaScript 语言做一下类别。还记得之前,大家都被浏览器兼容性支配的恐惧,特别是 IE 和其他浏览器,对上层 API 实现的不一致,导致一份代码需要运行在两端需要做适配。当浏览器 / JavaScript 相关的标准出现之后,各个浏览器进行了 API 的统一,使得我们终于可以从这部分工作中解放出来(PS:Babel 对于语言特性的转换是另一个方面的问题)。 + +而在《低代码引擎搭建协议规范》出现之前,低代码领域也有类似的问题。 + +### 概念不通 + +在交流的过程中,一些对于搭建产品的术语的不一致,导致了一些沟通成本,不管是在文章分享、技术分享、交流会上,都会有这个问题。 + +### 物料孤岛 + +由于低代码产品实现的方式不同,物料的消费方式也各不相同。这里分为两种物料,低代码物料和 ProCode 物料。 + +对于低代码物料来说,A 平台创建的物料无法使用到 B 平台上,如果想在 B 平台实现同样的物料,需要按照 B 平台的标准搭建一份物料。 + +对于 ProCode 物料来说,需要在低代码平台进行消费,是需要进行转换的,包括搭建配置项的生成、物料搭建视图等,可能还需要特殊的描述文件进行描述。由于这一层没有统一,同一份 ProCode 物料每接入一个低代码,可能需要的描述文件格式不同,转换的代码不同,使用的工具也不同。 + +### 生态隔离 + +不同低代码平台的生态体系也不相同,有的低代码平台的物料生态不错,有的低代码平台的搭建体验生态不错。但是这些利好的生态,都是无法互通的,甚至就算知道了代码也无法复用,因为底层是不一致的。对于阿里巴巴集团来说,每一个平台都创建一份自己的生态,这并不是利好的。 + +### 低水平重复建设 + +大家可能觉得,以上问题对于自己造轮子来说,其实也是有利的,因为自己得到了技术上的成长。 + +但是对于低代码的平台方,实际上更多的工作,在物料的转化、物料的生成、搭建体验的小优化、部分其他平台生态的实现。这些的技术深度其实并不高,属于低水平重复建设部分。 + +### 价值不高 + +如果每个业务都要从 0 开始做,做自己的平台,会花费大量的时间来构建底层基础设施,对业务本身而言并不是一件好事;而且前端领域的底层基础设施都大同小异,不同团队重复构建造成了极大的资源浪费。 + +这样的建设,会导致从 0 到 1 都需要花费大量的时间,往往在内部人力不足、投入有限时,产品很容易在未发展壮大的时候就面临了死亡相关的决策。 + +设想一下,如果可以开发一份全集团低代码平台都可以使用的物料,是否更有成就感呢?如果可以基于已有生态进行低代码平台的快速落地,而不是花费 1-2 年搭建一个可用的低代码平台,再验证市场。在快速的验证之后,再进行更深入的打磨,这其中的思考和技术含量是否更优于之前的模式呢? + +以 2019 年的阿里巴巴的情况举例,不同平台的低代码物料但不限于: + +1. vc-deep — vc 协议 + Deep 组件库 (阿里巴巴企业智能团队基于 Fusion Next 定制); +2. Iceluna 协议 + Fusion Next; +3. AIMake 物料; +4. vc-fusion-basic + 业务改造 — vc 协议 + Fusion Next(各业务 Fork 定制); +5. vision 魔改 + vc 协议扩展 + fusion 业务组件; +6. vc 协议 + antd; + +可以看到,各个搭建平台都需要维护一套自己的基础组件库,这是非常不合理的,对基础组件库的维护会分散开发同学完成业务目标的精力。 + +建立统一的低代码领域标准化,是百利而无一害的。于是,在阿里巴巴集团 2020 年进行了讨论,建立了搭建治理&物料流通战役,此战役便产出了上文中的协议规范,成为了低代码引擎和其生态的基础。 + +## 协议的作用 + +基于统一的协议,我们完成业务组件、区块、模板等各类物料的标准统一,各类中后台研发系统生产的物料可借助物料中心进行跨系统流通,通过丰富物料生态的共享提升各平台研发系统的效率。同时完成低代码引擎的标准统一以及低代码搭建中台能力的输出,帮助业务方快速孵化本业务域中后台研发系统。 + +### 打破物料孤岛 + +#### 物料中心 + +这里以阿里集团的前端物料中间建设为例,在《低代码引擎物料协议规范》落地之后,建立了阿里巴巴各个中后台研发平台沟通、对话的基础,物料流通的先决条件已经成熟,这个时候我们还需要一个统一的物料源,用于管理物料的上传、存储、检索、分发,一个典型的中心化架构,类似 npm 的管理,这便是我们物料中心。 + +Fusion Market 是物料中心的前身,它提供了业务组件的存储、文档展示和全局透出的功能,由于 fusion 体系在集团内的广泛使用,Fusion Market 沉淀了不少的业务组件,但是这个项目却一直不温不火,只看到业务组件数量的增加,却未看到物料流通起来。其中一个原因是,没有阿里巴巴前端委员会的背书,规范很难统一,规范如果不统一,物料就很难流通; + +在规范成立之后,物料中心也将有了建设的基础,最终于 2019 年建立了物料中心,提供了物料流通的平台能力。 + +#### 低代码基础物料 + +就像 AntD、Element 之于源码研发模式,在低代码研发模式下各个搭建平台也需要一套统一的、开箱即用的低代码基础组件库。基于低代码描述协议完成了两份低代码基础物料的建设,即“Fusion 低代码基础组件库”和“AntD 低代码基础组件库”。 + +#### 源码组件低代码化 + +将源码组件一键转化为低代码物料,符合低代码物料规范,可以在低代码平台进行流通。 +### 低代码物料中心 + +当低代码物料积累到一定的量级之后,所有的搭建平台的业务物料越来越多。这些物料通过低代码物料中心进行统一的管理和消费。 +### 设置器生态的基础 + +Snippet(组件默认搭建 schema ) 由《低代码引擎搭建协议规范》定义,低代码引擎会按照规范对组件进行渲染,Configure 由《低代码引擎物料协议规范》定义,它描述了组件的 props 以及每个 prop 对应的设置器 (Prop 配置面板),低代码引擎提供了 20+ 个内置设置器,但如果我们组件的 props 超出了引擎内置设置器的范围,就需要我们自己来开发对应设置器。 +设置器最终也慢慢形成了自己的生态,这使得开发物料更加容易,可以使用已有的生态中的设置器,进行物料配置描述。 +### 低代码引擎实现标准 + +低代码引擎是以上生态的消费端,它是实现了标准协议的低代码引擎。这是不可或缺的部分,低代码引擎这里就相当于一个标准浏览器,一方面给其他的低代码平台提供了一个 Demo,其他平台可以参考低代码引擎进行实现,满足官方协议,便也可以消费相关的物料生态和其他生态。 diff --git a/docs/docs/guide/design/summary.md b/docs/docs/guide/design/summary.md new file mode 100644 index 0000000000..38d523cac9 --- /dev/null +++ b/docs/docs/guide/design/summary.md @@ -0,0 +1,69 @@ +--- +title: 架构综述 +sidebar_position: 0 +--- +## 分层架构描述 +![image.png](https://img.alicdn.com/imgextra/i4/O1CN016l8gDo1z7zlRlW1P0_!!6000000006668-2-tps-1920-1080.png) + +我们设计了这样一套分层架构,自下而上分别是协议 - 引擎 - 生态 - 平台。 + +- 底层协议栈定义的是标准,**标准的统一让上层产物的互通成为可能**。 +- 引擎是**对协议的实现**,同时通过能力的输出,向上**支撑生态开放体系**,提供各种生态扩展能力。 +- 生态就好理解了,是基于引擎核心能力上扩展出来的,比如物料、设置器、插件等,还有工具链支撑开发体系。 +- 最后,各个平台基于引擎内核以及生态中的产品组合、衔接形成满足其需求的低代码平台。 + +**每一层都明确自身的定位,各司其职,协议不会去思考引擎如何实现,引擎也不会实现具体上层平台功能,上层平台的定制化均通过插件来实现,这些理念将会贯穿我们体系设计、实现的过程。** + +## 引擎内核简述 + +![image.png](https://img.alicdn.com/imgextra/i1/O1CN01QUUVu21LjTXqY6H8I_!!6000000001335-2-tps-1920-1080.png) + +低代码引擎分为 4 大模块,入料 - 编排 - 渲染 - 出码: + +- 入料模块就是将外部的物料,比如海量的 npm 组件,按照[《低代码引擎物料协议规范》](/site/docs/specs/material-spec)进行描述。将描述后的数据通过引擎 API 注册后,在编辑器中使用。 + > **注意,这里仅是增加描述,而非重写一套,这样我们能最大程度复用 ProCode 体系已沉淀的组件。** +- 编排,本质上来讲,就是**不断在生成符合[《低代码引擎搭建协议规范》](/site/docs/specs/lowcode-spec)的页面描述,将编辑器中的所有物料,进行布局设置、组件 CRUD 操作、以及 JS / CSS 编写/ 逻辑编排 **等,最终转换成页面描述,技术细节后文会展开。 +- 渲染,顾名思义,就是**将编排生成的页面描述结构渲染成视图的过程**,视图是面向用户的,所以必须处理好内部数据流、生命周期、事件绑定、国际化等。 +- 出码,就是**将编排过程产生的符合[《低代码引擎搭建协议规范》](/site/docs/specs/lowcode-spec)的页面描述转换成另一种 DSL 或 编程语言代码的过程**。 + +## 引擎生态简述 + +![image.png](https://img.alicdn.com/imgextra/i2/O1CN01LkRseZ23W31l8DPzS_!!6000000007262-2-tps-1920-1080.png) + +![image.png](https://img.alicdn.com/imgextra/i4/O1CN01PYBVfZ1hL82XPrXzX_!!6000000004260-2-tps-1920-1080.png) + +引擎生态主要分为 3 部分,物料、设置器和插件。 + +### 物料生态 + +物料是低代码平台的生产资料,没有物料低代码平台则变成了无源之水无本之木。低代码平台的物料即低代码组件。因此低代码物料生态指的是: +1. 低代码物料生产能力和规范。 +2. 对低代码物料进行统一管理的物料中心。 +3. 基于 Fusion Next 的低代码基础组件库。 + +### 设置器生态 + +对于已接入物料的属性配置,需要不同的设置器。 + +比如配置数值类型的 age,需要一个数值设置器,配置对象类型的 hobby,需要一个对象设置器,依次类推。 + +每个设置器本质上都是一个 React 组件,接受由引擎传入的参数,比如 value 和 onChange,value 是初始传入的值,onChange 是在设置器的值变化时的回传函数,将值写回到引擎中。 + +```typescript +// 一个最简单的文本设置器示例 +class TextSetter extends Component { + render() { + const { value, onChange } = this.props; + return onChange(e.target.value)} />; + } +} +``` + +大多数组件所使用的设置器都是一致或相似的。如同建设低代码基础组件库一样,设置器生态是一组基础的设置器,供大多数组件配置场景使用。 + +同时提供了设置器的定制功能。 + +### 插件生态 +低代码引擎本身只包含了最小的内核,而我们所能看到的设计器上的按钮、面板等都是插件提供的。插件是组成设计器的必要部分。 + +因此我们提供了一套官方的插件生态,提供最基础的设计器功能。帮助用户通过使用插件,快速完成自己的设计器。 diff --git a/docs/docs/guide/expand/_category_.json b/docs/docs/guide/expand/_category_.json new file mode 100644 index 0000000000..15aeb3dea1 --- /dev/null +++ b/docs/docs/guide/expand/_category_.json @@ -0,0 +1,6 @@ +{ + "label": "扩展低代码编辑器", + "position": 2, + "collapsed": false, + "collapsible": true +} diff --git a/docs/docs/guide/expand/editor/_category_.json b/docs/docs/guide/expand/editor/_category_.json new file mode 100644 index 0000000000..52662a9d1e --- /dev/null +++ b/docs/docs/guide/expand/editor/_category_.json @@ -0,0 +1,6 @@ +{ + "label": "扩展编辑态", + "position": 1, + "collapsed": false, + "collapsible": true +} diff --git a/docs/docs/guide/expand/editor/cli.md b/docs/docs/guide/expand/editor/cli.md new file mode 100644 index 0000000000..0577a181db --- /dev/null +++ b/docs/docs/guide/expand/editor/cli.md @@ -0,0 +1,198 @@ +--- +title: 低代码生态脚手架 & 调试机制 +sidebar_position: 10 +--- +## 脚手架简述 + +在 fork 低代码编辑器 demo 项目后,您可以直接在项目中任意扩展低代码编辑器。如果您想要将自己的组件/插件/设置器封装成一个独立的 npm 包并提供给社区,您可以使用我们的低代码脚手架建立低代码扩展。 + +> Windows 开发者请在 WSL 环境下使用开发工具 +> +> WSL 中文 doc:[https://docs.microsoft.com/zh-cn/windows/wsl/install](https://docs.microsoft.com/zh-cn/windows/wsl/install) +> +> 中文教程:[https://blog.csdn.net/weixin_45027467/article/details/106862520](https://blog.csdn.net/weixin_45027467/article/details/106862520) + + +## 脚手架功能 +### 脚手架初始化 + +```bash +npm init @alilc/element your-element-name +``` +不写 your-element-name 的情况下,则在当前目录创建。 + +> 注 1:如遇错误提示 `sh: create-element: command not found` 可先执行下述命令 +```bash +npm install -g @alilc/create-element +``` + +> 注 2:觉得安装速度比较慢的同学,可以设置 npm 国内镜像,如 +```bash +npm init @alilc/element your-element-name --registry=https://registry.npmmirror.com +``` + +选择对应的元素类型,并填写对应的问题,即可完成创建。 + +![image.png](https://img.alicdn.com/imgextra/i3/O1CN01LAaw2R1veHDYUzGB1_!!6000000006197-2-tps-676-142.png) + +### 脚手架本地环境调试 + +```bash +cd your-element-name +npm install +npm start +``` + +### 脚手架构建 + +```bash +npm run build +``` + +### 脚手架发布 + +修改版本号后,执行如下指令即可: + +```bash +npm publish +``` + +## 🔥🔥🔥 在低代码项目中调试物料/插件/设置器 + +> 📢📢📢 低代码生态脚手架提供的调试利器,在启动 setter/插件/物料 项目后,直接在已有的低代码平台就可以调试,不需要 npm link / 手改 npm main 入口等传统方式,轻松上手,强烈推荐使用!! + +### 组件/插件/Setter 侧 + +1. 插件/setter 在原有 alt 的配置中添加相关的调试配置 + ```json + // build.json 中 + { + "plugins": [ + [ + "@alilc/build-plugin-alt", + { + "type": "plugin", + "inject": true, // 开启注入调试 + // 配置要打开的页面,在注入调试模式下,不配置此项的话不会打开浏览器 + // 支持直接使用官方 demo 项目:https://lowcode-engine.cn/demo/index.html + "openUrl": "https://lowcode-engine.cn/demo/index.html?debug" + } + ], + ] + } + ``` + +2. 组件需先安装 @alilc/build-plugin-alt,再将组件内的 `build.lowcode.js`文件修改如下 + ```javascript + const { library } = require('./build.json'); + + module.exports = { + alias: { + '@': './src', + }, + plugins: [ + [ + // lowcode 的配置保持不变,这里仅为示意。 + '@alifd/build-plugin-lowcode', + { + library, + engineScope: "@alilc" + }, + ], + [ + '@alilc/build-plugin-alt', + { + type: 'component', + inject: true, + library, + // 配置要打开的页面,在注入调试模式下,不配置此项的话不会打开浏览器 + // 支持直接使用官方 demo 项目:https://lowcode-engine.cn/demo/index.html + openUrl: "https://lowcode-engine.cn/demo/index.html?debug" + } + ]], + }; + ``` + +3. 本地组件/插件/Setter正常启动调试,在项目的访问地址增加 debug,即可开启注入调试。 + ```url + https://lowcode-engine.cn/demo/demo-general/index.html?debug + ``` + +### 项目侧的准备 + +> 如果你的低代码项目 fork 自官方 demo,那么项目侧的准备已经就绪,不用再看以下内容~ + +1. 安装 @alilc/lowcode-plugin-inject + ```bash + npm i @alilc/lowcode-plugin-inject --save-dev + ``` + +2. 在引擎初始化侧引入插件 + ```typescript + import Inject, { injectAssets } from '@alilc/lowcode-plugin-inject'; + import { IPublicModelPluginContext } from '@alilc/lowcode-types'; + + export default async () => { + // 注意 Inject 插件必须在其他插件前注册,且所有插件的注册必须 await + await plugins.register(Inject); + await plugins.register(OtherPlugin); + await plugins.register((ctx: IPublicModelPluginContext) => { + return { + name: "editor-init", + async init() { + // 设置物料描述前,使用插件提供的 injectAssets 进行处理 + const { material, project } = ctx; + material.setAssets(await injectAssets(assets)); + }, + }; + }); + } + ``` + +3. 在 saveSchema 时过滤掉插入的 url,避免影响渲染态 + ```typescript + import { filterPackages } from '@alilc/lowcode-plugin-inject'; + export const saveSchema = async () => { + // ... + const packages = await filterPackages(editor.get('assets').packages); + window.localStorage.setItem( + 'packages', + JSON.stringify(packages), + ); + // ... + }; + ``` + +4. 如果希望预览态也可以注入调试组件,则需要在 preview 逻辑里插入组件 + ```javascript + import { injectComponents } from '@alilc/lowcode-plugin-inject'; + + async function init() { + // 在传递给 ReactRenderer 前,先通过 injectComponents 进行处理 + const components = await injectComponents(buildComponents(libraryMap, componentsMap)); + // ... + } + ``` + +注:若控制台出现如下错误,直接访问一次该 url 即可~ + +![image.png](https://img.alicdn.com/imgextra/i1/O1CN01cvKmeK1saCqpIxbLW_!!6000000005782-2-tps-1418-226.png) + + +## Meta 信息 +meta 信息是放在生态元素 package.json 中的一小段 json,用户可以通过 meta 了解到这个元素的一些基本信息,如元素类型,一些入口信息等。 + +```typescript +interface LcMeta { + type: 'plugin' | 'setter' | 'component'; // 元素类型,尚未实现 + pluginName: string; // 插件名,仅插件包含 + meta: { + dependencies: string[]; // 插件依赖的其他插件列表,仅插件包含 + engines: { + lowcodeEngine: string; // 适配的引擎版本 + } + prototype: string; // 物料描述入口,仅组件包含,尚未实现 + prototypeView: string; // 物料设计态入口,仅组件包含,尚未实现 + } +} +``` diff --git a/docs/docs/guide/expand/editor/graph.md b/docs/docs/guide/expand/editor/graph.md new file mode 100644 index 0000000000..a45f34baf0 --- /dev/null +++ b/docs/docs/guide/expand/editor/graph.md @@ -0,0 +1,155 @@ +--- +title: 图编排扩展 +sidebar_position: 8 +--- +## 项目运行 +### 前置准备 +1. 参考 https://lowcode-engine.cn/site/docs/guide/quickStart/start +2. 参考至Demo下载 https://lowcode-engine.cn/site/docs/guide/quickStart/start#%E4%B8%8B%E8%BD%BD-demo +### 选择demo-graph-x6 +在根目录下执行: +```bash +cd demo-graph-x6 +``` +### 安装依赖 +在 lowcode-demo/demo-graph-x6目录下执行: +```bash +npm install +``` +### 启动Demo +在 lowcode-demo/demo-graph-x6 目录下执行: +```bash +npm run start +``` +之后就可以通过 http://localhost:5556/ 来访问我们的 DEMO 了。 + +## 认识Demo +这里的Demo即通过图编排引擎加入了几个简单的物料而来,已经是可以面向真是用户的产品界面。 +![image.png](https://img.alicdn.com/imgextra/i1/O1CN016TbCI31hM2sJy8qkR_!!6000000004262-2-tps-5120-2726.png) +### 区域组成 +#### 顶部:操作区​ +- 右侧:保存到本地、重置页面、自定义按钮 +#### 顶部:工具区 +- 左侧:删除、撤销、重做、放大、缩小 +#### 左侧:面板与操作区​ +- 物料面板:可以查找节点,并在此拖动节点到编辑器画布中 +#### 中部:可视化页面编辑画布区域​ +- 点击节点/边在右侧面板中能够显示出对应组件的属性配置选项 +- 拖拽修改节点的排列顺序 +#### 右侧:组件级别配置​ +- 选中的组件:从页面开始一直到当前选中的节点/边位置,点击对应的名称可以切换到对应的节点上 +- 选中组件的配置:属性:节点的基础属性值设置 + +> 每个区域的组成都可以被替换和自定义来生成开发者需要的业务产品。 + +## 目录介绍 +![image.png](https://img.alicdn.com/imgextra/i3/O1CN01Luc8gr1tLq5QTbpb9_!!6000000005886-0-tps-832-1522.jpg) + +- public:与其他demo保持一致,均是lowcode engine所必要依赖 +- src + - plugins::自定义插件,完成了x6的切面回调处理功能 + - services:mock数据,真实场景中可能为异步获取数据 + +## 开发插件 +```typescript +function pluginX6DesignerExtension(ctx: IPublicModelPluginContext) { + return { + init() { + // 获取 x6 designer 内置插件的导出 api + const x6Designer = ctx.plugins['plugin-x6-designer'] as IDesigner; + + x6Designer.onNodeRender((model, node) => { + // @ts-ignore + // 自定义 node 渲染逻辑 + const { name, title } = model.propsData; + node.attr('text/textWrap/text', title || name); + }); + + x6Designer.onEdgeRender((model, edge) => { + // @ts-ignore + const { source, target, sourcePortId, targetPortId } = model.propsData; + console.log(sourcePortId, targetPortId); + requestAnimationFrame(() => { + edge.setSource({ cell: source, port: sourcePortId }); + edge.setTarget({ cell: target, port: targetPortId }); + }); + + // https://x6.antv.vision/zh/docs/tutorial/intermediate/edge-labels x6 标签模块 + // appendLabel 会触发 onEdgeLabelRender + edge.appendLabel({ + markup: Markup.getForeignObjectMarkup(), + attrs: { + fo: { + width: 120, + height: 30, + x: -60, + y: -15, + }, + }, + }); + }); + + x6Designer.onEdgeLabelRender((args) => { + const { selectors } = args + const content = selectors.foContent as HTMLDivElement + if (content) { + ReactDOM.render(
自定义 react 标签
, content) + } + }) + } + } +} + +pluginX6DesignerExtension.pluginName = 'plugin-x6-designer-extension'; + +export default pluginX6DesignerExtension; +``` +x6Designer为图实例暴露出来的一些接口,可基于此进行一些图的必要插件的封装,整个插件的封装完全follow低代码引擎的插件,详情可参考 https://lowcode-engine.cn/site/docs/guide/expand/editor/pluginWidget + +## 开发物料 +```bash +npm init @alilc/element your-material-demo +``` +![image.png](https://img.alicdn.com/imgextra/i3/O1CN01DCCqO82ADuhS8ztCt_!!6000000008170-2-tps-546-208.png) + +仓库初始化完成 +![image.png](https://img.alicdn.com/imgextra/i2/O1CN01qK2rUe1JNpdqbdhoW_!!6000000001017-0-tps-5120-2830.jpg) + +接下来即可编写物料内容了 +图物料与低代码的dom场景存在画布的差异,因此暂不支持物料单独调试,须通过项目demo进行物料调试 + +### 资产描述 +```bash +npm run lowcode:build +``` +如果物料是个React组件,则在执行上述命令时会自动生成对应的meta.ts,但图物料很多时候并非一个React组件,因此须手动生产meta.ts + +可参考: https://github.com/alibaba/lowcode-materials/blob/main/packages/graph-x6-materials/lowcode/send-email/meta.ts +同时会自动生成物料描述文件 + +### 物料调试 +#### 物料侧 +物料想要支持被项目动态inject调试,须在build.lowcode.js中加入 +```javascript +[ + '@alilc/build-plugin-alt', + { + type: 'component', + inject: true, + library + }, +] +``` +![image.png](https://img.alicdn.com/imgextra/i4/O1CN01HyXfL12992sDkOmOg_!!6000000008024-0-tps-5120-2824.jpg) + +本地启动 +```bash +npm run lowcode:dev +``` +#### 项目侧 +通过@alilc/lce-graph-core加载物料的天然支持了debug,因此无须特殊处理。 +若项目中自行加载,则参考 https://lowcode-engine.cn/site/docs/guide/expand/editor/cli +项目访问地址后拼接query "?debug"即可进入物料调试 +![image.png](https://img.alicdn.com/imgextra/i2/O1CN01ke58hT1aRoYJzkutk_!!6000000003327-2-tps-5120-2790.png) + + diff --git a/docs/docs/guide/expand/editor/material.md b/docs/docs/guide/expand/editor/material.md new file mode 100644 index 0000000000..6e4979553b --- /dev/null +++ b/docs/docs/guide/expand/editor/material.md @@ -0,0 +1,292 @@ +--- +title: 物料扩展 +sidebar_position: 1 +--- +## 物料简述 +物料是页面搭建的原料,按照粒度可分为组件、区块和模板: + +1. 组件:组件是页面搭建最小的可复用单元,其只对外暴露配置项,用户无需感知其内部实现; +2. 区块:区块是一小段符合低代码协议的 schema,其内部会包含一个或多个组件,用户向设计器中拖入一个区块后可以随意修改其内部内容; +3. 模板:模板和区块类似,也是一段符合低代码协议的 schema,不过其根节点的 componentName 需固定为 Page,它常常用于初始化一个页面; + +低代码编辑器中的物料需要进行一定的配置和处理,才能让用户在低代码平台使用起来。这个过程中,需要一份一份配置文件,也就是资产包。资产包文件中,针对每个物料定义了它们在低代码编辑器中的使用描述。 +## 资产包配置 +### 什么是低代码资产包 +![image.png](https://img.alicdn.com/imgextra/i4/O1CN01SQJfxh1Y8uwDXksaK_!!6000000003015-2-tps-3068-1646.png) +在低代码 Demo 中,我们可以看到,组件面板不只提供一个组件,组件是以集合的形式提供给低代码平台的,而低代码资产包正是这些组件构成集合的形式。 +**_它背后的 Interface,_**[**_在引擎中的定义摘抄如下_**](https://github.com/alibaba/lowcode-engine/blob/main/packages/types/src/assets.ts)**_:_** + +```typescript +export interface Assets { + version: string; // 资产包协议版本号 + packages?: Array; // 大包列表,external 与 package 的概念相似,融合在一起 + components: Array | Array; // 所有组件的描述协议列表 + sort: ComponentSort; // 新增字段,用于描述组件面板中的 tab 和 category +} + +export interface ComponentSort { + groupList?: String[]; // 用于描述组件面板的 tab 项及其排序,例如:["精选组件", "原子组件"] + categoryList?: String[]; // 组件面板中同一个 tab 下的不同区间用 category 区分,category 的排序依照 categoryList 顺序排列; +} + +export interface RemoteComponentDescription { + exportName: string; // 组件描述导出名字,可以通过 window[exportName] 获取到组件描述的 Object 内容; + url: string; // 组件描述的资源链接; + package: { // 组件 (库) 的 npm 信息; + npm: string; + } +} +``` +资产包协议 TS 描述 +### Demo 中的资产包 +在 Demo 项目中,自带了一份默认的资产包: +> [https://github.com/alibaba/lowcode-demo/blob/main/demo-general/src/services/assets.json](https://github.com/alibaba/lowcode-demo/blob/main/demo-general/src/services/assets.json) + +这份资产包里的物料是我们内部沉淀出的,用户可以通过这套资产包体验引擎提供的搭建、配置能力。 +**_在项目中正常注册资产包:_** +```typescript +import { material } from '@alilc/lowcode-engine'; +// 以任何方式引入 assets +material.setAssets(assets); +``` +**_以支持调试的方式注册资产包:_** +> 这样启动并部署出来的项目,可以通过在预览地址加上 ?debug 来调试本地物料。 +> 例如: +> - 通过插件初始化一个物料 +> - 按照参考文章配置物料支持调试 +> - 启动物料 +> - 访问:[https://lowcode-engine.cn/demo/demo-general/index.html?debug](https://lowcode-engine.cn/demo/demo-general/index.html) +> +详细参考:[低代码生态脚手架 & 调试机制](https://lowcode-engine.cn/site/docs/guide/expand/editor/cli) + +```typescript +import { material } from '@alilc/lowcode-engine'; +import Inject, { injectAssets } from '@alilc/lowcode-plugin-inject'; +await material.setAssets(await injectAssets(assets)); +``` + +### 手工配置资产包 +参考 Demo 中的[基础 Fusion Assets 定义](https://github.com/alibaba/lowcode-demo/blob/main/demo-basic-fusion/src/services/assets.json),如果我们修改 assets.json,我们就能做到配置资产包: + +- packages 对象:我们需要在其中定义这个包的获取方式,如果不定义,就不会被低代码引擎动态加载并对应上组件实例。定义方式是 UMD 的包,低代码引擎会尝试在 window 上寻找对应 library 的实例; +- components 对象:我们需要在其中定义物料描述,物料描述我们将在下一节继续讲解。 +## 物料描述配置 +### 什么是物料描述 +在低代码平台中,用户是不同的,有可能是开发、测试、运营、设计,也有可能是销售、行政、HR 等等各种角色。他们大多数不具备专业的前端开发知识,对于低代码平台来说,我们使用组件的流程如下: + +1. 用户通过拖拽/选择组件,在画布中看到组件; +2. 选中组件,出现组件的配置项; +3. 修改组件配置项; +4. 画布更新生效。 + +**_当我们选中一个组件,我们可以看到面板右侧会显示组件的配置项。_** +![image.png](https://img.alicdn.com/imgextra/i4/O1CN01T5hGcl25ABLpLIWKh_!!6000000007485-2-tps-1500-743.png) +**_它包含以下内容:_** + +1. 基础信息:描述组件的基础信息,通常包含包信息、组件名称、标题、描述等。 +2. 组件属性信息:描述组件属性信息,通常包含参数、说明、类型、默认值 4 项内容。 +3. 能力配置/体验增强:推荐用于优化搭建产品编辑体验,定制编辑能力的配置信息。 + +因此,我们设计了[**《中后台低代码组件描述协议》**](/site/docs/specs/material-spec)来描述一个低代码编辑器中可被配置的内容。 +### Demo 中的物料描述 +我们可以从 Demo 中的 assets.json 找到如下三个物料描述: + +- @alifd/pro-layout:布局组件,放在`window.AlifdProLayoutMeta`,[meta 文件地址](https://alifd.alicdn.com/npm/@alifd/pro-layout@1.0.1-beta.5/build/lowcode/meta.js); +- @alifd/fusion-ui:精选组件,放在`window.AlifdFusionUiMeta`,[meta 文件地址](https://alifd.alicdn.com/npm/@alifd/fusion-ui@1.0.5-beta.1/build/lowcode/meta.js); +- @alilc/lowcode-materials:原子组件,放在 `window.AlilcLowcodeMaterialsMeta`,[meta 文件地址](https://alifd.alicdn.com/npm/@alilc/lowcode-materials@1.0.1/build/lowcode/meta.js); + +**_引擎中,会尝试调用对应 meta 文件,并注入到全局:_** +```tsx +const src = 'https://alifd.alicdn.com/npm/@alifd/pro-layout@1.0.1-beta.5/build/lowcode/meta.js'; +const script = document.createElement('script'); +script.src = src; +document.head.appendChild(script); +``` +然后在 window 上就能拿到对应的物料描述内容了: +![image.png](https://img.alicdn.com/imgextra/i4/O1CN01DHSEOH1RwCEq19Ro9_!!6000000002175-2-tps-1896-1138.png) +手工配置物料描述时,可以用这样的方式参考一下 Demo 中的物料描述是如何实现的。 +### 手工配置物料描述 +详见:“物料描述详解”章节。 +## 物料的低代码开发 +> _**注意:引擎提供的 cli 并未对 windows 系统做适配,windows 环境必须使用 **_[_**WSL**_](https://docs.microsoft.com/zh-cn/windows/wsl/install)_**,其他终端不保证能正常运行**_ + +您可以通过本节内容,完成一个组件在低代码编辑器中的配置和调试。 +### 前言(必读) +引擎提供的物料开发脚手架内置了**_入料模块_**,初始化的时候会自动根据源码解析出一份_**低代码描述**_,但是从源码解析出来的低代码描述让用户直接使用是不够精细的,因为源码包含的信息不够,它没办法完全包含配置项的交互; +![image.png](https://img.alicdn.com/imgextra/i1/O1CN010t0YzC1znDPQB1LUA_!!6000000006758-2-tps-802-1830.png) +比如设计师出了上面的设计稿,这里面除了有哪些 props 可被配置,通过哪个设置器配置,还包含了 props 之间的聚合、排序,甚至有自定义 setter,这些信息源码里是不具备的,需要在低代码描述里进行开发; +**_因此我们建议只把 cli 初始化的低代码描述作为启动,要根据用户习惯对配置项进行设计,然后人工地去开发调试直接的低代码描述。_** +### 新开发组件 +#### 组件项目初始化 +```bash +npm init @alilc/element your-material-name +``` +#### 选择组件类型 +> 组件 -> <组件组织方式> + +![image.png](https://img.alicdn.com/imgextra/i4/O1CN01BTiMt51iLPtzDbuh8_!!6000000004396-2-tps-1596-464.png) +这里我们选择 react-组件库,之后便生出我们的组件库项目,目录结构如下: +``` +my-materials +├── README.md +├── components (业务组件目录) +│ ├── ExampleComponent // 业务组件1 +│ │ ├── build // 【编译生成】【必选】 +│ │ │ └── index.html // 【编译生成】【必选】可直接预览文件 +│ │ ├── lib // 【编译生成】【必选】 +│ │ │ ├── index.js // 【编译生成】【必选】js 入口文件 +│ │ │ ├── index.scss // 【编译生成】【必选】css 入口文件 +│ │ │ └── style.js // 【编译生成】【必选】js 版本 css 入口文件,方便去重 +│ │ ├── demo // 【必选】组件文档,用于生成组件开发预览,以及生成组件文档 +│ │ │ └── basic.md +│ │ ├── src // 【必选】组件源码 +│ │ │ ├── index.js // 【必选】,组件出口文件 +│ │ │ └── main.scss // 【必选】,仅包含组件自身样式的源码文件 +│ │ ├── README.md // 【必选】,组件说明及API +│ │ └── package.json // 【必选】 +└── └── ExampleComponent2 // 业务组件2 +``` +#### 组件开发与调试 +```bash +# 安装依赖 +npm install + +# 启动 lowcode 环境进行调试预览 +npm run lowcode:dev + +# 构建低代码产物 +npm run lowcode:build +``` +执行上述命令后会在组件 (库) 根目录生成一个 `lowcode` 文件夹,里面会包含每个组件的低代码描述: +![image.png](https://img.alicdn.com/imgextra/i2/O1CN016m7gOK1DvpIcnlTvY_!!6000000000279-2-tps-1446-906.png) + +在 src/components 目录新增一个组件并在 src/index.tsx 中导出,然后再执行 npm run lowcode:dev 时,低代码插件会在 lowcode/ 目录自动生成新增组件的低代码描述(meta.ts)。 + +用户可以直接修改低代码描述来修改组件的配置: + +- 设置组件的 setter(上一个章节介绍的设置器,也可以定制设置器用到物料中); +- 新增组件配置项; +- 更改当前配置项; +#### 配置示例 +隐藏一个 prop +```typescript +{ + name: 'dataSource', + condition: () => false, +} +``` +展示样式 +```typescript +{ + name: 'dataSource', + display: 'accordion' | 'inline' | 'block' | 'plain' | 'popup' | 'entry', // 常用的是 inline(默认), block、entry +} +``` +#### 编辑态视图 +用户可以在 lowcode/ 目录下新增 view.tsx 来增加编辑态视图。编辑态视图用于在编辑态时展示与真实预览不一样的视图。 +view.tsx 输出的也是一个 React 组件。 + +注意:如果是单组件,而非组件库模式的话,view.tsx 应置于 lowcode 而非 lowcode/ 目录下 + + +#### 发布组件 +```bash +# 在组件根目录下,执行 +$ npm publish +``` +### 现存组件低代码化 +组件低代码化是指,在引入低代码平台之前,我们大多数都是使用源码开发的组件,也就是 ProCode 组件。 + +在引入低代码平台之后,原来的源码组件是需要转化为低代码物料,这样才能在低代码平台进行消费。 + +所以接下来会说明,对于已有的源码组件,我们如何把它低代码化。 +#### 配置低代码开发环境 +在您的组件开发环境中,安装 [build-scripts](https://github.com/ice-lab/build-scripts) 和它的低代码开发插件: +```bash +npm install -D @alifd/build-plugin-lowcode @alib/build-scripts --save-dev +``` +新增 build-scripts 配置文件:build.lowcode.js + +```javascript +module.exports = { + alias: { + '@': './src', + }, + plugins: [ + [ + "@alifd/build-plugin-lowcode", + { + engineScope: '@alilc', + } + ] + ], +}; + +``` +在 package.json 中定义低代码开发相关命令 +```javascript +"lowcode:dev": "build-scripts start --config ./build.lowcode.js", +"lowcode:build": "build-scripts build --config ./build.lowcode.js", +``` +![image.png](https://img.alicdn.com/imgextra/i2/O1CN014iSa1P1dNdkUUtoMm_!!6000000003724-2-tps-1830-822.png) +#### 开发调试 + +```bash +# 启动低代码开发调试环境 +npm run lowcode:dev +``` + +组件开发形式还和原来的保持一致,但是新增了一份组件的配置文件,其中配置方式和低代码物料的配置是一样的。 + +#### 构建 + +```bash +# 构建低代码产物 +npm run lowcode:build +``` + +#### 发布组件 +```bash +# 在组件根目录下,执行 +npm publish +``` + +## 在项目中引入组件 (库) +> 以下内容可观看[《阿里巴巴低代码引擎项目实战 (3)-自定义组件接入》](https://www.bilibili.com/video/BV1dZ4y1m76S/)直播回放 + +对于平台或者用户来说,可能所需要的组件集合是不同的。如果需要自定义组件集合,就需要定制资产包,定制的资产包是配置了一系列组件的,将这份资产包用于引擎即可在引擎中使用自定义的组件集合。 + +### 管理一份资产包 +项目中使用的组件相关资源都需要在资产包中定义,那么我们自己开发的组件库如果要在项目中使用,只需要把组件构建好的相关资源 merge 到 assets.json 中就可以; + +#### 自定义组件加入到资产包 +通过官方脚手架自定义组件构建发布之后,npm 包里会出现一个 `build/lowcode/assets-prod.json`文件,我们只需要把该文件的内容 merge 到项目的 assets.json 中就可以; + +#### 资产包托管 + +- 最简单的方式就是类似[引擎 demo 项目](https://github.com/alibaba/lowcode-demo/blob/main/demo-general/src/services/assets.json)的做法,在项目中维护一份 assets.json,新增组件或者组件版本更新都需要修改这份资产包; +- 灵活一点的做法是通过 oss 等服务维护一份远程可配置的 assets.json,新增组件或者组件更新只需要修改这份远程的资产包,项目无需更新; +- 再高级一点的做法是实现一个资产包管理的服务,能够通过用户界面去更新资产包的内容; + +### 在项目中引入资产包 +```typescript +import { material, plugins } from '@alilc/lowcode-engine'; +import { IPublicModelPluginContext } from '@alilc/lowcode-types'; + +// 动态加载 assets +plugins.register((ctx: IPublicModelPluginContext) => { + return { + name: 'ext-assets', + async init() { + try { + // 将下述链接替换为您的物料即可。无论是通过 utils 从物料中心引入,还是通过其他途径如直接引入物料描述 + const res = await window.fetch('https://fusion.alicdn.com/assets/default@0.1.95/assets.json'); + const assets = await res.text(); + material.setAssets(assets); + } catch (err) { + console.error(err); + } + }, + } +}).catch(err => console.error(err)); +``` diff --git a/docs/docs/guide/expand/editor/metaSpec.md b/docs/docs/guide/expand/editor/metaSpec.md new file mode 100644 index 0000000000..dda16a9cb3 --- /dev/null +++ b/docs/docs/guide/expand/editor/metaSpec.md @@ -0,0 +1,565 @@ +--- +title: 物料描述详解 +sidebar_position: 2 +--- +## 物料描述概述 + +中后台前端体系中,存在大量的组件,程序员可以通过阅读文档,知悉组件的用法。可是搭建平台无法理解 README,而且很多时候,README 里并没有属性列表。这时,我们需要一份额外的描述,来告诉低代码搭建平台,组件接受哪些属性,又是该用怎样的方式来配置这些属性,于是,[**《中后台低代码组件描述协议》**](/site/docs/specs/material-spec)应运而生。协议主要包含三部分:基础信息、属性信息 props、能力配置/体验增强 configure。 + +物料配置,就是产出一份符合[**《中后台低代码组件描述协议》**](/site/docs/specs/material-spec)的 JSON Schema。如果需要补充属性描述信息,或需要定制体验增强部分(如修改 Setter、调整展示顺序等),就可以通过修改这份 Schema 来实现。目前有自动生成、手工配置这两种方式生成物料描述配置。 + +## 可视化生成物料描述 + +使用 Parts 造物平台:[使用文档](/site/docs/guide/expand/editor/parts/partsIntro) + +## 自动生成物料描述 + +可以使用官方提供的 `@alilc/lowcode-material-parser` 解析本地组件,自动生成物料描述。把物料描述放到资产包定义中,就能让低代码引擎理解如何制作物料。详见上一个章节“物料扩展”。 + +下面以某个组件代码片段为例: +```typescript +// /path/to/component +import { PureComponent } from 'react'; +import PropTypes from 'prop-types'; + +export default class FusionForm extends PureComponent { + static displayName = 'FusionForm'; + + static defaultProps = { + name: '张三', + age: 18, + friends: ['李四','王五','赵六'], + } + + static propTypes = { + /** + * 这是用于描述姓名 + */ + name: PropTypes.string.isRequired, + /** + * 这是用于描述年龄 + */ + age: PropTypes.number, + /** + * 这是用于描述好友列表 + */ + friends: PropTypes.array + }; + + render() { + return
dumb
; + } +} +``` + +引入 parse 工具自动解析 + +```typescript +import parse from '@alilc/lowcode-material-parser'; +(async () => { + const result = await parse({ entry: '/path/to/component' }); + console.log(JSON.stringify(result, null, 2)); +})(); +``` + +因为一个组件可能输出多个子组件,所以解析结果是个数组。 + +```json +[ + { + "componentName": "FusionForm", + "title": "", + "docUrl": "", + "screenshot": "", + "devMode": "proCode", + "npm": { + "package": "", + "version": "", + "exportName": "default", + "main": "", + "destructuring": false, + "subName": "" + }, + "props": [ + { + "name": "name", + "propType": "string", + "description": "这是用于描述姓名", + "defaultValue": "张三" + }, + { + "name": "age", + "propType": "number", + "description": "这是用于描述年龄", + "defaultValue": 18 + }, + { + "name": "friends", + "propType": "array", + "description": "这是用于描述好友列表", + "defaultValue": [ + "李四", + "王五", + "赵六" + ] + } + ] + } +] +``` + +## 手工配置物料描述 + +如果自动生成的物料无法满足需求,我们就需要手动配置物料描述。本节将分场景描述物料配置的内容。 + +### 常见配置 + +#### 组件的属性只有有限的值 + +增加一个 size 属性,只能从 'large'、'normal'、'small' 这个候选值中选择。 + +以上面自动解析的物料为例,在此基础上手工加上 size 属性: + +```json +[ + { + "componentName": "FusionForm", + "title": "", + "docUrl": "", + "screenshot": "", + "devMode": "proCode", + "npm": { + "package": "", + "version": "", + "exportName": "default", + "main": "", + "destructuring": false, + "subName": "" + }, + "props": [ + { + "name": "name", + "propType": "string", + "description": "这是用于描述姓名", + "defaultValue": "张三" + }, + { + "name": "age", + "propType": "number", + "description": "这是用于描述年龄", + "defaultValue": 18 + }, + { + "name": "friends", + "propType": "array", + "description": "这是用于描述好友列表", + "defaultValue": [ + "李四", + "王五", + "赵六" + ] + } + ], + // 手工增加的 size 属性 + "configure": { + "isExtend": true, + "props": [ + { + "title": "尺寸", + "name": "size", + "setter": { + "componentName": 'RadioGroupSetter', + "isRequired": true, + "props": { + "options": [ + { "title": "大", "value": "large" }, + { "title": "中", "value": "normal" }, + { "title": "小", "value": "small" } + ] + }, + } + } + ] + } + } +] +``` + +#### 组件的属性既可以设置固定值,也可以绑定到变量 + +我们知道一种属性形式就需要一种 setter 来设置,如果想要将 value 属性允许输入字符串,那就需要设置为 `StringSetter`,如果允许绑定变量,就需要设置为 `VariableSetter`,具体设置器请参考[预置设置器列表](/site/docs/guide/appendix/setters)。 + +那如果都想要呢?可以使用 `MixedSetter` 来实现。 + +```javascript +{ + // ... + configure: { + isExtend: true, + props: [ + { + title: '输入框的值', + name: 'activeValue', + setter: { + componentName: 'MixedSetter', + isRequired: true, + props: { + setters: [ + 'StringSetter', + 'NumberSetter', + 'VariableSetter', + ], + }, + } + } + ] + } +} +``` + +设置后,就会出现“切换设置器”的操作项了 + +![image.png](https://img.alicdn.com/imgextra/i3/O1CN01jBqcuK1xYRP00WyVx_!!6000000006455-2-tps-598-252.png) + +![image.png](https://img.alicdn.com/imgextra/i3/O1CN01944xqq1PYihvYQb4v_!!6000000001853-2-tps-244-308.png) + +#### 开启组件样式设置 + +![image.png](https://img.alicdn.com/imgextra/i2/O1CN01EBStyl24EvqJkAdh1_!!6000000007360-2-tps-820-772.png) + +```javascript +{ + configure: { + // ..., + supports: { + style: true, + }, + // ... + } +} +``` + +#### 设置组件的默认事件 + +![image.png](https://img.alicdn.com/imgextra/i2/O1CN012gijqt1NERwqF5f6Y_!!6000000001538-2-tps-776-800.png) + +```javascript +{ + configure: { + // ... + supports: { + events: ['onPressEnter', 'onClear', 'onChange', 'onKeyDown', 'onFocus', 'onBlur'], + }, + // ... + } +} +``` + +#### 设置 prop 标题的 tip + +![image.png](https://img.alicdn.com/imgextra/i1/O1CN01d8TdsY1jhENsKvwAv_!!6000000004579-2-tps-908-176.png) + +```javascript +{ + name: 'label', + setter: 'StringSetter', + title: { + label: { + type: 'i18n', + zh_CN: '标签文本', + en_US: 'Label', + }, + tip: { + type: 'i18n', + zh_CN: '属性:label | 说明:标签文本内容', + en_US: 'prop: label | description: label content', + }, + }, +} +``` + +#### 配置 prop 对应 setter 在配置面板的展示方式 + +##### inline + +![image.png](https://img.alicdn.com/imgextra/i2/O1CN01z1sXj420vkP7vbeHj_!!6000000006912-2-tps-790-266.png) + +```javascript +{ + configure: { + props: [{ + description: '标签文本', + display: 'inline', + }] + } +} +``` + +##### block + +![image.png](https://img.alicdn.com/imgextra/i2/O1CN01i3MVKF299xchs6kMX_!!6000000008026-2-tps-792-274.png) + +```javascript +{ + configure: { + props: [{ + description: '高级', + display: 'block', + }] + } +} +``` + +##### accordion + +![image.png](https://img.alicdn.com/imgextra/i2/O1CN01RePeyy1nhvRiBMm2w_!!6000000005122-2-tps-798-740.png) + +```javascript +{ + configure: { + props: [{ + description: '表单项配置', + display: 'accordion', + }] + } +} +``` + +##### entry + +![image.png](https://img.alicdn.com/imgextra/i4/O1CN01zkjBak1YY6igYUO1n_!!6000000003070-2-tps-796-424.png) + + +![image.png](https://img.alicdn.com/imgextra/i3/O1CN01lmuRTl1LOPKMnsfLJ_!!6000000001289-2-tps-794-632.png) + +```javascript +{ + configure: { + props: [{ + description: '风格与样式', + display: 'entry', + }] + } +} +``` + +##### plain + +![image.png](https://img.alicdn.com/imgextra/i1/O1CN01G0DOfV1jGD0v049gk_!!6000000004520-2-tps-776-438.png) + +```javascript +{ + configure: { + props: [{ + description: '返回上级', + display: 'plain', + }] + } +} +``` + + +### 进阶配置 + +#### 组件的 children 属性允许传入 ReactNode + +例如有一个如下的 Tab 选项卡组件,每个 TabPane 的 children 都是一个组件 + +![image.png](https://img.alicdn.com/imgextra/i1/O1CN01Cu09HV1m8pTucSc7Q_!!6000000004910-2-tps-2332-334.png) + +只需要增加 `isContainer` 配置即可 + +```javascript +{ + // ... + configure: { + // ... + component: { + // 新增,设置组件为容器组件,可拖入组件 + isContainer: true, + }, + } +} +``` + +假设我们希望只允许拖拽 Table、Button 等内容放在 TabPane 里。配置白名单 `childWhitelist` 即可 + +```javascript +{ + // ... + configure: { + // ... + component: { + isContainer: true, + nestingRule: { + // 允许拖入的组件白名单 + childWhitelist: ['Table', 'Button'], + // 同理也可以设置该组件允许被拖入哪些父组件里 + parentWhitelist: ['Tab'], + }, + }, + }, +} +``` +#### 组件的非 children 属性允许传入 ReactNode + +这就需要使用 `SlotSetter` 开启插槽了,如下面示例,给 Tab 的 title 开启插槽,允许拖拽组件 + +![image.png](https://img.alicdn.com/imgextra/i3/O1CN01P77m5m1pKEBXTk9Yt_!!6000000005341-2-tps-3016-580.png) + +```json +{ + // ... + configure: { + isExtend: true, + props: [ + { + title: '选项卡标题', + name: 'title', + setter: { + componentName: 'MixedSetter', + props: { + setters: [ + 'StringSetter', + 'SlotSetter', + 'VariableSetter', + ], + }, + }, + }, + ], + }, +} +``` + +#### 屏蔽组件在设计器中的操作按钮 + +正常情况下,组件允许复制: + +![image.png](https://img.alicdn.com/imgextra/i3/O1CN01925Nyl1a2AKNQ1XCP_!!6000000003271-2-tps-1158-226.png) + +如果希望禁止组件的复制行为,我们可以这样做: + +![image.png](https://img.alicdn.com/imgextra/i2/O1CN01IoLKUu1CXGRb0ileB_!!6000000000090-2-tps-1176-300.png) + +```javascript +{ + configure: { + component: { + disableBehaviors: ['copy'], + }, + }, +} +``` + +#### 实现一个 BackwardSetter + +![image.png](https://img.alicdn.com/imgextra/i2/O1CN01GI4VfT23ga8TUCjIh_!!6000000007285-2-tps-776-438.png) + +```javascript +{ + name: 'back', + title: ' ', + display: 'plain', + setter: BackwardSetter, +} + +// BackwardSetter +import { SettingTarget, DynamicSetter } from '@alilc/lowcode-types'; +const BackwardSetter: DynamicSetter = (target: SettingTarget) => { + return { + componentName: ( + + ), + }; +}; +``` + +### 高级配置 + +#### 不展现一个 prop 配置 + +- 始终隐藏当前 prop + +```javascript +{ + // 始终隐藏当前 prop 配置 + condition: () => false, +} +``` + +- 根据其它 prop 的值展示/隐藏当前 prop + +```javascript +{ + // direction 为 hoz 则展示当前 prop 配置 + condition: (target) => { + return target.getProps().getPropValue('direction') === 'hoz'; + } +} +``` + +#### props 联动 + +```javascript +// 根据当前 prop 的值动态设置其它 prop 的值 +{ + name: 'labelAlign', + // ... + extraProps: { + setValue: (target, value) => { + if (value === 'inset') { + target.getProps().setPropValue('labelCol', null); + target.getProps().setPropValue('wrapperCol', null); + } else if (value === 'left') { + target.getProps().setPropValue('labelCol', { fixedSpan: 4 }); + target.getProps().setPropValue('wrapperCol', null); + } + return target.getProps().setPropValue('labelAlign', value); + }, + }, +} +// 根据其它 prop 的值来设置当前 prop 的值 +{ + name: 'status', + // ... + extraProps: { + getValue: (target) => { + const isPreview = target.getProps().getPropValue('isPreview'); + return isPreview ? 'readonly' : 'editable'; + } + } +} +``` + +#### 动态 setter 配置 + +可以通过 DynamicSetter 传入的 target 获取一些引擎暴露的数据,例如当前有哪些组件被加载到引擎中,将这个数据作为 SelectSetter 的选项,让用户选择: + +```javascript +{ + setter: (target) => { + return { + componentName: 'SelectSetter', + props: { + options: target.designer.props.componentMetadatas.filter( + (item) => item.isFormItemComponent).map( + (item) => { + return { + title: item.title || item.componentName, + value: item.componentName, + }; + } + ), + ), + }, + }; + } +} +``` diff --git a/docs/docs/guide/expand/editor/parts/_category_.json b/docs/docs/guide/expand/editor/parts/_category_.json new file mode 100644 index 0000000000..005a3caf6c --- /dev/null +++ b/docs/docs/guide/expand/editor/parts/_category_.json @@ -0,0 +1,4 @@ +{ + "label": "Parts 造物", + "position": 1 +} diff --git a/docs/docs/guide/expand/editor/parts/partsIntro.md b/docs/docs/guide/expand/editor/parts/partsIntro.md new file mode 100644 index 0000000000..a6fc6e8817 --- /dev/null +++ b/docs/docs/guide/expand/editor/parts/partsIntro.md @@ -0,0 +1,18 @@ +--- +title: 介绍 +sidebar_position: 1 +--- +## 介绍 +![](https://gw.alicdn.com/imgextra/i2/O1CN01Gyq6AZ1nOENPTVXX7_!!6000000005079-2-tps-256-104.png) + + +「Parts·造物」是基于开源低代码引擎打造的次时代物料研发和集成工具,一方面作为低代码引擎搭建低代码平台的一个样板展示开源生态下的各个组件如何集合在一起形成生产力,另一方面也可以生产低代码平台所需的物料。 + +目前「Parts·造物」主要提供两大产品功能: + 1. React 组件导入低代码引擎:通过在线可视化的「物料描述」配置,任意工具开发的 React 组件都可以快速完成对低代码引擎的适配,导入到低代码引擎项目中进行使用。不必额外开发新的组件。 + 2. 低代码生产组件:通过低代码的形式生产组件,极低上手门槛,提供丰富的原子组件用于组合,完善的调试预览和组件生命周期控制。生产的组件既可以在低代码引擎项目中使用,也可以出码后在普通源码项目中使用。 + + +## 联系我们 + + diff --git a/docs/docs/guide/expand/editor/parts/partsassets.md b/docs/docs/guide/expand/editor/parts/partsassets.md new file mode 100644 index 0000000000..00670ecadc --- /dev/null +++ b/docs/docs/guide/expand/editor/parts/partsassets.md @@ -0,0 +1,267 @@ +--- +title: 资产包管理 +sidebar_position: 4 +--- + +## 介绍 + +通过前述介绍,相信大家已经了解如何使用「[Parts·造物](https://parts.lowcode-engine.cn/)」来将已有的 React 组件快速接入低代码引擎,以及生产低代码组件。 + +大家在使用的过程中,可能会希望构建出来的资产包可以后续随时访问下载,或者希望构建资产包时各个组件的版本等信息可以持久化起来并且能够多人维护。 + +通过「[Parts·造物](https://parts.lowcode-engine.cn/)」的 `资产包` 管理功能帮助大家解决这个问题 + +![image.png](https://img.alicdn.com/imgextra/i3/O1CN01Fkaznh1zWj9wYKpcH_!!6000000006722-2-tps-1702-628.png) + +## 新建资产包 + +首先,我们在 我的资产包 tab 中点击 `新建资产包` +![image.png](https://img.alicdn.com/imgextra/i1/O1CN01qe8zfO1ilysebSfD5_!!6000000004454-2-tps-3064-1432.png) + +- 填写资产包名称 +- 配置资产包管理员,管理员拥有该资产包的所有权限,初始默认为资产包的创建者,还可以添加其他人作为管理员, +- 配置资产包描述 (可选) +- 点击 `确定`, 即可完成资产包的创建 + +接下来需要为资产包添加一个或者多个组件。 + +## 添加组件 + +第二步:新建完资产包以后,我们就可以为其添加组件了,如果是新建资产包流程,新建完成之后会自动弹出组件配置的弹窗,当然,你可可以通过点击资产包卡片的方式打开组件配置的弹窗。 +![image.png](https://img.alicdn.com/imgextra/i3/O1CN01kqymdB1nkDQclPk7F_!!6000000005127-2-tps-965-261.png) + +- 点击弹窗中 `添加组件` 按钮,在弹出的组件选择面板中,选中需要添加的组件并点击 `下一步`。 + ![image.png](https://img.alicdn.com/imgextra/i1/O1CN014Baihf1r742Qi1Wel_!!6000000005583-2-tps-1856-1520.png) +- 进入组件版本以及描述协议版本选择界面,选择所需要的正确版本,点击 `安装` 即可完成一个组件的添加。 + ![image.png](https://img.alicdn.com/imgextra/i2/O1CN01Y7aWWi1MMPDVlidgz_!!6000000001420-2-tps-1668-1462.png) + +## 构建资产包 + +添加完组件以后就点击 `保存并构建资产包` 进入资产包构建配置弹窗 +![image.png](https://img.alicdn.com/imgextra/i4/O1CN01iZf4Ue1PlXnyKYxnK_!!6000000001881-2-tps-1288-670.png) + +- `开启缓存` : 可充分利用之前的构建结果缓存来加速资产包的生成,我们会将每个组件的构建结果以 包名和版本号为 key 进行缓存。 +- `任务描述` : 当前构建任务的一些描述信息。 + +点击 `确认` 按钮 会自动跳转到当前资产包的构建历史界面: +![image.png](https://img.alicdn.com/imgextra/i2/O1CN01krDaFc1TuTztMPssI_!!6000000002442-2-tps-1726-696.png) +构建历史界面会显示当前资产包所有的构建历史记录,表格状态栏展示了构建的状态:`成功`,`失败`,`正在运行` 三种状态,操作列可以在构建成功时复制或者下载资产包结果 + +## 使用资产包 +你可以在 [lowcode-demo](https://github.com/alibaba/lowcode-demo) 中直接引用,可直接替换 demo 中原来的资产包文件: +例如,在 [demo-lowcode-component](https://github.com/alibaba/lowcode-demo/tree/main/demo-lowcode-component) 中,直接用你的资产包文件替换文件[assets.json](https://github.com/alibaba/lowcode-demo/blob/main/demo-lowcode-component/src/services/assets.json),即可快速使用自己的物料了。 + +### 在编辑器中使用资产包 +在使用含有低代码组件的资产包注意 注意引擎版本必须大于等于 `1.1.0-beta.9`。 +然后直接替换 [lowcode-demo](https://github.com/alibaba/lowcode-demo) demo 中的 `assets.json` 文件即可。 + +### 在预览中使用资产包 +在预览中使用资产包的整体思路是从 `资产包` 中提取并转换出 `ReactRenderer` 渲染所需要的 react 组件列表 (`components` 参数),然后将 `schema` 以及 `components` 传入到 `ReactRenderer` 中进行渲染,需要注意的是,在 `资产包` 的转换过程中,我们也需要将 `低代码组件` 转换成 react 组件,具体逻辑可以参考下 [demo-lowcode-component](https://github.com/alibaba/lowcode-demo/tree/main/demo-lowcode-component) 中 `src/parse-assets.ts` 文件的实现。 +基于资产包进行预览的整体逻辑如下: [详见](https://github.com/alibaba/lowcode-demo/blob/main/demo-lowcode-component/src/preview.tsx): +```ts +import ReactDOM from 'react-dom'; +import React, { useState } from 'react'; +import { Loading } from '@alifd/next'; +import ReactRenderer from '@alilc/lowcode-react-renderer'; +import { createFetchHandler } from '@alilc/lowcode-datasource-fetch-handler'; +import { + getProjectSchemaFromLocalStorage, +} from './services/mockService'; +import assets from './services/assets.json'; +import { parseAssets } from './parse-assets'; + +const getScenarioName = function () { + if (location.search) { + return new URLSearchParams(location.search.slice(1)).get('scenarioName') || 'index'; + } + return 'index'; +}; + +const SamplePreview = () => { + const [data, setData] = useState({}); + async function init() { + const scenarioName = getScenarioName(); + const projectSchema = getProjectSchemaFromLocalStorage(scenarioName); + const { componentsMap: componentsMapArray, componentsTree } = projectSchema; + const schema = componentsTree[0]; + const componentsMap: any = {}; + componentsMapArray.forEach((component: any) => { + componentsMap[component.componentName] = component; + }); + + // 特别提醒重点注意!!!:从资产包中解析出所有的 react 组件列表 + const { components } = await parseAssets(assets); + + setData({ + schema, + components, + }); + } + + const { schema, components } = data; + + if (!schema || !components) { + init(); + return ; + } + + return ( +
+ +
+ ); +}; + +ReactDOM.render(, document.getElementById('ice-container')); +``` + +从资产包中解析 react 组件列表的逻辑如下,[详见](https://github.com/alibaba/lowcode-demo/blob/main/demo-lowcode-component/src/parse-assets.ts): +```ts +import { ComponentDescription, ComponentSchema, RemoteComponentDescription } from '@alilc/lowcode-types'; +import { buildComponents, AssetsJson, AssetLoader } from '@alilc/lowcode-utils'; +import ReactRenderer from '@alilc/lowcode-react-renderer'; +import { injectComponents } from '@alilc/lowcode-plugin-inject'; +import React, { createElement } from 'react'; + +export async function parseAssets(assets: AssetsJson) { + const { components: rawComponents, packages } = assets; + const libraryAsset = []; + const libraryMap = {}; + const packagesMap = {}; + packages.forEach(pkg => { + const { package: _package, library, urls, renderUrls, id } = pkg; + if (_package) { + libraryMap[id || _package] = library; + } + packagesMap[id || _package] = pkg; + if (renderUrls) { + libraryAsset.push(renderUrls); + } else if (urls) { + libraryAsset.push(urls); + } + }); + const assetLoader = new AssetLoader(); + await assetLoader.load(libraryAsset); + let newComponents = rawComponents; + if (rawComponents && rawComponents.length) { + const componentDescriptions: ComponentDescription[] = []; + const remoteComponentDescriptions: RemoteComponentDescription[] = []; + rawComponents.forEach((component: any) => { + if (!component) { + return; + } + if (component.exportName && component.url) { + remoteComponentDescriptions.push(component); + } else { + componentDescriptions.push(component); + } + }); + newComponents = [...componentDescriptions]; + + // 如果有远程组件描述协议,则自动加载并补充到资产包中,同时出发 designer.incrementalAssetsReady 通知组件面板更新数据 + if (remoteComponentDescriptions && remoteComponentDescriptions.length) { + await Promise.all( + remoteComponentDescriptions.map(async (component: any) => { + const { exportName, url, npm } = component; + await (new AssetLoader()).load(url); + function setAssetsComponent(component: any, extraNpmInfo: any = {}) { + const components = component.components; + if (Array.isArray(components)) { + components.forEach(d => { + newComponents = newComponents.concat({ + npm: { + ...npm, + ...extraNpmInfo, + }, + ...d, + } || []); + }); + return; + } + newComponents = newComponents.concat({ + npm: { + ...npm, + ...extraNpmInfo, + }, + ...component.components, + } || []); + } + + function setArrayAssets(value: any[], preExportName: string = '', preSubName: string = '') { + value.forEach((d: any, i: number) => { + const exportName = [preExportName, i.toString()].filter(d => !!d).join('.'); + const subName = [preSubName, i.toString()].filter(d => !!d).join('.'); + Array.isArray(d) ? setArrayAssets(d, exportName, subName) : setAssetsComponent(d, { + exportName, + subName, + }); + }); + } + if (window[exportName]) { + if (Array.isArray(window[exportName])) { + setArrayAssets(window[exportName] as any); + } else { + setAssetsComponent(window[exportName] as any); + } + } + return window[exportName]; + }), + ); + } + } + const lowcodeComponentsArray = []; + const proCodeComponentsMap = newComponents.reduce((acc, cur) => { + if ((cur.devMode || '').toLowerCase() === 'lowcode') { + lowcodeComponentsArray.push(cur); + } else { + acc[cur.componentName] = { + ...(cur.reference || cur.npm), + componentName: cur.componentName, + }; + } + return acc; + }, {}) + + function genLowCodeComponentsMap(components) { + const lowcodeComponentsMap = {}; + lowcodeComponentsArray.forEach((lowcode) => { + const id = lowcode.reference?.id; + const schema = packagesMap[id]?.schema; + const comp = genLowcodeComp(schema, {...components, ...lowcodeComponentsMap}); + lowcodeComponentsMap[lowcode.componentName] = comp; + }); + return lowcodeComponentsMap; + } + let components = await injectComponents(buildComponents(libraryMap, proCodeComponentsMap)); + const lowCodeComponents = genLowCodeComponentsMap(components); + return { + components: { ...components, ...lowCodeComponents } + } +} + +function genLowcodeComp(schema: ComponentSchema, components: any) { + return class LowcodeComp extends React.Component { + render(): React.ReactNode { + return createElement(ReactRenderer, { + ...this.props, + schema, + components, + designMode: '', + }); + } + }; +} +``` +## 联系我们 + + \ No newline at end of file diff --git a/docs/docs/guide/expand/editor/parts/partslcc.md b/docs/docs/guide/expand/editor/parts/partslcc.md new file mode 100644 index 0000000000..4d24b72f3a --- /dev/null +++ b/docs/docs/guide/expand/editor/parts/partslcc.md @@ -0,0 +1,92 @@ +--- +title: 低代码组件 +sidebar_position: 2 +--- +## 什么是低代码组件 +我们先了解一下什么是低代码组件,为什么要用低代码组件。 + +低代码组件是通过可视化的方式生产的组件,这些组件既可以用于低代码搭建体系,也可以用于 ProCode 开发体系(后续迭代)。 + +那么为什么我们要使用低代码的形式来开发组件: +* 首先轻快,低代码组件只需通过浏览器秒级完成初始化工作,不需要 ProCode 繁重的环境准备;环境一致(低代码环境),同时能够保证物料的开发环境和真实的运行环境是一致的,不会存在开发和运行环境不一致的问题。 +* 其次通用能力可视化方式抽象,提升研发效能,比如获取远程数据、视图开发、依赖管理、生命周期、事件绑定等功能。 + +低代码组件不是用来替代 ProCode 的开发方式,而是让开发者可以从 ProCode 中重复的工作脱离出来,抽象更多业务垂直的能力,从而起到提效的作用。 + +## 创建组件 + +环境准备:我们可以通过 Parts 提供的通用[低代码组件开发环境](https://parts.lowcode-engine.cn/material#/)开发。 + +点击开发新组件 --> 填写组件标题 --> 填写组件名称 --> 点击确定,完成组件创建工作。 + +![](https://img.alicdn.com/imgextra/i2/O1CN01OTQRew25y6WxuONIx_!!6000000007594-2-tps-3396-1696.png) + +## 组件开发 + +一张图速览低代码组件开发的功能模块,其中大部分功能可以参考[低代码引擎文档](https://lowcode-engine.cn/site/docs/guide/quickStart/intro)。 + +![](https://img.alicdn.com/imgextra/i1/O1CN01gx96E121qzv4smV2v_!!6000000007037-2-tps-3456-1930.png) + +### 依赖管理 + +依赖管理用于管理低代码组件本身的依赖(类似于 dependencies)。步骤:点击添加组件 --> 选择安装的组件 --> 保存并构建 (需要等待几分钟构建)。 + +![](https://img.alicdn.com/imgextra/i4/O1CN01wC9JPK1J9dKLca9wK_!!6000000000986-2-tps-1438-819.png) + +### 属性定义 + +用于定义组件接收外部传入的 propTypes,组件内部可以通过this.props.${属性名称}的方式获取属性值。 + +属性定义前建议先阅读 [物料描述详解](https://lowcode-engine.cn/site/docs/guide/expand/editor/metaSpec)、[预置设置器](https://lowcode-engine.cn/site/docs/guide/appendix/setters)。 + +![](https://img.alicdn.com/imgextra/i2/O1CN01wesIJA1nL1eSPrk7U_!!6000000005072-2-tps-1438-821.png) + +![](https://img.alicdn.com/imgextra/i3/O1CN01FZIRwv1es9lGplgIB_!!6000000003926-2-tps-1438-821.png) + +### 生命周期 + +低代码组件的开发支持 componentDidMount、componentDidUpdate、componentDidCatch、componentWillUnmount 几个生命周期 + +![](https://img.alicdn.com/imgextra/i4/O1CN010bnrxJ1oLlujlfFqj_!!6000000005209-2-tps-1438-819.png) + +### 组件调试 + +我们提供了一套线上实时调试的方案,只需点击右上角的调试按钮,就能自动创建一个低代码应用,在这个应用中可以实时调试当前的低代码组件。 + +![](https://img.alicdn.com/imgextra/i2/O1CN01Tk96vp1xrDeNeIUJD_!!6000000006496-2-tps-1438-820.png) + +在低代码应用中使用,组件面板 --> 低代码组件,找到对应的低代码组件拖入画布即可。 + +![](https://img.alicdn.com/imgextra/i2/O1CN01oGHLea1lzDAhZQQVO_!!6000000004889-2-tps-1438-819.png) + +### 组件发布 + +同时我们提供了组件发布的功能,用于组件版本管理,点击右上角的发布按钮即可发布组件 + +![](https://img.alicdn.com/imgextra/i2/O1CN017suVAD1NXEC8zQgO1_!!6000000001579-2-tps-1438-821.png) + +## 组件使用 + +组件的消费是通过资产包来管理的,详情请参考 [资产包管理](./partsassets)。 + +## 组件导出 + +开发好的低代码组件可以导出成为 React 组件,脱离低代码引擎独立使用。同时导出功能也为您的组件留出一份备份,您可以放心使用本产品的服务,而不用担心万一出现的不能服务的场景。 + +在物料列表页面,低代码组件会有一个导出的动作。 + +![](https://img.alicdn.com/imgextra/i2/O1CN016oUByO21spVHZvvw2_!!6000000007041-2-tps-1395-413.png) + +点击导出后,就会开启导出低代码组件的过程。这个过程持续 10s+,导出完成后会为您自动下载对应的 zip 包。 + +![](https://img.alicdn.com/imgextra/i1/O1CN01lctpIo1aDcEvu75Mo_!!6000000003296-2-tps-1399-512.png) + +zip 包解压后可以看到一个完整的组件脚手架工程,您可以在这个工程里继续开发调试,或者发布到合适的 npm 源中。 + +![](https://img.alicdn.com/imgextra/i1/O1CN010aAjsf1xYRPZBAh7d_!!6000000006455-2-tps-2154-1072.png) + +注意:目前导出功能暂不支持 低代码组件嵌套。 + +## 联系我们 + + \ No newline at end of file diff --git a/docs/docs/guide/expand/editor/parts/prototype.md b/docs/docs/guide/expand/editor/parts/prototype.md new file mode 100644 index 0000000000..b90728f657 --- /dev/null +++ b/docs/docs/guide/expand/editor/parts/prototype.md @@ -0,0 +1,121 @@ +--- +title: React 组件导入 +sidebar_position: 3 +--- +## 介绍 +大家在使用[低代码引擎](https://lowcode-engine.cn/)构建低代码应用平台时,遇到的一个主要问题是如何让已有的 React 组件能够快速低成本地接入进来。这个问题拆解下来主要包括两个子问题: +1. 如何给已有组件[配置物料描述](/site/docs/specs/material-spec), +2. 如何构建出一个低代码引擎能够识别的资产包 (Assets)。 + +我们的产品「[Parts·造物](https://parts.lowcode-engine.cn/)」可以帮助大家解决这个问题。我们通过在线可视化的方式完成物料描述配置,并且提供一键打包的功能生成引擎可以识别的资产包。 + +## 导入物料 +首先,我们需要在 [物料管理](/site/docs/specs/material-spec) 页面导入我们需要进行在线物料描述配置的物料。 +![image.png](https://img.alicdn.com/imgextra/i1/O1CN01IyZdZf1L1VWWU3dnp_!!6000000001239-2-tps-1399-342.png) + +- 点击列表左上方的 导入已有物料 按钮 +- 在弹框中输入 npm 包名 +- 点击 获取包信息 按钮,获取 npm 包基本信息 +- 点击确定,导入成功 + +![image.png](https://img.alicdn.com/imgextra/i4/O1CN019FwWgs1kqgAXq5UNJ_!!6000000004735-2-tps-640-315.png) +## 配置管理 +第二步:物料导入以后,我们就可以为导入的物料新增[物料描述配置](/site/docs/specs/material-spec),点击右侧的组件配置开始配置。 + +![image.png](https://img.alicdn.com/imgextra/i3/O1CN01kqymdB1nkDQclPk7F_!!6000000005127-2-tps-965-261.png) +### 新增配置 + +- 点击配置管理右上角的 新增配置 + - 选择组件的版本号 + - 填写组件路径,一般和 npm 包的 package.json 里的 main 字段相同(如果填写错误,后面会渲染不出来) + - 描述字段用于给这份配置增加一些备注信息。 + +![image.png](https://img.alicdn.com/imgextra/i2/O1CN01i78OhT1cKbVWnXRNu_!!6000000003582-2-tps-596-418.png) + +为了降低配置成本,第一次新增配置的时候会自动解析组件代码,生成一份初始化组件物料描述。所以需要等待片刻,用于代码解析。解析完成后,点击配置按钮即可进入在线配置界面。 + +![image.png](https://img.alicdn.com/imgextra/i2/O1CN01R24mTl1tJY3oJ5DCi_!!6000000005881-2-tps-963-232.png) + +### 组件描述配置 +操作界面如下,接下来讲具体的配置流程 + +![image.png](https://img.alicdn.com/imgextra/i4/O1CN01XjSW9I1u662raRg8E_!!6000000005987-2-tps-1438-938.png) + +#### 新增组件 + +如果新增配置的过程中,代码自动解析失败或者解析出来的组件列表不满足开发要求,我们可以点击左侧组件列表插件 新增 按钮,添加新的组件,具体的字段描述可以参考提示内容,以 [react-color](https://github.com/casesandberg/react-color) 为例: + +![image.png](https://img.alicdn.com/imgextra/i4/O1CN01A9VFfQ1m9kH2Qliz4_!!6000000004912-2-tps-1436-1005.png) + +![image.png](https://img.alicdn.com/imgextra/i3/O1CN01klci7y1IUPflKpeVB_!!6000000000896-2-tps-1193-704.png) +#### 给组件增加物料描述 + +- 打开左侧 Setter 面板 +- 按照组件的属性拖入需要 Setter 类型(如图中组件的 width 属性,拖入数字 Setter) +- 各种 Setter 的介绍可以参看这篇文档:[预置设置器列表](/site/docs/guide/appendix/setters) +- 配置属性的基本信息(如图所示) +- 配置完成后点击右上角的保存 + +![image.png](https://img.alicdn.com/imgextra/i1/O1CN01gxLKBp1RaDEMPS54O_!!6000000002127-2-tps-1434-967.png) + +![image.png](https://img.alicdn.com/imgextra/i1/O1CN01uReCQ825yYuwIfj2J_!!6000000007595-2-tps-925-360.png) + +#### 高级配置(属性联动) + +举个栗子:如图所示,如果期望“设置器”这个配置项的值“被修改”的时候,下面的“默认值”跟着变化。 + +![image.png](https://img.alicdn.com/imgextra/i3/O1CN01bg7X571bpSZdnXTBW_!!6000000003514-2-tps-371-572.png) + +如何使用 + +组件的属性配置目前支持 3 个基本的联动函数: + +- 显示状态:返回 true | false,如果返回 true,表示组件配置显示,否则配置时不显示 +- 获取值:当调用该配置节点的 getValue 方法时触发的方法 +- 值变化:当调用该配置节点的 setValue 方法时触发的方法 + +![image.png](https://img.alicdn.com/imgextra/i3/O1CN018ZJAJO21q57TdWfjM_!!6000000007035-2-tps-316-142.png) + +方法的第一个参数都是当前配置节点的对象,常用到的有以下几个: + +- getValue(): 获取当前节点的值,如果当前节点是子节点的话,否则为 undefined +- setValue(): 设置当前节点的值,如果当前节点是子节点的话 +- parent: 当前节点的父节点 +- getPropValue(propName): 父节点获取子节点的属性值,propName 为子节点的属性名称 +- setPropValue(propName, value): 父节点设置子节点的属性值,propName 为子节点的属性名称,value 为设置的值 +- getConfig: 获取当前节点的配置,如 title、setter 等 + + +#### 调试物料描述 + +点击右上角的预览按钮,开始调试我们刚刚配置的属性,如果是组件的首次预览,会有一段组件构建的过程(构建出 umd 包的过程),构建完成后就可以调试我们的配置了。 + +![image.png](https://img.alicdn.com/imgextra/i2/O1CN012biqEn1uGAl650nb2_!!6000000006009-2-tps-1431-373.png) + +#### 发布物料描述 +物料描述调试没问题后,就可以到项目中去使用了,使用前需要先发布物料描述 + +- 点击右上角的发布按钮 +- 选择需要发布的组件 +- 点击确定发布完成 + +![image.png](https://img.alicdn.com/imgextra/i4/O1CN01uwa8RH1QDwM7FN31k_!!6000000001943-2-tps-1431-734.png) +## 资产包 +第三步:物料描述发布完成后,接下来我们就需要构建出可用的资产包用于低代码应用中。 + +#### 资产包构建 +有两种方式可以构建资产包: +- 一种是通过 [`我的资产包`] 资产包管理模块进行整个资产包生命周期的管理,当然也包括资产包的构建,可参考 [资产包管理](./partsassets) +- 一种是通过 [`我的物料`] 组件物料管理模块的 `资产包构建` 进行构建, 具体操作如下: + + - 选择需要构建的组件 + - 点击构建资产包按钮 + - 选择刚刚的物料描述配置 + - 开始构建,构建完成后你将得到一份 json 文件(里面包含了物料描述和 umd 包),就可以到项目中使用了 + +#### 资产包使用 +详情请参考 [资产包管理](./partsassets#使用资产包) + +## 联系我们 + + diff --git a/docs/docs/guide/expand/editor/pluginContextMenu.md b/docs/docs/guide/expand/editor/pluginContextMenu.md new file mode 100644 index 0000000000..962c913e7e --- /dev/null +++ b/docs/docs/guide/expand/editor/pluginContextMenu.md @@ -0,0 +1,82 @@ +--- +title: 插件扩展 - 编排扩展 +sidebar_position: 6 +--- + +## 场景一:扩展选中节点操作项 + +### 增加节点操作项 +![image.png](https://img.alicdn.com/imgextra/i2/O1CN01J7PrJc1S86XNDBIFQ_!!6000000002201-2-tps-1240-292.png) + +选中节点后,在选中框的右上角有操作按钮,编排模块默认实现了查看组件直系父节点、复制节点和删除节点按钮外,还可以通过相关 API 来扩展更多操作,如下代码: + +```typescript +import { plugins } from '@alilc/lowcode-engine'; +import { IPublicModelPluginContext, IPublicModelNode } from '@alilc/lowcode-types'; +import { Icon, Message } from '@alifd/next'; + +const addHelloAction = (ctx: IPublicModelPluginContext) => { + return { + async init() { + ctx.material.addBuiltinComponentAction({ + name: 'hello', + content: { + icon: , + title: 'hello', + action(node: IPublicModelNode) { + Message.show('Welcome to Low-Code engine'); + }, + }, + condition: (node: IPublicModelNode) => { + return node.componentMeta.componentName === 'NextTable'; + }, + important: true, + }); + }, + }; +}; +addHelloAction.pluginName = 'addHelloAction'; +await plugins.register(addHelloAction); +``` + +**_效果如下:_** + +![image.png](https://img.alicdn.com/imgextra/i2/O1CN01O8W2H61ybw2b7K5nV_!!6000000006598-2-tps-1315-343.png) + +具体 API 参考:[API 文档](/site/docs/api/material#addbuiltincomponentaction) +### 删除节点操作项 + +```typescript +import { plugins } from '@alilc/lowcode-engine'; +import { IPublicModelPluginContext } from '@alilc/lowcode-types'; + +const removeCopyAction = (ctx: IPublicModelPluginContext) => { + return { + async init() { + ctx.material.removeBuiltinComponentAction('copy'); + } + } +}; +removeCopyAction.pluginName = 'removeCopyAction'; +await plugins.register(removeCopyAction); +``` + +**_效果如下:_** + +![image.png](https://img.alicdn.com/imgextra/i2/O1CN01Gfnu8J1O7PTRdoFQZ_!!6000000001658-2-tps-1319-290.png) + +具体 API 参考:[API 文档](/site/docs/api/material#removebuiltincomponentaction) + +## 实际案例 + +### 区块管理 + +- 仓库地址:[https://github.com/alibaba/lowcode-plugins](https://github.com/alibaba/lowcode-plugins) +- 具体代码:[https://github.com/alibaba/lowcode-plugins/tree/main/packages/action-block](https://github.com/alibaba/lowcode-plugins/tree/main/packages/action-block) +- 直播回放: + - [低代码引擎项目实战 (9)-区块管理 (1)-保存为区块](https://www.bilibili.com/video/BV1YF411M7RK/) + - [低代码引擎项目实战 (10)-区块管理 - 区块面板](https://www.bilibili.com/video/BV1FB4y1S7tu/) + - [阿里巴巴低代码引擎项目实战 (11)-区块管理 - ICON 优化](https://www.bilibili.com/video/BV1zr4y1H7Km/) + - [阿里巴巴低代码引擎项目实战 (11)-区块管理 - 自动截图](https://www.bilibili.com/video/BV1GZ4y117VH/) + - [阿里巴巴低代码引擎项目实战 (11)-区块管理 - 样式优化](https://www.bilibili.com/video/BV1Pi4y1S7ZT/) + - [阿里低代码引擎项目实战 (12)-区块管理 (完结)-给引擎插件提个 PR](https://www.bilibili.com/video/BV1hB4y1277o/) diff --git a/docs/docs/guide/expand/editor/pluginWidget.md b/docs/docs/guide/expand/editor/pluginWidget.md new file mode 100644 index 0000000000..06125575f6 --- /dev/null +++ b/docs/docs/guide/expand/editor/pluginWidget.md @@ -0,0 +1,214 @@ +--- +title: 插件扩展 - 面板扩展 +sidebar_position: 5 +--- + +## 插件简述 + +插件功能赋予低代码引擎更高的灵活性,低代码引擎的生态提供了一些官方的插件,但是无法满足所有人的需求,所以提供了强大的插件定制功能。 + +通过定制插件,在和低代码引擎解耦的基础上,我们可以和引擎核心模块进行交互,从而满足多样化的功能。不仅可以自定义插件的 UI,还可以实现一些非 UI 的逻辑: + +1. 调用编辑器框架提供的 API 进行编辑器操作或者 schema 操作; +2. 通过插件类的生命周期函数实现一些插件初始化的逻辑; +3. 通过实现监听编辑器内的消息实现特定的切片逻辑(例如面板打开、面板关闭等); + +> 本文仅介绍面板层面的扩展,编辑器插件层面的扩展可以参考 ["插件扩展 - 编排扩展"](./pluginContextMenu.md) 章节。 + +## 注册插件 API + +```typescript +import { plugins } from '@alilc/lowcode-engine'; +import { IPublicModelPluginContext } from '@alilc/lowcode-types'; + +const pluginA = (ctx: IPublicModelPluginContext, options: any) => { + return { + init() { + console.log(options.key); + // 往引擎增加面板 + ctx.skeleton.add({ + // area 配置见下方说明 + area: 'leftArea', + // type 配置见下方说明 + type: 'PanelDock', + content:
demo
, + }); + ctx.logger.log('打个日志'); + }, + destroy() { + console.log('我被销毁了~'); + }, + }; +}; + +pluginA.pluginName = 'pluginA'; + +plugins.register(pluginA, { key: 'test' }); +``` + +> 如果您想了解抽取出来的插件如何封装成为一个 npm 包并提供给社区,可以参考[“低代码生态脚手架 & 调试机制”](./cli)章节。 + +## 面板插件配置说明 + +面板插件是作用于设计器的,主要是通过按钮、图标等展示在设计器的骨架中。设计器的骨架我们分为下面的几个区域,而我们的插件大多数都是作用于这几个区域的。 + +![image.png](https://img.alicdn.com/imgextra/i2/O1CN01Bkfm9E1MQWmBWeIOh_!!6000000001429-2-tps-1920-1080.png) + +![image.png](https://img.alicdn.com/imgextra/i3/O1CN01y05ZHC1Gix0p4nXxH_!!6000000000657-2-tps-3068-1648.png) + +### 展示区域 area + +#### topArea + +展示在设计器的顶部区域,常见的相关区域的插件主要是:、 + +1. 注册设计器 Logo; +2. 设计器操作回退和撤销按钮; +3. 全局操作按钮,例如:保存、预览等; + +#### leftArea + +左侧区域的展示形式大多数是 Icon 和对应的面板,通过点击 Icon 可以展示对应的面板并隐藏其他的面板。 + +该区域相关插件的主要有: + +1. 大纲树展示,展示该设计器设计页面的大纲。 +2. 组件库,展示注册到设计器中的组件,点击之后,可以从组件库面板中拖拽到设计器的画布中。 +3. 数据源面板 +4. JS 等代码面板。 + +可以发现,这个区域的面板大多数操作时是不需要同时并存的,且交互比较复杂的,需要一个更整块的区域来进行操作。 + +#### centerArea + +画布区域,由于画布大多数是展示作用,所以一般扩展的种类比较少。常见的扩展有: + +1. 画布大小修改 +2. 物料选中扩展区域修改 + +#### rightArea + +右侧区域,常用于组件的配置。常见的扩展有:统一处理组件的配置项,例如统一删除某一个配置项,统一添加某一个配置项的。 + +#### toolbar + +跟 topArea 类似,按需放置面板插件~ + +### 展示形式 type + +#### PanelDock + +PanelDock 是以面板的形式展示在设计器的左侧区域的。其中主要有两个部分组成,一个是图标,一个是面板。当点击图标时可以控制面板的显示和隐藏。 + +下图是组件库插件的展示效果。 + +![Feb-08-2022 19-44-15.gif](https://img.alicdn.com/imgextra/i3/O1CN01XCrv5Q1hR5BgsyAiq_!!6000000004273-1-tps-1536-790.gif) + +其中右上角可以进行固定,可以对弹出的宽度做设定 + +接入可以参考代码 + +```javascript +import { skeleton } from '@alilc/lowcode-engine'; + +skeleton.add({ + area: 'leftArea', // 插件区域 + type: 'PanelDock', // 插件类型,弹出面板 + name: 'sourceEditor', + content: SourceEditor, // 插件组件实例 + props: { + align: "left", + icon: "wenjian", + description: "JS 面板", + }, + panelProps: { + floatable: true, // 是否可浮动 + height: 300, + hideTitleBar: false, + maxHeight: 800, + maxWidth: 1200, + title: "JS 面板", + width: 600, + }, +}); +``` + +#### Widget + +Widget 形式是直接渲染在当前编辑器的对应位置上。如 demo 中在设计器顶部的所有组件都是这种展现形式。 + +![image.png](https://img.alicdn.com/imgextra/i3/O1CN01h89p5W1pfknnzwMqS_!!6000000005388-2-tps-1988-94.png) + +接入可以参考代码: + +```javascript +import { skeleton } from '@alilc/lowcode-engine'; +// 注册 logo 面板 +skeleton.add({ + area: 'topArea', + type: 'Widget', + name: 'logo', + content: Logo, // Widget 组件实例 + contentProps: { // Widget 插件 props + logo: + "https://img.alicdn.com/tfs/TB1_SocGkT2gK0jSZFkXXcIQFXa-66-66.png", + href: "/", + }, + props: { + align: 'left', + width: 100, + }, +}); +``` + +#### Dock + +一个图标的表现形式,可以用于语言切换、跳转到外部链接、打开一个 widget 等场景。 + +```javascript +import { skeleton } from '@alilc/lowcode-engine'; + +skeleton.add({ + area: 'leftArea', + type: 'Dock', + name: 'opener', + props: { + icon: Icon, // Icon 组件实例 + align: 'bottom', + onClick: function () { + // 打开外部链接 + window.open('https://lowcode-engine.cn'); + // 显示 widget + skeleton.showWidget('xxx'); + } + } +}); +``` + +#### Panel + +一般不建议单独使用,通过 PanelDock 使用~ + +## 实际案例 + +### 页面管理面板 + +- 仓库地址:[https://github.com/mark-ck/lowcode-portal](https://github.com/mark-ck/lowcode-portal) +- 具体代码:[https://github.com/mark-ck/lowcode-portal/blob/master/src/plugins/pages-plugin/index.tsx](https://github.com/mark-ck/lowcode-portal/blob/master/src/plugins/pages-plugin/index.tsx) +- 直播回放: + - [低代码引擎项目实战 (4)-自定义插件 - 页面管理](https://www.bilibili.com/video/BV17a411i73f/) + - [低代码引擎项目实战 (4)-自定义插件 - 页面管理 - 后端](https://www.bilibili.com/video/BV1uZ4y1U7Ly/) + - [低代码引擎项目实战 (4)-自定义插件 - 页面管理 - 前端](https://www.bilibili.com/video/BV1Yq4y1a74P/) + - [低代码引擎项目实战 (4)-自定义插件 - 页面管理 - 完结](https://www.bilibili.com/video/BV13Y4y1e7EV/) + +### 区块面板 + +- 仓库地址:[https://github.com/alibaba/lowcode-plugins](https://github.com/alibaba/lowcode-plugins) +- 具体代码:[https://github.com/alibaba/lowcode-plugins/tree/main/packages/plugin-block](https://github.com/alibaba/lowcode-plugins/tree/main/packages/plugin-block) +- 直播回放: + - [低代码引擎项目实战 (9)-区块管理 (1)-保存为区块](https://www.bilibili.com/video/BV1YF411M7RK/) + - [低代码引擎项目实战 (10)-区块管理 - 区块面板](https://www.bilibili.com/video/BV1FB4y1S7tu/) + - [阿里巴巴低代码引擎项目实战 (11)-区块管理 - ICON 优化](https://www.bilibili.com/video/BV1zr4y1H7Km/) + - [阿里巴巴低代码引擎项目实战 (11)-区块管理 - 自动截图](https://www.bilibili.com/video/BV1GZ4y117VH/) + - [阿里巴巴低代码引擎项目实战 (11)-区块管理 - 样式优化](https://www.bilibili.com/video/BV1Pi4y1S7ZT/) + - [阿里低代码引擎项目实战 (12)-区块管理 (完结)-给引擎插件提个 PR](https://www.bilibili.com/video/BV1hB4y1277o/) diff --git a/docs/docs/guide/expand/editor/setter.md b/docs/docs/guide/expand/editor/setter.md new file mode 100644 index 0000000000..4f0e0219fc --- /dev/null +++ b/docs/docs/guide/expand/editor/setter.md @@ -0,0 +1,241 @@ +--- +title: 设置器扩展 +sidebar_position: 7 +--- +## 设置器简述 + +设置器主要用于低代码组件属性值的设置,顾名思义叫"设置器",又称为 Setter。由于组件的属性有各种类型,需要有与之对应的设置器支持,每一个设置器对应一个值的类型。 + +### 设计器展示位置 + +设置器展示在编辑器的右边区域,如下图: + +![image.png](https://img.alicdn.com/imgextra/i3/O1CN01F0yBV91jNzkZKLzvJ_!!6000000004537-2-tps-3836-1730.png) + +其中包含四类设置器: + +- 属性:展示该物料常规的属性 +- 样式:展示该物料样式的属性 +- 事件:如果该物料有声明事件,则会出现事件面板,用于绑定事件。 +- 高级:两个逻辑相关的属性,**条件渲染**和**循环** + +### 设置器类型 + +上述区域中是有多项设置器的,对于一个组件来说,每一项配置都对应一个设置器,比如我们的配置是一个文本,我们需要的是文本设置器,我们需要配置的是数字,我们需要的就是数字设置器。 +下图中的标题和按钮类型配置就分别是文本设置器和下拉框设置器。 + +![image.png](https://img.alicdn.com/imgextra/i1/O1CN01uMd1zQ20fiXawR4IU_!!6000000006877-2-tps-2120-1460.png) + +我们提供了常用的设置器作为内置设置器,也提供了定制能力帮助大家开发特定需求的设置器。 + +## 为物料配置设置器 + +我们提供了[常用的设置器](/site/docs/guide/appendix/setters)作为内置设置器。 + +我们可以将目标组件的属性值类型值配置到物料资源配置文件中: + +```json +{ + "componentName": "Message", + "title": "Message", + "configure": { + "props": [ + { + "name": "type", + "setter": "InputSetter" + } + ] + } +} +``` + +props 字段是入料模块扫描自动填入的类型,用户可以通过 configure 节点进行配置通过 override 节点对属性的声明重新定义,setter 就是注册在引擎中的 setter。 + +为物料配置引擎内置的 setter 时,均可以使用对应 setter 的高级功能,对应功能参考“全部内置设置器”章节下的对应 setter 文章。 + +### 对高级功能的配置如下: + +例如我们需要在 NumberSetter 中配置 units 属性,可以在 asset.json 中声明。 + +```json +"configure": { + "component": { + "isContainer": true, + "nestingRule": { + "parentWhitelist": [ + "NextP" + ] + } + }, + "props": [ + { + "name": "width", + "title": "宽度", + "initialValue": "auto", + "defaultValue": "auto", + "condition": { + "type": "JSFunction", + "value": "() => false" + }, + "setter": { + "componentName": "NumberSetter", + "props": { + "units": [ + { + "type": "px", + "list": true + }, + { + "type": "%", + "list": true + } + ] + } + } + }, + ], + "supports": { + "style": true + } +}, +``` + +## 自定义设置器 +### 编写 AltStringSetter + +我们编写一个简单的 Setter,它的功能如下: + +![image.png](https://img.alicdn.com/imgextra/i3/O1CN01fQ4GLd1RzrPSdULiw_!!6000000002183-2-tps-720-90.png) + +**代码如下:** +```tsx +import * as React from "react"; +import { Input } from "@alifd/next"; +import "./index.scss"; + +interface AltStringSetterProps { + // 当前值 + value: string; + // 默认值 + defaultValue: string; + // setter 唯一输出 + onChange: (val: string) => void; + // AltStringSetter 特殊配置 + placeholder: string; +} + +export default class AltStringSetter extends React.PureComponent { + // 声明 Setter 的 title + static displayName = 'AltStringSetter'; + + componentDidMount() { + const { onChange, value, defaultValue } = this.props; + if (value == undefined && defaultValue) { + onChange(defaultValue); + } + } + + render() { + const { onChange, value, placeholder } = this.props; + return ( + onChange(val)} + > + ); + } +} +``` + +#### setter 和 setter/plugin 之间的联动 + +我们采用 emit 来进行相互之前的通信,首先我们在 A setter 中进行事件注册: + +```javascript +import { event } from '@alilc/lowcode-engine'; + +componentDidMount() { + // 这里由于面板上会有多个 setter,这里我用 field.id 来标记 setter 名 + this.emitEventName = `${SETTER_NAME}-${this.props.field.id}`; + event.on(`${this.emitEventName}.bindEvent`, this.bindEvent); +} + +bindEvent = (eventName) => { + // do someting +} + +componentWillUnmount() { + // setter 是以实例为单位的,每个 setter 注销的时候需要把事件也注销掉,避免事件池过多 + event.off(`${this.emitEventName}.bindEvent`, this.bindEvent); +} +``` + +在 B setter 中触发事件,来完成通信: + +```javascript +import { event } from '@alilc/lowcode-engine'; + +bindFunction = () => { + const { field, value } = this.props; + // 这里展示的和插件进行通信,事件规则是插件名 + 方法 + event.emit('eventBindDialog.openDialog', field.name, this.emitEventName); +} +``` + +#### 修改同级 props 的其他属性值 + +setter 本身只影响其中一个 props 的值,如果需要影响其他组件的 props 的值,需要使用 field 的 props: + +```javascript +bindFunction = () => { + const { field, value } = this.props; + const propsField = field.parent; + // 获取同级其他属性 showJump 的值 + const otherValue = propsField.getPropValue('showJump'); + // set 同级其他属性 showJump 的值 + propsField.setPropValue('showJump', false); +} +``` + +### 注册 AltStringSetter + +我们需要在低代码引擎中注册 Setter,这样就可以通过 AltStringSetter 的名字在物料中使用了。 + +```typescript +import AltStringSetter from './AltStringSetter'; +const registerSetter = window.AliLowCodeEngine.setters.registerSetter; +registerSetter('AltStringSetter', AltStringSetter); +``` + +### 物料中使用 + +我们需要将目标组件的属性值类型值配置到物料资源配置文件中,其中核心配置如下: + +```json +{ + "props": [ + { + "name": "type", + "setter": "AltStringSetter" + } + ] +} +``` + +在物料中的相关配置如下: + +```json +{ + "componentName": "Message", + "title": "Message", + "configure": { + "props": [ + { + "name": "type", + "setter": "AltStringSetter" + } + ] + } +} +``` \ No newline at end of file diff --git a/docs/docs/guide/expand/editor/summary.md b/docs/docs/guide/expand/editor/summary.md new file mode 100644 index 0000000000..814340f3d3 --- /dev/null +++ b/docs/docs/guide/expand/editor/summary.md @@ -0,0 +1,92 @@ +--- +title: 编辑态扩展简述 +sidebar_position: 0 +--- +## 扩展点简述 + +我们可以从 Demo 的项目中看到页面中有很多的区块: +![image.png](https://img.alicdn.com/imgextra/i3/O1CN01WkdvNi1TamxZblYFA_!!6000000002399-2-tps-3840-2160.png) +这些功能点背后都是可扩展项目,如下图所示: +![image.png](https://img.alicdn.com/imgextra/i3/O1CN01wZLOzm24hmnMTwXdF_!!6000000007423-2-tps-3838-1914.png) + +- 插件定制:可以配置低代码编辑器的功能和面板 +- 物料定制:可以配置能够拖入的物料 +- 操作辅助区定制:可以配置编辑器画布中的操作辅助区功能 +- 设置器定制:可以配置编辑器中组件的配置表单 + +我们从可扩展项目的视角,可以把低代码引擎架构理解为下图: + +![image.png](https://img.alicdn.com/imgextra/i3/O1CN01fhZ3Q11hwE7RwSq7g_!!6000000004341-2-tps-3840-2160.png) +(注:引擎内核中大量数据交互的细节被简化,这张图仅仅强调编辑器和外部扩展的交互) + +## 配置扩展点 + +### 配置物料 +通过配置注入物料,这里的配置是物料中心根据物料资产包协议生成的,后面“物料扩展”章节会有详细说明。 +```typescript +import { material } from '@alilc/lowcode-engine'; +// 假设您已把物料配置在本地 +import assets from './assets.json'; + +// 静态加载 assets +material.setAssets(assets); +``` + +也可以通过异步加载物料中心上的物料。 +```typescript +import { material, plugins } from '@alilc/lowcode-engine'; +import { IPublicModelPluginContext } from '@alilc/lowcode-types'; + +// 动态加载 assets +plugins.register((ctx: IPublicModelPluginContext) => { + return { + name: 'ext-assets', + async init() { + try { + // 将下述链接替换为您的物料即可。无论是通过 utils 从物料中心引入,还是通过其他途径如直接引入物料描述 + const res = await window.fetch('https://fusion.alicdn.com/assets/default@0.1.95/assets.json') + const assets = await res.text() + material.setAssets(assets) + } catch (err) { + console.error(err) + } + }, + } +}).catch(err => console.error(err)); +``` + +### 配置插件 +可以通过 npm 包的方式引入社区插件,配置如下所示: +```typescript +import { plugins } from '@alilc/lowcode-engine'; +import { IPublicModelPluginContext } from '@alilc/lowcode-types'; +import PluginIssueTracker from '@alilc/lowcode-plugin-issue-tracker'; + +// 注册一个提 issue 组件到您的编辑器中,方位默认在左栏下侧 +plugins.register(PluginIssueTracker) + .catch(err => console.error(err)); +``` +后续“插件扩展”章节会详细说明。 + +### 配置设置器 +低代码引擎默认内置了设置器(详见“配置设置器”章节)。您可以通过 npm 包的方式引入自定义的设置器,配置如下所示: +```typescript +import { setters } from '@alilc/lowcode-engine'; +// 假设您自定义了一个 setter +import MuxMonacoEditorSetter from './components/setters/MuxMonacoEditorSetter'; + +// 注册设置器 +setters.registerSetter({ + MuxMonacoEditorSetter: { + component: MuxMonacoEditorSetter, + title: 'Textarea', + condition: (field) => { + const v = field.getValue() + return typeof v === 'string' + }, + }, +}); +``` +后续“设置器扩展”章节会详细说明。 + +> 本章节所有可扩展配置内容在 demo 中均可找到:[https://github.com/alibaba/lowcode-demo/tree/main/demo-general](https://github.com/alibaba/lowcode-demo/tree/main/demo-general) diff --git a/docs/docs/guide/expand/editor/theme.md b/docs/docs/guide/expand/editor/theme.md new file mode 100644 index 0000000000..897b6b360b --- /dev/null +++ b/docs/docs/guide/expand/editor/theme.md @@ -0,0 +1,157 @@ +--- +title: 主题色扩展 +sidebar_position: 9 +--- + +## 简介 + +主题色扩展允许用户定制多样的设计器主题,增加界面的个性化和品牌识别度。 + +## 设计器主题色定制 + +在 CSS 的根级别定义主题色变量可以确保这些变量在整个应用中都可用。例如: + +```css +:root { + --color-brand: rgba(0, 108, 255, 1); /* 主品牌色 */ + --color-brand-light: rgba(25, 122, 255, 1); /* 浅色品牌色 */ + --color-brand-dark: rgba(0, 96, 229, 1); /* 深色品牌色 */ +} + +``` + +将样式文件引入到你的设计器中,定义的 CSS 变量就可以改变设计器的主题色了。 + +### 主题色变量 + +以下是低代码引擎设计器支持的主题色变量列表,以及它们的用途说明: + +#### 品牌相关颜色 + +- `--color-brand`: 主品牌色 +- `--color-brand-light`: 浅色品牌色 +- `--color-brand-dark`: 深色品牌色 + +#### Icon 相关颜色 + +- `--color-icon-normal`: 默认状态 +- `--color-icon-light`: icon light 状态 +- `--color-icon-hover`: 鼠标悬停状态 +- `--color-icon-active`: 激活状态 +- `--color-icon-reverse`: 反色状态 +- `--color-icon-disabled`: 禁用状态 +- `--color-icon-pane`: 面板颜色 + +#### 线条和文本颜色 + +- `--color-line-normal`: 线条颜色 +- `--color-line-darken`: 线条颜色(darken) +- `--color-title`: 标题颜色 +- `--color-text`: 文字颜色 +- `--color-text-dark`: 文字颜色(dark) +- `--color-text-light`: 文字颜色(light) +- `--color-text-reverse`: 反色情况下,文字颜色 +- `--color-text-disabled`: 禁用态文字颜色 + +#### 菜单颜色 +- `--color-context-menu-text`: 菜单项颜色 +- `--color-context-menu-text-hover`: 菜单项 hover 颜色 +- `--color-context-menu-text-disabled`: 菜单项 disabled 颜色 + +#### 字段和边框颜色 + +- `--color-field-label`: field 标签颜色 +- `--color-field-text`: field 文本颜色 +- `--color-field-placeholder`: field placeholder 颜色 +- `--color-field-border`: field 边框颜色 +- `--color-field-border-hover`: hover 态下,field 边框颜色 +- `--color-field-border-active`: active 态下,field 边框颜色 +- `--color-field-background`: field 背景色 + +#### 状态颜色 + +- `--color-success`: success 颜色 +- `--colo-success-dark`: success 颜色(dark) +- `--color-success-light`: success 颜色(light) +- `--color-warning`: warning 颜色 +- `--color-warning-dark`: warning 颜色(dark) +- `--color-warning-light`: warning 颜色(light) +- `--color-information`: information 颜色 +- `--color-information-dark`: information 颜色(dark) +- `--color-information-light`: information 颜色(light) +- `--color-error`: error 颜色 +- `--color-error-dark`: error 颜色(dark) +- `--color-error-light`: error 颜色(light) +- `--color-purple`: purple 颜色 +- `--color-brown`: brown 颜色 + +#### 区块背景色 + +- `--color-block-background-normal`: 区块背景色 +- `--color-block-background-light`: 区块背景色(light)。 +- `--color-block-background-shallow`: 区块背景色 shallow +- `--color-block-background-dark`: 区块背景色(dark) +- `--color-block-background-disabled`: 区块背景色(disabled) +- `--color-block-background-active`: 区块背景色(active) +- `--color-block-background-active-light`: 区块背景色(active light) +- `--color-block-background-warning`: 区块背景色(warning) +- `--color-block-background-error`: 区块背景色(error) +- `--color-block-background-success`: 区块背景色(success) +- `--color-block-background-deep-dark`: 区块背景色(deep-dark),作用于多个组件同时拖拽的背景色。 + +#### 引擎相关颜色 + +- `--color-canvas-detecting-background`: 画布组件 hover 时遮罩背景色。 + +#### 其他区域背景色 + +- `--color-layer-mask-background`: 拖拽元素时,元素原来位置的遮罩背景色 +- `--color-layer-tooltip-background`: tooltip 背景色 +- `--color-pane-background`: 面板背景色 +- `--color-background`: 设计器主要背景色 +- `--color-top-area-background`: topArea 背景色,优先级大于 `--color-pane-background` +- `--color-left-area-background`: leftArea 背景色,优先级大于 `--color-pane-background` +- `--color-toolbar-background`: toolbar 背景色,优先级大于 `--color-pane-background` +- `--color-workspace-left-area-background`: 应用级 leftArea 背景色,优先级大于 `--color-pane-background` +- `--color-workspace-top-area-background`: 应用级 topArea 背景色,优先级大于 `--color-pane-background` +- `--color-workspace-sub-top-area-background`: 应用级二级 topArea 背景色,优先级大于 `--color-pane-background` + +#### 其他变量 + +- `--workspace-sub-top-area-height`: 应用级二级 topArea 高度 +- `--top-area-height`: 顶部区域的高度 +- `--workspace-sub-top-area-margin`: 应用级二级 topArea margin +- `--workspace-sub-top-area-padding`: 应用级二级 topArea padding +- `--workspace-left-area-width`: 应用级 leftArea width +- `--left-area-width`: leftArea width +- `--simulator-top-distance`: simulator 距离容器顶部的距离 +- `--simulator-bottom-distance`: simulator 距离容器底部的距离 +- `--simulator-left-distance`: simulator 距离容器左边的距离 +- `--simulator-right-distance`: simulator 距离容器右边的距离 +- `--toolbar-padding`: toolbar 的 padding +- `--toolbar-height`: toolbar 的高度 +- `--pane-title-height`: 面板标题高度 +- `--pane-title-font-size`: 面板标题字体大小 +- `--pane-title-padding`: 面板标题边距 +- `--context-menu-item-height`: 右键菜单项高度 + + + +### 低代码引擎生态主题色定制 + +插件、物料、设置器等生态为了支持主题色需要对样式进行改造,需要对生态中的样式升级为 css 变量。例如: + +```css +/* before */ +background: #006cff; + +/* after */ +background: var(--color-brand, #006cff); + +``` + +这里 `var(--color-brand, #默认色)` 表示使用 `--color-brand` 变量,如果该变量未定义,则使用默认颜色(#默认色)。 + +### fusion 物料进行主题色扩展 + +如果使用了 fusion 组件时,可以通过 [fusion 平台](https://fusion.design/) 进行主题色定制。在平台上,您可以选择不同的主题颜色,并直接应用于您的 fusion 组件,这样可以无缝地集成到您的应用设计中。 \ No newline at end of file diff --git a/docs/docs/guide/expand/runtime/_category_.json b/docs/docs/guide/expand/runtime/_category_.json new file mode 100644 index 0000000000..f382ad4068 --- /dev/null +++ b/docs/docs/guide/expand/runtime/_category_.json @@ -0,0 +1,6 @@ +{ + "label": "扩展运行时", + "position": 2, + "collapsed": false, + "collapsible": true +} diff --git a/docs/docs/guide/expand/runtime/codeGeneration.md b/docs/docs/guide/expand/runtime/codeGeneration.md new file mode 100644 index 0000000000..71cf81bd1c --- /dev/null +++ b/docs/docs/guide/expand/runtime/codeGeneration.md @@ -0,0 +1,133 @@ +--- +title: 使用出码功能 +sidebar_position: 1 +--- + +## 出码简述 +所谓出码,即将低代码编排出的 schema 进行解析并转换成最终可执行的代码的过程。 +## 出码的适用范围 +出码是为了更高效的运行和更灵活地定制渲染,相对而言,基于 Schema 的运行时渲染,有着能实时响应内容的变化和接入成本低的优点,但是也存在着实时解析运行的性能开销比较大和包大小比较大的问题,而且无法自由地进行扩展二次开发,功能自由度受到一定程度限制。 +当然,出码也会存在一些限制:一方面需要额外的接入成本,另一方面通常需要额外的生成代码和打包构建的时间,难以做到基于 Schema 的运行时渲染那样保存即预览的效果。 + +所以不是所有场景都建议做出码,一般来说以下 3 个场景可以考虑使用出码进行优化。 + +### 场景一:想要极致的打开速度,降低 LCP/FID +这种场景比较常见的是 C 端应用,比如手淘上的页面和手机钉钉上的页面,要求能够尽快得响应用户操作,不要出现卡死的情况。当一个流入协议大小比较大的时候,前端进行解析时的开销也比较大。如果能把这部分负担转移到编译时去完成的话,前端依赖包大小就会减少许多。从而也提升了加载速度,降低了带宽消耗。页面越简单,这其中的 gap 就会越明显。 + +### 场景二:老项目 + 新需求,想用搭建产出 +这是一个很常见的场景,毕竟迁移或者重构都是有一个过程的,阿里的业务都是一边跑一边换发动机。在这种场景中,我们不可能要求使用运行时方案来做实现,因为运行时是一个项目级别的能力,最好是项目中统一使用他这一种方式,保证体验的一致性与连贯性。所以我们可以只在低代码平台上搭建新的业务页面,然后通过出码模块导出这些页面的源码,连同一些全局依赖模块,一起 Merge 到老项目中。完成开发体验的优化。 + +### 场景三:协议不能描述部分代码逻辑(协议功能不足或特别定制化的逻辑) +当我们发现一些逻辑诉求不能在目前协议中很好地表达的时候,这其实是项目复杂度较高的一个信号。比较好的方式就是将低代码研发和源码研发结合起来。这种模式下最大的诉求点之一就是,需要将搭建的内容输出为可读性和确定性都比较良好的代码模块。这也就是出码模块需要支持好的使用场景了。 + +## 如何使用 +### 1) 通过命令行快速体验 + +欢迎使用命令行工具快速体验:`npx @alilc/lowcode-code-generator -i example-schema.json -o generated -s icejs3` + +--其中 example-schema.json 可以从[这里下载](https://alifd.alicdn.com/npm/@alilc/lowcode-code-generator@latest/example-schema.json) + +### 2) 通过设计器插件快速体验 + +1. 安装依赖: `npm install --save @alilc/lowcode-plugin-code-generator` +2. 注册插件: + +```typescript +import { plugins } from '@alilc/lowcode-engine'; +import CodeGenPlugin from '@alilc/lowcode-plugin-code-generator'; + +// 在你的初始化函数中: +await plugins.register(CodeGenPlugin); + +// 如果您不希望自动加上出码按钮,则可以这样注册 +await plugins.register(CodeGenPlugin, { disableCodeGenActionBtn: true }); +``` + +然后运行你的低代码编辑器项目即可 -- 在设计器的右上角会出现一个“出码”按钮,点击即可在浏览器中出码并预览。 + +### 3)服务端出码接入 + +此代码生成器一开始就是为服务端出码设计的,你可以直接这样来在 node.js 环境中使用: + +1. 安装依赖: `npm install --save @alilc/lowcode-code-generator` +2. 引入代码生成器: + +```javascript +import CodeGenerator from '@alilc/lowcode-code-generator'; +``` + +3. 创建项目构建器: + +```javascript +const projectBuilder = CodeGenerator.solutions.icejs(); +``` + +4. 生成代码 + +```javascript +const project = await projectBuilder.generateProject( + schema, // 编排搭建出来的 schema +); +``` + +5. 将生成的代码写入到磁盘中 (也可以生成一个 zip 包) + +```javascript +// 写入磁盘 +await CodeGenerator.publishers.disk().publish({ + project, // 上一步生成的 project + outputPath: '/path/to/your/output/dir', // 输出目录 + projectSlug: 'your-project-slug', // 项目标识 +}); + +// 写入到 zip 包 +await CodeGenerator.publishers.zip().publish({ + project, // 上一步生成的 project + outputPath: '/path/to/your/output/dir', // 输出目录 + projectSlug: 'your-project-slug', // 项目标识 -- 对应生成 your-project-slug.zip 文件 +}); +``` + +注:一般来说在服务端出码可以跟 github/gitlab, CI 和 CD 流程等一起串起来使用,通常用于优化性能。 + +### 4)浏览器中出码接入 + +随着现在电脑性能和浏览器技术的发展,出码其实已经不必非得在服务端做了,借助于 Web Worker 特性,可以在浏览器中进行出码: + +1. 安装依赖: `npm install --save @alilc/lowcode-code-generator` +2. 引入代码生成器: + +```javascript +import * as CodeGenerator from '@alilc/lowcode-code-generator/standalone-loader'; +``` + +3. 【可选】提前初始化代码生成器: + +```javascript +// 提前初始化下,这样后面用的时候更快 (这个 init 内部会提前准备好创建 worker 的一些资源) +await CodeGenerator.init(); +``` + +4. 出码 + +```javascript +const result = await CodeGenerator.generateCode({ + solution: 'icejs', // 出码方案 (目前内置有 icejs、icejs3 和 rax ) + schema, // 编排搭建出来的 schema +}); + +console.log(result); // 出码结果 (默认是递归结构描述的,可以传 flattenResult: true 以生成扁平结构的结果) +``` + +注:一般来说在浏览器中出码适合做即时预览功能。 + +### 5)自定义出码 +前端框架灵活多变,默认内置的出码方案很难满足所有人的需求,好在此代码生成器支持非常灵活的插件机制 -- 内置功能大多都是通过插件完成的(在 `src/plugins`下),比如: +![image.png](https://img.alicdn.com/imgextra/i1/O1CN01CEl2Hq1omnH0UCyGF_!!6000000005268-2-tps-457-376.png) + +所以您可以通过添加自己的插件或替换掉默认内置的插件来实现您的自定义功能。 +为了方便自定义出码方案,出码模块还提供自定义出码方案的脚手架功能,即执行下面脚本即可生成一个自定义出码方案: +```shell +npx @alilc/lowcode-code-generator init-solution +``` +里面内置了一个示例的插件 (在 `src/plugins/example.ts`中),您可以根据注释引导来完善相关插件,从而组合生成您的专属出码方案 (`src/index.ts`)。您所生成的出码方案可以发布成 NPM 包,从而能按上文 1~4 中的使用方案进行使用。 diff --git a/docs/docs/guide/expand/runtime/renderer.md b/docs/docs/guide/expand/runtime/renderer.md new file mode 100644 index 0000000000..4e6d914bbf --- /dev/null +++ b/docs/docs/guide/expand/runtime/renderer.md @@ -0,0 +1,348 @@ +--- +title: 使用渲染模块 +sidebar_position: 0 +--- +## 快速使用 +渲染依赖于 schema 和 components。其中 schema 和 components 需要一一对应,schema 中使用到的组件都需要在 components 中进行声明,否则无法正常渲染。 +### 简单示例 + +```jsx +import ReactRenderer from '@alilc/lowcode-react-renderer'; +import ReactDOM from 'react-dom'; +import { Button } from '@alifd/next'; + +const schema = { + componentName: 'Page', + props: {}, + children: [ + { + componentName: 'Button', + props: { + type: 'primary', + style: { + color: '#2077ff' + }, + }, + children: '确定', + }, + ], +}; + +const components = { + Button, +}; + +ReactDOM.render(( + +), document.getElementById('root')); +``` + +#### +### 项目使用示例 +> [设计器 demo](https://lowcode-engine.cn/demo/demo-general/index.html) +> 项目代码完整示例:[https://github.com/alibaba/lowcode-demo](https://github.com/alibaba/lowcode-demo) + +**step 1:在设计器中获取组件列表** +```typescript +import { material, project } from '@alilc/lowcode-engine'; +const packages = material.getAssets().packages +``` +**step 2:在设计器中获取当前配置页面的 schema** +```typescript +import { material, project } from '@alilc/lowcode-engine'; + +const schema = project.exportSchema(); +``` + + +**step 3:以某种方式存储 schema 和 packages** +这里用 localStorage 作为存储示例,真实项目中使用数据库或者其他存储方式。 +```typescript +window.localStorage.setItem( + 'projectSchema', + JSON.stringify(project.exportSchema()) +); +const packages = await filterPackages(material.getAssets().packages); +window.localStorage.setItem( + 'packages', + JSON.stringify(packages) +); +``` +**step 4:预览时,获取存储的 schema 和 packages** +```typescript +const packages = JSON.parse(window.localStorage.getItem('packages') || ''); +const projectSchema = JSON.parse(window.localStorage.getItem('projectSchema') || ''); +const { componentsMap: componentsMapArray, componentsTree } = projectSchema; +``` +**step 5:通过整合 schema 和 packages 信息,进行渲染** +```typescript +import ReactDOM from 'react-dom'; +import React, { useState } from 'react'; +import { Loading } from '@alifd/next'; +import { buildComponents, assetBundle, AssetLevel, AssetLoader } from '@alilc/lowcode-utils'; +import ReactRenderer from '@alilc/lowcode-react-renderer'; +import { injectComponents } from '@alilc/lowcode-plugin-inject'; + +const SamplePreview = () => { + const [data, setData] = useState({}); + + async function init() { + // 渲染前置处理,初始化项目 schema 和资产包为渲染模块所需的 schema prop 和 components prop + const packages = JSON.parse(window.localStorage.getItem('packages') || ''); + const projectSchema = JSON.parse(window.localStorage.getItem('projectSchema') || ''); + const { componentsMap: componentsMapArray, componentsTree } = projectSchema; + const componentsMap: any = {}; + componentsMapArray.forEach((component: any) => { + componentsMap[component.componentName] = component; + }); + const schema = componentsTree[0]; + + const libraryMap = {}; + const libraryAsset = []; + packages.forEach(({ package: _package, library, urls, renderUrls }) => { + libraryMap[_package] = library; + if (renderUrls) { + libraryAsset.push(renderUrls); + } else if (urls) { + libraryAsset.push(urls); + } + }); + + const vendors = [assetBundle(libraryAsset, AssetLevel.Library)]; + + const assetLoader = new AssetLoader(); + await assetLoader.load(libraryAsset); + const components = await injectComponents(buildComponents(libraryMap, componentsMap)); + + setData({ + schema, + components, + }); + } + + const { schema, components } = data; + + if (!schema || !components) { + init(); + return ; + } + + return ( +
+ +
+ ); +}; + +ReactDOM.render(, document.getElementById('ice-container')); + +``` +### 国际化示例 +```typescript +class Demo extends PureComponent { + static displayName = 'renderer-demo'; + render() { + return ( +
+ +
+ ); + } +} +``` + +## API + +| 参数 | 说明 | 类型 | 必选 | +| --- | --- | --- | --- | +| schema | 符合[搭建协议](https://lowcode-engine.cn/lowcode)的数据 | Object | 是 | +| components | 组件依赖的实例 | Object | 是 | +| componentsMap | 组件的配置信息 | Object | 否 | +| appHelper | 渲染模块全局上下文 | Object | 否 | +| designMode | 设计模式,可选值:extend、border、preview | String | 否 | +| suspended | 是否挂起 | Boolean | 否 | +| onCompGetRef | 组件 ref 回调(schema, ref)=> {} | Function | 否 | +| onCompGetCtx | 组件 ctx 更新回调 (schema, ctx) => {} | Function | 否 | +| rendererName | 渲染类型,标识当前模块是以什么类型进行渲染的 | string | 否 | +| customCreateElement | 自定义创建 element 的钩子 +(Component, props, children) => {} | Function | 否 | +| notFoundComponent | 当组件找不到时,可以通过这个参数自定义展示文案。 | Component | 否 | +| thisRequiredInJSE | 为 true 的情况下 JSExpression 仅支持通过 this 来访问。假如需要兼容原来的 'state.xxx',则设置为 false,推荐使用 true。 | Boolean | 否 | +| locale | 国际化语言类型 | string | 否 | +| messages | 国际化语言对象 | Object | 否 | + + +### schema + +搭建基础协议数据,渲染模块将基于 schema 中的内容进行实时渲染。 + +### messages +国际化内容,需要配合 locale 使用 +messages 格式示例: +```typescript +{ + 'zh-CN': { + 'hello-world': '你好,世界!', + }, + 'en-US': { + 'hello-world': 'Hello world!', + }, +} +``` + +### locale +当前语言类型 +示例:'zh-CN' | 'en-US' + +### components + +渲染模块渲染页面需要用到的组件依赖的实例,`components` 对象中的 Key 需要和搭建 schema 中的`componentName` 字段对应。 + +### componentsMap + +> 在生产环境下不需要设置。 + + +配置规范参见[《低代码引擎搭建协议规范》](https://lowcode-engine.cn/lowcode),主要在搭建场景中使用,用于提升用户搭建体验。 + +- 属性配置校验:用户可以配置组件特定属性的 `propTypes`,在搭建场景中用户输入的属性值不满足 `propType` 配置时,渲染模块会将当前属性设置为 `undefined`,避免组件抛错导致编辑器崩溃; +- `isContainer` 标记:当组件被设置为容器组件且当前容器组件内没有其他组件时,用户可以通过拖拽方式将组件直接添加到容器组件内部; +- `parentRule` 校验:当用户使用的组件未出现在组件配置的 `parentRule` 组件内部时,渲染模块会使用 `visualDom` 组件进行占位,避免组件抛错的同时在下钻编辑场景也能够不阻塞用户配置,典型的场景如`Step.Item`、`Table.Column`、`Tab.Item` 等等。 + +### appHelper + +appHelper 主要用于设置渲染模块的全局上下文,目前 appHelper 支持设置以下上下文: + +- `utils`:全局公共函数 +- `constants`:全局常量 +- `location`:react-router 的 `location` 实例 +- `history`:react-router 的 `history` 实例 + +设置了 appHelper 以后,上下文会直接挂载到容器组件的 this 上,用户可以在搭建协议中的 function 及变量表达式场景使用上下文,具体使用方式如下所示: +**schema:** + +```javascript +export default { + "componentName": "Page", + "fileName": "test", + "props": {}, + "children": [{ + "componentName": "Div", + "props": {}, + "children": [{ + "componentName": "Text", + "props": { + "text": { + "type": "JSExpression", + "value": "this.location.pathname" + } + } + }, { + "componentName": "Button", + "props": { + "type": "primary", + "style": { + "marginLeft": 10 + }, + "onClick": { + "type": "JSExpression", + "value": "function onClick(e) { this.utils.xxx(this.constants.yyy);}" + } + }, + "children": "click me" + }] + }] +} +``` + +```typescript +import ReactRenderer from '@alilc/lowcode-react-renderer'; +import ReactDOM from 'react-dom'; +import { Button } from '@alifd/next'; +import schema from './schema' + +const components = { + Button, +}; + +ReactDOM.render(( + {} + } + }} + /> +), document.getElementById('root')); +``` +### designMode + +> 在生产环境下不需要设置。 + + +designMode 属性主要在搭建场景中使用,主要有以下作用: + +- 当 `designMode` 改变时,触发当前 schema 中所有组件重新渲染 +- 当 `designMode` 设置为 `design` 时,渲染模块会为 `Dialog`、`Overlay` 等初始状态无 dom 渲染的组件外层包裹一层 div,使其在画布中能够展示边框供用户选中 + +### suspended + +渲染模块是否挂起,当设置为 `true` 时,渲染模块最外层容器的 `shouldComponentUpdate`将始终返回 false,在下钻编辑或者多引擎渲染的场景会用到该参数。 + +### onCompGetRef + +组件 ref 的回调,在搭建场景下编排模块可以根据该回调获取组件实例并实现生命周期注入或者组件 DOM 操作等功能,回调函数主要包含两个参数: + +- `schema`:当前组件的 schema 模型结构 +- `ref`:当前组件的 ref 实例 + +### onCompGetCtx +组件 ctx 更新的回调,在组件每次 render 渲染周期我们都会为组件构造新的上下文环境,因此该回调函数会在组件每次 render 过程中触发,主要包含两个参数: + +- `schema`:当前组件的 schema 模型结构 +- `ctx`:当前组件的上下文信息,主要包含以下内容: + - `page`:当前页面容器实例 + - `this`: 当前组件所属的容器组件实例 + - `item`/`index`: 循环上下文(属性 key 可以根据 loopArgs 进行定制) + - `form`: 表单上下文 + +### rendererName +渲染类型,标识当前模块是以什么类型进行渲染的 + +- `LowCodeRenderer`: 低代码组件 +- `PageRenderer`: 页面 + +### customCreateElement +自定义创建 element 的钩子,用于在渲染前后对组件进行一些处理,包括但不限于增加 props、删除部分 props。主要包含三个参数: + +- `Component`:要渲染的组件 +- `props`:要渲染的组件的 props +- `children`:要渲染的组件的子元素 + +### thisRequiredInJSE +> 版本 >= 1.0.11 + +默认值:true +为 true 的情况下 JSExpression 仅支持通过 this 来访问。假如需要兼容原来的 'state.xxx',则设置为 false,推荐使用 true。 diff --git a/docs/docs/guide/quickStart/_category_.json b/docs/docs/guide/quickStart/_category_.json new file mode 100644 index 0000000000..0a47c9da50 --- /dev/null +++ b/docs/docs/guide/quickStart/_category_.json @@ -0,0 +1,6 @@ +{ + "label": "入门", + "position": 0, + "collapsed": false, + "collapsible": true +} diff --git a/docs/docs/guide/quickStart/demo.md b/docs/docs/guide/quickStart/demo.md new file mode 100644 index 0000000000..ee76d5aa1b --- /dev/null +++ b/docs/docs/guide/quickStart/demo.md @@ -0,0 +1,56 @@ +--- +title: 试用低代码引擎 Demo +sidebar_position: 2 +--- +## 访问地址 + +低代码引擎的 Demo 可以通过如下永久链接访问到: + +[设计器 demo](https://lowcode-engine.cn/demo/demo-general/index.html) + +> 注意我们会经常更新 demo,所以您可以通过上述链接得到最新版地址。 + + +## 低代码引擎 Demo 功能概览 + +我们可以从 Demo 的项目中看到页面中有很多的区块: + +![image.png](https://img.alicdn.com/imgextra/i2/O1CN01vlxdTD28c4JZcebbf_!!6000000007952-2-tps-3840-2160.png) + +它主要包含这些功能点: + +![image.png](https://img.alicdn.com/imgextra/i2/O1CN01QITHRY1sQaWzlvJv9_!!6000000005761-2-tps-3840-2160.png) + +### 顶部:操作区 + +- 右侧:撤回和重做、保存到本地、重置页面、预览、异步加载资源 +### 左侧:面板与操作区 +- 大纲面板:可以调整页面内的组件树结构 +- 物料面板:可以查找组件,并在此拖动组件到编辑器画布中 +- 源码面板:可以编辑页面级别的 JavaScript 代码和 CSS 配置 +- 提交 Issue:可以给引擎开发提 bug +- Schema 编辑:可以编辑页面的底层数据 +- 中英文切换:可以切换编辑器的语言 + +### 中部:可视化页面编辑画布区域 +- 点击组件在右侧面板中能够显示出对应组件的属性配置选项 +- 拖拽修改组件的排列顺序 +- 将组件拖拽到容器类型的组件中 +- 复制组件:点击组件右上角的复制按钮 +- 删除组件:点击组件右上角的 X 或者直接使用 `Delete` 键 + +### 右侧:组件级别配置 +- 选中的组件:从页面开始一直到当前选中的组件位置,点击对应的名称可以切换到对应的组件上 +- 选中组件的配置:当前组件的大类目选项,根据组件类型不同,包含如下子类目: + - 属性:组件的基础属性值设置 + - 样式:组件的样式配置 + - 事件:绑定组件对外暴露的事件 + - 高级:循环、条件渲染与 key 设置 + +## 深入使用低代码引擎 Demo + +我们在低代码引擎 Demo 中直接内置了产品使用文档,对常见场景中的使用进行了向导,它的入口如下: + +![image.png](https://img.alicdn.com/imgextra/i3/O1CN01YU2LYS29YEbuLTtLL_!!6000000008079-2-tps-3070-1650.png) + +如果暂时没有看到对应的产品使用文档,可以通过此永久链接直接访问:[https://lowcode-engine.cn/site/docs/demoUsage/intro](https://lowcode-engine.cn/site/docs/demoUsage/intro) diff --git a/docs/docs/guide/quickStart/intro.md b/docs/docs/guide/quickStart/intro.md new file mode 100644 index 0000000000..b65baac269 --- /dev/null +++ b/docs/docs/guide/quickStart/intro.md @@ -0,0 +1,63 @@ +--- +title: 简介 +sidebar_position: 1 +--- + +# 阿里低代码引擎简介 + +## 低代码介绍 + +零代码、低代码的概念在整个全球行业内已经流行了很长一段时间。通常意义上的低代码定义会有三个关键点: + +1. 一个用于生产软件的可视化编辑器 +2. 中间包含了一些用于组装的物料,可以通过编排、组合和配置它们以生成丰富的功能或表现 +3. 最后的实施结果是成本降低 + +通常情况下低代码平台会具备以下的几个能力: + +- **可视化页面搭建**,通过简单的拖拽完成应用页面开发,对前端技能没有要求或不需要特别专业的了解; +- **可视化模型设计**,与业务相关的数据存储变得更容易理解,甚至大多数简单场景可以做到表单即模型,模型字段的类型更加业务化; +- **可视化流程设计**,不管是业务流程还是审批流程,都可以通过简单的点线连接来进行配置; +- **可视化报表及数据分析**,BI 数据分析能力成为标配,随时随地通过拖拽选择来定义自定义分析报表; +- **可视化服务与数据开放、集成**,具备与其他系统互联互通的配置; +- **权限、角色设置标准化和业务化**,通过策略规则配置来将数据、操作的权限进行精细化管理; +- **无需关心服务器、数据库等底层运维、计算设施设备、网络等等复杂技术概念**,具备安全、性能的统一解决方案,开发者只需要专注于业务本身; + +有了上面这些,你会发现即使是个技术小白,只要你了解业务,就能不受束缚的完成大多数业务应用的搭建。但低代码本身也不仅仅是为技术小白准备的。在实践中,低代码因为通过组件化、模块化的思路让业务的抽象更加容易,而且在扩展及配置化上带来了更加新鲜的模式探索,技术人员的架构设计成本和实施成本也就降了很多。 + +市面上常见的低代码产品[可以看 Golden 的梳理](https://golden.com/wiki/No-code_%2F_low-code_development-NMGMEA6)。 + +## 低代码引擎介绍 + +**低代码引擎是一款为低代码平台开发者提供的,具备强大定制扩展能力的低代码设计器研发框架。** + +下面简单描述定义中的子部分: + +**低代码设计器** +现如今低代码平台越来越多,而每一个低代码平台中都会有的一个能力就是搭建和配置页面、模块的页面,这个页面我们称为设计器。例如,下图是中后台低代码平台的设计器。 +![image.png](https://img.alicdn.com/imgextra/i3/O1CN01sXuwkK1j8sg4S53Dx_!!6000000004504-2-tps-1682-969.png) +设计器承载着低代码平台的核心功能,包括入料、编排、组件配置、画布渲染等等。由于其功能多,打磨精细难,也是低代码平台建设最耗时的地方。 + +**定制扩展能力** + +什么是扩展能力呢,一方面我们可以快速拥有一份标准的低代码设计器,另外一方面如果有业务独特的功能需要,我们可以不用看它的源码、不用关心其实现,可以使用 API、插件等方式快速完成能力的开发。 +而低代码引擎对于设计器的扩展能力支持基本上覆盖了低代码设计器的所有功能点。下图是针对标准的设计器提供了扩展功能的区域。 +![](https://img.alicdn.com/imgextra/i1/O1CN01ZVgAE31wltQ4BVnCe_!!6000000006349-2-tps-3838-1914.png) +**低代码设计器研发框架** + +低代码引擎的核心是设计器,通过扩展、周边生态等可以产出各式各样的设计器。它不是一套可以适合所有人的低代码平台,而是帮助低代码平台的开发者,快速生产低代码平台的工具。 + +## 寻找适合您的低代码解决方案 + +帮助用户根据个人或企业需求选择合适的低代码产品。 + +| 特性/产品 | 低代码引擎 | Lab平台 | UIPaaS | +|-----------------|-----------------------------------------|-----------------------------------------|--------------------------------------------| +| **适用用户** | 前端开发者 | 需要快速搭建应用/页面的用户 | 企业用户,需要大规模部署低代码解决方案的组织 | +| **产品特点** | 设计器研发框架,适合定制开发 | 低代码平台, 可视化操作界面,易于上手 | 低代码平台孵化器,企业级功能 | +| **使用场景** | 定制和开发低代码平台的设计器部分 | 通过可视化, 快速开发应用或页面 | 帮助具有一定规模软件研发团队的的企业低成本定制低代码平台 | +| **产品关系** | 开源产品 | 基于UIPaaS技术实现, 展示了UIPaaS的部分能力 | 提供完整的低代码平台解决方案,商业产品 | +| **收费情况** | 免费 | 可免费使用(有额度限制),不提供私有化部署售卖 | 仅提供私有化部署售卖 | +| **官方网站** | [低代码引擎官网](https://lowcode-engine.cn/) | [Lab平台官网](https://lab.lowcode-engine.cn/) | [UIPaaS官网](https://uipaas.net/) | + +*注:请根据您的具体需求和条件选择合适的产品。如需更详细的信息,请访问各产品的官方网站。* diff --git a/docs/docs/guide/quickStart/start.md b/docs/docs/guide/quickStart/start.md new file mode 100644 index 0000000000..356f501769 --- /dev/null +++ b/docs/docs/guide/quickStart/start.md @@ -0,0 +1,411 @@ +--- +sidebar_position: 3 +title: 快速开始 +--- + +## 前置知识 + +我们假定你已经对 HTML 和 JavaScript 都比较熟悉了。即便你之前使用其他编程语言,你也可以跟上这篇教程的。除此之外,我们假定你也已经熟悉了一些编程的概念,例如,函数、对象、数组,以及 class 的一些内容。 + +如果你想回顾一下 JavaScript,你可以阅读[这篇教程](https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/A_re-introduction_to_JavaScript)。注意,我们也用到了一些 ES6(较新的 JavaScript 版本)的特性。在这篇教程里,我们主要使用了[箭头函数(arrow functions)](https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Functions/Arrow_functions)、[class](https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Classes)、[let](https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Statements/let) 语句和 [const](https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Statements/const) 语句。你可以使用 [Babel REPL](https://babeljs.io/repl/#?presets=react&code_lz=MYewdgzgLgBApgGzgWzmWBeGAeAFgRgD4AJRBEAGhgHcQAnBAEwEJsB6AwgbgChRJY_KAEMAlmDh0YWRiGABXVOgB0AczhQAokiVQAQgE8AkowAUAcjogQUcwEpeAJTjDgUACIB5ALLK6aRklTRBQ0KCohMQk6Bx4gA) 在线预览 ES6 的编译结果。 + +## 环境准备 + +### WSL(Windows 电脑) + +Window 环境需要使用 WSL 在 windows 下进行低代码引擎相关的开发。安装教程 ➡️ [WSL 安装教程](https://docs.microsoft.com/zh-cn/windows/wsl/install)。
**对于 Window 环境来说,之后所有需要执行命令的操作都是在 WSL 终端执行的。** + +### Node + +node 版本推荐 16.18.0。 + +#### 查看 Node 版本 + +![image.png](https://img.alicdn.com/imgextra/i4/O1CN01oCZKNz290LIu8YUTk_!!6000000008005-2-tps-238-70.png) + +#### 通过 n 来管理 node 版本 + +可以安装 [n](https://www.npmjs.com/package/n) 来管理和变更 node 版本。 + +##### 安装 n + +```bash +npm install -g n +``` + +##### 变更 node 版本 + +```bash +n 14.17.0 +``` + +### React + +低代码引擎的扩展能力都是基于 React 来研发的,在继续阅读之前最好有一定的 React 基础,React 学习教程 ➡️ [React 快速开始教程](https://zh-hans.reactjs.org/docs/getting-started.html)。 + +### 下载 Demo + +可以前往 github()将 DEMO 下载到本地。 + +#### git clone + +##### HTTPS + +需要使用到 git 工具 + +```bash +git clone https://github.com/alibaba/lowcode-demo.git +``` + +##### SSH + +需要配置 SSH key,如果没有配置可以 + +```bash +git clone git@github.com:alibaba/lowcode-demo.git +``` + +#### 下载 Zip 包 + +![image.png](https://img.alicdn.com/imgextra/i2/O1CN01iYC7E11phaNwLFUrN_!!6000000005392-2-tps-3584-1794.png) + +### 选择一个 demo 项目 + +在 以 `demo-general` 为例: + +```bash +cd demo-general +``` + +### 安装依赖 + +在 `lowcode-demo/demo-general` 目录下执行: + +```bash +npm install +``` + +### 启动 demo + +在 `lowcode-demo/demo-general` 目录下执行: + +```bash +npm run start +``` + +之后就可以通过 [http://localhost:5556/](http://localhost:5556/) 来访问我们的 DEMO 了。 + +## 认识 Demo + +我们的 Demo 是一个**低代码平台的设计器**。它是一个低代码平台中最重要的一环,用户可以在这里通过拖拽、配置、写代码等等来完成一个页面的开发。由于用户的人群不同、场景不同、诉求不同等等,这个页面的功能就会有所差异。 + +这里记住**设计器**这个词,它描述的就是下面的这个页面,后面我们会经常看到它。 +![image.png](https://img.alicdn.com/imgextra/i1/O1CN014nYXgF20pKrQIG2zV_!!6000000006898-2-tps-3584-1808.png) + +### 场景介绍 + +![image.png](https://img.alicdn.com/imgextra/i3/O1CN01nnP60l1dqUhUiNSx6_!!6000000003787-2-tps-2852-1156.png) + +Demo 根据**不同的设计器所需要的物料不同**,分为了下面的 8 个场景: + +- 综合场景 +- 基础 fusion 组件 +- 基础 fusion 组件 + 单自定义组件 +- 基础 antd 组件 +- 自定义初始化引擎 +- 扩展节点操作项 +- 基于 next 实现的高级表单低代码物料 +- antd 高级组件 + formily 表单组件 + +可以点开不同的场景,看看他们使用的物料。 +![](https://img.alicdn.com/imgextra/i1/O1CN01EU2jRN1wUwlal17WK_!!6000000006312-2-tps-3110-1974.png) + +### 目录介绍 + +仓库下每个 demo-xxx-xxx 目录都是一个可独立运行的 demo 工程,分别对应到刚刚介绍的场景。 + +![image.png](https://img.alicdn.com/imgextra/i1/O1CN01ztxv5Y1mJozBsLdni_!!6000000004934-2-tps-696-958.png) + +不同场景的目录结构实际上都是类似的,这里我们主要介绍一下综合场景的目录结构即可。 + +![image.png](https://img.alicdn.com/imgextra/i1/O1CN01A50oW522S5zg2eDUH_!!6000000007118-2-tps-732-1384.png) + +介绍下其中主要的内容 + +- 设计器入口文件 `src/index.ts` 这个文件做了下述几个事情: + - 通过 plugins.register 注册各种插件,包括官方插件 (已发布 npm 包形式的插件) 和 `plugins` 目录下内置的示例插件 + - 通过 init 初始化低代码设计器 +- plugins 目录,存放的都是示例插件,方便用户从中看到一个插件是如何实现的 +- services 目录,模拟数据请求、提供默认 schema、默认资产包等,此目录下内容在真实项目中应替换成真实的与服务端交互的服务。 +- 预览页面入口文件 `preview.tsx` + +剩下的各位看官可以通过源码来进一步了解。 + +做了这些事情之后,我们的低代码设计器就已经有了基本的能力了。也就是最开始我们看到的这样。 + +![image.png](https://img.alicdn.com/imgextra/i4/O1CN01YJVcOd1PiL1am6bz2_!!6000000001874-2-tps-3248-1970.png) + +接下来我们就根据我们自己的诉求通过对设计器进行扩展,改动成我们需要的设计器功能。 + +## 开发一个插件 + +### 方式 1:在 DEMO 中直接新增插件 + +![image.png](https://img.alicdn.com/imgextra/i4/O1CN01pXpSRs1QvRyut2EE3_!!6000000002038-2-tps-718-1144.png) + +可以在 demo/sample-plugins 直接新增插件,这里我新增的插件目录是 plugin-demo。并且新增了 index.tsx 文件,将下面的代码粘贴到 index.tsx 中。 + +```javascript +import * as React from 'react'; +import { IPublicModelPluginContext } from '@alilc/lowcode-types'; + +const LowcodePluginPluginDemo = (ctx: IPublicModelPluginContext) => { + return { + // 插件对外暴露的数据和方法 + exports() { + return { + data: '你可以把插件的数据这样对外暴露', + func: () => { + console.log('方法也是一样'); + }, + }; + }, + // 插件的初始化函数,在引擎初始化之后会立刻调用 + init() { + // 你可以拿到其他插件暴露的方法和属性 + // const { data, func } = ctx.plugins.pluginA; + // func(); + + // console.log(options.name); + + // 往引擎增加面板 + ctx.skeleton.add({ + area: 'leftArea', + name: 'LowcodePluginPluginDemoPane', + type: 'PanelDock', + props: { + description: 'Demo', + }, + content:
这是一个 Demo 面板
, + }); + + ctx.logger.log('打个日志'); + }, + }; +}; + +// 插件名,注册环境下唯一 +LowcodePluginPluginDemo.pluginName = 'LowcodePluginPluginDemo'; +LowcodePluginPluginDemo.meta = { + // 依赖的插件(插件名数组) + dependencies: [], + engines: { + lowcodeEngine: '^1.0.0', // 插件需要配合 ^1.0.0 的引擎才可运行 + }, +}; + +export default LowcodePluginPluginDemo; +``` + +在 src/index.ts 中新增下面代码 + +![image.png](https://img.alicdn.com/imgextra/i1/O1CN01pNTr4N1kldoYZRzgI_!!6000000004724-2-tps-1976-1250.png) + +这样在我们的设计器中就新增了一个 Demo 面板。 + +![image.png](https://img.alicdn.com/imgextra/i2/O1CN01wtPIOV1TQiFLz5Vkf_!!6000000002377-2-tps-3584-1806.png) + +### 方式 2:在新的仓库下开发插件 + +初始化 + +```bash +npm init @alilc/element your-plugin-name +``` + +选择设计器插件(plugin) + +![image.png](https://img.alicdn.com/imgextra/i4/O1CN01sA6sYW1tijqVeQCuq_!!6000000005936-2-tps-730-214.png) + +根据操作完善信息 + +![image.png](https://img.alicdn.com/imgextra/i2/O1CN01BzM1Jb1RcxbiJ0tJi_!!6000000002133-2-tps-866-218.png) + +插件项目就初始化完成了 + +![image.png](https://img.alicdn.com/imgextra/i4/O1CN01iVIAXD1XVWsOdKttI_!!6000000002929-2-tps-3584-2020.png) + +在插件项目下安装依赖 + +```bash +npm install +``` + +启动项目 + +```bash +npm run start +``` + +调试项目 + +![image.png](https://img.alicdn.com/imgextra/i3/O1CN01A4vPqC1xbeAqNxBRM_!!6000000006462-2-tps-3584-1936.png) + +在 Demo 中调试项目 + +在 build.json 下面新增 "inject": true,就可以在 [https://lowcode-engine.cn/demo/demo-general/index.html?debug](https://lowcode-engine.cn/demo/demo-general/index.html?debug) 页面下进行调试了。 + +![image.png](https://img.alicdn.com/imgextra/i4/O1CN01uqSmrX1oqupxeGH1m_!!6000000005277-2-tps-3584-2020.png) + +## 开发一个自定义物料 + +### 初始化物料 + +```bash +npm init @alilc/element your-material-demo +``` + +选择组件/物料栏 + +![image.png](https://img.alicdn.com/imgextra/i3/O1CN01qVJQvG1Yhj2PJhhvk_!!6000000003091-2-tps-824-208.png) + +配置其他信息 + +![image.png](https://img.alicdn.com/imgextra/i3/O1CN017fFT8O1IVmrLYg87F_!!6000000000899-2-tps-800-248.png) + +这样我们就初始化好了一个 React 物料。 + +![image.png](https://img.alicdn.com/imgextra/i1/O1CN01SU2xn91TZPlzcARVI_!!6000000002396-2-tps-3584-2020.png) + +### 启动并调试物料 + +#### 安装依赖 + +```bash +npm i +``` + +#### 启动 + +```bash +npm run lowcode:dev +``` + +我们就可以通过 [http://localhost:3333/](http://localhost:3333/) 看到我们的研发的物料了。 + +![image.png](https://img.alicdn.com/imgextra/i4/O1CN01JqoHqc1z7zlSWFYJD_!!6000000006668-2-tps-3584-1790.png) + +#### 在 Demo 中调试 + +```bash +npm i @alilc/build-plugin-alt +``` + +修改 build.lowcode.js + +![image.png](https://img.alicdn.com/imgextra/i2/O1CN01K7u7ci1KCfYlBj2yf_!!6000000001128-2-tps-1388-1046.png) + +如图,新增如下代码 + +```javascript +[ + '@alilc/build-plugin-alt', + { + type: 'component', + inject: true, + library, + // 配置要打开的页面,在注入调试模式下,不配置此项的话不会打开浏览器 + // 支持直接使用官方 demo 项目:https://lowcode-engine.cn/demo/index.html + openUrl: 'https://lowcode-engine.cn/demo/index.html?debug', + }, +], +``` + +我们重新启动项目,就可以在 Demo 中找到我们的自定义组件。 + +![image.png](https://img.alicdn.com/imgextra/i1/O1CN0166WywE26Lv7NuJMus_!!6000000007646-2-tps-3584-1812.png) + +### 发布 + +首先进行构建 + +```bash +npm run lowcode:build +``` + +发布组件 + +```bash +npm publish +``` + +这里我发布的组件是 [my-material-demo](https://www.npmjs.com/package/my-material-demo)。在发布之后我们就会有两个重要的文件: + +- 低代码描述:[https://unpkg.com/my-material-demo@0.1.0/build/lowcode/meta.js](https://unpkg.com/my-material-demo@0.1.0/build/lowcode/meta.js) +- 组件代码:[https://unpkg.com/my-material-demo@0.1.0/build/lowcode/render/default/view.js](https://unpkg.com/my-material-demo@0.1.0/build/lowcode/render/default/view.js) + +我们也可以从 [https://unpkg.com/my-material-demo@0.1.0/build/lowcode/assets-prod.json](https://unpkg.com/my-material-demo@0.1.0/build/lowcode/assets-prod.json) 找到我们的资产包描述。 + +```bash +{ + "packages": [ + { + "package": "my-material-demo", + "version": "0.1.0", + "library": "BizComp", + "urls": [ + "https://unpkg.com/my-material-demo@0.1.0/build/lowcode/render/default/view.js", + "https://unpkg.com/my-material-demo@0.1.0/build/lowcode/render/default/view.css" + ], + "editUrls": [ + "https://unpkg.com/my-material-demo@0.1.0/build/lowcode/view.js", + "https://unpkg.com/my-material-demo@0.1.0/build/lowcode/view.css" + ], + "advancedUrls": { + "default": [ + "https://unpkg.com/my-material-demo@0.1.0/build/lowcode/render/default/view.js", + "https://unpkg.com/my-material-demo@0.1.0/build/lowcode/render/default/view.css" + ] + }, + "advancedEditUrls": {} + } + ], + "components": [ + { + "exportName": "MyMaterialDemoMeta", + "npm": { + "package": "my-material-demo", + "version": "0.1.0" + }, + "url": "https://unpkg.com/my-material-demo@0.1.0/build/lowcode/meta.js", + "urls": { + "default": "https://unpkg.com/my-material-demo@0.1.0/build/lowcode/meta.js" + }, + "advancedUrls": { + "default": [ + "https://unpkg.com/my-material-demo@0.1.0/build/lowcode/meta.js" + ] + } + } + ], +} +``` + +### 使用 + +我们将刚刚发布的组件的 assets-prod.json 的内容放到 demo 的 src/universal/assets.json 中。 + +> 最好放到最后,防止因为资源加载顺序问题导致出现报错。 + +如图,新增 packages 配置 +![image.png](https://img.alicdn.com/imgextra/i1/O1CN018dnIB91XHmzeTrq3n_!!6000000002899-2-tps-3584-2020.png) + +如图,新增 components 配置 + +![image.png](https://img.alicdn.com/imgextra/i2/O1CN01UNp89s1vQXKyfsFaL_!!6000000006167-2-tps-3584-2020.png) + +这时候再启动 DEMO 项目,就会有新的低代码物料了。接下来就按照你们的需求,继续扩展物料吧。 + +## 总结 + +这里只是简单的介绍了一些低代码引擎的基础能力,带大家简单的对低代码 DEMO 进行扩展,定制一些新的功能。低代码引擎的能力还有很多很多,可以继续去探索更多的功能。 diff --git a/docs/code-specification.md b/docs/docs/participate/code-specification.md similarity index 90% rename from docs/code-specification.md rename to docs/docs/participate/code-specification.md index 0a7c9f5556..d6b387e305 100644 --- a/docs/code-specification.md +++ b/docs/docs/participate/code-specification.md @@ -1,3 +1,8 @@ +--- +title: 编码规约 +sidebar_position: 5 +--- + 编码规约 --- @@ -22,7 +27,7 @@ - 不要在全局命名空间内定义类型/值 - 共享的类型应该在 `types.ts` 里定义 - 在一个文件里,类型定义应该出现在顶部 - - interface 和 type 很类似,原则上能用 interface 实现,就用 interface , 如果不能才用 type + - interface 和 type 很类似,原则上能用 interface 实现,就用 interface , 如果不能才用 type ### 注释 diff --git a/docs/docs/participate/flow.md b/docs/docs/participate/flow.md new file mode 100644 index 0000000000..b8b804e123 --- /dev/null +++ b/docs/docs/participate/flow.md @@ -0,0 +1,187 @@ +--- +title: 研发协作流程 +sidebar_position: 2 +--- +## 代码风格 +引擎项目配置了 eslint 和 stylelint,在每次 git commit 前都会检查代码风格,假如有报错,请修改后再提交。(**严禁 -n 提交,-n 也逃脱不了 github workflow 的 lint 检查,放弃吧,骚年~**) + +## 测试机制 +每次提交代码前,务必本地跑一次单元测试,通过后再提交 MR。 + +假如涉及新的功能,需要**补充相应的单元测试**,目前引擎核心模块的单测覆盖率都在 80%+,假如降低了覆盖率,将会不予以通过。 + +跑单测流程: + +1. 项目根目录下执行 npm run build +2. 只改了一个包,比如 designer,则在 designer 目录下,执行 npm test +3. (or)改了多个包,则在根目录下执行 npm test +## commit 风格 +几点要求: + +1. commit message 格式遵循 [ConvensionalCommits](https://www.conventionalcommits.org/en/v1.0.0/#summary) + + +2. 请按照一个 bugfix / feature 对应一个 commit,假如不是,请 rebase 后再提交 MR,不要一堆无用的、试验性的 commit。 + +好处:从引擎的整体 commit 历史来看,会很清晰,**每个 commit 完成一件确定的事,changelog 也能自动生成**。另外,假如因为某个 commit 导致了 bug,也很容易通过 rebase drop 等方式快速修复。 + +## 分支用途 + +- main 分支,最稳定的分支,跟 npm latest 包的内容保持一致 +- develop 分支,开发分支,拥有最新的、已经验证过的 feature / bugfix,Pull Request 的**目标合入分支** +- release 分支 + - 正式发布分支,命名规则为 release/x.y.z,一般从 develop 拉出来进行发布,x.y.z 为待发布的版本号 + - beta 发布分支,命名规则为 release/x.y.z-beta(\.\d+)?,可以快速验证修改,发布 npm beta 版本。 + +验证通过后,因为 beta 发布分支上会存在无用的 commit(比如 lerna 修改 package.json 这种),所以不直接 PR 到 develop,而是从 develop 拉分支,从 beta 发布分支 cherry pick 有用的 commit 到新分支,然后 PR 到 develop。 + +## 引擎发布机制 + +日常迭代先从 develop 拉分支,然后自测、单测通过后,提交 PR 到 develop 分支,由发布负责人基于 develop 拉 release/1.0.z 分支~ + +### 版本规划 + +> 此处是理想节奏,实际情况可能会有调整 + +- 日常迭代 2 周,一般月中或月底,发版日两天前发最后一个 beta 版本,原则上不接受新 pr,灰度 2 天后,发正式版。 +- 特殊情况紧急迭代随时发 +- 大 Feature 迭代,每年 2 - 4 次 + + +### 发布步骤 +> **发布需要权限,如果提 PR 之后着急发布可以**[**加入贡献者交流群**](../participate/#核心贡献者交流)**。** + +#### 发正式版 +步骤如下(以发布 1.0.0 版本为例): + +1. git checkout develop + ```bash + git checkout develop + ``` +2. 创建 release 分支 + ```bash + git checkout -b release/1.0.0 + ``` +3. build + ```bash + npm run build + ``` +4. 发布到 npm + ```bash + npm run pub + ``` +5. 同步到 tnpm 源 & alifd CDN & uipaas CDN(此步骤将发布在 npm 源的包同步到阿里内网源,因为 alifd cdn 将依赖内网 npm 源) + ```bash + tnpm run sync + tnpm run syncOss + ``` +6. 更新[发布日志](https://github.com/alibaba/lowcode-engine/releases) +7. 合并 release/x.x.x 到 main 分支 +8. 合并 main 分支到 develop 分支 + +如果是发布 beta 版本,步骤如下(以发布 1.0.1 版本为例): + +#### 发某 y 位版本首个 beta,如 1.1.0-beta.0 +1. 拉 develop 分支 + ```bash + git checkout develop + ``` + 更新到最新(如需) + ```bash + git pull + ``` +2. 拉 release 分支,此处以 1.1.0 版本做示例 + ```bash + git checkout -b release/1.1.0-beta + git push --set-upstream origin release/1.1.0-beta + ``` +3. build + ```bash + npm run build + ``` +4. 发布,此处需有 @alilc scope 发包权限 + ```bash + npm run pub:preminor + ``` +5. 同步到 tnpm 源 & alifd CDN & uipaas CDN + ```bash + tnpm run sync + tnpm run syncOss + ``` + +#### 发某 z 位版本首个 beta,如 1.0.1-beta.0 +1. 拉 develop 分支 + ```bash + git checkout develop + ``` + 更新到最新(如需) + ```bash + git pull + ``` +2. 拉 release 分支,此处以 1.0.1 版本做示例 + ```bash + git checkout -b release/1.0.1-beta + git push --set-upstream origin release/1.0.1-beta + ``` +3. build + ```bash + npm run build + ``` +4. 发布,此处需有 @alilc scope 发包权限 + ```bash + npm run pub:prepatch + ``` +5. 同步到 tnpm 源 & alifd CDN & uipaas CDN + ```bash + tnpm run sync + tnpm run syncOss + ``` + +#### 发某版本非首个 beta,如 1.0.1-beta.0 -> 1.0.1-beta.1 +1. 切换到 release 分支 + ```bash + git checkout release/1.0.1-beta + ``` +2. 更新到 develop 分支最新代码 + ```bash + git rebase origin/develop + ``` +3. build + ```bash + npm run build + ``` +4. 发布,此处需有 @alilc scope 发包权限 ***此处命令与发首个 beta 时有变化*** + ```bash + npm run pub:prerelease + ``` +5. 同步到 tnpm 源 & alifd CDN & uipaas CDN + ```bash + tnpm run sync + tnpm run syncOss + ``` + + + +## DEMO 发布机制 +1. **修改版本号** + 手动修改 package.json 的版本号 +2. **build** + ```bash + npm run build + ``` +3. publish(此步骤需要 npm 发包权限) + ```bash + npm run pub + ``` + 如发 beta 版 + ```bash + npm publish --tag beta + ``` +4. 同步到 tnpm 源 & alifd CDN & uipaas CDN + ```bash + tnpm run sync + tnpm run syncOss + ``` + +**官网生效** +需要在通过阿里内部系统更新 demo 版本 diff --git a/docs/docs/participate/index.md b/docs/docs/participate/index.md new file mode 100644 index 0000000000..e09f2ddad2 --- /dev/null +++ b/docs/docs/participate/index.md @@ -0,0 +1,118 @@ +--- +title: 参与贡献 +sidebar_position: 0 +--- + +### 环境准备 + +开发 LowcodeEngine 需要 Node.js 16+。 + +推荐使用 nvm 管理 Node.js,避免权限问题的同时,还能够随时切换当前使用的 Node.js 的版本。 + +### 贡献低代码引擎 + +#### clone 项目 + +``` +git clone git@github.com:alibaba/lowcode-engine.git +cd lowcode-engine +``` + +#### 安装依赖并构建 + +``` +npm install && npm run setup +``` + +#### 调试环境配置 + +本质上是将 demo 页面引入的几个 js/css 代理到 engine 项目,可以使用趁手的代理工具,这里推荐 [XSwitch](https://chrome.google.com/webstore/detail/xswitch/idkjhjggpffolpidfkikidcokdkdaogg?hl=en-US)。 + +本地开发代理规则如下: +```json +{ + "proxy": [ + [ + "https://uipaas-assets.com/prod/npm/@alilc/lowcode-engine/(.*)/dist/js/engine-core.js", + "http://localhost:5555/js/AliLowCodeEngine.js" + ], + [ + "https://uipaas-assets.com/prod/npm/@alilc/lowcode-engine/(.*)/dist/css/engine-core.css", + "http://localhost:5555/css/AliLowCodeEngine.css" + ], + [ + "https?://uipaas-assets.com/prod/npm/@alilc/lowcode-engine/(.*)/dist/js/react-simulator-renderer.js", + "http://localhost:5555/js/ReactSimulatorRenderer.js" + ], + [ + "https?://uipaas-assets.com/prod/npm/@alilc/lowcode-engine/(.*)/dist/css/react-simulator-renderer.css", + "http://localhost:5555/css/ReactSimulatorRenderer.css" + ] + ] +} +``` + +#### 开发 + +``` +npm start +``` + +选择一个环境进行调试,例如[低代码引擎在线 DEMO](https://lowcode-engine.cn/demo/demo-general/index.html) + +开启代理之后,就可以进行开发调试了。 + + +### 贡献低代码引擎文档 + +#### 开发文档 + +在 lowcode-engine 目录下执行下面命令 +``` +cd docs + +npm start +``` + +#### 维护方式 +- 官方文档通过 github 管理文档源,官网文档与[主仓库 develop 分支](https://github.com/alibaba/lowcode-engine/tree/develop/docs)保持同步。 +- 点击每篇文档下发的 `编辑此页` 可直接定位到 github 中位置。 +- 欢迎 PR,文档 PR 也会作为贡献者贡献,会用于贡献度统计。 +- **文档同步到官方网站由官方人员进行操作**,如有需要可以通过 issue 或 贡献者群与相关人员沟通。 +- 为了提供更好的阅读和使用体验,文档中的图片文件会定期转换成可信的 CDN 地址。 + +#### 文档格式 + +本项目文档参考[文档编写指南](https://github.com/sparanoid/chinese-copywriting-guidelines)。 + +使用 vscode 进行编辑的朋友可以安装 vscode 插件 [huacnlee.autocorrect](https://github.com/huacnlee/autocorrect) 辅助文档 lint。 + + +### 贡献低代码引擎生态 + +相关源码详见[NPM 包对应源码位置汇总](/site/docs/guide/appendix/npms) + +开发调试方式详见[低代码生态脚手架 & 调试机制](/site/docs/guide/expand/editor/cli) + +### 发布 + +PR 被合并之后,我们会尽快发布相关的正式版本或者 beta 版本。 + +### 加入 Contributor 群 +提交过 Bugfix 或 Feature 类 PR 的同学,如果有兴趣一起参与维护 LowcodeEngine,我们提供了一个核心贡献者交流群。 + +1. 可以通过[填写问卷](https://survey.taobao.com/apps/zhiliao/4YEtu9gHF)的方式,参与到其中。 +2. 填写问卷后加微信号 `wxidvlalalalal` (注明 github id)我们会拉你到群里。 + +如果你不知道可以贡献什么,可以到源码里搜 TODO 或 FIXME 找找。 + +为了使你能够快速上手和熟悉贡献流程,我们这里有个列表 [good first issues](https://github.com/alibaba/lowcode-engine/issues?q=is:open+is:issue+label:%22good+first+issue%22),里面有相对没那么笼统的漏洞,从这开始是个不错的选择。 + +### PR 提交注意事项 + +- lowcode-engine 仓库建议从 develop 创建分支,PR 指向 develop 分支。 +- 其他仓库从 main 分支创建分支,PR 指向 main 分支 +- 如果你修复了 bug 或者添加了代码,而这些内容需要测试,请添加测试! +- 确保通过测试套件(yarn test)。 +- 请签订贡献者许可证协议(Contributor License Agreement)。 + > 如已签署 CLA 仍被提示需要签署,[解决办法](/site/docs/faq/faq021) \ No newline at end of file diff --git a/docs/docs/participate/meet.md b/docs/docs/participate/meet.md new file mode 100644 index 0000000000..23226bf1cd --- /dev/null +++ b/docs/docs/participate/meet.md @@ -0,0 +1,55 @@ +--- +title: 开源社区例会 +sidebar_position: 0 +--- + +## **简介** + +低代码引擎开源社区致力于共同推动低代码技术的发展和创新。本社区汇集了低代码技术领域的开发者、技术专家和行业观察者,通过定期的例会来交流思想、分享经验、讨论新技术,并探索低代码技术的未来发展方向。 + +## 参与要求 + +为了确保例会的质量和效果,我们建议以下人员参加: + +- **已参与低代码引擎贡献的成员**:那些对低代码引擎有实际贡献的社区成员。 +- **参考贡献指南**:可查阅[贡献指南](https://lowcode-engine.cn/site/docs/participate/)获取更多信息。 +- **提供过优秀建议的成员**:那些在过去为低代码引擎提供过有价值建议的成员。 + +## **时间周期** + +- **周期性**:月例会 + +### **特别说明** + +- 例会周期可根据成员反馈进行调整。如果讨论的议题较多,可增加例会频率;若议题较少,单次例会可能取消。若多次取消,可能会暂停例会。 + +## **例会流程** + +### **准备阶段** + +- **定期确定议题**:会前一周确定下一次会议的议题。 +- **分发会议通知**:提前发送会议时间、议程和参与方式。 + +### **会议阶段** + +- **开场和介绍**:简短开场和自我介绍,特别是新成员加入时。 +- **议题讨论**:按照议程进行议题讨论,每个议题分配一定时间,并留足够时间供讨论和提问。 +- **记录要点和决定**:记录讨论要点、决策和任何行动事项。 + +### **后续阶段** + +- **分享会议纪要**:会后将会议纪要和行动计划分发给所有成员。 +- **执行和跟进**:根据会议中的讨论和决策执行相关任务,并在下次会议中进行跟进汇报。 + +## **开源例会议题** + +开源例会议题包括但不限于: + +- **共建低代码行业发展**:探讨通过开源社区合作加速低代码行业发展。 +- **改进建议和反馈收集**:讨论社区成员对低代码引擎的使用体验和改进建议。 +- **前端技术与低代码的结合**:针对前端开发者,讨论将前端技术与低代码引擎结合的方式。 +- **低代码业务场景和经验分享**:邀请社区成员分享低代码引擎的实际应用经验。 +- **低代码技术原理介绍**:深入理解低代码引擎的技术原理和实现方式。 +- **低代码引擎的最新进展**:分享低代码引擎的最新进展,包括新版本发布和新功能实现等。 +- **低代码技术的未来展望**:讨论低代码技术的未来发展方向。 +- **最新低代码平台功能和趋势分析**:分享和讨论当前低代码平台的新功能、趋势和发展方向。 \ No newline at end of file diff --git a/docs/docs/specs/assets-spec.md b/docs/docs/specs/assets-spec.md new file mode 100644 index 0000000000..5a91b8dde3 --- /dev/null +++ b/docs/docs/specs/assets-spec.md @@ -0,0 +1,689 @@ +--- +title: 《低代码引擎资产包协议规范》 +sidebar_position: 2 +--- +## 1 介绍 + +### 1.1 本协议规范涉及的问题域 + +- 定义本协议版本号规范 +- 定义本协议中每个子规范需要被支持的 Level +- 定义本协议相关的领域名词 +- 定义低代码资产包协议版本号规范(A) +- 定义低代码资产包协议组件及依赖资源描述规范(A) +- 定义低代码资产包协议组件描述资源加载规范(A) +- 定义低代码资产包协议组件在面板展示规范(AA) + +### 1.2 协议草案起草人 + +- 撰写:金禅、璿玑、彼洋 +- 审阅:力皓、絮黎、光弘、戊子、潕量、游鹿 + +### 1.3 版本号 + +1.1.0 + +### 1.4 协议版本号规范(A) + +本协议采用语义版本号,版本号格式为 `major.minor.patch` 的形式。 + +- major 是大版本号:用于发布不向下兼容的协议格式修改 +- minor 是小版本号:用于发布向下兼容的协议功能新增 +- patch 是补丁号:用于发布向下兼容的协议问题修正 + +### 1.5 协议中子规范 Level 定义 + +| 规范等级 | 实现要求 | +| -------- | ------------------------------------------------------------ | +| A | 基础规范,低代码引擎核心层支持; | +| AA | 推荐规范,由低代码引擎官方插件、setter 支持。 | +| AAA | 参考规范,需由基于引擎的上层搭建平台支持,实现可参考该规范。 | + +### 1.6 名词术语 + +- **资产包**: 低代码引擎加载资源的动态数据集合,主要包含组件及其依赖的资源、组件低代码描述、动态插件/设置器资源等。 + +### 1.7 背景 + +根据低代码引擎的实现,一个组件要在引擎上渲染和配置,需要提供组件的 umd 资源以及组件的`低代码描述`,并且组件通常都是以集合的形式被引擎消费的;除了组件之外,还有组件的依赖资源、引擎的动态插件/设置器等资源也需要注册到引擎中;因此我们定义了“低代码资产包”这个数据结构,来描述引擎所需加载的动态资源的集合。 + +### 1.8 受众 + +本协议适用于使用“低代码引擎”构建搭建平台的开发者,通过本协议的定义来进行资源的分类和加载。阅读及使用本协议,需要对低代码搭建平台的交互和实现有一定的了解,对前端开发相关技术栈的熟悉也会有帮助,协议中对通用的前端相关术语不会做进一步的解释说明。 + +## 2 协议结构 + +协议最顶层结构如下,包含 7 方面的描述内容: + +- version { String } 当前协议版本号 +- packages { Array } 低代码编辑器中加载的资源列表 +- components { Array } 所有组件的描述协议列表 +- sort { Object } 用于描述组件面板中的 tab 和 category +- plugins { Array } 设计器插件描述协议列表 +- setters { Array } 设计器中设置器描述协议列表 +- extConfig { Object } 平台自定义扩展字段 + +### 2.1 version (A) + +定义当前协议 schema 的版本号; + +| 根属性名称 | 类型 | 说明 | 变量支持 | 默认值 | +| ---------- | ------ | ---------- | -------- | ------ | +| version | String | 协议版本号 | - | 1.1.0 | + +### 2.2 packages (A) + +定义低代码编辑器中加载的资源列表,包含公共库和组件 (库) cdn 资源等; + +| 字段 | 字段描述 | 字段类型 | 规范等级 | 备注 | +| -------------------- | --------------------------------------------------------------- | ------------- | -------- | -------------------------------------------------------------------------------------------------------- | +| packages[].id? | 资源唯一标识 | String | A | 资源唯一标识,如果为空,则以 package 为唯一标识 | +| packages[].title? | 资源标题 | String | A | 资源标题 | +| packages[].package | npm 包名 | String | A | 组件资源唯一标识 | +| packages[].version | npm 包版本号 | String | A | 组件资源版本号 | +| packages[].type | 资源包类型 | String | AA | 取值为: proCode(源码)、lowCode(低代码,默认为 proCode | +| packages[].schema | 低代码组件 schema 内容 | object | AA | 取值为: proCode(源码)、lowCode(低代码) | +| packages[].deps | 当前资源包的依赖资源的唯一标识列表 | Array | A | 唯一标识为 id 或者 package 对应的值 | +| packages[].library | 作为全局变量引用时的名称,用来定义全局变量名 | String | A | 低代码引擎通过该字段获取组件实例 | +| packages[].editUrls | 组件编辑态视图打包后的 CDN url 列表,包含 js 和 css | Array | A | 低代码引擎编辑器会加载这些 url | +| packages[].urls | 组件渲染态视图打包后的 CDN url 列表,包含 js 和 css | Array | AA | 低代码引擎渲染模块会加载这些 url | +| packages[].advancedEditUrls | 组件多个编辑态视图打包后的 CDN url 列表集合,包含 js 和 css | Object | AAA | 上层平台根据特定标识提取某个编辑态的资源,低代码引擎编辑器会加载这些资源,优先级高于 packages[].editUrls | +| packages[].advancedUrls | 组件多个端的渲染态视图打包后的 CDN url 列表集合,包含 js 和 css | Object | AAA | 上层平台根据特定标识提取某个渲染态的资源, 低代码引擎渲染模块会加载这些资源,优先级高于 packages[].urls | +| packages[].external | 当前资源在作为其他资源的依赖,在其他依赖打包时时是否被排除了(同 webpack 中 external 概念) | Boolean | AAA | 某些资源会被单独提取出来,是其他依赖的前置依赖,根据这个字段决定是否提前加载该资源 | +| packages[].loadEnv | 指定当前资源加载的环境 | Array | AAA | 主要用于指定 external 资源加载的环境,取值为 design(设计态)、runtime(预览态) 中的一个或多个 | +| packages[].exportSourceId | 标识当前 package 内容是从哪个 package 导出来的 | String | AAA | 此时 urls 无效 | +| packages[].exportSourceLibrary | 标识当前 package 是从 window 上的哪个属性导出来的 | String | AAA | exportSourceId 的优先级高于exportSourceLibrary ,此时 urls 无效 | +| packages[].async | 标识当前 package 资源加载在 window.library 上的是否是一个异步对象 | Boolean | A | async 为 true 时,需要通过 await 才能拿到真正内容 | +| packages[].exportMode | 标识当前 package 从其他 package 的导出方式 | String | A | 目前只支持 `"functionCall"`, exportMode等于 `"functionCall"` 时,当前package 的内容以函数的方式从其他 package 中导出,具体导出接口如: (library: string, packageName: string, isRuntime?: boolean) => any | Promise, library 为当前 package 的 library, packageName 为当前的包名,返回值为当前 package 的导出内容 | + +描述举例: + +```json +{ + "packages": [ + { + "title": "fusion 组件库", + "package": "@alifd/next", + "version": "1.23.0", + "urls": [ + "https://g.alicdn.com/code/lib/alifd__next/1.23.18/next.min.css", + "https://g.alicdn.com/code/lib/alifd__next/1.23.18/next-with-locales.min.js" + ], + "library": "Next" + }, + { + "title": "Fusion 精品组件库", + "package": "@alife/fusion-ui", + "version": "0.1.5", + "editUrls": [ + "https://g.alicdn.com/code/npm/@alife/fusion-ui/0.1.7/build/lowcode/view.js", + "https://g.alicdn.com/code/npm/@alife/fusion-ui/0.1.7/build/lowcode/view.css" + ], + "urls": [ + "https://g.alicdn.com/code/npm/@alife/fusion-ui/0.1.7/dist/FusionUI.js", + "https://g.alicdn.com/code/npm/@alife/fusion-ui/0.1.7/dist/FusionUI.css" + ], + "library": "FusionUI" + }, + { + "title": "低代码组件 A", + "id": "lcc-a", + "version": "0.1.5", + "type": "lowCode", + "schema": { + "componentsMap": [ + { + "package": "@ali/vc-text", + "componentName": "Text", + "version": "4.1.1" + } + ], + "utils": [ + { + "name": "dataSource", + "type": "npm", + "content": { + "package": "@ali/vu-dataSource", + "exportName": "dataSource", + "version": "1.0.4" + } + } + ], + "componentsTree": [ + { + "defaultProps": { + "content": "这是默认值" + }, + "methods": { + "__initMethods__": { + "compiled": "function (exports, module) { /*set actions code here*/ }", + "source": "function (exports, module) { /*set actions code here*/ }", + "type": "js" + } + }, + "loopArgs": ["item", "index"], + "props": { + "mobileSlot": { + "type": "JSBlock", + "value": { + "children": [ + { + "condition": true, + "hidden": false, + "isLocked": false, + "conditionGroup": "", + "componentName": "Text", + "id": "node_ockxiczf4m2", + "title": "", + "props": { + "maxLine": 0, + "showTitle": false, + "behavior": "NORMAL", + "content": { + "en-US": "Title", + "zh-CN": "页面标题", + "type": "i18n" + }, + "__style__": {}, + "fieldId": "text_kxiczgj4" + } + } + ], + "componentName": "Slot", + "props": { + "slotName": "mobileSlot", + "slotTitle": "mobile 容器" + } + } + }, + "className": "component_k8e4naln", + "useDevice": false, + "fieldId": "symbol_k8bnubw4" + }, + "condition": true, + "children": [ + { + "condition": true, + "loopArgs": [null, null], + "componentName": "Text", + "id": "node_ockxiczf4m4", + "props": { + "maxLine": 0, + "showTitle": false, + "behavior": "NORMAL", + "content": { + "variable": "props.content", + "type": "variable", + "value": { + "use": "zh-CN", + "en-US": "Tips content", + "zh-CN": "这是一个低代码组件", + "type": "i18n" + } + }, + "fieldId": "text_kxid1d9n" + } + } + ], + "propTypes": [ + { + "defaultValue": "这是默认值", + "name": "content", + "title": "文本内容", + "type": "string" + } + ], + "componentName": "Component", + "id": "node_k8bnubvz", + "state": {} + } + ] + }, + "library": "LCCA" + }, + { + "title": "多端组件库", + "package": "@ali/atest1", + "version": "1.23.0", + "advancedUrls": { + "default": [ + "https://g.alicdn.com/legao-comp/web_bundle_0724/@alife/theme-254/1.24.0/@ali/atest1/1.0.0/theme.7c897c2.css", + "https://g.alicdn.com/legao-comp/web_bundle_0724/@ali/atest1/1.0.0/main.3354663.js" + ], + "mobile": [ + "https://g.alicdn.com/legao-comp/web_bundle_0724/@alife/theme-254/1.24.0/@ali/atest1/1.0.0/theme.7c897c2.css", + "https://g.alicdn.com/legao-comp/web_bundle_0724/@ali/atest1/1.0.0/main.mobile.3354663.js" + ], + "rax": [ + "https://g.alicdn.com/legao-comp/web_bundle_0724/@alife/theme-254/1.24.0/@ali/atest1/1.0.0/theme.7c897c2.css", + "https://g.alicdn.com/legao-comp/web_bundle_0724/@ali/atest1/1.0.0/main.rax.3354663.js" + ] + }, + "advancedEditUrls": { + "design": [ + "https://g.alicdn.com/legao-comp/web_bundle_0724/@alife/theme-254/1.24.0/@ali/atest1/1.0.0/theme.7c897c2.css", + "https://g.alicdn.com/legao-comp/web_bundle_0724/@ali/atest1/1.0.0/editView.design.js" + ], + "default": [ + "https://g.alicdn.com/legao-comp/web_bundle_0724/@alife/theme-254/1.24.0/@ali/atest1/1.0.0/theme.7c897c2.css", + "https://g.alicdn.com/legao-comp/web_bundle_0724/@ali/atest1/1.0.0/editView.js" + ] + }, + "library": "Atest1" + }, + { + "library":"UiPaaSServerless3", + "advancedUrls":{ + "default":[ + "https://g.alicdn.com/legao-comp/serverless3/1.1.0/env-staging-d224466e-0614-497d-8cd5-e4036dc50b70/main.js" + ] + }, + "id":"UiPaaSServerless3-view", + "type":"procode", + "version":"1.0.0" + }, + { + "package":"react-color", + "library":"ReactColor", + "id":"react-color", + "type":"procode", + "version":"2.19.3", + "async":true, + "exportMode":"functionCall", + "exportSourceId":"UiPaaSServerless3-view" + } + ] +} +``` + +### 2.3 components (A) + +定义资产包中包含的所有组件的低代码描述的集合,分为“ComponentDescription”和“RemoteComponentDescription”(详见 2.6 TypeScript 定义): + +- ComponentDescription: 符合“组件描述协议”的数据,详见物料规范中`2.2.2 组件描述协议`部分; +- RemoteComponentDescription 是将一个或多个 ComponentDescription 构建打包的 js 资源的描述,在浏览器中加载该资源后可获取到其中包含的每个组件的 ComponentDescription 的具体内容; + +### 2.4 sort (AA) + +定义组件列表分组 + +| 根属性名称 | 类型 | 说明 | 变量支持 | 默认值 | +| ----------------- | -------- | -------------------------------------------------------------------------------------------- | -------- | ---------------------------------------- | +| sort.groupList | String[] | 组件分组,用于组件面板 tab 展示 | - | ['精选组件', '原子组件'] | +| sort.categoryList | String[] | 组件面板中同一个 tab 下的不同区间用 category 区分,category 的排序依照 categoryList 顺序排列 | - | ['通用', '数据展示', '表格类', '表单类'] | + +### 2.5 plugins (AAA) + +自定义设计器插件列表 + +| 根属性名称 | 类型 | 说明 | 变量支持 | 默认值 | +| --------------------- | --------- | -------------------- | -------- | ------ | +| plugins[].name | String | 插件名称 | - | - | +| plugins[].title | String | 插件标题 | - | - | +| plugins[].description | String | 插件描述 | - | - | +| plugins[].docUrl | String | 插件文档地址 | - | - | +| plugins[].screenshot | String | 插件截图地址 | - | - | +| plugins[].tags | String[] | 插件标签分类 | - | - | +| plugins[].keywords | String[] | 插件检索关键字 | - | - | +| plugins[].reference | Reference | 插件引用的资源包信息 | - | - | + +### 2.6 setters (AAA) + +自定义设置器列表 + +| 根属性名称 | 类型 | 说明 | 变量支持 | 默认值 | +| --------------------- | --------- | ---------------------- | -------- | ------ | +| setters[].name | String | 设置器组件名称 | - | - | +| setters[].title | String | 设置器标题 | - | - | +| setters[].description | String | 设置器描述 | - | - | +| setters[].docUrl | String | 设置器文档地址 | - | - | +| setters[].screenshot | String | 设置器截图地址 | - | - | +| setters[].tags | String[] | 设置器标签分类 | - | - | +| setters[].keywords | String[] | 设置器检索关键字 | - | - | +| setters[].reference | Reference | 设置器引用的资源包信息 | - | - | + +### 2.7 extConfig (AAA) + +定义平台相关的扩展内容,用于存放平台自身实现的一些私有协议,以允许存量平台能够平滑地迁移至标准协议。extConfig 是一个 key-value 结构的对象,协议不会规定 extConfig 中的字段名称以及类型,完全自定义 + +### 2.8 TypeScript 定义 + +_组件低代码描述相关部分字段含义详见物料规范中`2.2.2 组件描述协议`部分;_ + +```TypeScript + +/** + * 资产包协议 + */ +export interface Assets { + /** + * 资产包协议版本号 + */ + version: string; + /** + * 资源列表 + */ + packages?: Array; + /** + * 所有组件的描述协议集合 + */ + components: Array; + /** + * 低代码编辑器插件集合 + */ + plugins?: Array; + /** + * 低代码设置器集合 + */ + setters?: Array; + /** + * 平台扩展配置 + */ + extConfig?: AssetsExtConfig; + /** + * 用于描述组件面板中的 tab 和 category + */ + sort: ComponentSort; +} + +export interface AssetsExtConfig{ + [index: string]: any; +} + +/** + * 描述组件面板中的 tab 和 category 排布 + */ +export interface ComponentSort { + /** + * 用于描述组件面板的 tab 项及其排序,例如:["精选组件", "原子组件"] + */ + groupList?: String[]; + /** + * 组件面板中同一个 tab 下的不同区间用 category 区分,category 的排序依照 categoryList 顺序排列; + */ + categoryList?: String[]; +} + +/** + * 定义资产包依赖信息 + */ +export interface Package { + /** + * 唯一标识 + */ + id: string; + /** + * 包名 + */ + package: string; + /** + * 包版本号 + */ + version: string; + /** + * 资源类型 + */ + type: string; + /** + * 组件渲染态视图打包后的 CDN url 列表,包含 js 和 css + */ + urls?: string[] | any; + /** + * 组件多个渲染态视图打包后的 CDN url 列表,包含 js 和 css,优先级高于 urls + */ + advancedUrls?: ComplexUrls; + /** + * 组件编辑态视图打包后的 CDN url 列表,包含 js 和 css + */ + editUrls?: string[] | any; + /** + * 组件多个编辑态视图打包后的 CDN url 列表,包含 js 和 css,优先级高于 editUrls + */ + advancedEditUrls?: ComplexUrls; + /** + * 低代码组件的 schema 内容 + */ + schema?: ComponentSchema; + /** + * 当前资源所依赖的其他资源包的 id 列表 + */ + deps?: string[]; + /** + * 指定当前资源加载的环境 + */ + loadEnv?: LoadEnv[]; + /** + * 当前资源是否是 external 资源 + */ + external?: boolean; + /** + * 作为全局变量引用时的名称,和 webpack output.library 字段含义一样,用来定义全局变量名 + */ + library: string; + /** + * 组件描述导出名字,可以通过 window[exportName] 获取到组件描述的 Object 内容; + */ + exportName?: string; + /** + * 标识当前 package 资源加载在 window.library 上的是否是一个异步对象 + */ + async?: boolean; + /** + * 标识当前 package 从其他 package 的导出方式 + */ + exportMode?: string; + /** + * 标识当前 package 内容是从哪个 package 导出来的 + */ + exportSourceId?: string; + /** + * 标识当前 package 是从 window 上的哪个属性导出来的 + */ + exportSourceLibrary?: string; +} + + +/** + * 复杂 urls 结构,同时兼容简单结构和多模态结构 + */ +export type ComplexUrls = string[] | MultiModeUrls; + +/** + * 多模态资源 + */ +export interface MultiModeUrls { + /** + * 默认的资源 url + */ + default: string[]; + /** + * 其他模态资源的 url + */ + [index: string]: string[]; +} + + +/** + * 资源加载环境种类 + */ +export enum LoadEnv { + /** + * 设计态 + */ + design = "design", + /** + * 运行态 + */ + runtime = "runtime" +} + +/** + * 低代码设置器描述 + */ +export type SetterDescription = PluginDescription; + +/** + * 低代码插件器描述 + */ +export interface PluginDescription { + /** + * 插件名称 + */ + name: string; + /** + * 插件标题 + */ + title: string; + /** + * 插件类型 + */ + type?: string; + /** + * 插件描述 + */ + description?: string; + /** + * 插件文档地址 + */ + docUrl: string; + /** + * 插件截图 + */ + screenshot: string; + /** + * 插件相关的标签 + */ + tags?: string[]; + /** + * 插件关键字 + */ + keywords?: string[]; + /** + * 插件引用的资源信息 + */ + reference: Reference; +} + +/** + * 资源引用信息,Npm 的升级版本, + */ +export interface Reference { + /** + * 引用资源的 id 标识 + */ + id?: string; + /** + * 引用资源的包名 + */ + package?: string; + /** + * 引用资源的导出对象中的属性值名称 + */ + exportName: string; + /** + * 引用 exportName 上的子对象 + */ + subName: string; + /** + * 引用的资源主入口 + */ + main?: string; + /** + * 是否从引用资源的导出对象中获取属性值 + */ + destructuring: boolean; + /** + * 资源版本号 + */ + version: string; +} + + +/** + * 低代码片段 + * + * 内容为组件不同状态下的低代码 schema (可以有多个),用户从组件面板拖入组件到设计器时会向页面 schema 中插入 snippets 中定义的组件低代码 schema + */ +export interface Snippet { + title: string; + screenshot?: string; + schema: ElementJSON; +} + +/** + * 组件低代码描述 + */ +export interface ComponentDescription { + componentName: string; + title: string; + description?: string; + docUrl: string; + screenshot: string; + icon?: string; + tags?: string[]; + keywords?: string[]; + devMode?: 'proCode' | 'lowCode'; + npm: Npm; + props: Prop[]; + configure: Configure; + /** + * 多模态下的组件描述, 优先级高于 configure + */ + advancedConfigures: MultiModeConfigures; + snippets: Snippet[]; + group: string; + category: string; + priority: number; + /** + * 组件引用的资源信息 + */ + reference: Reference; +} + +export interface MultiModeConfigures { + default: Configure; + [index: string]: Configure; +} + +/** + * 远程物料描述 + */ +export interface RemoteComponentDescription { + /** + * 组件描述导出名字,可以通过 window[exportName] 获取到组件描述的 Object 内容; + */ + exportName?: string; + /** + * 组件描述的资源链接; + */ + url?: string; + /** + * 组件多模态描述的资源信息,优先级高于 url + */ + advancedUrls?: ComplexUrl; + /** + * 组件(库)的 npm 信息; + */ + package?: { + npm?: string; + }; +} + +export type ComplexUrl = string | MultiModeUrl + +export interface MultiModeUrl { + default: string; + [index: string]: string; +} + +export interface ComponentSchema { + version: string; + componentsMap: ComponentsMap; + componentsTree: [ComponentTree]; + i18n: I18nMap; + utils: UtilItem[]; +} + +``` + +`ComponentSchema` 的定义见[低代码业务组件描述](./material-spec.md#221-组件规范) diff --git a/docs/docs/specs/lowcode-spec.md b/docs/docs/specs/lowcode-spec.md new file mode 100644 index 0000000000..c277214106 --- /dev/null +++ b/docs/docs/specs/lowcode-spec.md @@ -0,0 +1,1653 @@ +--- +title: 《低代码引擎搭建协议规范》 +sidebar_position: 0 +--- + +## 1 介绍 + +### 1.1 本协议规范涉及的问题域 + +- 定义本协议版本号规范 +- 定义本协议中每个子规范需要被支持的 Level +- 定义本协议相关的领域名词 +- 定义搭建基础协议版本号规范(A) +- 定义搭建基础协议组件映射关系规范(A) +- 定义搭建基础协议组件树描述规范(A) +- 定义搭建基础协议国际化多语言支持规范(AA) +- 定义搭建基础协议无障碍访问规范(AAA) + + +### 1.2 协议草案起草人 + +- 撰写:月飞、康为、林熠 +- 审阅:大果、潕量、九神、元彦、戊子、屹凡、金禅、前道、天晟、戊子、游鹿、光弘、力皓 + + +### 1.3 版本号 + +1.1.0 + +### 1.4 协议版本号规范(A) + +本协议采用语义版本号,版本号格式为 `major.minor.patch` 的形式。 + +- major 是大版本号:用于发布不向下兼容的协议格式修改 +- minor 是小版本号:用于发布向下兼容的协议功能新增 +- patch 是补丁号:用于发布向下兼容的协议问题修正 + + +### 1.5 协议中子规范 Level 定义 + +| 规范等级 | 实现要求 | +| -------- | ---------------------------------------------------------------------------------- | +| A | 强制规范,必须实现;违反此类规范的协议描述数据将无法写入物料中心,不支持流通。 | +| AA | 推荐规范,推荐实现;遵守此类规范有助于业务未来的扩展性和跨团队合作研发效率的提升。 | +| AAA | 参考规范,根据业务场景实际诉求实现;是集团层面鼓励的技术实现引导。 | + + +### 1.6 名词术语 + +#### 1.6.1 物料系统名词 + +- **基础组件(Basic Component)**:前端领域通用的基础组件,阿里巴巴前端委员会官方指定的基础组件库是 Fusion Next/AntD。 +- **图表组件(Chart Component)**:前端领域通用的图表组件,有代表性的图表组件库有 BizCharts。 +- **业务组件(Business Component)**:业务领域内基于基础组件之上定义的组件,可能会包含特定业务域的交互或者是业务数据,对外仅暴露可配置的属性,且必须发布到公域(如阿里 NPM);在同一个业务域内可以流通,但不需要确保可以跨业务域复用。 + - **低代码业务组件(Low-Code Business Component)**:通过低代码编辑器搭建而来,有别于源码开发的业务组件,属于业务组件中的一种类型,遵循业务组件的定义;同时低代码业务组件还可以通过低代码编辑器继续多次编辑。 +- **布局组件(Layout Component)**:前端领域通用的用于实现基础组件、图表组件、业务组件之间各类布局关系的组件,如三栏布局组件。 +- **区块(Block)**:通过低代码搭建的方式,将一系列业务组件、布局组件进行嵌套组合而成,不对外提供可配置的属性。可通过 区块容器组的包裹,实现区块内部具备有完整的样式、事件、生命周期管理、状态管理、数据流转机制。能独立存在和运行,可通过复制 schema 实现跨页面、跨应用的快速复用,保障功能和数据的正常。 +- **页面(Page)**:由组件 + 区块组合而成。由页面容器组件包裹,可描述页面级的状态管理和公共函数。 +- **模板(Template)**:特定垂直业务领域内的业务组件、区块可组合为单个页面,或者是再配合路由组合为多个页面集,统称为模板。 + + +#### 1.6.2 低代码搭建系统名词 + +- **搭建编辑器**:使用可视化的方式实现页面搭建,支持组件 UI 编排、属性编辑、事件绑定、数据绑定,最终产出符合搭建基础协议规范的数据。 + - **属性面板**:低代码编辑器内部用于组件、区块、页面的属性编辑、事件绑定、数据绑定的操作面板。 + - **画布面板**:低代码编辑器内部用于 UI 编排的操作面板。 + - **大纲面板**:低代码编辑器内部用于页面组件树展示的面板。 +- **编辑器框架**:搭建编辑器的基础框架,包含主题配置机制、插件机制、setter 控件机制、快捷键管理、扩展点管理等底层基础设施。 +- **入料模块**:专注于物料接入,能自动扫描、解析源码组件,并最终产出一份符合《低代码引擎物料协议规范》的 Schema JSON。 +- **编排模块**:专注于 Schema 可视化编排,以可视化的交互方式提供页面结构编排服务,并最终产出一份符合《低代码搭建基础协议规范》的 Schema JSON。 +- **渲染模块**:专注于将 Schema JSON 渲染为 UI 界面,最终呈现一个可交互的页面。 +- **出码模块 Schema2Code**:专注于通过 Schema JSON 生成高质量源代码,将符合《低代码搭建基础协议规范》的 Schema JSON 数据分别转化为面向 React / Rax / 阿里小程序等终端可渲染的代码。 +- **事件绑定**:是指为某个组件的某个事件绑定相关的事件处理动作,比如为某个组件的**点击事件**绑定**一段处理函数**或**响应动作**(比如弹出对话框),每个组件可绑定的事件由该组件自行定义。 +- **数据绑定**:是指为某个组件的某个属性绑定用于该属性使用的数据。 +- **生命周期**: 一般指某个对象的生老病死,本文中指某个实体(组件、容器、区块等等)的创建、加载、显示、销毁等关键生命阶段的统称。 + +### 1.7 背景 + +- **协议目标**:通过约束低代码引擎的搭建协议规范,让上层低代码编辑器的产出物(低代码业务组件、区块、应用)保持一致性,可跨低代码研发平台进行流通而提效,亦不阻碍集团业务间融合的发展。  +- **协议通**: + - 协议顶层结构统一 + - 协议 schema 具备有完整的描述能力,包含版本、国际化、组件树、组件映射关系等; + - 顶层属性 key、value 值的格式,必须保持一致; + - 组件树描述统一 + - 源码组件描述; + - 页面、区块、低代码业务组件这三种容器组件的描述; + - 数据流描述,包含数据请求、数据状态管理、数据绑定描述; + - 事件描述,包含统一事件上下文、统一搭建 API; +- **物料通**:指在相同领域内的不同搭建产品,可直接使用的物料。比如模版、区块、组件; + +### 1.8 受众 + +本协议适用于所有使用低代码搭建平台来开发页面或组件的开发者,以及围绕此协议的相关工具或工程化方案的开发者。阅读及使用本协议,需要对低代码搭建平台的交互和实现有一定的了解,对前端开发相关技术栈的熟悉也会有帮助,协议中对通用的前端相关术语不会做进一步的解释说明。 + +### 1.9 使用范围 + +本协议描述的是低代码搭建平台产物(应用、页面、区块、组件)的 schema 结构,以及实现其数据状态更新(内置 api)、能力扩展、国际化等方面完整,只在低代码搭建场景下可用; + +### 1.10 协议目标 + +一套面向开发者的 schema 规范,用于规范化约束搭建编辑器的输出,以及渲染模块和出码模块的输入,将搭建编辑器、渲染模块、出码模块解耦,保障搭建编辑器、渲染模块、出码模块的独立升级。 + +### 1.11 设计说明 + +- **语义化**:语义清晰,简明易懂,可读性强。 +- **渐进性描述**:搭建的本质是通过 源码组件 进行嵌套组合,从小往大、依次组合生成 组件、区块、页面,最终通过云端构建生成 应用 的过程。因此在搭建基础协议中,我们需要知道如何去渐进性的描述组件、区块、页面、应用这 4 个实体概念。 +- **生成标准源码**:明确每一个属性与源码对应的转换关系,可生成跟手写无差异的高质量标准源代码。 +- **可流通性**:产物能在不同搭建产品中流通,不涉及任何私域数据存储。 +- **面向多端**:不能仅面向 React,还有小程序等多端。 +- **支持国际化&无障碍访问标准的实现** + + +## 2 协议结构 + +协议最顶层结构如下: + +- version { String } 当前协议版本号 +- componentsMap { Array } 组件映射关系 +- componentsTree { Array } 描述模版/页面/区块/低代码业务组件的组件树 +- utils { Array } 工具类扩展映射关系 +- i18n { Object } 国际化语料 +- constants { Object } 应用范围内的全局常量 +- css { string } 应用范围内的全局样式 +- config: { Object } 当前应用配置信息 +- meta: { Object } 当前应用元数据信息 +- dataSource: { Array } 当前应用的公共数据源 +- router: { Object } 当前应用的路由配置信息 +- pages: { Array } 当前应用的所有页面信息 + +描述举例: + +```json +{ + "version": "1.0.0", // 当前协议版本号 + "componentsMap": [{ // 组件描述 + "componentName": "Button", + "package": "@alifd/next", + "version": "1.0.0", + "destructuring": true, + "exportName": "Select", + "subName": "Button" + }], + "utils": [{ + "name": "clone", + "type": "npm", + "content": { + "package": "lodash", + "version": "0.0.1", + "exportName": "clone", + "subName": "", + "destructuring": false, + "main": "/lib/clone" + } + }, { + "name": "moment", + "type": "npm", + "content": { + "package": "@alifd/next", + "version": "0.0.1", + "exportName": "Moment", + "subName": "", + "destructuring": true, + "main": "" + } + }], + "componentsTree": [{ // 描述内容,值类型 Array + "id": "page1", + "componentName": "Page", // 单个页面,枚举类型 Page|Block|Component + "fileName": "Page1", + "props": {}, + "css": "body {font-size: 12px;} .table { width: 100px;}", + "children": [{ + "componentName": "Div", + "props": { + "className": "" + }, + "children": [{ + "componentName": "Button", + "props": { + "prop1": 1234, // 简单 json 数据 + "prop2": [{ // 简单 json 数据 + "label": "选项 1", + "value": 1 + }, { + "label": "选项 2", + "value": 2 + }], + "prop3": [{ + "name": "myName", + "rule": { + "type": "JSExpression", + "value": "/\w+/i" + } + }], + "valueBind": { // 变量绑定 + "type": "JSExpression", + "value": "this.state.user.name" + }, + "onClick": { // 动作绑定 + "type": "JSFunction", + "value": "function(e) { console.log(e.target.innerText) }" + }, + "onClick2": { // 动作绑定 2 + "type": "JSExpression", + "value": "this.submit" + } + } + }] + }] + }], + "constants": { + "ENV": "prod", + "DOMAIN": "xxx.com" + }, + "css": "body {font-size: 12px;} .table { width: 100px;}", + "config": { // 当前应用配置信息 + "sdkVersion": "1.0.3", // 渲染模块版本 + "historyMode": "hash", // 不推荐,推荐在 router 字段中配置 + "targetRootID": "J_Container", + "layout": { + "componentName": "BasicLayout", + "props": { + "logo": "...", + "name": "测试网站" + }, + }, + "theme": { + // for Fusion use dpl defined + "package": "@alife/theme-fusion", + "version": "^0.1.0", + // for Antd use variable + "primary": "#ff9966" + } + }, + "meta": { // 应用元数据信息,key 为业务自定义 + "name": "demo 应用", // 应用中文名称, + "git_group": "appGroup", // 应用对应 git 分组名 + "project_name": "app_demo", // 应用对应 git 的 project 名称 + "description": "这是一个测试应用", // 应用描述 + "spma": "spa23d", // 应用 spm A 位信息 + "creator": "月飞", + "gmt_create": "2020-02-11 00:00:00", // 创建时间 + "gmt_modified": "2020-02-11 00:00:00", // 修改时间 + ... + }, + "i18n": { + "zh-CN": { + "i18n-jwg27yo4": "你好", + "i18n-jwg27yo3": "中国" + }, + "en-US": { + "i18n-jwg27yo4": "Hello", + "i18n-jwg27yo3": "China" + } + }, + "router": { + "baseUrl": "/", + "historyMode": "hash", // 浏览器路由:browser 哈希路由:hash + "routes": [ + { + "path": "home", + "page": "page1" + } + ] + }, + "pages": [ + { + "id": "page1", + "treeId": "page1" + } + ] +} +``` + +### 2.1 协议版本号(A) + +定义当前协议 schema 的版本号,不同的版本号对应不同的渲染 SDK,以保障不同版本搭建协议产物的正常渲染; + + +| 根属性名称 | 类型 | 说明 | 变量支持 | 默认值 | +| ---------- | ------ | ---------- | -------- | ------ | +| version | String | 协议版本号 | - | 1.0.0 | + + +描述示例: + +```javascript +{ + "version": "1.0.0" +} +``` + +### 2.2 组件映射关系(A) + +协议中用于描述 componentName 到公域组件映射关系的规范。 + + +| 参数 | 说明 | 类型 | 变量支持 | 默认值 | +| --------------- | ---------------------- | ------------------------- | -------- | ------ | +| componentsMap[] | 描述组件映射关系的集合 | **ComponentMap**[] | - | null | + +**ComponentMap 结构描述**如下: + +| 参数 | 说明 | 类型 | 变量支持 | 默认值 | +| ------------- | ------------------------------------------------------------------------------------------------------ | ------- | -------- | ------ | +| componentName | 协议中的组件名,唯一性,对应包导出的组件名,是一个有效的 **JS 标识符**,而且是大写字母打头 | String | - | - | +| package | npm 公域的 package name | String | - | - | +| version | package version | String | - | - | +| destructuring | 使用解构方式对模块进行导出 | Boolean | - | - | +| exportName | 包导出的组件名 | String | - | - | +| subName | 下标子组件名称 | String | - | | +| main | 包导出组件入口文件路径 | String | - | - | + + +描述示例: + +```json +{ + "componentsMap": [{ + "componentName": "Button", + "package": "@alifd/next", + "version": "1.0.0", + "destructuring": true + }, { + "componentName": "MySelect", + "package": "@alifd/next", + "version": "1.0.0", + "destructuring": true, + "exportName": "Select" + }, { + "componentName": "ButtonGroup", + "package": "@alifd/next", + "version": "1.0.0", + "destructuring": true, + "exportName": "Button", + "subName": "Group" + }, { + "componentName": "RadioGroup", + "package": "@alifd/next", + "version": "1.0.0", + "destructuring": true, + "exportName": "Radio", + "subName": "Group" + }, { + "componentName": "CustomCard", + "package": "@ali/custom-card", + "version": "1.0.0" + }, { + "componentName": "CustomInput", + "package": "@ali/custom", + "version": "1.0.0", + "main": "/lib/input", + "destructuring": true, + "exportName": "Input" + }] +} +``` + +出码结果: + +```javascript +// 使用解构方式,destructuring is true. +import { Button } from '@alifd/next'; + +// 使用解构方式,且 exportName 和 componentName 不同 +import { Select as MySelect } from '@alifd/next'; + +// 使用解构方式,并导出其子组件 +import { Button } from '@alifd/next'; +const ButtonGroup = Button.Group; + +import { Radio } from '@alifd/next'; +const RadioGroup = Radio.Group; + +// 不使用解构方式进行导出 +import CustomCard from '@ali/custom-card'; + +// 使用特定路径进行导出 +import { Input as CustomInput } from '@ali/custom/lib/input'; + +``` + + +### 2.3 组件树描述(A) + + +协议中用于描述搭建出来的组件树结构的规范,整个组件树的描述由**组件结构**&**容器结构**两种结构嵌套构成。 + +- 组件结构:描述单个组件的名称、属性、子集的结构; +- 容器结构:描述单个容器的数据、自定义方法、生命周期的结构,用于将完整页面进行模块化拆分。 + +与源码对应的转换关系如下: + +- 组件结构:转换成一个 .jsx 文件内 React Class 类 render 函数返回的 **jsx** 代码。 +- 容器结构:将转换成一个标准文件,如 React 的 jsx 文件,export 一个 React Class,包含生命周期定义、自定义方法、事件属性绑定、异步数据请求等。 + +#### 2.3.1 基础结构描述 (A) + +此部分定义了组件结构、容器结构的公共基础字段。 + +> 阅读时可先跳到后续章节,待需要时回来参考阅读 + +##### 2.3.1.1 Props 结构描述 + +| 参数 | 说明 | 类型 | 支持变量 | 默认值 | 备注 | +| ----------- | ------------ | ------ | -------- | ------ | ------------------------------------- | +| id | 组件 ID | String | ✅ | - | 系统属性 | +| className | 组件样式类名 | String | ✅ | - | 系统属性,支持变量表达式 | +| style | 组件内联样式 | Object | ✅ | - | 系统属性,单个内联样式属性值 | +| ref | 组件 ref 名称 | String | ✅ | - | 可通过 `this.$(ref)` 获取组件实例 | +| extendProps | 组件继承属性 | 变量 | ✅ | - | 仅支持变量绑定,常用于继承属性对象 | +| ... | 组件私有属性 | - | - | - | | + +##### 2.3.1.2 css/less/scss 样式描述 + +| 参数 | 说明 | 类型 | 支持变量 | 默认值 | +| ------------- | -------------------------------------------------------------------------- | ------ | -------- | ------ | +| css/less/scss | 用于描述容器组件内部节点的样式,对应生成一个独立的样式文件,不支持 @import | String | - | null | + +描述示例: + +```json +{ + "css": "body {font-size: 12px;} .table { width: 100px; }" +} +``` + +##### 2.3.1.3 ComponentDataSource 对象描述 + +| 参数 | 说明 | 类型 | 支持变量 | 默认值 | 备注 | +| ----------- | ---------------------- | -------------------------------------- | -------- | ------ | ----------------------------------------------------------------------------------------------------------- | +| list[] | 数据源列表 | **ComponentDataSourceItem**[] | - | - | 成为为单个请求配置, 内容定义详见 [ComponentDataSourceItem 对象描述](#2314-componentdatasourceitem-对象描述) | +| dataHandler | 所有请求数据的处理函数 | Function | - | - | 详见 [dataHandler Function 描述](#2317-datahandler-function 描述) | + +##### 2.3.1.4 ComponentDataSourceItem 对象描述 + +| 参数 | 说明 | 类型 | 支持变量 | 默认值 | 备注 | +| -------------- | ---------------------------- | ---------------------------------------------------- | -------- | --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| id | 数据请求 ID 标识 | String | - | - | | +| isInit | 是否为初始数据 | Boolean | ✅ | true | 值为 true 时,将在组件初始化渲染时自动发送当前数据请求 | +| isSync | 是否需要串行执行 | Boolean | ✅ | false | 值为 true 时,当前请求将被串行执行 | +| type | 数据请求类型 | String | - | fetch | 支持四种类型:fetch/mtop/jsonp/custom | +| shouldFetch | 本次请求是否可以正常请求 | (options: ComponentDataSourceItemOptions) => boolean | - | ```() => true``` | function 参数参考 [ComponentDataSourceItemOptions 对象描述](#2315-componentdatasourceitemoptions-对象描述) | +| willFetch | 单个数据结果请求参数处理函数 | Function | - | options => options | 只接受一个参数(options),返回值作为请求的 options,当处理异常时,使用原 options。也可以返回一个 Promise,resolve 的值作为请求的 options,reject 时,使用原 options | +| requestHandler | 自定义扩展的外部请求处理器 | Function | - | - | 仅 type='custom' 时生效 | +| dataHandler | request 成功后的回调函数 | Function | - | `response => response.data`| 参数:请求成功后 promise 的 value 值 || +| errorHandler | request 失败后的回调函数 | Function | - | - | 参数:请求出错 promise 的 error 内容 | +| options {} | 请求参数 | **ComponentDataSourceItemOptions**| - | - | 每种请求类型对应不同参数,详见 | 每种请求类型对应不同参数,详见 [ComponentDataSourceItemOptions 对象描述](#2315-componentdatasourceitemoptions-对象描述) | + +**关于 dataHandler 于 errorHandler 的细节说明:** + +request 返回的是一个 promise,dataHandler 和 errorHandler 遵循 Promise 对象的 then 方法,实际使用方式如下: + +```ts +// 伪代码 +try { + const result = await request(fetchConfig).then(dataHandler, errorHandler); + dataSourceItem.data = result; + dataSourceItem.status = 'success'; +} catch (err) { + dataSourceItem.error = err; + dataSourceItem.status = 'error'; +} +``` +**注意:** +- dataHandler 和 errorHandler 只会走其中的一个回调 +- 它们都有修改 promise 状态的机会,意味着可以修改当前数据源最终状态 +- 最后返回的结果会被认为是当前数据源的最终结果,如果被 catch 了,那么会认为数据源请求出错 +- dataHandler 会有默认值,考虑到返回结果入参都是 response 完整对象,默认值会返回 `response.data`,errorHandler 没有默认值 + + +##### 2.3.1.5 ComponentDataSourceItemOptions 对象描述 + +| 参数 | 说明 | 类型 | 支持变量 | 默认值 | 备注 | +| ------- | ------------ | ------- | -------- | ------ | ----------------------------------------------------------------------------------------------------------- | +| uri | 请求地址 | String | ✅ | - | | +| params | 请求参数 | Object | ✅ | {} | 当前数据源默认请求参数(在运行时会被实际的 load 方法的参数替换,如果 load 的 params 没有则会使用当前 params) | +| method | 请求方法 | String | ✅ | GET | | +| isCors | 是否支持跨域 | Boolean | ✅ | true | 对应 `credentials = 'include'` | +| timeout | 超时时长 | Number | ✅ | 5000 | 单位 ms | +| headers | 请求头信息 | Object | ✅ | - | 自定义请求头 | + + + +##### 2.3.1.6 ComponentLifeCycles 对象描述 + +生命周期对象,schema 面向多端,不同 DSL 有不同的生命周期方法: + +- React:对于中后台 PC 物料,已明确使用 React 作为最终渲染框架,因此提案采用 [React16 标准生命周期方法](https://reactjs.org/docs/react-component.html)标准来定义生命周期方法,降低理解成本,支持生命周期如下: + - constructor(props, context)  + - 说明:初始化渲染时执行,常用于设置 state 值。 + - render()  + - 说明:执行于容器组件 React Class 的 render 方法最前,常用于计算变量挂载到 this 对象上,供 props 上属性绑定。此 render() 方法不需要设置 return 返回值。 + - componentDidMount() + - 说明:组件已加载 + - componentDidUpdate(prevProps, prevState, snapshot) + - 说明:组件已更新 + - componentWillUnmount() + - 说明:组件即将从 DOM 中移除 + - componentDidCatch(error, info) + - 说明:组件捕获到异常 + +该对象由一系列 key-value 组成,key 为生命周期方法名,value 为 JSFunction 的描述,详见下方示例: + +```json +{ + "componentDidMount": { // key 为上文中 React 的生命周期方法名 + "type": "JSFunction", // type 目前仅支持 JSFunction + "value": "function() {\ // value 为 javascript 函数 + console.log('did mount');\ + }" + }, + "componentWillUnmount": { + "type": "JSFunction", + "value": "function() {\ + console.log('will unmount');\ + }" + } + ... +}, +``` + + +##### 2.3.1.7 dataHandler Function 描述 + +- 参数:为 dataMap 对象,包含字段如下: + - key: 数据 id + - value: 单个请求结果 +- 返回值:数据对象 data,将会在渲染引擎和 schemaToCode 中通过调用 `this.setState(...)` 将返回的数据对象生效到 state 中;支持返回一个 Promise,通过 `resolve(返回数据)`,常用于串行发送请求场景。 + +##### 2.3.1.8 ComponentPropDefinition 对象描述 + +| 参数 | 说明 | 类型 | 支持变量 | 默认值 | 备注 | +| ------------ | ---------- | -------------- | -------- | --------- | ----------------------------------------------------------------------------------------------------------------- | +| name | 属性名称 | String | - | - | | +| propType | 属性类型 | String\|Object | - | - | 具体值内容结构,参考《低代码引擎物料协议规范》内的“2.2.2.3 组件属性信息”中描述的**基本类型**和**复合类型** | +| description | 属性描述 | String | - | '' | | +| defaultValue | 属性默认值 | Any | - | undefined | 当 defaultValue 和 defaultProps 中存在同一个 prop 的默认值时,优先使用 defaultValue。 | + +范例: +```json +{ + "propDefinitions": [{ + "name": "title", + "propType": "string", + "defaultValue": "Default Title" + }, { + "name": "onClick", + "propType": "func" + }] + ... +}, +``` + +#### 2.3.2 组件结构描述(A) + +对应生成源码开发体系中 render 函数返回的 jsx 代码,主要描述有以下属性: + + +| 参数 | 说明 | 类型 | 支持变量 | 默认值 | 备注 | +| ------------- | ---------------------- | ---------------- | -------- | ----------------- | ---------------------------------------------------------------------------------------------------------- | +| id | 组件唯一标识 | String | - | | 可选,组件 id 由引擎随机生成(UUID),并保证唯一性,消费方为上层应用平台,在组件发生移动等场景需保持 id 不变 | +| componentName | 组件名称 | String | - | Div | 必填,首字母大写,同 [componentsMap](#22-组件映射关系 a) 中的要求 | +| props {} | 组件属性对象 | **Props**| - | {} | 必填,详见 | 必填,详见 [Props 结构描述](#2311-props-结构描述) | +| condition | 渲染条件 | Boolean | ✅ | true | 选填,根据表达式结果判断是否渲染物料;支持变量表达式 | +| loop | 循环数据 | Array | ✅ | - | 选填,默认不进行循环渲染;支持变量表达式 | +| loopArgs | 循环迭代对象、索引名称 | [String, String] | | ["item", "index"] | 选填,仅支持字符串 | +| children | 子组件 | Array | | | 选填,支持变量表达式 | + + +描述举例: + +```json +{ + "componentName": "Button", + "props": { + "className": "btn", + "style": { + "width": 100, + "height": 20 + }, + "text": "submit", + "onClick": { + "type": "JSFunction", + "value": "function(e) {\ + console.log('btn click')\ + }" + } + }, + "condition": { + "type": "JSExpression", + "value": "!!this.state.isshow" + }, + "loop": [], + "loopArgs": ["item", "index"], + "children": [] +} +``` + + +#### 2.3.3 容器结构描述 (A)  + +容器是一类特殊的组件,在组件能力基础上增加了对生命周期对象、自定义方法、样式文件、数据源等信息的描述。包含**低代码业务组件容器 Component**、**区块容器 Block**、**页面容器 Page** 3 种。主要描述有以下属性: + +- 组件类型:componentName +- 文件名称:fileName +- 组件属性:props +- state 状态管理:state +- 生命周期 Hook 方法:lifeCycles +- 自定义方法设置:methods +- 异步数据源配置:dataSource +- 条件渲染:condition +- 样式文件:css/less/scss + + +详细描述: + +| 参数 | 说明 | 类型 | 支持变量 | 默认值 | 备注 | +| --------------- | -------------------------- | ---------------------------------------------------------------------------------------------------------- | -------- | ------ | ----------------------------------------------------------------------------------------------------------------------------- | +| componentName | 组件名称 | 枚举类型,包括`'Page'` (代表页面容器)、`'Block'` (代表区块容器)、`'Component'` (代表低代码业务组件容器) | - | 'Div' | 必填,首字母大写 | +| fileName | 文件名称 | String | - | - | 必填,英文 | +| props { } | 组件属性对象 | **Props** | - | {} | 必填,详见 [Props 结构描述](#2311-props-结构描述) | +| static | 低代码业务组件类的静态对象 | | | | | +| defaultProps | 低代码业务组件默认属性 | Object | - | - | 选填,仅用于定义低代码业务组件的默认属性 | +| propDefinitions | 低代码业务组件属性类型定义 | **ComponentPropDefinition**[] | - | - | 选填,仅用于定义低代码业务组件的属性数据类型。详见 [ComponentPropDefinition 对象描述](#2318-componentpropdefinition-对象描述) | +| condition | 渲染条件 | Boolean | ✅ | true | 选填,根据表达式结果判断是否渲染物料;支持变量表达式 | +| state | 容器初始数据 | Object | ✅ | - | 选填,支持变量表达式 | +| children | 子组件 | Array | - | | 选填,支持变量表达式 | +| css/less/scss | 样式属性 | String | ✅ | - | 选填,详见 [css/less/scss 样式描述](#2312-csslessscss 样式描述) | +| lifeCycles | 生命周期对象 | **ComponentLifeCycles** | - | - | 详见 [ComponentLifeCycles 对象描述](#2316-componentlifecycles-对象描述) | +| methods | 自定义方法对象 | Object | - | - | 选填,对象成员为函数类型 | +| dataSource {} | 数据源对象 | **ComponentDataSource**| - | - | 选填,异步数据源,详见 | - | - | 选填,异步数据源,详见 [ComponentDataSource 对象描述](#2313-componentdatasource-对象描述) | + + + +#### 完整描述示例 + +描述示例 1:(正常 fetch/mtop/jsonp 请求): + +```json +{ + "componentName": "Block", + "fileName": "block-1", + "props": { + "className": "luna-page", + "style": { + "background": "#dd2727" + } + }, + "children": [{ + "componentName": "Button", + "props": { + "text": { + "type": "JSExpression", + "value": "this.state.btnText" + } + } + }], + "state": { + "btnText": "submit" + }, + "css": "body {font-size: 12px;}", + "lifeCycles": { + "componentDidMount": { + "type": "JSFunction", + "value": "function() {\ + console.log('did mount');\ + }" + }, + "componentWillUnmount": { + "type": "JSFunction", + "value": "function() {\ + console.log('will unmount');\ + }" + } + }, + "methods": { + "testFunc": { + "type": "JSFunction", + "value": "function() {\ + console.log('test func');\ + }" + } + }, + "dataSource": { + "list": [{ + "id": "list", + "isInit": true, + "type": "fetch/mtop/jsonp", + "options": { + "uri": "", + "params": {}, + "method": "GET", + "isCors": true, + "timeout": 5000, + "headers": {} + }, + "dataHandler": { + "type": "JSFunction", + "value": "function(data, err) {}" + } + }], + "dataHandler": { + "type": "JSFunction", + "value": "function(dataMap) { }" + } + }, + "condition": { + "type": "JSExpression", + "value": "!!this.state.isShow" + } +} +``` + +描述示例 2:(自定义扩展请求处理器类型): + +```json +{ + "componentName": "Block", + "fileName": "block-1", + "props": { + "className": "luna-page", + "style": { + "background": "#dd2727" + } + }, + ... + "dataSource": { + "list": [{ + "id": "list", + "isInit": true, + "type": "custom", + "requestHandler": { + "type": "JSFunction", + "value": "this.utils.hsfHandler" + }, + "options": { + "uri": "hsf://xxx", + "param1": "a", + "param2": "b", + ... + }, + "dataHandler": { + "type": "JSFunction", + "value": "function(data, err) { }" + } + }], + "dataHandler": { + "type": "JSFunction", + "value": "function(dataMap) { }" + } + } +} +``` + +#### 2.3.4 属性值类型描述(A) + +在上述**组件结构**和**容器结构**描述中,每一个属性所对应的值,除了传统的 JS 值类型(String、Number、Object、Array、Boolean)外,还包含有**节点类型**、**事件函数类型**、**变量类型**等多种复杂类型;接下来将对于复杂类型的详细描述方式进行详细介绍。 + +##### 2.3.4.1 节点类型(A) + +通常用于描述组件的某一个属性为 **ReactNode** 或 **Function-Return-ReactNode** 的场景。该类属性的描述均以 **JSSlot** 的方式进行描述,详细描述如下: + +**ReactNode** 描述: + +| 参数 | 说明 | 值类型 | 默认值 | 备注 | +| ----- | ---------- | --------------------- | -------- | -------------------------------------------------------------- | +| type | 值类型描述 | String | 'JSSlot' | 固定值 | +| value | 具体的值 | NodeSchema \| NodeSchema[] | null | 内容为 NodeSchema 类型,详见[组件结构描述](#232-组件结构描述(A)) | + + +举例描述:如 **Card** 的 **title** 属性 + +```json +{ + "componentName": "Card", + "props": { + "title": { + "type": "JSSlot", + "value": [{ + "componentName": "Icon", + "props": {} + },{ + "componentName": "Text", + "props": {} + }] + }, + ... + } +} + +``` + + +**Function-Return-ReactNode** 描述: + +| 参数 | 说明 | 值类型 | 默认值 | 备注 | +| ------ | ---------- | --------------------- | -------- | -------------------------------------------------------------- | +| type | 值类型描述 | String | 'JSSlot' | 固定值 | +| value | 具体的值 | NodeSchema \| NodeSchema[] | null | 内容为 NodeSchema 类型,详见[组件结构描述](#232-组件结构描述 a) | +| params | 函数的参数 | String[] | null | 函数的入参,其子节点可以通过 `this[参数名]` 来获取对应的参数。 | + + +举例描述:如 **Table.Column** 的 **cell** 属性 + +```json +{ + "componentName": "TabelColumn", + "props": { + "cell": { + "type": "JSSlot", + "params": ["value", "index", "record"], + "value": [{ + "componentName": "Input", + "props": {} + }] + }, + ... + } +} + +``` + +##### 2.3.4.2 事件函数类型(A) + +协议内的事件描述,主要包含**容器结构**的**生命周期**和**自定义方法**,以及**组件结构**的**事件函数类属性**三类。所有事件函数的描述,均以 **JSFunction** 的方式进行描述,保留与原组件属性、生命周期(React / 小程序)一致的输入参数,并给所有事件函数 binding 统一一致的上下文(当前组件所在容器结构的 **this** 对象)。 + +**事件函数类型**的属性值描述如下: + +```json +{ + "type": "JSFunction", + "value": "function onClick(){\ + console.log(123);\ + }" +} +``` + +描述举例: + +```json +{ + "componentName": "Block", + "fileName": "block1", + "props": {}, + "state": { + "name": "lucy" + }, + "lifeCycles": { + "componentDidMount": { + "type": "JSFunction", + "value": "function() {\ + console.log('did mount');\ + }" + }, + "componentWillUnmount": { + "type": "JSFunction", + "value": "function() {\ + console.log('will unmount');\ + }" + } + }, + "methods": { + "getNum": { + "type": "JSFunction", + "value": "function() {\ + console.log('名称是:' + this.state.name)\ + }" + } + }, + "children": [{ + "componentName": "Button", + "props": { + "text": "按钮", + "onClick": { + "type": "JSFunction", + "value": "function(e) {\ + console.log(e.target.innerText);\ + }" + } + } + }] +} +``` + +##### 2.3.4.3 变量类型(A) + +在上述**组件结构** 或**容器结构**中,有多个属性的值类型是支持变量类型的,通常会通过变量形式来绑定某个数据,所有的变量表达式均通过 JSExpression 表达式,上下文与事件函数描述一致,表达式内通过 **this** 对象获取上下文; + +变量**类型**的属性值描述如下: + + +- return 数字类型 + + ```json + { + "type": "JSExpression", + "value": "this.state.num" + } + ``` +- return 数字类型 + + ```json + { + "type": "JSExpression", + "value": "this.state.num - this.state.num2" + } + ``` +- return "8 万" 字符串类型 + + ```json + { + "type": "JSExpression", + "value": "`${this.state.num}万`" + } + ``` +- return "8 万" 字符串类型 + + ```json + { + "type": "JSExpression", + "value": "this.state.num + '万'" + } + ``` +- return 13 数字类型 + + ```json + { + "type": "JSExpression", + "value": "getNum(this.state.num, this.state.num2)" + } + ``` +- return true 布尔类型 + + ```json + { + "type": "JSExpression", + "value": "this.state.num > this.state.num2" + } + ``` + +描述举例: + +```json +{ + "componentName": "Block", + "fileName": "block1", + "props": {}, + "state": { + "num": 8, + "num2": 5 + }, + "methods": { + "getNum": { + "type": "JSFunction", + "value": "function(a, b){\ + return a + b;\ + }" + } + }, + "children": [{ + "componentName": "Button", + "props": { + "text": { + "type": "JSExpression", + "value": "this.getNum(this.state.num, this.state.num2) + '万'" + } + }, + "condition": { + "type": "JSExpression", + "value": "this.state.num > this.state.num2" + } + }] +} +``` + +##### 2.3.4.4 国际化多语言类型(AA) + +协议内的一些文本值内容,我们希望是和协议全局的国际化多语言语料是关联的,会按照全局国际化语言环境的不同使用对应的语料。所有国际化多语言值均以 **i18n** 结构描述。这样可以更为清晰且结构化得表达使用场景。 + +**国际化多语言类型**的属性值类型描述如下: + +```typescript +type Ti18n = { + type: 'i18n'; + key: string; // i18n 结构中字段的 key 标识符 + params?: Record; // 模版型 i18n 文案的入参,JSDataType 指代传统 JS 值类型 +} +``` + +其中 `key` 对应协议 `i18n` 内容的语料键值,`params` 为语料为字符串模板时的变量内容。 + +假设协议已加入如下 i18n 内容: +```json +{ + "i18n": { + "zh-CN": { + "i18n-jwg27yo4": "你好", + "i18n-jwg27yo3": "{name}博士" + }, + "en-US": { + "i18n-jwg27yo4": "Hello", + "i18n-jwg27yo3": "Doctor {name}" + } + } +} +``` + +**国际化多语言类型**简单范例: + +```json +{ + "type": "i18n", + "key": "i18n-jwg27yo4" +} +``` + +**国际化多语言类型**模板范例: + +```json +{ + "type": "i18n", + "key": "i18n-jwg27yo3", + "params": { + "name": "Strange" + } +} +``` + +描述举例: + +```json +{ + "componentName": "Button", + "props": { + "text": { + "type": "i18n", + "key": "i18n-jwg27yo4" + } + } +} +``` + + +#### 2.3.5 上下文 API 描述(A) + +在上述**事件类型描述**和**变量类型描述**中,在函数或 JS 表达式内,均可以通过 **this** 对象获取当前组件所在容器(React Class)的实例化对象,在搭建场景下的渲染模块和出码模块实现上,统一约定了该实例化 **this** 对象下所挂载的最小 API 集合,以保障搭建协议具备有一致的**数据流**和**事件上下文**。  + +##### 2.3.5.1 容器 API: + +| 参数 | 说明 | 类型 | 备注 | +| ----------------------------------- | --------------------------------------- | ---------------------------- | -------------------------------------------------------------------------------------------------------------- | +| **this {}** | 当前区块容器的实例对象 | Class Instance | - | +| *this*.state | 三种容器实例的数据对象 state | Object | - | +| *this*.setState(newState, callback) | 三种容器实例更新数据的方法 | Function | 这个 setState 通常会异步执行,详见下文 [setState](#setstate) | +| *this*.customMethod() | 三种容器实例的自定义方法 | Function | - | +| *this*.dataSourceMap {} | 三种容器实例的数据源对象 Map | Object | 单个请求的 id 为 key, value 详见下文 [DataSourceMapItem 结构描述](#datasourcemapitem-结构描述) | +| *this*.reloadDataSource() | 三种容器实例的初始化异步数据请求重载 | Function | 返回 \ | +| **this.page {}** | 当前页面容器的实例对象 | Class Instance | | +| *this.page*.props | 读取页面路由,参数等相关信息 | Object | query 查询参数 { key: value } 形式;path 路径;uri 页面唯一标识;其它扩展字段 | +| *this.page*.xxx | 继承 this 对象所有 API | | 此处 `xxx` 代指 `this.page` 中的其他 API | +| **this.component {}** | 当前低代码业务组件容器的实例对象 | Class Instance | | +| *this.component*.props | 读取低代码业务组件容器的外部传入的 props | Object | | +| *this.component*.xxx | 继承 this 对象所有 API | | 此处 `xxx` 代指 `this.component` 中的其他 API | +| **this.$(ref)** | 获取组件的引用(单个) | Component Instance | `ref` 对应组件上配置的 `ref` 属性,用于唯一标识一个组件;若有同名的,则会返回第一个匹配的。 | +| **this.$$(ref)** | 获取组件的引用(所有同名的) | Array of Component Instances | `ref` 对应组件上配置的 `ref` 属性,用于唯一标识一个组件;总是返回一个数组,里面是所有匹配 `ref` 的组件的引用。 | + +##### setState + +`setState()` 将对容器 `state` 的更改排入队列,并通知低代码引擎需要使用更新后的 `state` 重新渲染此组件及其子组件。这是用于更新用户界面以响应事件处理器和处理服务器数据的主要方式。 + +请将 `setState()` 视为请求而不是立即更新组件的命令。为了更好的感知性能,低代码引擎会延迟调用它,然后通过一次传递更新多个组件。低代码引擎并不会保证 state 的变更会立即生效。 + +`setState()`并不总是立即更新组件,它会批量推迟更新。这使得在调用用 `setState()` 后立即读取 `this.state` 成为了隐患。为了消除隐患,请使用 `setState` 的回调函数(`setState(updater, callback)`),`callback` 将在应用更新后触发。即,如下例所示: + +```js +this.setState(newState, () => { + // 在这里更新已经生效了 + // 可以通过 this.state 拿到更新后的状态 + console.log(this.state); +}); + +// ⚠注意:这里拿到的并不是更新后的状态,这里还是之前的状态 +console.log(this.state); +``` + +如需基于之前的 `state` 来设置当前的 `state`,则可以将传递一个 `updater` 函数:`(state, props) => newState`,例如: + +```js +this.setState((prevState) => ({ count: prevState.count + 1 })); +``` + +为了方便更新部分状态,`setState` 会将 `newState` 浅合并到新的 `state` 上。 + + +##### DataSourceMapItem 结构描述 + +| 参数 | 说明 | 类型 | 备注 | +| ------------ | -------------------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------ | +| load(params) | 调用单个数据源 | Function | 当前参数 params 会替换 [ComponentDataSourceItemOptions 对象描述](#2315-componentdatasourceitemoptions-对象描述)中的 params 内容 | +| status | 获取单个数据源上次请求状态 | String | loading、loaded、error、init | +| data | 获取上次请求成功后的数据 | Any | | +| error | 获取上次请求失败的错误对象 | Error 对象 | | + +备注:如果组件没有在区块容器内,而是直接在页面内,那么 `this === this.page` + + +##### 2.3.5.2 循环数据 API + +获取在循环场景下的数据对象。举例:上层组件设置了 loop 循环数据,且设置了 `loopArgs:["item", "index"]`,当前组件的属性表达式或绑定的事件函数中,可以通过 this 上下文获取所在循环的数据环境;默认值为 `['item','index']` ,如有多层循环,需要自定义不同 loopArgs,同样通过 `this[自定义循环别名]` 获取对应的循环数据和序号; + + +| 参数 | 说明 | 类型 | 可选值 | +| ---------- | --------------------------------- | ------ | ------ | +| this.item | 获取当前 index 对应的循环体数据; | Any | - | +| this.index | 当前物料在循环体中的 index | Number | - | + +### 2.4 工具类扩展描述(AA) + +用于描述物料开发过程中,自定义扩展或引入的第三方工具类(例如:lodash 及 moment),增强搭建基础协议的扩展性,提供通用的工具类方法的配置方案及调用 API。 + +| 参数 | 说明 | 类型 | 支持变量 | 默认值 | +| ------------------ | ------------------ | ---------------------------------------------------------------------------------------------------------------- | -------- | ------ | +| utils[] | 工具类扩展映射关系 | **UtilItem**[] | - | | +| *UtilItem*.name | 工具类扩展项名称 | String | - | | +| *UtilItem*.type | 工具类扩展项类型 | 枚举, `'npm'` (代表公网 npm 类型) / `'tnpm'` (代表阿里巴巴内部 npm 类型) / `'function'` (代表 Javascript 函数类型) | - | | +| *UtilItem*.content | 工具类扩展项内容 | [ComponentMap 类型](#22-组件映射关系 a) 或 [JSFunction](#2432事件函数类型 a) | - | | + +描述示例: + +```javascript +{ + utils: [{ + name: 'clone', + type: 'npm', + content: { + package: 'lodash', + version: '0.0.1', + exportName: 'clone', + subName: '', + destructuring: false, + main: '/lib/clone' + } + }, { + name: 'moment', + type: 'npm', + content: { + package: '@alifd/next', + version: '0.0.1', + exportName: 'Moment', + subName: '', + destructuring: true, + main: '' + } + }, { + name: 'recordEvent', + type: 'function', + content: { + type: 'JSFunction', + value: "function(logkey, gmkey, gokey, reqMethod) {\n goldlog.record('/xxx.event.' + logkey, gmkey, gokey, reqMethod);\n}" + } + }] +} +``` + +出码结果: + +```javascript +import clone from 'lodash/lib/clone'; +import { Moment } from '@alifd/next'; + +export const recordEvent = function(logkey, gmkey, gokey, reqMethod) { + goldlog.record('/xxx.event.' + logkey, gmkey, gokey, reqMethod); +} + +... +``` + +扩展的工具类,用户可以通过统一的上下文 this.utils 方法获取所有扩展的工具类或自定义函数,例如:this.utils.moment、this.utils.clone。搭建协议中的使用方式如下所示: + +```javascript +{ + componentName: 'Div', + props: { + onClick: { + type: 'JSFunction, + value: 'function(){ this.utils.clone(this.state.data); }' + } + } +} +``` + +### 2.5 国际化多语言支持(AA) + +协议中用于描述国际化语料和组件引用国际化语料的规范,遵循集团国际化中台关于国际化语料规范定义。 + + +| 参数 | 说明 | 类型 | 可选值 | 默认值 | +| ---- | -------------- | ------ | ------ | ------ | +| i18n | 国际化语料信息 | Object | - | null | + + +描述示例: + +```json +{ + "i18n": { + "zh-CN": { + "i18n-jwg27yo4": "你好", + "i18n-jwg27yo3": "中国" + }, + "en-US": { + "i18n-jwg27yo4": "Hello", + "i18n-jwg27yo3": "China" + } + } +} +``` + +使用举例: + +```json +{ + "componentName": "Button", + "props": { + "text": { + "type": "i18n", + "key": "i18n-jwg27yo4" + } + } +} +``` + +```json +{ + "componentName": "Button", + "props": { + "text": "按钮", + "onClick": { + "type": "JSFunction", + "value": "function() {\ + console.log(this.i18n('i18n-jwg27yo4'));\ + }" + } + } +} +``` + +使用举例(已废弃) +```json +{ + "componentName": "Button", + "props": { + "text": { + "type": "JSExpression", + "value": "this.i18n['i18n-jwg27yo4']" + } + } +} +``` + +### 2.6 应用范围内的全局常量(AA) + +用于描述在整个应用内通用的全局常量,比如请求 API 的域名、环境等。 + +### 2.7 应用范围内的全局样式(AA) + +用于描述在应用范围内的全局样式,比如 reset.css 等。 + +### 2.8 当前应用配置信息(AA) + +用于描述当前应用的配置信息,比如当前应用的 Shell/Layout、主题等。 + +> 注意:该字段为扩展字段,消费方式由各自场景自己决定,包括运行时和出码。 + +### 2.9 当前应用元数据信息(AA) + +用于描述当前应用的元数据信息,比如当前应用的名称、Git 信息、版本号等等。 + +> 注意:该字段为扩展字段,消费方式由各自场景自己决定,包括运行时和出码。 + +### 2.10 当前应用的公共数据源(AA) + +用于描述当前应用的公共数据源,数据结构跟容器结构里的 ComponentDataSource 保持一致。 +在运行时 / 出码使用时,API 和应用级数据源 API 保持一致,都是 `this.dataSourceMap['globalDSName'].load()` + +### 2.11 当前应用的路由信息(AA) + +用于描述当前应用的路径 - 页面的关系。通过声明路由信息,应用能够在不同的路径里显示对应的页面。 + +##### 2.11.1 Router (应用路由配置)结构描述 + +路由配置的结构说明: + +| 参数 | 说明 | 类型 | 可选值 | 默认值 | 备注 | +| ----------- | ---------------------- | ------------------------------- | ------ | --------- | ------ | +| baseName | 应用根路径 | String | - | '/' | 选填| | +| historyMode | history 模式 | 枚举类型,包括'browser'、'hash' | - | 'browser' | 选填| | +| routes | 路由对象组,路径与页面的关系对照组 | Route[] | - | - | 必填| | + + +##### 2.11.2 Route (路由记录)结构描述 + +路由记录,路径与页面的关系对照。Route 的结构说明: + +| 参数 | 说明 | 类型 | 可选值 | 默认值 | 备注 | +| -------- | ---------------------------- | ---------------------------- | ------ | ------ | ---------------------------------------------------------------------- | +| name | 该路径项的名称 | String | - | - | 选填 | +| path | 路径 | String | - | - | 必填,路径规则详见下面说明 | +| query | 路径的 query 参数 | Object | - | - | 选填 | +| page | 路径对应的页面 ID | String | - | - | 选填,page 与 redirect 字段中必须要有有一个存在 | +| redirect | 此路径需要重定向到的路由信息 | String \| Object \| Function | - | - | 选填,page 与 redirect 字段中必须要有有一个存在,详见下文 **redirect** | +| meta | 路由元数据 | Object | - | - | 选填 | +| children | 子路由 | Route[] | - | - | 选填 | + +以上结构仅说明了路由记录需要的必需字段,如果需要更多的信息字段可以自行实现。 + +关于 **path** 字段的详细说明: + +路由记录通常通过声明 path 字段来匹配对应的浏览器 URL 来确认是否满足匹配条件,如 `path=abc` 能匹配到 `/abc` 这个 URL。 + +> 在声明 path 字段的时候,可省略 `/`,只声明后面的字符,如 `/abc` 可声明为 `abc`。 + +path(页面路径)是浏览器URL的组成部分,同时大部分网站的 URL 也都受到了 Restful 思想的影响,所以我们也是用类似的形式作为路径的规则基底。 +路径规则是路由配置的重要组成部分,我们希望一个路径配置的基本能力需要支持具体的路径(/xxx)与路径参数 (/:abc)。 + +以一个 `/one/:two?/three/:four?/:five?` 路径为例,它能够解析以下路径: +- `/one/three` +- `/one/:two/three` +- `/one/three/:four` +- `/one/three/:five` +- `/one/:two/three/:four` +- `/one/:two/three/:five` +- `/one/three/:four/:five` +- `/one/:two/three/:four/:five` + +更多的路径规则,如路径中的通配符、多次匹配等能力如有需要可自行实现。 + +关于 **redirect** 字段的详细说明: + +**redirect** 字段有三种填入类型,分别是 `String`、`Object`、`Function`: +1. 字符串(`String`)格式下默认处理为重定向到路径,支持传入 '/xxx'、'/xxx?ab=c'。 +2. 对象(`String`)格式下可传入路由对象,如 { name: 'xxx' }、{ path: '/xxx' },可重定向到对应的路由对象。 +3. 函数`Function`格式为`(to) => Route`,它的入参为当前路由项信息,支持返回一个 Route 对象或者字符串,存在一些特殊情况,在重定向的时候需要对重定向之后的路径进行处理的情况下,需要使用函数声明。 + +```json +{ + "redirect": { + "type": "JSFunction", + "value": "(to) => { return { path: '/a', query: { fromPath: to.path } } }", + } +} +``` + +##### 完整描述示例 + +``` json +{ + "router": { + "baseName": "/", + "historyMode": "hash", + "routes": [ + { + "path": "home", + "page": "home" + }, + { + "path": "/*", + "redirect": "notFound" + } + ] + }, + "componentsTree": [ + { + "id": "home", + ... + }, + { + "id": "notFound", + ... + } + ] +} +``` + +### 2.12 当前应用的页面信息(AA) + +用于描述当前应用的页面信息,比如页面对应的低代码搭建内容、页面标题、页面配置等。 +在一些比较复杂的场景下,允许声明一层页面映射关系,以支持页面声明更多信息与配置,同时能够支持不同类型的产物。 + +| 参数 | 说明 | 类型 | 可选值 | 默认值 | 备注 | +| ------- | --------------------- | ------ | ------ | ------ | -------------------------------------------------------- | +| id | 页面 id | String | - | - | 必填 | +| type | 页面类型 | String | - | - | 选填,可用来区分页面的类型 | +| treeId | 对应的低代码树中的 id | String | - | - | 选填,页面对应的 componentsTree 中的子项 id | +| packageId | 对应的资产包对象 | String | - | - | 选填,页面对应的资产包对象,一般用于微应用场景下,当路由匹配到当前页面的时候,会加载 `packageId` 对应的微应用进行渲染。 | +| meta | 页面元信息 | Object | - | - | 选填,用于描述当前应用的配置信息 | +| config | 页面配置 | Object | - | - | 选填,用于描述当前应用的元数据信息 | + + +#### 2.12.1 微应用(低代码+)相关说明 + +在开发过程中,我们经常会遇到一些特殊的情况,比如一个低代码应用想要集成一些别的系统的页面或者系统中的一些页面只能是源码开发(与低代码相对的纯工程代码形式),为了满足更多的使用场景,应用级渲染引擎引入了微应用(微前端)的概念,使低代码页面与其他的页面结合成为可能。 + +微应用对象通过资产包加载,需要暴露两个生命周期方法: +- mount(container: HTMLElement, props: any) + - 说明:微应用挂载到 container(dom 节点)的调用方法,会在渲染微应用时调用 +- unmout(container: HTMLElement, props: any) + - 说明:微应用从容器节点(container)卸载的调用方法,会在卸载微应用时调用 + +> 在微应用的场景下,可能会存在多个页面路由到同一个应用,应用可通过资产包加载,所以需要将对应的页面配置指向对应的微应用(资产包)对象。 + +**描述示例** + +```json +{ + "router": { + "baseName": "/", + "historyMode": "hash", + "routes": [ + { + "path": "home", + "page": "home" + }, + { + "page": "guide", + "page": "guide" + }, + { + "path": "/*", + "redirect": "notFound" + } + ] + }, + "pages": [ + { + "id": "home", + "treeId": "home", + "meta": { + "title": "首页" + } + }, + { + "id": "notFound", + "treeId": "notFound", + "meta": { + "title": "404页面" + } + }, + { + "id": "guide", + "packagId": "microApp" + } + ] +} + +// 资产包 +[ + { + "id": "microApp", + "package": "microApp", + "version": "1.23.0", + "urls": [ + "https://g.alicdn.com/code/lib/microApp.min.css", + "https://g.alicdn.com/code/lib/microApp.min.js" + ], + "library": "microApp" + }, +] +``` + + +## 3 应用描述 + +### 3.1 文件目录 + +以下是推荐的应用目录结构,与标准源码 build-scripts 对齐,这里的目录结构是帮助理解应用级协议的设计,不做强约束 + +```html +├── META/ # 低代码元数据信息,用于多分支冲突解决、数据回滚等功能 +├── public/ # 静态文件,构建时会 copy 到 build/ 目录 +│ ├── index.html # 应用入口 HTML +│ └── favicon.png # Favicon +├── src/ +│ ├── components/ # 应用内的低代码业务组件 +│ │ └── guide-component/ +│ │ ├── index.js # 组件入口 +│ │ ├── components.js # 组件依赖的其他组件 +│ │ ├── schema.js # schema 描述 +│ │ └── index.scss # css 样式 +│ ├── pages/ # 页面 +│ │ └── home/ # Home 页面 +│ │ ├── index.js # 页面入口 +│ │ └── index.scss # css 样式 +│ ├── layouts/ +│ │ └── basic-layout/ # layout 组件名称 +│ │ ├── index.js # layout 入口 +│ │ ├── components.js # layout 组件依赖的其他组件 +│ │ ├── schema.js # layout schema 描述 +│ │ └── index.scss # layout css 样式 +│ ├── config/ # 配置信息 +│ │ ├── components.js # 应用上下文所有组件 +│ │ ├── routes.js # 页面路由列表 +│ │ └── app.js # 应用配置文件 +│ ├── utils/ # 工具库 +│ │ └── index.js # 应用第三方扩展函数 +│ ├── locales/ # [可选] 国际化资源 +│ │ ├── en-US +│ │ └── zh-CN +│ ├── global.scss # 全局样式 +│ └── index.jsx # 应用入口脚本,依赖 config/routes.js 的路由配置动态生成路由; +├── webpack.config.js # 项目工程配置,包含插件配置及自定义 webpack 配置等 +├── README.md +├── package.json +├── .editorconfig +├── .eslintignore +├── .eslintrc.js +├── .gitignore +├── .stylelintignore +└── .stylelintrc.js +``` + +### 3.2 应用级别 APIs +> 下文中 `xxx` 代指任意 API +#### 3.2.1 路由 Router API + - this.location.`xxx` 「不推荐,推荐统一通过 this.router api」 + - this.history.`xxx` 「不推荐,推荐统一通过 this.router api」 + - this.match.`xxx` 「不推荐,推荐统一通过 this.router api」 + - this.router.`xxx` + +##### Router 结构说明 + +| API | 函数签名 | 说明 | +| -------------- | ---------------------------------------------------------- | -------------------------------------------------------------- | +| getCurrentRoute | () => RouteLocation | 获取当前解析后的路由信息,RouteLocation 结构详见下面说明 | +| push | (target: string \| Route) => void | 路由跳转方法,跳转到指定的路径或者 Route | +| replace | (target: string \| Route) => void | 路由跳转方法,与 `push` 的区别在于不会增加一条历史记录而是替换当前的历史记录 | +| beforeRouteLeave | (guard: (to: RouteLocation, from: RouteLocation) => boolean \| Route) => void | 路由跳转前的守卫方法,详见下面说明 | +| afterRouteChange | (fn: (to: RouteLocation, from: RouteLocation) => void) => void | 路由跳转后的钩子函数,会在每次路由改变后执行 | + +##### 3.2.1.1 RouteLocation(路由信息)结构说明 + +**RouteLocation** 是路由控制器匹配到对应的路由记录后进行解析产生的对象,它的结构如下: + +| 参数 | 说明 | 类型 | 可选值 | 默认值 | 备注 | +| -------------- | ---------------------- | ------ | ------ | ------ | ------ | +| path | 当前解析后的路径 | String | - | - | 必填 | +| hash | 当前路径的 hash 值,以 # 开头 | String | - | - | 必填 | +| href | 当前的全部路径 | String | - | - | 必填 | +| params | 匹配到的路径参数 | Object | - | - | 必填 | +| query | 当前的路径 query 对象 | Object | - | - | 必填,代表当前地址的 search 属性的对象 | +| name | 匹配到的路由记录名 | String | - | - | 选填 | +| meta | 匹配到的路由记录元数据 | Object | - | - | 选填 | +| redirectedFrom | 原本指向向的路由记录 | Route | - | - | 选填,在重定向到当前地址之前,原先想访问的地址 | +| fullPath | 包括 search 和 hash 在内的完整地址 | String | - | - | 选填 | + + +##### beforeRouteLeave +通过 beforeRouteLeave 注册的路由守卫方法会在每次路由跳转前执行。该方法一般会在应用鉴权,路由重定向等场景下使用。 + +> `beforeRouteLeave` 只在 `router.push/replace` 的方法调用时生效。 + +传入守卫的入参为: +* to: 即将要进入的目标路由(RouteLocation) +* from: 当前导航正要离开的路由(RouteLocation) + +该守卫返回一个 `boolean` 或者路由对象来告知路由控制器接下来的行为。 +* 如果返回 `false`, 则停止跳转 +* 如果返回 `true`,则继续跳转 +* 如果返回路由对象,则重定向至对应的路由 + +**使用范例:** + +```json +{ + "componentsTree": [{ + "componentName": "Page", + "fileName": "Page1", + "props": {}, + "children": [{ + "componentName": "Div", + "props": {}, + "children": [{ + "componentName": "Button", + "props": { + "text": "跳转到首页", + "onClick": { + "type": "JSFunction", + "value": "function () { this.router.push('/home'); }" + } + }, + }] + }], + }] +} +``` + + +#### 3.2.2 应用级别的公共函数或第三方扩展 + - this.utils.`xxx` + +#### 3.2.3 国际化相关 API +| API | 函数签名 | 说明 | +| -------------- | ---------------------------------------------------------------------- | ------------------------------------------------------------------ | +| this.i18n | (i18nKey: string, params?: { [paramName: string]: string; }) => string | i18nKey 是语料的标识符,params 可选,是用来做模版字符串替换的。返回语料字符串 | +| this.getLocale | () => string | 返回当前环境语言 code | +| this.setLocale | (locale: string) => void | 设置当前环境语言 code | + +**使用范例:** +```json +{ + "componentsTree": [{ + "componentName": "Page", + "fileName": "Page1", + "props": {}, + "children": [{ + "componentName": "Div", + "props": {}, + "children": [{ + "componentName": "Button", + "props": { + "children": { + "type": "JSExpression", + "value": "this.i18n('i18n-hello')" + }, + "onClick": { + "type": "JSFunction", + "value": "function () { this.setLocale('en-US'); }" + } + }, + }, { + "componentName": "Button", + "props": { + "children": { + "type": "JSExpression", + "value": "this.i18n('i18n-chicken', { count: this.state.count })" + }, + }, + }] + }], + }], + "i18n": { + "zh-CN": { + "i18n-hello": "你好", + "i18n-chicken": "我有{count}只鸡" + }, + "en-US": { + "i18n-hello": "Hello", + "i18n-chicken": "I have {count} chicken" + } + } +} +``` diff --git a/docs/docs/specs/material-spec.md b/docs/docs/specs/material-spec.md new file mode 100644 index 0000000000..c766c68347 --- /dev/null +++ b/docs/docs/specs/material-spec.md @@ -0,0 +1,1671 @@ +--- +title: 《低代码引擎物料协议规范》 +sidebar_position: 1 +--- + +## 1 介绍 + +### 1.1 本协议规范涉及的问题域 + +- 定义本协议版本号规范 +- 定义本协议中每个子规范需要被支持的 Level +- 定义中后台物料目录规范(A) +- 定义中后台物料 API 规范(A) +- 定义中后台物料入库规范(A) +- 定义中后台物料国际化多语言支持规范(AA) +- 定义中后台物料主题配置规范(AAA) +- 定义中后台物料无障碍访问规范(AAA) + + +### 1.2 协议草案起草人 + +- 撰写:九神、大果、元彦、戊子、林熠、屹凡、金禅 +- 审阅:潕量、月飞、康为、力皓、荣彬、暁仙、度城、金禅、戊子、林熠、絮黎 + +### 1.3 版本号 + +1.0.0 + +### 1.4 协议版本号规范(A) + +本协议采用语义版本号,版本号格式为 `major.minor.patch` 的形式。 + +- major 是大版本号:用于发布不向下兼容的协议格式修改 +- minor 是小版本号:用于发布向下兼容的协议功能新增 +- patch 是补丁号:用于发布向下兼容的协议问题修正 + + +### 1.5 协议中子规范 Level 定义 + +| 规范等级 | 实现要求 | +| -------- | ---------------------------------------------------------------------------------- | +| A | 强制规范,必须实现;违反此类规范的协议描述数据将无法写入物料中心,不支持流通。 | +| AA | 推荐规范,推荐实现;遵守此类规范有助于业务未来的扩展性和跨团队合作研发效率的提升。 | +| AAA | 参考规范,根据业务场景实际诉求实现;是集团层面鼓励的技术实现引导。 | + + +### 1.6 名词术语 +- **物料**:能够被沉淀下来直接使用的前端能力,一般表现为业务组件、区块、模板。 +- **业务组件(Business Component)**:业务领域内基于基础组件之上定义的组件,可能会包含特定业务域的交互或者是业务数据,对外仅暴露可配置的属性,且必须发布到公域(如阿里 NPM);在同一个业务域内可以流通,但不需要确保可以跨业务域复用。 + - **低代码业务组件(Low-Code Business Component)**:通过低代码编辑器搭建而来,有别于源码开发的业务组件,属于业务组件中的一种类型,遵循业务组件的定义;同时低代码业务组件还可以通过低代码编辑器继续多次编辑。 +- **区块(Block)**:通过低代码搭建的方式,将一系列业务组件、布局组件进行嵌套组合而成,不对外提供可配置的属性。可通过区块容器组件的包裹,实现区块内部具备有完整的样式、事件、生命周期管理、状态管理、数据流转机制。能独立存在和运行,可通过复制 schema 实现跨页面、跨应用的快速复用,保障功能和数据的正常。 +- **模板(Template)**:特定垂直业务领域内的业务组件、区块可组合为单个页面,或者是再配合路由组合为多个页面集,统称为模板。 + +### 1.7 物料规范背景 +目前集团业务融合频繁,而物料规范的不统一给业务融合带来额外的高成本,另一方面集团各个 BU 的前端物料也存在不同程度的重复建设。我们期望通过集团层面的物料通不阻碍业务融合的发展,同时通过集团层面的物料流通来提升物料丰富度,通过丰富物料的复用来提效中后台系统研发,同时也能给新业务场景提供高质量的启动物料。 + +### 1.8 物料规范定义 + +- **源码物料规范**:一套面向开发者的目录规范,用于规范化约束开发过程中的代码、文档、接口规范,以方便物料在集团内的流通。 +- **搭建物料规范**:一套面向开发者的 Schema 规范,用于规范化约束开发过程中的代码、文档、接口规范,以方便物料在集团内的流通。 + +## 2. 物料规范 - 业务组件规范 + +### 2.1 源码规范 + +#### 2.1.1 目录规范(A) + + +``` +component // 组件名称, 比如 biz-button + ├── build // 【编译生成】【必选】 + │ └── index.html // 【编译生成】【必选】可直接预览文件 + ├── lib // 【编译生成】【必选】 + │ ├── index.js // 【编译生成】【必选】js 入口文件 + │ ├── index.scss // 【编译生成】【必选】css 入口文件 + │ └── style.js // 【编译生成】【必选】js 版本 css 入口文件,方便去重 + ├── demo // 【必选】组件文档目录,可以有多个 md 文件 + │ └── basic.md // 【必选】组件文档示例,用于生成组件开发预览,以及生成组件文档 + ├── src // 【必选】组件源码 + │ ├── index.js // 【必选】组件出口文件 + │ └── index.scss // 【必选】仅包含组件自身样式的源码文件 + ├── README.md // 【必选】组件说明及 API + └── package.json // 【必选】组件 package.json +``` + + +##### README.md + +- README.md 应该包含业务组件的源信息、使用说明以及 API,示例如下: + +``` +# 按钮 // 这一行是标题 + +按钮用于开始一个即时操作。 // 这一行是描述 + +{这段通过工程能力自动注入, 开发者无需编写 +## 安装方法 +npm install @alifd/ice-layout -S +} + +## API + +| 参数 | 说明 | 类型 | 可选值 | 默认值 | +| ---- | ---- | ------ | ------------------- | ------ | +| type | 类型 | String | `primary`、`normal` | `normal` | +``` + + + +- README.en-US.md(文件命名采取 [bcp47 规范](http://www.rfc-editor.org/rfc/bcp/bcp47.txt))多语言的情况,可选 + +``` +# Button + +Button use to trigger an action. + +{这段通过工程能力自动注入, 开发者无需编写 +## Install +npm install @alifd/ice-layout -S +} + +## API + +| Param | Description | Type | Enum | Default | +| ----- | ----------- | ------ | ------------------- | ------- | +| type | type | String | `primray`、`normal` | normal | +``` + +##### package.json +`package.json` 中包含了一些依赖信息和配置信息,示例如下: + +```json +{ + "name": "@alife/1688-button", + "description": "业务组件描述", + "version": "0.0.1", + "main": "lib/index.js", + "stylePath": "lib/style.js", // 【私有字段】样式文件地址,webpack 插件引用 + "files": [ + "demo/", + "lib/", + "build/" // 存放编译后的 demo,发布前应该编译生成该目录 + ], + "dependencies": { + "@alifd/next": "1.x" // 【可选】可以是一个 util 类型的组件,如果依赖 next,请务必写语义化版本号,不要写*这种 + }, + "devDependencies": { + "react": "^16.5.0", + "react-dom": "^16.5.0" + }, + "peerDependencies": { + "react": "^16.5.0" + }, + "componentConfig": { // 【私有字段】组件配置信息 + "name": "button", // 组件英文名 + "title": "按钮", // 组件中文名 + "category": "form" // 组件分类 + } +} +``` + +##### src/index.js + +包含组件的出口文件,示例如下: + +```javascript +import Button from './Button.jsx'; +import ButtonGroup from './ButtonGroup.jsx'; + +export const Group = ButtonGroup; // 子组件推荐写法 + +export default Button; +``` + +推荐用法 + +```javascript +import Button, { Group } form '@scope/button'; +``` + +##### src/index.scss + +```css +/* 不引入依赖组件的样式,比如组件 import { Button } from '@alifd/next'; */ +/* 不需要在 index.scss 中引入 @import '~@alifd/next/lib/button/index.scss'; */ + +/* 如果需要引入主题变量引入此段 */ +@import '~@alifd/next/variables.scss'; + +/* 组件自身样式 */ +.custom-component { + color: $color-brand1-1; +} +``` + +##### demo +demo 目录存放的是组件的文档,无文档的业务组件无法带来任何价值,因此 demo 是必选项。demo 目录下的文件采取 markdown 的写法,可以是多个文件,示例(demo/basic.md)如下: + +demo/basic.md + +~~~ +--- +title: {按钮类型} +order: {文档的排序,数字,0 最小,从小到大排序} +--- + +按钮有三种视觉层次:主按钮、次按钮、普通按钮。不同的类型可以用来区别按钮的重要程度。 + +:::lang=en-US +--- +title: Container +order: 3 +--- + +Change the default container by passing a function to `container`; +enable `useAbsolute` to use `absolute position` to implement affix component; + +::: + +```jsx // 以下建议用英文编写 +import Button from '@alife/1688-button'; + +ReactDOM.render(
+ +
, mountNode); +``` + +```css +.test { + background: #CCC; +} +``` +~~~ + +#### 2.1.2 API 规范(A) + +API 是组件的属性解释,给开发者作为组件属性配置的参考。为了保持 API 的一致性,我们制定这个 API 命名规范。对于业界通用的,约定俗成的命名,我们遵循社区的约定。对于业界有多种规则难以确定的,我们确定其中一种,大家共同遵守。 + +##### 通用规则 + +- 所有的 API 采用小驼峰的书写规则,如 `onChange`、`direction`、`defaultVisible`。 +- 标签名采用大驼峰书写规则,如 `Menu`、`Slider`、`DatePicker`。 + +##### 通用命名 + +| API 名称 | 类型 | 描述 | 常见变量 | +| :------------- | :------------- | :----------------------------------------------------------- | :---------------------------------------------------- | +| shape | string | 形状,从组件的外形来看有区别的时候,使用 shape | | +| direction | enum | 方向,取值采用缩写的方式。 | hoz(水平), ver(垂直) | +| align | enum | 对齐方式 | tl, tc, tr, cl, cc, cr, bl, bc, br | +| status | enum | 状态 | normal, success, error, warning | +| size | enum | 大小 | small, medium, large 更大或更小可用 (xxs, xs, xl, xxl) | +| type | enum or string | 分类:1. dom 结构不变、只有皮肤的变化 2.组件类型只有并列的几类 | normal, primary, secondary | +| visible | boolean | 是否显示 | | +| defaultVisible | boolean | 是否显示(非受控) | | +| disabled | boolean | 禁用组件 | | +| closable | bool/string | 允许关闭的方式 | | +| htmlType | string | 当原生组件与 Fusion 组件的 type 产生冲突时,原生组件使用 `htmlType` | | +| link | string | 链接 | | +| dataSource | array | 列表数据源 | [{label, value}, {label, value}] | +| has+'属性' | boolean | 拥有某个属性 | 例如 `hasArrow`, `hasHeader`, `hasClose` 等等 | + + +##### 多选枚举 + +当某个 API 的接口,允许用户指定多个枚举值的时候,我们把这个接口定义为多选枚举。一个很典型的例子是某个弹层组件的 `closable` 属性,我们会允许:键盘 esc 按键、点击 mask、点击 close 按钮、点击组件以外的任何区域进行关闭。 + +不要有一个 API 值,支持多种类型。例如某个弹层的组件,我们会允许 esc、点击 mask、点击 close 按钮等进行关闭。此时 API 设计可以通过多个 API 承载,例如: + +```js +closable?: boolean; // 默认为 true +closeMode?: CM[] | string; // 默认值是 ['close', 'mask', 'esc'] +``` + +true 表示触发规则都会关闭,false 表示触发规则不会关闭。 + +示例: + +- ``,所有合法条件都会关闭 +- ``,任何情况下都不关闭,只能通过受控设置 visible +- ``,用户按 esc 或者点击关闭按钮会关闭 + +##### 事件 + +- 标准事件或者自定义的符合 w3c 标准的事件,命名必须 on 开头, 即 `on` + 事件名,如 onExpand。 + +##### 表单规范 + +- 支持[受控模式](https://reactjs.org/docs/forms.html#controlled-components)(value + onChange) (A) + - value 控制组件数据展现 + - onChange 组件发生变化时候的回调函数(第一个参数可以给到 value) +- `value={undefined}`的时候清空数据,field 的 reset 函数会给所有组件下发 undefined 数据 (AA)) +- 一次完整操作抛一次 onChange 事件 `建议` 比如有 Process 表示进展中的状态,建议增加 API `onProcess`;如果有 Start 表示启动状态,建议增加 API `onStart`  (AA) + +##### 属性的传递 +**1. 原子组件(Atomic Component)** +> 最小粒子,不能再拆分的组件 + +举例:Input/Button/NumberPicker + +期望使用起来像普通的 html 标签一样,能够把用户传入的参数,透传到真正的节点上。 + +```jsx + +``` + +渲染后的 dom 结构: + +```jsx + + + +``` + +**2. 复合组件(Composite component)** + +复合组件一般由两个及以上的原子组件/复合组件构成,比如:Select 由 Inupt + 弹窗组成,Search 由 Select + Button 组成,TreeSelect 由 Tree + Select 组成。 + +为了提高组件使用的便利性,对 API 属性的要求如下: +1. 复合组件核心的原子组件(比如 Search 的核心原子组件是 Input)的属性以及使用频率高的属性建议扁平化,让复合组件可以直接使用其属性; +2. 复合组件内的非核心原子组件,则通过 `xxxProps` (如 inputProps/btnProps)的方式,将参数传递到相应原子组件上。 + + +**属性扁平化例子**: + +比如 `Search` 组件由 `Input` 和 `Button` 构成,但是 `Search` 更像是 `Input` ,因此把 `Input` 作为主要组件,将属性扁平化。即在 `Search` 组件上直接使用一些 `Input` 的属性。 `` + +比如 `Select` `TreeSelect` 都有弹层部分,`Overlay` `Overlay.Popup` 的 `visible` 属性使用率较高,一般用于 fixed 布局下的弹窗滚动跟随。因此把该属性暴露到最外层,简化使用 ` ) : ( - + {/* @ts-ignore */} + <Title + title={this.state.title} + match={filterWorking && matchSelf} + keywords={keywords} + /> + {Extra && <Extra node={treeNode?.node} />} {node.slotFor && ( <a className="tree-node-tag slot"> {/* todo: click redirect to prop */} + {/* @ts-ignore */} <Tip>{intlNode('Slot for {prop}', { prop: node.slotFor.key })}</Tip> </a> )} @@ -154,82 +211,140 @@ export default class TreeTitle extends Component<{ <a className="tree-node-tag loop"> {/* todo: click todo something */} <IconLoop /> + {/* @ts-ignore */} <Tip>{intlNode('Loop')}</Tip> </a> )} - {node.hasCondition() && !node.conditionGroup && ( + {this.state.condition && ( <a className="tree-node-tag cond"> {/* todo: click todo something */} <IconCond /> + {/* @ts-ignore */} <Tip>{intlNode('Conditional')}</Tip> </a> )} </Fragment> )} </div> - {isCNode && isNodeParent && !isModal && <HideBtn treeNode={treeNode} />} - {engineConfig.get('enableCanvasLock', false) && isContainer && isCNode && isNodeParent && <LockBtn treeNode={treeNode} />} + {shouldShowHideBtn && <HideBtn hidden={this.props.hidden} treeNode={treeNode} />} + {shouldShowLockBtn && <LockBtn locked={this.props.locked} treeNode={treeNode} />} + {shouldEditBtn && <RenameBtn treeNode={treeNode} onClick={this.enableEdit} />} + {shouldDeleteBtn && <DeleteBtn treeNode={treeNode} onClick={this.deleteClick} />} </div> ); } } -@observer -class LockBtn extends Component<{ treeNode: TreeNode }> { +class DeleteBtn extends PureComponent<{ + treeNode: TreeNode; + onClick: () => void; +}> { render() { - const { treeNode } = this.props; + const { intl, common } = this.props.treeNode.pluginContext; + const { Tip } = common.editorCabin; + return ( + <div + className="tree-node-delete-btn" + onClick={this.props.onClick} + > + <IconDelete /> + {/* @ts-ignore */} + <Tip>{intl('Delete')}</Tip> + </div> + ); + } +} + +class RenameBtn extends PureComponent<{ + treeNode: TreeNode; + onClick: (e: any) => void; +}> { + render() { + const { intl, common } = this.props.treeNode.pluginContext; + const { Tip } = common.editorCabin; + return ( + <div + className="tree-node-rename-btn" + onClick={this.props.onClick} + > + <IconSetting /> + {/* @ts-ignore */} + <Tip>{intl('Rename')}</Tip> + </div> + ); + } +} + +class LockBtn extends PureComponent<{ + treeNode: TreeNode; + locked: boolean; +}> { + render() { + const { treeNode, locked } = this.props; + const { intl, common } = this.props.treeNode.pluginContext; + const { Tip } = common.editorCabin; return ( <div className="tree-node-lock-btn" onClick={(e) => { e.stopPropagation(); - treeNode.setLocked(!treeNode.locked); + treeNode.setLocked(!locked); }} > - {treeNode.locked ? <IconUnlock /> : <IconLock /> } - <Tip>{treeNode.locked ? intl('Unlock') : intl('Lock')}</Tip> + {locked ? <IconUnlock /> : <IconLock /> } + {/* @ts-ignore */} + <Tip>{locked ? intl('Unlock') : intl('Lock')}</Tip> </div> ); } } -@observer -class HideBtn extends Component<{ treeNode: TreeNode }> { +class HideBtn extends PureComponent<{ + treeNode: TreeNode; + hidden: boolean; +}, { + hidden: boolean; +}> { render() { - const { treeNode } = this.props; + const { treeNode, hidden } = this.props; + const { intl, common } = treeNode.pluginContext; + const { Tip } = common.editorCabin; return ( <div className="tree-node-hide-btn" onClick={(e) => { e.stopPropagation(); - emitOutlineEvent(treeNode.hidden ? 'show' : 'hide', treeNode); - treeNode.setHidden(!treeNode.hidden); + emitOutlineEvent(treeNode.pluginContext.event, hidden ? 'show' : 'hide', treeNode); + treeNode.setHidden(!hidden); }} > - {treeNode.hidden ? <IconEye /> : <IconEyeClose />} - <Tip>{treeNode.hidden ? intl('Show') : intl('Hide')}</Tip> + {hidden ? <IconEye /> : <IconEyeClose />} + {/* @ts-ignore */} + <Tip>{hidden ? intl('Show') : intl('Hide')}</Tip> </div> ); } } - -@observer -class ExpandBtn extends Component<{ treeNode: TreeNode }> { +class ExpandBtn extends PureComponent<{ + treeNode: TreeNode; + expanded: boolean; + expandable: boolean; +}> { render() { - const { treeNode } = this.props; - if (!treeNode.expandable) { + const { treeNode, expanded, expandable } = this.props; + if (!expandable) { return <i className="tree-node-expand-placeholder" />; } return ( <div className="tree-node-expand-btn" onClick={(e) => { - if (treeNode.expanded) { + if (expanded) { e.stopPropagation(); } - emitOutlineEvent(treeNode.expanded ? 'collapse' : 'expand', treeNode); - treeNode.setExpanded(!treeNode.expanded); + emitOutlineEvent(treeNode.pluginContext.event, expanded ? 'collapse' : 'expand', treeNode); + treeNode.setExpanded(!expanded); }} > <IconArrowRight size="small" /> @@ -237,52 +352,3 @@ class ExpandBtn extends Component<{ treeNode: TreeNode }> { ); } } - -/* -interface Point { - clientX: number; - clientY: number; -} - -function setCaret(point: Point) { - debugger; - const range = getRangeFromPoint(point); - if (range) { - selectRange(range); - setTimeout(() => selectRange(range), 1); - } -} - -function getRangeFromPoint(point: Point): Range | undefined { - const x = point.clientX; - const y = point.clientY; - let range; - let pos: CaretPosition | null = null; - if (document.caretRangeFromPoint) { - range = document.caretRangeFromPoint(x, y); - } else if ((pos = document.caretPositionFromPoint(x, y))) { - range = document.createRange(); - range.setStart(pos.offsetNode, pos.offset); - range.collapse(true); - - } - return range; -} - -function selectRange(range: Range) { - const selection = document.getSelection(); - if (selection) { - selection.removeAllRanges(); - selection.addRange(range); - } -} - -function setCaretAfter(elem) { - const range = document.createRange(); - const node = elem.lastChild; - if (!node) return; - range.setStartAfter(node); - range.setEndAfter(node); - selectRange(range); -} -*/ diff --git a/packages/plugin-outline-pane/src/views/tree.tsx b/packages/plugin-outline-pane/src/views/tree.tsx index db99987d40..8428ec944c 100644 --- a/packages/plugin-outline-pane/src/views/tree.tsx +++ b/packages/plugin-outline-pane/src/views/tree.tsx @@ -1,9 +1,9 @@ -import { Component, MouseEvent as ReactMouseEvent } from 'react'; -import { observer, Editor, globalContext } from '@alilc/lowcode-editor-core'; -import { isRootNode, Node, DragObjectType, isShaken } from '@alilc/lowcode-designer'; -import { isFormEvent, canClickNode } from '@alilc/lowcode-utils'; -import { Tree } from '../tree'; -import RootTreeNodeView from './root-tree-node'; +import { MouseEvent as ReactMouseEvent, PureComponent } from 'react'; +import { isFormEvent, canClickNode, isShaken } from '@alilc/lowcode-utils'; +import { Tree } from '../controllers/tree'; +import TreeNodeView from './tree-node'; +import { IPublicEnumDragObjectType, IPublicModelNode } from '@alilc/lowcode-types'; +import TreeNode from '../controllers/tree-node'; function getTreeNodeIdByEvent(e: ReactMouseEvent, stop: Element): null | string { let target: Element | null = e.target as Element; @@ -18,20 +18,29 @@ function getTreeNodeIdByEvent(e: ReactMouseEvent, stop: Element): null | string return (target as HTMLDivElement).dataset.id || null; } -@observer -export default class TreeView extends Component<{ tree: Tree }> { +export default class TreeView extends PureComponent<{ + tree: Tree; +}> { private shell: HTMLDivElement | null = null; - private hover(e: ReactMouseEvent) { - const { tree } = this.props; + private ignoreUpSelected = false; + + private boostEvent?: MouseEvent; + + state: { + root: TreeNode | null; + } = { + root: null, + }; - const doc = tree.document; - const { detecting } = doc.designer; - if (!detecting.enable) { + private hover(e: ReactMouseEvent) { + const { project } = this.props.tree.pluginContext; + const detecting = project.currentDocument?.detecting; + if (detecting?.enable) { return; } const node = this.getTreeNodeFromEvent(e)?.node; - detecting.capture(node || null); + node?.id && detecting?.capture(node.id); } private onClick = (e: ReactMouseEvent) => { @@ -54,31 +63,44 @@ export default class TreeView extends Component<{ tree: Tree }> { return; } - const { designer } = treeNode; - const doc = node.document; - const { selection, focusNode } = doc; + const { project, event, canvas } = this.props.tree.pluginContext; + const doc = project.currentDocument; + const selection = doc?.selection; + const focusNode = doc?.focusNode; const { id } = node; const isMulti = e.metaKey || e.ctrlKey || e.shiftKey; - designer.activeTracker.track(node); - if (isMulti && !node.contains(focusNode) && selection.has(id)) { + canvas.activeTracker?.track(node); + if (isMulti && focusNode && !node.contains(focusNode) && selection?.has(id)) { if (!isFormEvent(e.nativeEvent)) { selection.remove(id); } } else { - selection.select(id); - const editor = globalContext.get(Editor); - const selectedNode = designer.currentSelection?.getNodes()?.[0]; + selection?.select(id); + const selectedNode = selection?.getNodes()?.[0]; const npm = selectedNode?.componentMeta?.npm; const selected = [npm?.package, npm?.componentName].filter((item) => !!item).join('-') || selectedNode?.componentMeta?.componentName || ''; - editor?.emit('outlinePane.select', { + event.emit('outlinePane.select', { selected, }); } }; + private onDoubleClick = (e: ReactMouseEvent) => { + e.preventDefault(); + const treeNode = this.getTreeNodeFromEvent(e); + if (treeNode?.nodeId === this.state.root?.nodeId) { + return; + } + if (!treeNode?.expanded) { + this.props.tree.expandAllDecendants(treeNode); + } else { + this.props.tree.collapseAllDecendants(treeNode); + } + }; + private onMouseOver = (e: ReactMouseEvent) => { this.hover(e); }; @@ -96,10 +118,6 @@ export default class TreeView extends Component<{ tree: Tree }> { return tree.getTreeNodeById(id); } - private ignoreUpSelected = false; - - private boostEvent?: MouseEvent; - private onMouseDown = (e: ReactMouseEvent) => { if (isFormEvent(e.nativeEvent)) { return; @@ -114,36 +132,37 @@ export default class TreeView extends Component<{ tree: Tree }> { if (!canClickNode(node, e)) { return; } - - const { designer } = treeNode; - const doc = node.document; - const { selection, focusNode } = doc; + const { project, canvas } = this.props.tree.pluginContext; + const selection = project.currentDocument?.selection; + const focusNode = project.currentDocument?.focusNode; // TODO: shift selection const isMulti = e.metaKey || e.ctrlKey || e.shiftKey; const isLeftButton = e.button === 0; - if (isLeftButton && !node.contains(focusNode)) { - let nodes: Node[] = [node]; + if (isLeftButton && focusNode && !node.contains(focusNode)) { + let nodes: IPublicModelNode[] = [node]; this.ignoreUpSelected = false; if (isMulti) { // multi select mode, directily add - if (!selection.has(node.id)) { - designer.activeTracker.track(node); - selection.add(node.id); + if (!selection?.has(node.id)) { + canvas.activeTracker?.track(node); + selection?.add(node.id); this.ignoreUpSelected = true; } // todo: remove rootNodes id - selection.remove(focusNode.id); + selection?.remove(focusNode.id); // 获得顶层 nodes - nodes = selection.getTopNodes(); - } else if (selection.has(node.id)) { + if (selection) { + nodes = selection.getTopNodes(); + } + } else if (selection?.has(node.id)) { nodes = selection.getTopNodes(); } this.boostEvent = e.nativeEvent; - designer.dragon.boost( + canvas.dragon?.boost( { - type: DragObjectType.Node, + type: IPublicEnumDragObjectType.Node, nodes, }, this.boostEvent, @@ -152,15 +171,32 @@ export default class TreeView extends Component<{ tree: Tree }> { }; private onMouseLeave = () => { - const { tree } = this.props; - const doc = tree.document; - doc.designer.detecting.leave(doc); + const { pluginContext } = this.props.tree; + const { project } = pluginContext; + const doc = project.currentDocument; + doc?.detecting.leave(); }; - render() { + componentDidMount() { const { tree } = this.props; const { root } = tree; - if (!root) { + const { project } = tree.pluginContext; + this.setState({ root }); + const doc = project.currentDocument; + doc?.onFocusNodeChanged(() => { + this.setState({ + root: tree.root, + }); + }); + doc?.onImportSchema(() => { + this.setState({ + root: tree.root, + }); + }); + } + + render() { + if (!this.state.root) { return null; } return ( @@ -170,9 +206,14 @@ export default class TreeView extends Component<{ tree: Tree }> { onMouseDownCapture={this.onMouseDown} onMouseOver={this.onMouseOver} onClick={this.onClick} + onDoubleClick={this.onDoubleClick} onMouseLeave={this.onMouseLeave} > - <RootTreeNodeView key={root.id} treeNode={root} /> + <TreeNodeView + key={this.state.root?.id} + treeNode={this.state.root} + isRootNode + /> </div> ); } diff --git a/packages/rax-renderer/README.md b/packages/rax-renderer/README.md deleted file mode 100644 index 7b430de630..0000000000 --- a/packages/rax-renderer/README.md +++ /dev/null @@ -1,49 +0,0 @@ -# Rax Renderer - -Rax 渲染模块。 - -## 安装 - -``` -$ npm install @alilc/lowcode-rax-renderer --save -``` - -## 使用 - -```js -import { createElement, render } from 'rax'; -import DriverUniversal from 'driver-universal'; -import RaxRenderer from '@ali/lowcode-rax-renderer'; - -const components = { - View, - Text -}; - -const schema = { - componentName: 'Page', - fileName: 'home', - children: [ - { - componentName: 'View', - children: [ - { - componentName: 'Text', - props: { - type: 'primary' - }, - children: ['Welcome to Your Rax App'] - } - ] - } - ] -}; - -render( - <RaxRenderer - schema={schema} - components={components} - />, - document.getElementById('root'), { driver: DriverUniversal } -); -``` diff --git a/packages/rax-renderer/build.json b/packages/rax-renderer/build.json deleted file mode 100644 index 3edf143801..0000000000 --- a/packages/rax-renderer/build.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "plugins": [ - [ - "build-plugin-rax-component", - { - "type": "rax", - "targets": ["web"] - } - ] - ] -} diff --git a/packages/rax-renderer/demo/index.jsx b/packages/rax-renderer/demo/index.jsx deleted file mode 100644 index cfcae2a201..0000000000 --- a/packages/rax-renderer/demo/index.jsx +++ /dev/null @@ -1,35 +0,0 @@ -import { createElement, render } from 'rax'; -import DriverUniversal from 'driver-universal'; -import View from 'rax-view'; -import Text from 'rax-text'; -import { Engine } from '../src/index'; - -const components = { - View, - Text, -}; - -const schema = { - componentName: 'Page', - fileName: 'home', - props: {}, - children: [ - { - componentName: 'View', - props: {}, - children: [ - { - componentName: 'Text', - props: { - type: 'primary', - }, - children: ['Welcome to Your Rax App!'], - }, - ], - }, - ], -}; - -render(<Engine schema={schema} components={components} />, document.getElementById('root'), { - driver: DriverUniversal, -}); diff --git a/packages/rax-renderer/demo/miniapp/app.js b/packages/rax-renderer/demo/miniapp/app.js deleted file mode 100644 index 3482935519..0000000000 --- a/packages/rax-renderer/demo/miniapp/app.js +++ /dev/null @@ -1 +0,0 @@ -App({}); diff --git a/packages/rax-renderer/demo/miniapp/app.json b/packages/rax-renderer/demo/miniapp/app.json deleted file mode 100644 index 94127c774c..0000000000 --- a/packages/rax-renderer/demo/miniapp/app.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "pages": ["pages/index"], - "window": { - "defaultTitle": "demo" - } -} diff --git a/packages/rax-renderer/demo/miniapp/pages/index.axml b/packages/rax-renderer/demo/miniapp/pages/index.axml deleted file mode 100644 index 41b536b4ce..0000000000 --- a/packages/rax-renderer/demo/miniapp/pages/index.axml +++ /dev/null @@ -1 +0,0 @@ -<my-component></my-component> diff --git a/packages/rax-renderer/demo/miniapp/pages/index.js b/packages/rax-renderer/demo/miniapp/pages/index.js deleted file mode 100644 index 687d87e197..0000000000 --- a/packages/rax-renderer/demo/miniapp/pages/index.js +++ /dev/null @@ -1,4 +0,0 @@ -Page({ - onLoad() {}, - onShow() {}, -}); diff --git a/packages/rax-renderer/demo/miniapp/pages/index.json b/packages/rax-renderer/demo/miniapp/pages/index.json deleted file mode 100644 index 89b15c54ca..0000000000 --- a/packages/rax-renderer/demo/miniapp/pages/index.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "defaultTitle": "Miniapp Rax Text demo", - "usingComponents": { - "my-component": "../components/Target/index" - } -} diff --git a/packages/rax-renderer/demo/wechat-miniprogram/app.js b/packages/rax-renderer/demo/wechat-miniprogram/app.js deleted file mode 100644 index 3482935519..0000000000 --- a/packages/rax-renderer/demo/wechat-miniprogram/app.js +++ /dev/null @@ -1 +0,0 @@ -App({}); diff --git a/packages/rax-renderer/demo/wechat-miniprogram/app.json b/packages/rax-renderer/demo/wechat-miniprogram/app.json deleted file mode 100644 index be00ced601..0000000000 --- a/packages/rax-renderer/demo/wechat-miniprogram/app.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "pages": ["pages/index"], - "window": { - "title": "demo" - } -} diff --git a/packages/rax-renderer/demo/wechat-miniprogram/pages/index.js b/packages/rax-renderer/demo/wechat-miniprogram/pages/index.js deleted file mode 100644 index 687d87e197..0000000000 --- a/packages/rax-renderer/demo/wechat-miniprogram/pages/index.js +++ /dev/null @@ -1,4 +0,0 @@ -Page({ - onLoad() {}, - onShow() {}, -}); diff --git a/packages/rax-renderer/demo/wechat-miniprogram/pages/index.json b/packages/rax-renderer/demo/wechat-miniprogram/pages/index.json deleted file mode 100644 index 9448c84eaf..0000000000 --- a/packages/rax-renderer/demo/wechat-miniprogram/pages/index.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "title": "Wechat MiniProgram Rax Text demo", - "usingComponents": { - "my-component": "../components/Target/index" - } -} diff --git a/packages/rax-renderer/demo/wechat-miniprogram/pages/index.wxml b/packages/rax-renderer/demo/wechat-miniprogram/pages/index.wxml deleted file mode 100644 index 41b536b4ce..0000000000 --- a/packages/rax-renderer/demo/wechat-miniprogram/pages/index.wxml +++ /dev/null @@ -1 +0,0 @@ -<my-component></my-component> diff --git a/packages/rax-renderer/package.json b/packages/rax-renderer/package.json deleted file mode 100644 index 64e817d2c3..0000000000 --- a/packages/rax-renderer/package.json +++ /dev/null @@ -1,53 +0,0 @@ -{ - "name": "@alilc/lowcode-rax-renderer", - "version": "1.0.3", - "description": "Rax renderer for Ali lowCode engine", - "main": "lib/index.js", - "module": "es/index.js", - "miniappConfig": { - "main": "lib/miniapp/index", - "main:wechat": "lib/wechat-miniprogram/index" - }, - "files": [ - "dist", - "es", - "lib" - ], - "keywords": [ - "low-code", - "lowcode", - "Rax" - ], - "engines": { - "npm": ">=3.0.0" - }, - "peerDependencies": { - "prop-types": "^15.7.2", - "rax": "^1.1.0" - }, - "scripts": { - "start": "build-scripts start", - "build": "build-scripts build" - }, - "dependencies": { - "@alilc/lowcode-renderer-core": "1.0.3", - "@alilc/lowcode-utils": "1.0.3", - "rax-find-dom-node": "^1.0.1" - }, - "devDependencies": { - "@alib/build-scripts": "^0.1.0", - "build-plugin-rax-component": "^0.2.11", - "driver-universal": "^3.1.3" - }, - "publishConfig": { - "access": "public", - "registry": "https://registry.npmjs.org/" - }, - "repository": { - "type": "http", - "url": "https://github.com/alibaba/lowcode-engine/tree/main/packages/rax-renderer" - }, - "license": "MIT", - "homepage": "https://unpkg.alibaba-inc.com/@alilc/lowcode-rax-renderer@0.1.2/build/index.html", - "gitHead": "2669f179e6f899d395ce1942d0fe04f9c5ed48a6" -} diff --git a/packages/rax-renderer/src/hoc/compFactory.tsx b/packages/rax-renderer/src/hoc/compFactory.tsx deleted file mode 100644 index 58c7aa7634..0000000000 --- a/packages/rax-renderer/src/hoc/compFactory.tsx +++ /dev/null @@ -1,82 +0,0 @@ -// @ts-nocheck - -import { Component, forwardRef } from 'rax'; -import PropTypes from 'prop-types'; -import { AppHelper } from '@alilc/lowcode-utils'; -import { utils, contextFactory } from '@alilc/lowcode-renderer-core'; -import componentRendererFactory from '../renderer/component'; -import blockRendererFactory from '../renderer/block'; - -const { forEach, isFileSchema } = utils; - -export default function compFactory(schema, components = {}, componentsMap = {}, config = {}) { - // 自定义组件需要有自己独立的appHelper - const appHelper = new AppHelper(config); - const CompRenderer = componentRendererFactory(); - const BlockRenderer = blockRendererFactory(); - const AppContext = contextFactory(); - - class LNCompView extends Component { - static dislayName = 'lce-comp-factory'; - - static version = config.version || '0.0.0'; - - static contextType = AppContext; - - static propTypes = { - forwardedRef: PropTypes.func, - }; - - render() { - if (!schema || schema.componentName !== 'Component' || !isFileSchema(schema)) { - console.warn('自定义组件模型结构异常!'); - return null; - } - const { forwardedRef, ...otherProps } = this.props; - // 低代码组件透传应用上下文 - const ctx = ['utils', 'constants', 'history', 'location', 'match']; - ctx.forEach(key => { - if (!appHelper[key] && this.context?.appHelper && this.context?.appHelper[key]) { - appHelper.set(key, this.context.appHelper[key]); - } - }); - // 支持通过context透传国际化配置 - const localeProps = {}; - const { locale, messages } = this.context; - if (locale && messages && messages[schema.fileName]) { - localeProps.locale = locale; - localeProps.messages = messages[schema.fileName]; - } - const props = { - ...schema.defaultProps, - ...localeProps, - ...otherProps, - __schema: schema, - ref: forwardedRef, - }; - - return ( - <AppContext.Consumer> - {context => { - this.context = context; - return ( - <CompRenderer - {...props} - __appHelper={appHelper} - __components={{ ...components, Component: CompRenderer, Block: BlockRenderer }} - __componentsMap={componentsMap} - /> - ); - }} - </AppContext.Consumer> - ); - } - } - - const ResComp = forwardRef((props, ref) => <LNCompView {...props} forwardedRef={ref} />); - forEach(schema.static, (val, key) => { - ResComp[key] = val; - }); - ResComp.version = config.version || '0.0.0'; - return ResComp; -} diff --git a/packages/rax-renderer/src/index.ts b/packages/rax-renderer/src/index.ts deleted file mode 100644 index 2ea14ec1ae..0000000000 --- a/packages/rax-renderer/src/index.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { Component, PureComponent, createElement, createContext, forwardRef } from 'rax'; -import findDOMNode from 'rax-find-dom-node'; -import { - adapter, - addonRendererFactory, - tempRendererFactory, - rendererFactory, -} from '@alilc/lowcode-renderer-core'; -import pageRendererFactory from './renderer/page'; -import componentRendererFactory from './renderer/component'; -import blockRendererFactory from './renderer/block'; -import CompFactory from './hoc/compFactory'; - -adapter.setRuntime({ - Component, - PureComponent, - createContext, - createElement, - forwardRef, - findDOMNode, -}); - -adapter.setRenderers({ - PageRenderer: pageRendererFactory(), - ComponentRenderer: componentRendererFactory(), - BlockRenderer: blockRendererFactory(), - AddonRenderer: addonRendererFactory(), - TempRenderer: tempRendererFactory(), -}); - -function factory() { - const Renderer = rendererFactory(); - return class extends Renderer { - constructor(props: any, context: any) { - super(props, context); - } - - isValidComponent(obj: any) { - return obj?.prototype?.setState || obj?.prototype instanceof Component; - } - }; -} - -const RaxRenderer = factory(); -const Engine = RaxRenderer; - -export { - Engine, - CompFactory, -}; - -export default RaxRenderer; diff --git a/packages/rax-renderer/src/renderer/block.tsx b/packages/rax-renderer/src/renderer/block.tsx deleted file mode 100644 index 2b2d6c93ad..0000000000 --- a/packages/rax-renderer/src/renderer/block.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { blockRendererFactory, types } from '@alilc/lowcode-renderer-core'; - -export default function raxBlockRendererFactory() { - const OriginBlock = blockRendererFactory(); - return class BlockRenderer extends OriginBlock { - render() { - // @ts-ignore - const that: types.IRenderer = this; - const { __schema, __components } = that.props; - if (that.__checkSchema(__schema)) { - return '区块 schema 结构异常!'; - } - that.__debug(`render - ${__schema.fileName}`); - - const children = ((context) => { - that.context = context; - that.__generateCtx({}); - that.__render(); - return that.__renderComp((__components as any)?.Block, { blockContext: that }); - }); - return that.__renderContextConsumer(children); - } - }; -} diff --git a/packages/rax-renderer/src/renderer/component.tsx b/packages/rax-renderer/src/renderer/component.tsx deleted file mode 100644 index 6f221b3133..0000000000 --- a/packages/rax-renderer/src/renderer/component.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { componentRendererFactory, types } from '@alilc/lowcode-renderer-core'; - -export default function raxComponentRendererFactory() { - const OriginComponent = componentRendererFactory(); - return class ComponentRenderer extends OriginComponent { - render() { - // @ts-ignore - const that: types.IRenderer = this; - const { __schema, __components } = that.props; - if (that.__checkSchema(__schema)) { - return '自定义组件 schema 结构异常!'; - } - that.__debug(`render - ${__schema.fileName}`); - - const { noContainer } = that.__parseData(__schema.props); - - const children = ((context) => { - that.context = context; - that.__generateCtx({ component: that }); - that.__render(); - // 传 null,使用内置的 div 来渲染,解决在页面中渲染 vc-component 报错的问题 - return that.__renderComp(null, { - compContext: that, - blockContext: that, - }); - }); - const content = that.__renderContextConsumer(children); - - if (noContainer) { - return content; - } - - return that.__renderContent(content); - } - }; -} diff --git a/packages/rax-renderer/src/renderer/page.tsx b/packages/rax-renderer/src/renderer/page.tsx deleted file mode 100644 index af8b78d882..0000000000 --- a/packages/rax-renderer/src/renderer/page.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { pageRendererFactory, types } from '@alilc/lowcode-renderer-core'; - -export default function raxPageRendererFactory() { - const OriginPage = pageRendererFactory(); - return class PageRenderer extends OriginPage { - async componentDidUpdate() { - // @ts-ignore - super.componentDidUpdate(...arguments); - } - - render() { - // @ts-ignore - const that: types.IRenderer = this; - const { __schema, __components } = that.props; - if (that.__checkSchema(__schema)) { - return '页面 schema 结构异常!'; - } - that.__debug(`render - ${__schema?.fileName}`); - - const { Page } = __components as any; - if (Page) { - const children = ((context) => { - that.context = context; - that.__render(); - return that.__renderComp(Page, { pageContext: that }); - }); - return that.__renderContextConsumer(children); - } - - return that.__renderContent(that.__renderContextConsumer((context) => { - that.context = context; - return that.__renderContextProvider({ pageContext: that }); - })); - } - }; -} diff --git a/packages/rax-renderer/tsconfig.json b/packages/rax-renderer/tsconfig.json deleted file mode 100644 index 7e264d1f05..0000000000 --- a/packages/rax-renderer/tsconfig.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "compilerOptions": { - "lib": ["es2015", "dom"], - "target": "esnext", - "module": "esnext", - "moduleResolution": "node", - "strict": false, - "strictPropertyInitialization": false, - "allowSyntheticDefaultImports": true, - "esModuleInterop": true, - "jsx": "react", - "jsxFactory": "createElement", - "importHelpers": true, - "experimentalDecorators": true, - "emitDecoratorMetadata": true, - "sourceMap": true, - "forceConsistentCasingInFileNames": true, - "resolveJsonModule": true, - "skipLibCheck": true, - "outDir": "lib" - }, - "exclude": ["test", "lib", "es", "node_modules"], - "include": [ - "src" - ] -} diff --git a/packages/rax-simulator-renderer/.babelrc b/packages/rax-simulator-renderer/.babelrc deleted file mode 100644 index e0e2e5f343..0000000000 --- a/packages/rax-simulator-renderer/.babelrc +++ /dev/null @@ -1,9 +0,0 @@ -{ - "plugins": [ - ["@babel/plugin-transform-react-jsx", { - "pragma": "createElement", // default pragma is React.createElement - "pragmaFrag": "createFragment", // default is React.Fragment - "throwIfNamespace": false // defaults to true - }] - ] -} diff --git a/packages/rax-simulator-renderer/build.json b/packages/rax-simulator-renderer/build.json deleted file mode 100644 index b95a17aafe..0000000000 --- a/packages/rax-simulator-renderer/build.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "plugins": ["build-plugin-component", "./build.plugin.js"] -} diff --git a/packages/rax-simulator-renderer/build.plugin.js b/packages/rax-simulator-renderer/build.plugin.js deleted file mode 100644 index d613f1f56a..0000000000 --- a/packages/rax-simulator-renderer/build.plugin.js +++ /dev/null @@ -1,5 +0,0 @@ -module.exports = ({ onGetWebpackConfig }) => { - onGetWebpackConfig((config) => { - config.performance.hints(false); - }); -}; diff --git a/packages/rax-simulator-renderer/build.umd.json b/packages/rax-simulator-renderer/build.umd.json deleted file mode 100644 index 833c92b246..0000000000 --- a/packages/rax-simulator-renderer/build.umd.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "entry": { - "rax-simulator-renderer": "src/index" - }, - "sourceMap": true, - "library": "___RaxSimulatorRenderer___", - "libraryTarget": "umd", - "externals": { - "react": "var window.React", - "react-dom": "var window.ReactDOM", - "prop-types": "var window.PropTypes", - "@alifd/next": "var Next", - "@alilc/lowcode-engine-ext": "var window.AliLowCodeEngineExt", - "rax": "var window.Rax", - "moment": "var moment", - "lodash": "var _" - }, - "polyfill": false, - "outputDir": "dist", - "vendor": false, - "ignoreHtmlTemplate": true, - "plugins": [ - "build-plugin-react-app", - [ - "build-plugin-fusion", - { - "externalNext": "umd" - } - ], - [ - "build-plugin-moment-locales", - { - "locales": ["zh-cn"] - } - ], - "./build.plugin.js" - ] -} diff --git a/packages/rax-simulator-renderer/package.json b/packages/rax-simulator-renderer/package.json deleted file mode 100644 index 4beac7087b..0000000000 --- a/packages/rax-simulator-renderer/package.json +++ /dev/null @@ -1,55 +0,0 @@ -{ - "name": "@alilc/lowcode-rax-simulator-renderer", - "version": "1.0.3", - "description": "rax simulator renderer for alibaba lowcode designer", - "main": "lib/index.js", - "module": "es/index.js", - "license": "MIT", - "files": [ - "dist" - ], - "scripts": { - "build": "NODE_OPTIONS=--max_old_space_size=8192 build-scripts build --skip-demo", - "build:umd": "build-scripts build --config build.umd.json" - }, - "dependencies": { - "@alilc/lowcode-designer": "1.0.3", - "@alilc/lowcode-rax-renderer": "1.0.3", - "@alilc/lowcode-types": "1.0.3", - "@alilc/lowcode-utils": "1.0.3", - "classnames": "^2.2.6", - "driver-universal": "^3.1.3", - "history": "^5.0.0", - "lodash": "^4.17.19", - "mobx": "^6.3.0", - "mobx-react": "^7.2.0", - "path-to-regexp": "3.2.0", - "rax-find-dom-node": "^1.0.0", - "react": "^16", - "react-dom": "^16.7.0" - }, - "devDependencies": { - "@alib/build-scripts": "^0.1.18", - "@babel/plugin-transform-react-jsx": "^7.10.4", - "@types/classnames": "^2.2.7", - "@types/node": "^13.7.1", - "@types/rax": "^1.0.0", - "@types/react": "^16", - "@types/react-dom": "^16", - "build-plugin-component": "^0.2.11", - "build-plugin-rax-component": "^0.2.11" - }, - "peerDependencies": { - "rax": "^1.1.0" - }, - "publishConfig": { - "access": "public", - "registry": "https://registry.npmjs.org/" - }, - "repository": { - "type": "http", - "url": "https://github.com/alibaba/lowcode-engine/tree/main/packages/rax-simulator-renderer" - }, - "homepage": "https://unpkg.alibaba-inc.com/@alilc/lowcode-rax-simulator-renderer@1.0.73/build/index.html", - "gitHead": "2669f179e6f899d395ce1942d0fe04f9c5ed48a6" -} diff --git a/packages/rax-simulator-renderer/src/builtin-components/UnusualComponent/index.tsx b/packages/rax-simulator-renderer/src/builtin-components/UnusualComponent/index.tsx deleted file mode 100644 index 6608942c4b..0000000000 --- a/packages/rax-simulator-renderer/src/builtin-components/UnusualComponent/index.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { Component } from 'rax'; -import lg from '@ali/vu-logger'; - -import './index.less'; - -export class UnknownComponent extends Component { - props: { - _componentName: string; - }; - - render() { - lg.log('ERROR_NO_COMPONENT_VIEW'); - lg.error('Error component information:', this.props); - return <div className="engine-unknow-component">组件 {this.props._componentName} 无视图,请打开控制台排查</div>; - } -} - -export class FaultComponent extends Component { - props: { - _componentName: string; - }; - - render() { - return <div className="engine-fault-component">组件 {this.props._componentName} 渲染错误,请打开控制台排查</div>; - } -} - -export class HiddenComponent extends Component { - render() { - return <div className="engine-hidden-component">在本页面不显示</div>; - } -} - -export default { FaultComponent, HiddenComponent, UnknownComponent }; diff --git a/packages/rax-simulator-renderer/src/builtin-components/leaf.tsx b/packages/rax-simulator-renderer/src/builtin-components/leaf.tsx deleted file mode 100644 index 5276b0018d..0000000000 --- a/packages/rax-simulator-renderer/src/builtin-components/leaf.tsx +++ /dev/null @@ -1,251 +0,0 @@ -import { Component } from 'rax'; - -class Leaf extends Component { - static displayName = 'Leaf'; - - static componentMetadata = { - componentName: 'Leaf', - configure: { - props: [{ - name: 'children', - setter: 'StringSetter', - }], - // events/className/style/general/directives - supports: false, - }, - }; - - render() { - const { children } = this.props; - return children; - } -} - -export default Leaf; - -// import { Component, createElement } from 'rax'; -// import findDOMNode from 'rax-find-dom-node'; -// import { each, get, omit } from 'lodash'; -// import { getView, setNativeNode, createNodeStyleSheet } from '../renderUtils'; - -// import { FaultComponent, HiddenComponent, UnknownComponent } from '../UnusualComponent'; - -// export interface ILeaf { -// leaf: any; -// } -// export default class Leaf extends Component<ILeaf, {}> { -// static displayName = 'Leaf'; - -// state = { -// hasError: false, -// }; - -// willDetach: any[]; - -// styleSheet: any; - -// context: any; -// refs: any; - -// componentWillMount() { -// const { leaf } = this.props; -// this.willDetach = [ -// leaf.onPropsChange(() => { -// // 强制刷新 -// this.setState(this.state); -// }), -// leaf.onChildrenChange(() => { -// // 强制刷新 -// this.setState(this.state); -// }), -// leaf.onStatusChange((status: { dropping: boolean }, field: string) => { -// // console.log({...status}, field) -// if (status.dropping !== false) { -// // 当 dropping 为 Insertion 对象时,强制渲染会出错,原因待查 -// return; -// } -// if (field === 'dragging' || field === 'dropping' || field === 'pseudo' || field === 'visibility') { -// // 强制刷新 -// this.setState(this.state); -// } -// }), -// ]; - -// /** -// * while props replaced -// * bind the new event on it -// */ -// leaf.onPropsReplace(() => { -// this.willDetach[0](); -// this.willDetach[0] = leaf.onPropsChange(() => { -// // 强制刷新 -// this.setState(this.state); -// }); -// }); -// } - -// componentDidMount() { -// this.modifyDOM(); -// } - -// shouldComponentUpdate() { -// // forceUpdate 的替代方案 -// return true; -// // const pageCanRefresh = this.leaf.getPage().canRefresh(); -// // if (pageCanRefresh) { -// // return pageCanRefresh; -// // } -// // const getExtProps = obj => { -// // const { leaf, ...props } = obj; -// // return props; -// // }; -// // return !shallowEqual(getExtProps(this.props), getExtProps(nextProps)); -// } - -// componentDidUpdate() { -// this.modifyDOM(); -// } - -// componentWillUnmount() { -// if (this.willDetach) { -// this.willDetach.forEach((off) => off()); -// } -// setNativeNode(this.props.leaf, null); -// } - -// componentDidCatch() { -// this.setState({ hasError: true }, () => { -// console.log('error'); -// }); -// } - -// modifyDOM() { -// const shell = findDOMNode(this); -// const { leaf } = this.props; -// // 与 React 不同,rax 的 findDOMNode 找不到节点时, -// // shell 会是 <!-- empty -->,而不是 null, -// // 所以这里进行是否为注释的判断 -// if (shell && shell.nodeType !== window.Node.COMMENT_NODE) { -// setNativeNode(leaf, shell); -// if (leaf.getStatus('dragging')) { -// get(shell, 'classList').add('engine-dragging'); -// } else { -// get(shell, 'classList').remove('engine-dragging'); -// } -// each(get(shell, 'classList'), (cls) => { -// if (cls.substring(0, 8) === '-pseudo-') { -// get(shell, 'classList').remove(cls); -// } -// }); -// const pseudo = leaf.getStatus('pseudo'); -// if (pseudo) { -// get(shell, 'classList').add(`-pseudo-${pseudo}`); -// } -// } else { -// setNativeNode(leaf, null); -// } -// } - -// render() { -// const props = omit(this.props, ['leaf']); -// const { leaf } = this.props; -// const componentName = leaf.getComponentName(); - -// const View = getView(componentName); - -// const newProps = { -// _componentName: componentName, -// }; - -// if (!View) { -// return createElement(UnknownComponent, { -// // _componentName: componentName, -// ...newProps, -// }); -// } - -// let staticProps = { -// ...leaf.getStaticProps(false), -// ...props, -// _componentName: componentName, -// _leaf: leaf, -// componentId: leaf.getId(), -// }; - -// if (!leaf.isVisibleInPane()) { -// return null; -// } - -// if (!leaf.isVisible()) { -// return createElement(HiddenComponent, { -// ...staticProps, -// }); -// } - -// if (this.state.hasError) { -// return createElement(FaultComponent, { -// // _componentName: componentName, -// ...newProps, -// }); -// } - -// if (this.styleSheet) { -// this.styleSheet.parentNode.removeChild(this.styleSheet); -// } - -// this.styleSheet = createNodeStyleSheet(staticProps); - -// if (leaf.ableToModifyChildren()) { -// const children = leaf -// .getChildren() -// .filter((child: any) => child.getComponentName() !== 'Slot') -// .map((child: any) => -// createElement(Leaf, { -// key: child.getId(), -// leaf: child, -// }), -// ); -// // const insertion = leaf.getStatus('dropping'); -// // InsertionGhost 都是React节点,用Rax渲染会报错,后面这些节点需要通过Rax组件来实现 -// // if (children.length < 1 && insertion && insertion.getIndex() !== null) { - -// // //children = []; -// // children = [<InsertionGhost key="insertion" />]; -// // } else if (insertion && insertion.isNearEdge()) { -// // if (insertion.isNearAfter()) { -// // children.push(<InsertionGhost key="insertion" />); -// // } else { -// // children.unshift(<InsertionGhost key="insertion" />); -// // } -// // } -// staticProps = { -// ...staticProps, -// ...this.processSlots(this.props.leaf.getChildren()), -// }; - -// return createElement( -// View, -// { -// ...staticProps, -// }, -// children, -// ); -// } - -// return createElement(View, { -// ...staticProps, -// }); -// } - -// processSlots(children: Rax.RaxNodeArray) { -// const slots: any = {}; -// children && -// children.length && -// children.forEach((child: any) => { -// if (child.getComponentName() === 'Slot') { -// slots[child.getPropValue('slotName')] = <Leaf key={child.getId()} leaf={child} />; -// } -// }); -// return slots; -// } -// } diff --git a/packages/rax-simulator-renderer/src/builtin-components/renderUtils.ts b/packages/rax-simulator-renderer/src/builtin-components/renderUtils.ts deleted file mode 100644 index 10f8438fb2..0000000000 --- a/packages/rax-simulator-renderer/src/builtin-components/renderUtils.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { isObject } from 'lodash'; -import { css } from '@alilc/lowcode-utils'; - -const { toCss } = css; -const engine = (window as any).VisualEngine; -const { Trunk, Viewport } = engine; - -export const NativeNodeCache: any = {}; - -function ucfirst(s: string) { - return s.charAt(0).toUpperCase() + s.substring(1); -} - -export function shallowEqual(obj: { [key: string]: string }, tObj: { [key: string]: string }) { - for (const i in obj) { - if (Object.prototype.hasOwnProperty.call(obj, i) && obj[i] !== tObj[i]) { - return false; - } - } - return true; -} - -export function createNodeStyleSheet(props: any) { - if (props && props.fieldId) { - let styleProp = props.__style__; - - if (isObject(styleProp)) { - styleProp = toCss(styleProp); - } - - if (typeof styleProp === 'string') { - const s = document.createElement('style'); - const cssId = `_style_pesudo_${ props.fieldId}`; - const cssClass = `_css_pesudo_${ props.fieldId}`; - - props.className = cssClass; - s.setAttribute('type', 'text/css'); - s.setAttribute('id', cssId); - document.getElementsByTagName('head')[0].appendChild(s); - - s.appendChild( - document.createTextNode( - styleProp - .replace(/(\d+)rpx/g, (a, b) => { - return `${b / 2}px`; - }) - .replace(/:root/g, `.${ cssClass}`), - ), - ); - return s; - } - } -} - -export function setNativeNode(leaf: any, node: Rax.RaxNode) { - const id = leaf.getId(); - if (NativeNodeCache[id] === node) { - return; - } - NativeNodeCache[id] = node; - leaf.mountChange(); -} - -export function getView(componentName: string) { - // let view = new Trunk().getPrototypeView(componentName); - let view = Trunk.getPrototypeView(componentName); - if (!view) { - return null; - } - const viewport = Viewport.getViewport(); - if (viewport) { - const [mode, device] = viewport.split('-', 2).map(ucfirst); - if (view.hasOwnProperty(device)) { - view = view[device]; - } - - if (view.hasOwnProperty(mode)) { - view = view[mode]; - } - } - - return view; -} diff --git a/packages/rax-simulator-renderer/src/builtin-components/slot.tsx b/packages/rax-simulator-renderer/src/builtin-components/slot.tsx deleted file mode 100644 index 3a77491bc0..0000000000 --- a/packages/rax-simulator-renderer/src/builtin-components/slot.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import { Component } from 'rax'; - -class Slot extends Component { - static displayName = 'Slot'; - - static componentMetadata = { - componentName: 'Slot', - configure: { - props: [{ - name: '___title', - title: { - type: 'i18n', - 'en-US': 'Slot Title', - 'zh-CN': '插槽标题', - }, - setter: 'StringSetter', - defaultValue: '插槽容器', - }, { - name: '___params', - title: { - type: 'i18n', - 'en-US': 'Slot Params', - 'zh-CN': '插槽入参', - }, - setter: { - componentName: 'ArraySetter', - props: { - itemSetter: { - componentName: 'StringSetter', - props: { - placeholder: { - type: 'i18n', - 'zh-CN': '参数名称', - 'en-US': 'Argument Name', - }, - }, - }, - }, - }, - }], - // events/className/style/general/directives - supports: false, - }, - }; - - render() { - const { children } = this.props; - return ( - <div className="lc-container">{children}</div> - ); - } -} - -export default Slot; diff --git a/packages/rax-simulator-renderer/src/host.ts b/packages/rax-simulator-renderer/src/host.ts deleted file mode 100644 index c5cf2e3e1c..0000000000 --- a/packages/rax-simulator-renderer/src/host.ts +++ /dev/null @@ -1,4 +0,0 @@ -// NOTE: 仅做类型标注,切勿做其它用途 -import { BuiltinSimulatorHost } from '@alilc/lowcode-designer'; - -export const host: BuiltinSimulatorHost = (window as any).LCSimulatorHost; diff --git a/packages/rax-simulator-renderer/src/image.d.ts b/packages/rax-simulator-renderer/src/image.d.ts deleted file mode 100644 index 7ed4ad925c..0000000000 --- a/packages/rax-simulator-renderer/src/image.d.ts +++ /dev/null @@ -1,2 +0,0 @@ -declare module 'rax-find-dom-node'; -declare module '@alilc/lowcode-rax-renderer/lib/index'; diff --git a/packages/rax-simulator-renderer/src/index.ts b/packages/rax-simulator-renderer/src/index.ts deleted file mode 100644 index 3a88726657..0000000000 --- a/packages/rax-simulator-renderer/src/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -import renderer from './renderer'; - -if (typeof window !== 'undefined') { - (window as any).SimulatorRenderer = renderer; -} - -export default renderer; diff --git a/packages/rax-simulator-renderer/src/rax-use-router.js b/packages/rax-simulator-renderer/src/rax-use-router.js deleted file mode 100644 index a759d127ce..0000000000 --- a/packages/rax-simulator-renderer/src/rax-use-router.js +++ /dev/null @@ -1,288 +0,0 @@ -// Inspired by react-router and universal-router -import { useState, useEffect, useLayoutEffect, createElement } from 'rax'; -import pathToRegexp from 'path-to-regexp'; - -const cache = {}; -function decodeParam(val) { - try { - return decodeURIComponent(val); - } catch (err) { - return val; - } -} - -function matchPath(route, pathname, parentParams) { - let { path, routes, exact: end = true, strict = false, sensitive = false } = route; - // If not has path or has routes that should do not exact match - if (path == null || routes) { - end = false; - } - - // Default path is empty - path = path || ''; - - const regexpCacheKey = `${path}|${end}|${strict}|${sensitive}`; - const keysCacheKey = `${regexpCacheKey }|`; - - let regexp = cache[regexpCacheKey]; - const keys = cache[keysCacheKey] || []; - - if (!regexp) { - regexp = pathToRegexp(path, keys, { - end, - strict, - sensitive, - }); - cache[regexpCacheKey] = regexp; - cache[keysCacheKey] = keys; - } - - const result = regexp.exec(pathname); - if (!result) { - return null; - } - - const url = result[0]; - const params = { ...parentParams, history: router.history, location: router.history.location }; - - for (let i = 1; i < result.length; i++) { - const key = keys[i - 1]; - const prop = key.name; - const value = result[i]; - if (value !== undefined || !Object.prototype.hasOwnProperty.call(params, prop)) { - if (key.repeat) { - params[prop] = value ? value.split(key.delimiter).map(decodeParam) : []; - } else { - params[prop] = value ? decodeParam(value) : value; - } - } - } - - return { - path: !end && url.charAt(url.length - 1) === '/' ? url.substr(1) : url, - params, - }; -} - -function matchRoute(route, baseUrl, pathname, parentParams) { - let matched; - let childMatches; - let childIndex = 0; - - return { - next() { - if (!matched) { - matched = matchPath(route, pathname, parentParams); - - if (matched) { - return { - done: false, - $: { - route, - baseUrl, - path: matched.path, - params: matched.params, - }, - }; - } - } - - if (matched && route.routes) { - while (childIndex < route.routes.length) { - if (!childMatches) { - const childRoute = route.routes[childIndex]; - childRoute.parent = route; - - childMatches = matchRoute( - childRoute, - baseUrl + matched.path, - pathname.substr(matched.path.length), - matched.params, - ); - } - - const childMatch = childMatches.next(); - if (!childMatch.done) { - return { - done: false, - $: childMatch.$, - }; - } - - childMatches = null; - childIndex++; - } - } - - return { done: true }; - }, - }; -} - -let _initialized = false; -let _routerConfig = null; -const router = { - history: null, - handles: [], - errorHandler() { }, - addHandle(handle) { - return router.handles.push(handle); - }, - removeHandle(handleId) { - router.handles[handleId - 1] = null; - }, - triggerHandles(component) { - router.handles.forEach((handle) => { - handle && handle(component); - }); - }, - match(fullpath) { - if (fullpath == null) return; - - router.fullpath = fullpath; - - const parent = router.root; - const matched = matchRoute( - parent, - parent.path, - fullpath, - ); - - function next(parent) { - const current = matched.next(); - - if (current.done) { - const error = new Error(`No match for ${fullpath}`); - return router.errorHandler(error, router.history.location); - } - - let { component } = current.$.route; - if (typeof component === 'function') { - component = component(current.$.params, router.history.location); - } - if (component instanceof Promise) { - // Lazy loading component by import('./Foo') - return component.then((component) => { - // Check current fullpath avoid router has changed before lazy loading complete - if (fullpath === router.fullpath) { - router.triggerHandles(component); - } - }); - } else if (component != null) { - router.triggerHandles(component); - return component; - } else { - return next(parent); - } - } - - return next(parent); - }, -}; - -function matchLocation({ pathname }) { - router.match(pathname); -} - - -function getInitialComponent(routerConfig) { - let InitialComponent = []; - - if (_routerConfig === null) { - if (process.env.NODE_ENV !== 'production') { - if (!routerConfig) { - throw new Error('Error: useRouter should have routerConfig, see: https://www.npmjs.com/package/rax-use-router.'); - } - if (!routerConfig.history || !routerConfig.routes) { - throw new Error('Error: routerConfig should contain history and routes, see: https://www.npmjs.com/package/rax-use-router.'); - } - } - _routerConfig = routerConfig; - } - if (_routerConfig.InitialComponent) { - InitialComponent = _routerConfig.InitialComponent; - } - router.history = _routerConfig.history; - - return InitialComponent; -} - -let unlisten = null; -let handleId = null; -let pathes = ''; -export function useRouter(routerConfig) { - const [component, setComponent] = useState(getInitialComponent(routerConfig)); - - let newPathes = ''; - if (routerConfig) { - _routerConfig = routerConfig; - const { routes } = _routerConfig; - router.root = Array.isArray(routes) ? { routes } : routes; - if (Array.isArray(routes)) { - newPathes = routes.map(it => it.path).join(','); - } else { - newPathes = routes.path; - } - } - if (_initialized && _routerConfig.history) { - if (newPathes !== pathes) { - matchLocation(_routerConfig.history.location); - pathes = newPathes; - } - } - - useLayoutEffect(() => { - if (unlisten) { - unlisten(); - unlisten = null; - } - - if (handleId) { - router.removeHandle(handleId); - handleId = null; - } - - const { history } = _routerConfig; - const { routes } = _routerConfig; - - router.root = Array.isArray(routes) ? { routes } : routes; - - handleId = router.addHandle((component) => { - setComponent(component); - }); - - // Init path match - if (_initialized || !_routerConfig.InitialComponent) { - matchLocation(history.location); - pathes = newPathes; - } - - unlisten = history.listen(({ location }) => { - matchLocation(location); - pathes = newPathes; - }); - - _initialized = true; - - return () => { - pathes = ''; - router.removeHandle(handleId); - handleId = null; - unlisten(); - unlisten = null; - }; - }, []); - - return { component }; -} - -export function withRouter(Component) { - function Wrapper(props) { - const { history } = router; - return createElement(Component, { ...props, history, location: history.location }); - } - - Wrapper.displayName = `withRouter(${ Component.displayName || Component.name })`; - Wrapper.WrappedComponent = Component; - return Wrapper; -} diff --git a/packages/rax-simulator-renderer/src/renderer-view.tsx b/packages/rax-simulator-renderer/src/renderer-view.tsx deleted file mode 100644 index c11c2dfdfa..0000000000 --- a/packages/rax-simulator-renderer/src/renderer-view.tsx +++ /dev/null @@ -1,274 +0,0 @@ -import RaxRenderer from '@alilc/lowcode-rax-renderer'; -import { History } from 'history'; -import { Component, createElement, Fragment } from 'rax'; -import { useRouter } from './rax-use-router'; -import { DocumentInstance, SimulatorRendererContainer } from './renderer'; -import './renderer.less'; -import { uniqueId } from '@alilc/lowcode-utils'; -import { GlobalEvent } from '@alilc/lowcode-types'; -import { host } from './host'; - -// patch cloneElement avoid lost keyProps -const originCloneElement = (window as any).Rax.cloneElement; -(window as any).Rax.cloneElement = (child: any, { _leaf, ...props }: any = {}, ...rest: any[]) => { - if (child.ref && props.ref) { - const dRef = props.ref; - const cRef = child.ref; - props.ref = (x: any) => { - if (cRef) { - if (typeof cRef === 'function') { - cRef(x); - } else { - try { - cRef.current = x; - } catch (e) { - console.error(e); - } - } - } - if (dRef) { - if (typeof dRef === 'function') { - dRef(x); - } else { - try { - dRef.current = x; - } catch (e) { - console.error(e); - } - } - } - }; - } - return originCloneElement(child, props, ...rest); -}; - -export default class SimulatorRendererView extends Component<{ rendererContainer: SimulatorRendererContainer }> { - private unlisten: any; - - componentDidMount() { - const { rendererContainer } = this.props; - this.unlisten = rendererContainer.onLayoutChange(() => { - this.forceUpdate(); - }); - } - - componentWillUnmount() { - if (this.unlisten) { - this.unlisten(); - } - } - - render() { - const { rendererContainer } = this.props; - return ( - <Layout rendererContainer={rendererContainer}> - <Routes rendererContainer={rendererContainer} history={rendererContainer.history} /> - </Layout> - ); - } -} - -export const Routes = (props: { - rendererContainer: SimulatorRendererContainer; - history: History; -}) => { - const { rendererContainer, history } = props; - const { documentInstances } = rendererContainer; - - const routes = { - history, - routes: documentInstances.map(instance => { - return { - path: instance.path, - component: (props: any) => <Renderer key={instance.id} rendererContainer={rendererContainer} documentInstance={instance} {...props} />, - }; - }), - }; - const { component } = useRouter(routes); - return component; -}; - -function ucfirst(s: string) { - return s.charAt(0).toUpperCase() + s.substring(1); -} -function getDeviceView(view: any, device: string, mode: string) { - if (!view || typeof view === 'string') { - return view; - } - - // compatible vision Mobile | Preview - device = ucfirst(device); - if (device === 'Mobile' && view.hasOwnProperty(device)) { - view = view[device]; - } - mode = ucfirst(mode); - if (mode === 'Preview' && view.hasOwnProperty(mode)) { - view = view[mode]; - } - return view; -} - -class Layout extends Component<{ rendererContainer: SimulatorRendererContainer }> { - constructor(props: any) { - super(props); - this.props.rendererContainer.onReRender(() => { - this.forceUpdate(); - }); - } - - render() { - const { rendererContainer, children } = this.props; - const { layout } = rendererContainer; - - if (layout) { - const { Component, props, componentName } = layout; - if (Component) { - return <Component props={props}>{children}</Component>; - } - if (componentName && rendererContainer.getComponent(componentName)) { - return createElement( - rendererContainer.getComponent(componentName), - { - ...props, - rendererContainer, - }, - [children], - ); - } - } - - return <Fragment>{children}</Fragment>; - } -} - -class Renderer extends Component<{ - rendererContainer: SimulatorRendererContainer; - documentInstance: DocumentInstance; -}> { - private unlisten: any; - private key: string; - private startTime: number | null = null; - - componentWillMount() { - this.key = uniqueId('renderer'); - } - - componentDidMount() { - const { documentInstance } = this.props; - this.unlisten = documentInstance.onReRender((params) => { - if (params && params.shouldRemount) { - this.key = uniqueId('renderer'); - } - this.forceUpdate(); - }); - } - - componentWillUnmount() { - if (this.unlisten) { - this.unlisten(); - } - } - shouldComponentUpdate() { - return false; - } - - componentDidUpdate() { - if (this.startTime) { - const time = Date.now() - this.startTime; - const nodeCount = host.designer.currentDocument?.getNodeCount?.(); - host.designer.editor?.emit(GlobalEvent.Node.Rerender, { - componentName: 'Renderer', - type: 'All', - time, - nodeCount, - }); - } - } - - schemaChangedSymbol = false; - - getSchemaChangedSymbol = () => { - return this.schemaChangedSymbol; - }; - - setSchemaChangedSymbol = (symbol: boolean) => { - this.schemaChangedSymbol = symbol; - }; - - render() { - const { documentInstance } = this.props; - const { container, document } = documentInstance; - const { designMode, device } = container; - const { rendererContainer: renderer } = this.props; - this.startTime = Date.now(); - this.schemaChangedSymbol = false; - - return ( - <RaxRenderer - schema={documentInstance.schema} - components={renderer.components} - appHelper={renderer.context} - context={renderer.context} - device={device} - designMode={renderer.designMode} - key={this.key} - __host={host} - __container={container} - suspended={documentInstance.suspended} - self={documentInstance.scope} - onCompGetRef={(schema: any, ref: any) => { - documentInstance.mountInstance(schema.id, ref); - }} - documentId={document.id} - getNode={(id: string) => documentInstance.getNode(id) as any} - rendererName="PageRenderer" - customCreateElement={(Component: any, props: any, children: any) => { - const { __id, ...viewProps } = props; - viewProps.componentId = __id; - const leaf = documentInstance.getNode(__id); - viewProps._leaf = leaf; - viewProps._componentName = leaf?.componentName; - // 如果是容器 && 无children && 高宽为空 增加一个占位容器,方便拖动 - if ( - !viewProps.dataSource && - leaf?.isContainer() && - (children == null || (Array.isArray(children) && !children.length)) && - (!viewProps.style || Object.keys(viewProps.style).length === 0) - ) { - children = ( - <div className="lc-container-placeholder" style={viewProps.placeholderStyle}> - {viewProps.placeholder || '拖拽组件或模板到这里'} - </div> - ); - } - - // if (viewProps._componentName === 'Menu') { - // Object.assign(viewProps, { - // _componentName: 'Menu', - // className: '_css_pesudo_menu_kbrzyh0f', - // context: { VE: (window as any).VisualLowCodeRenderer }, - // direction: undefined, - // events: { ignored: true }, - // fieldId: 'menu_kbrzyh0f', - // footer: '', - // header: '', - // mode: 'inline', - // onItemClick: { ignored: true }, - // onSelect: { ignored: true }, - // popupAlign: 'follow', - // selectMode: false, - // triggerType: 'click', - // }); - // console.info('menuprops', viewProps); - // } - - return createElement( - getDeviceView(Component, device, designMode), - viewProps, - leaf?.isContainer() ? (children == null ? [] : Array.isArray(children) ? children : [children]) : children, - ); - }} - /> - ); - } -} diff --git a/packages/rax-simulator-renderer/src/renderer.less b/packages/rax-simulator-renderer/src/renderer.less deleted file mode 100644 index b71dde9896..0000000000 --- a/packages/rax-simulator-renderer/src/renderer.less +++ /dev/null @@ -1,125 +0,0 @@ -body, html { - display: block; - background: white; - padding: 0; - margin: 0; -} - -html.engine-cursor-move, html.engine-cursor-move * { - cursor: grabbing !important; -} - -html.engine-cursor-copy, html.engine-cursor-copy * { - cursor: copy !important; -} - -html.engine-cursor-ew-resize, html.engine-cursor-ew-resize * { - cursor: ew-resize !important; -} - -::-webkit-scrollbar { - display: none; -} - -.lc-container { - &:empty { - background: #f2f3f5; - color: #a7b1bd; - outline: 1px dashed rgba(31, 56, 88, 0.2); - outline-offset: -1px !important; - height: 66px; - max-height: 100%; - min-width: 140px; - text-align: center; - overflow: hidden; - display: flex; - align-items: center; - &:before { - content: '\62D6\62FD\7EC4\4EF6\6216\6A21\677F\5230\8FD9\91CC'; - font-size: 14px; - z-index: 1; - width: 100%; - white-space: nowrap; - height: 100%; - display: flex; - align-items: center; - justify-content: center; - } - } -} - -.engine-empty { - background: #f2f3f5; - color: #a7b1bd; - outline: 1px dashed rgba(31, 56, 88, 0.2); - outline-offset: -1px !important; - height: 66px; - max-height: 100%; - min-width: 140px; - text-align: center; - overflow: hidden; - display: flex; - align-items: center; -} - -.engine-empty:before { - content: '\62D6\62FD\7EC4\4EF6\6216\6A21\677F\5230\8FD9\91CC'; - font-size: 14px; - z-index: 1; - width: 100%; - white-space: nowrap; - height: 100%; - display: flex; - align-items: center; - justify-content: center; -} - -.lc-container-placeholder { - min-height: 60px; - height: 100%; - width: 100%; - background-color: rgb(240, 240, 240); - border: 1px dotted; - color: rgb(167, 177, 189); - display: flex; - align-items: center; - justify-content: center; - font-size: 14px; -} - -body.engine-document { - &:after, &:before { - content: ""; - display: table; - } - &:after { - clear: both; - } - - /* - .next-input-group, - .next-checkbox-group,.next-date-picker,.next-input,.next-month-picker, - .next-number-picker,.next-radio-group,.next-range,.next-range-picker, - .next-rating,.next-select,.next-switch,.next-time-picker,.next-upload, - .next-year-picker, - .next-breadcrumb-item,.next-calendar-header,.next-calendar-table { - pointer-events: none !important; - } */ -} - -.engine-live-editing { - cursor: text; - outline: none; - box-shadow: 0 0 0 2px rgb(102, 188, 92); - user-select: text; -} - -/* stylelint-disable-next-line selector-max-id */ -#app { - height: 100vh; -} - - -.luna-page { - height: 100%; -} diff --git a/packages/rax-simulator-renderer/src/renderer.ts b/packages/rax-simulator-renderer/src/renderer.ts deleted file mode 100644 index 05cf09f5d4..0000000000 --- a/packages/rax-simulator-renderer/src/renderer.ts +++ /dev/null @@ -1,660 +0,0 @@ -import { BuiltinSimulatorRenderer, Component, DocumentModel, Node, NodeInstance } from '@alilc/lowcode-designer'; -import { ComponentSchema, NodeSchema, NpmInfo, RootSchema, TransformStage } from '@alilc/lowcode-types'; -import { Asset, compatibleLegaoSchema, cursor, isElement, isESModule, isPlainObject, isReactComponent, setNativeSelection } from '@alilc/lowcode-utils'; -import LowCodeRenderer from '@alilc/lowcode-rax-renderer'; -import { computed, observable as obx, untracked, makeObservable, configure } from 'mobx'; -import DriverUniversal from 'driver-universal'; -import { EventEmitter } from 'events'; -import { createMemoryHistory, MemoryHistory } from 'history'; -// @ts-ignore -import Rax, { ComponentType, createElement, render as raxRender, shared } from 'rax'; -import Leaf from './builtin-components/leaf'; -import Slot from './builtin-components/slot'; -import { host } from './host'; -import SimulatorRendererView from './renderer-view'; -import { raxFindDOMNodes } from './utils/find-dom-nodes'; -import { getClientRects } from './utils/get-client-rects'; -import loader from './utils/loader'; -import { parseQuery, withQueryParams } from './utils/url'; - -configure({ enforceActions: 'never' }); -const { Instance } = shared; - -export interface LibraryMap { - [key: string]: string; -} - -const SYMBOL_VNID = Symbol('_LCNodeId'); -const SYMBOL_VDID = Symbol('_LCDocId'); - -const INTERNAL = '_internal'; - -function accessLibrary(library: string | object) { - if (typeof library !== 'string') { - return library; - } - - return (window as any)[library]; -} - -// Slot/Leaf and Fragment|FunctionComponent polyfill(ref) - -const builtinComponents = { - Slot, - Leaf, -}; - -function buildComponents( - libraryMap: LibraryMap, - componentsMap: { [componentName: string]: NpmInfo | ComponentType<any> | ComponentSchema }, - createComponent: (schema: ComponentSchema) => Component | null, -) { - const components: any = { - ...builtinComponents, - }; - Object.keys(componentsMap).forEach((componentName) => { - let component = componentsMap[componentName]; - if (component && (component as ComponentSchema).componentName === 'Component') { - components[componentName] = createComponent(component as ComponentSchema); - } else if (isReactComponent(component)) { - components[componentName] = component; - } else { - component = findComponent(libraryMap, componentName, component as NpmInfo); - if (component) { - components[componentName] = component; - } - } - }); - return components; -} - -let REACT_KEY = ''; -function cacheReactKey(el: Element): Element { - if (REACT_KEY !== '') { - return el; - } - REACT_KEY = Object.keys(el).find((key) => key.startsWith('__reactInternalInstance$')) || ''; - if (!REACT_KEY && (el as HTMLElement).parentElement) { - return cacheReactKey((el as HTMLElement).parentElement!); - } - return el; -} - -function checkInstanceMounted(instance: any): boolean { - if (isElement(instance)) { - return instance.parentElement != null; - } - return true; -} - -function isValidDesignModeRaxComponentInstance( - raxComponentInst: any, -): raxComponentInst is { - props: { - _leaf: Exclude<NodeInstance<any>['node'], null | undefined>; - }; -} { - const leaf = raxComponentInst?.props?._leaf; - return leaf && typeof leaf === 'object' && leaf.isNode; -} - -export class DocumentInstance { - private instancesMap = new Map<string, any[]>(); - - private emitter = new EventEmitter(); - - get schema(): any { - return this.document.export(TransformStage.Render); - } - - constructor(readonly container: SimulatorRendererContainer, readonly document: DocumentModel) { - makeObservable(this); - } - - @computed get suspended(): any { - return false; - } - - @computed get scope(): any { - return null; - } - - get path(): string { - return `/${ this.document.fileName}`; - } - - get id() { - return this.document.id; - } - - private unmountIntance(id: string, instance: any) { - const instances = this.instancesMap.get(id); - if (instances) { - const i = instances.indexOf(instance); - if (i > -1) { - instances.splice(i, 1); - host.setInstance(this.document.id, id, instances); - } - } - } - - refresh() { - this.emitter.emit('rerender', { shouldRemount: true }); - } - - onReRender(fn: () => void) { - this.emitter.on('rerender', fn); - return () => { - this.emitter.removeListener('renderer', fn); - }; - } - - mountInstance(id: string, instance: any) { - const docId = this.document.id; - const { instancesMap } = this; - if (instance == null) { - let instances = this.instancesMap.get(id); - if (instances) { - instances = instances.filter(checkInstanceMounted); - if (instances.length > 0) { - instancesMap.set(id, instances); - host.setInstance(this.document.id, id, instances); - } else { - instancesMap.delete(id); - host.setInstance(this.document.id, id, null); - } - } - return; - } - const unmountIntance = this.unmountIntance.bind(this); - const origId = (instance as any)[SYMBOL_VNID]; - if (origId && origId !== id) { - // 另外一个节点的 instance 在此被复用了,需要从原来地方卸载 - unmountIntance(origId, instance); - } - if (isElement(instance)) { - cacheReactKey(instance); - } else if (origId !== id) { - // 涵盖 origId == null || origId !== id 的情况 - let origUnmount: any = instance.componentWillUnmount; - if (origUnmount && origUnmount.origUnmount) { - origUnmount = origUnmount.origUnmount; - } - // hack! delete instance from map - const newUnmount = function (this: any) { - unmountIntance(id, instance); - origUnmount && origUnmount.call(this); - }; - (newUnmount as any).origUnmount = origUnmount; - instance.componentWillUnmount = newUnmount; - } - - (instance as any)[SYMBOL_VNID] = id; - (instance as any)[SYMBOL_VDID] = docId; - let instances = this.instancesMap.get(id); - if (instances) { - const l = instances.length; - instances = instances.filter(checkInstanceMounted); - let updated = instances.length !== l; - if (!instances.includes(instance)) { - instances.push(instance); - updated = true; - } - if (!updated) { - return; - } - } else { - instances = [instance]; - } - instancesMap.set(id, instances); - host.setInstance(this.document.id, id, instances); - } - - mountContext(docId: string, id: string, ctx: object) { - // this.ctxMap.set(id, ctx); - } - - getComponentInstances(id: string): any[] | null { - return this.instancesMap.get(id) || null; - } - - getNode(id: string): Node<NodeSchema> | null { - return this.document.getNode(id); - } -} - -export class SimulatorRendererContainer implements BuiltinSimulatorRenderer { - readonly isSimulatorRenderer = true; - private dispose?: () => void; - readonly history: MemoryHistory; - - private emitter = new EventEmitter(); - - @obx.ref private _documentInstances: DocumentInstance[] = []; - get documentInstances() { - return this._documentInstances; - } - - get currentDocumentInstance() { - return this._documentInstances.find((item) => item.id === host.project.currentDocument?.id); - } - - constructor() { - this.dispose = host.connect(this, () => { - // sync layout config - // debugger; - this._layout = host.project.get('config').layout; - // todo: split with others, not all should recompute - if (this._libraryMap !== host.libraryMap || this._componentsMap !== host.designer.componentsMap) { - this._libraryMap = host.libraryMap || {}; - this._componentsMap = host.designer.componentsMap; - this.buildComponents(); - } - - // sync designMode - this._designMode = host.designMode; - - // sync requestHandlersMap - this._requestHandlersMap = host.requestHandlersMap; - - // sync device - this._device = host.device; - - this.emitter.emit('layoutChange'); - }); - const documentInstanceMap = new Map<string, DocumentInstance>(); - let initialEntry = '/'; - let firstRun = true; - host.autorun(() => { - this._documentInstances = host.project.documents.map((doc) => { - let inst = documentInstanceMap.get(doc.id); - if (!inst) { - inst = new DocumentInstance(this, doc); - documentInstanceMap.set(doc.id, inst); - } - return inst; - }); - - const path = host.project.currentDocument ? documentInstanceMap.get(host.project.currentDocument.id)!.path : '/'; - if (firstRun) { - initialEntry = path; - firstRun = false; - } else { - if (this.history.location.pathname !== path) { - this.history.replace(path); - } - this.emitter.emit('layoutChange'); - } - }); - const history = createMemoryHistory({ - initialEntries: [initialEntry], - }); - this.history = history; - history.listen(({ location }) => { - host.project.open(location.pathname.substr(1)); - }); - host.componentsConsumer.consume(async (componentsAsset) => { - if (componentsAsset) { - await this.load(componentsAsset); - this.buildComponents(); - } - }); - this._appContext = { - utils: { - router: { - push(path: string, params?: object) { - history.push(withQueryParams(path, params)); - }, - replace(path: string, params?: object) { - history.replace(withQueryParams(path, params)); - }, - back() { - history.back(); - }, - }, - legaoBuiltins: { - getUrlParams() { - const { search } = history.location; - return parseQuery(search); - }, - }, - }, - constants: {}, - requestHandlersMap: this._requestHandlersMap, - }; - host.injectionConsumer.consume((data) => { - // sync utils, i18n, contants,... config - }); - } - - @obx private _layout: any = null; - @computed get layout(): any { - // TODO: parse layout Component - return this._layout; - } - set layout(value: any) { - this._layout = value; - } - - private _libraryMap: { [key: string]: string } = {}; - private buildComponents() { - // TODO: remove this.createComponent - this._components = buildComponents(this._libraryMap, this._componentsMap, this.createComponent.bind(this)); - } - @obx.ref private _components: any = {}; - @computed get components(): object { - // 根据 device 选择不同组件,进行响应式 - // 更好的做法是,根据 device 选择加载不同的组件资源,甚至是 simulatorUrl - return this._components; - } - // context from: utils、constants、history、location、match - @obx.ref private _appContext = {}; - @computed get context(): any { - return this._appContext; - } - @obx.ref private _designMode: string = 'design'; - @computed get designMode(): any { - return this._designMode; - } - @obx.ref private _device: string = 'default'; - @computed get device() { - return this._device; - } - @obx.ref private _requestHandlersMap = null; - @computed get requestHandlersMap(): any { - return this._requestHandlersMap; - } - @obx.ref private _componentsMap = {}; - @computed get componentsMap(): any { - return this._componentsMap; - } - /** - * 加载资源 - */ - load(asset: Asset): Promise<any> { - return loader.load(asset); - } - - getComponent(componentName: string) { - const paths = componentName.split('.'); - const subs: string[] = []; - - while (true) { - const component = this._components[componentName]; - if (component) { - return getSubComponent(component, subs); - } - - const sub = paths.pop(); - if (!sub) { - return null; - } - subs.unshift(sub); - componentName = paths.join('.'); - } - } - - getNodeInstance(dom: HTMLElement): NodeInstance<any> | null { - const INTERNAL = '_internal'; - let instance: any = dom; - if (!isElement(instance)) { - return { - docId: instance.props._leaf.document.id, - nodeId: instance.props._leaf.getId(), - instance, - node: instance.props._leaf, - }; - } - instance = Instance.get(dom); - - let loopNum = 0; // 防止由于某种意外而导致死循环 - while (instance && instance[INTERNAL] && loopNum < 1000) { - if (isValidDesignModeRaxComponentInstance(instance)) { - // if (instance && SYMBOL_VNID in instance) { - // const docId = (instance.props as any).schema.docId; - return { - docId: instance.props._leaf.document.id, - nodeId: instance.props._leaf.getId(), - instance, - node: instance.props._leaf, - }; - } - - instance = getRaxVDomParentInstance(instance); - loopNum += 1; - } - - return null; - } - - getClosestNodeInstance(from: any, nodeId?: string): NodeInstance<any> | null { - const el: any = from; - if (el) { - // if (isElement(el)) { - // el = cacheReactKey(el); - // } else { - // return getNodeInstance(el, specId); - // } - return this.getNodeInstance(el); - } - return null; - } - - findDOMNodes(instance: any, selector?: string): Array<Element | Text> | null { - let el = instance; - if (selector) { - el = document.querySelector(selector); - } - try { - return raxFindDOMNodes(el); - } catch (e) { - // ignore - } - if (el && el.type && el.props && el.props.componentId) { - el = document.querySelector(`${el.type}[componentid=${el.props.componentId}]`); - } else { - console.error(instance); - throw new Error('This instance may not a valid element'); - } - return raxFindDOMNodes(el); - } - - getClientRects(element: Element | Text) { - return getClientRects(element); - } - - setNativeSelection(enableFlag: boolean) { - setNativeSelection(enableFlag); - } - setDraggingState(state: boolean) { - cursor.setDragging(state); - } - setCopyState(state: boolean) { - cursor.setCopy(state); - } - clearState() { - cursor.release(); - } - - onLayoutChange(cb: () => void) { - this.emitter.on('layoutChange', cb); - return () => { - this.emitter.removeListener('layoutChange', cb); - }; - } - - onReRender(fn: () => void) { - this.emitter.on('rerender', fn); - return () => { - this.emitter.removeListener('renderer', fn); - }; - } - - rerender() { - this.currentDocumentInstance?.refresh(); - } - - createComponent(schema: NodeSchema): Component | null { - const _schema: any = { - ...compatibleLegaoSchema(schema), - }; - _schema.methods = {}; - _schema.lifeCycles = {}; - - if (schema.componentName === 'Component' && (schema as ComponentSchema).css) { - const doc = window.document; - const s = doc.createElement('style'); - s.setAttribute('type', 'text/css'); - s.setAttribute('id', `Component-${schema.id || ''}`); - s.appendChild(doc.createTextNode((schema as ComponentSchema).css || '')); - doc.getElementsByTagName('head')[0].appendChild(s); - } - - - const renderer = this; - const { componentsMap: components } = renderer; - - class LowCodeComp extends Rax.Component { - render() { - const extraProps = getLowCodeComponentProps(this.props); - // @ts-ignore - return createElement(LowCodeRenderer, { - ...extraProps, - schema: _schema, - components, - designMode: renderer.designMode, - device: renderer.device, - appHelper: renderer.context, - rendererName: 'LowCodeRenderer', - customCreateElement: (Comp: any, props: any, children: any) => { - const componentMeta = host.currentDocument?.getComponentMeta(Comp.displayName); - if (componentMeta?.isModal) { - return null; - } - - const { __id, __designMode, ...viewProps } = props; - // mock _leaf,减少性能开销 - const _leaf = { - isEmpty: () => false, - }; - viewProps._leaf = _leaf; - return createElement(Comp, viewProps, children); - }, - }); - } - } - - return LowCodeComp; - } - - private _running = false; - run() { - if (this._running) { - return; - } - this._running = true; - const containerId = 'app'; - let container = document.getElementById(containerId); - if (!container) { - container = document.createElement('div'); - document.body.appendChild(container); - container.id = containerId; - } - - // ==== compatiable vision - document.documentElement.classList.add('engine-page'); - document.body.classList.add('engine-document'); // important! Stylesheet.invoke depends - - raxRender(createElement(SimulatorRendererView, { - rendererContainer: this, - }), container, { - driver: DriverUniversal, - }); - host.project.setRendererReady(this); - } -} - -function getSubComponent(library: any, paths: string[]) { - const l = paths.length; - if (l < 1 || !library) { - return library; - } - let i = 0; - let component: any; - while (i < l) { - const key = paths[i]!; - let ex: any; - try { - component = library[key]; - } catch (e) { - ex = e; - component = null; - } - if (i === 0 && component == null && key === 'default') { - if (ex) { - return l === 1 ? library : null; - } - component = library; - } else if (component == null) { - return null; - } - library = component; - i++; - } - return component; -} - -function findComponent(libraryMap: LibraryMap, componentName: string, npm?: NpmInfo) { - if (!npm) { - return accessLibrary(componentName); - } - // libraryName the key access to global - // export { exportName } from xxx exportName === global.libraryName.exportName - // export exportName from xxx exportName === global.libraryName.default || global.libraryName - // export { exportName as componentName } from package - // if exportName == null exportName === componentName; - // const componentName = exportName.subName, if exportName empty subName donot use - const exportName = npm.exportName || npm.componentName || componentName; - const libraryName = libraryMap[npm.package] || exportName; - const library = accessLibrary(libraryName); - const paths = npm.exportName && npm.subName ? npm.subName.split('.') : []; - if (npm.destructuring) { - paths.unshift(exportName); - } else if (isESModule(library)) { - paths.unshift('default'); - } - return getSubComponent(library, paths); -} - -function getLowCodeComponentProps(props: any) { - if (!props || !isPlainObject(props)) { - return props; - } - const newProps: any = {}; - Object.keys(props).forEach(k => { - if (['children', 'componentId', '__designMode', '_componentName', '_leaf'].includes(k)) { - return; - } - newProps[k] = props[k]; - }); - return newProps; -} - -/** - * 获取 Rax 里面 VDOM 的上一级的实例 - * 注意:Rax 的 development 的包是带有 __parentInstance, - * 但是 production 的包 __parentInstance 会被压缩掉, - * 所以这里遍历下其中的所有值,尝试找到有 _internal 的那个(别的值不会带有这个属性的) - */ -function getRaxVDomParentInstance(instance: { _internal: any }) { - const internalInstance = instance._internal; - return internalInstance.__parentInstance || - Object.values(internalInstance).find(v => ( - v !== null && - v !== instance && - typeof v === 'object' && - typeof (v as {_internal: unknown})._internal === 'object' - )); -} - -export default new SimulatorRendererContainer(); diff --git a/packages/rax-simulator-renderer/src/utils/create-defer.ts b/packages/rax-simulator-renderer/src/utils/create-defer.ts deleted file mode 100644 index e7997365a0..0000000000 --- a/packages/rax-simulator-renderer/src/utils/create-defer.ts +++ /dev/null @@ -1,17 +0,0 @@ -export interface Defer<T = any> { - resolve(value?: T | PromiseLike<T>): void; - reject(reason?: any): void; - promise(): Promise<T>; -} - -export function createDefer<T = any>(): Defer<T> { - const r: any = {}; - const promise = new Promise<T>((resolve, reject) => { - r.resolve = resolve; - r.reject = reject; - }); - - r.promise = () => promise; - - return r; -} diff --git a/packages/rax-simulator-renderer/src/utils/find-dom-nodes.ts b/packages/rax-simulator-renderer/src/utils/find-dom-nodes.ts deleted file mode 100644 index 106af2efac..0000000000 --- a/packages/rax-simulator-renderer/src/utils/find-dom-nodes.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { isElement } from '@alilc/lowcode-utils'; -import findDOMNode from 'rax-find-dom-node'; -// import { isDOMNode } from './is-dom-node'; - -export function raxFindDOMNodes(instance: any): Array<Element | Text> | null { - if (!instance) { - return null; - } - if (isElement(instance)) { - return [instance]; - } - // eslint-disable-next-line react/no-find-dom-node - const result = findDOMNode(instance); - if (Array.isArray(result)) { - return result; - } - return [result]; -} diff --git a/packages/rax-simulator-renderer/src/utils/get-client-rects.ts b/packages/rax-simulator-renderer/src/utils/get-client-rects.ts deleted file mode 100644 index dd13aba81e..0000000000 --- a/packages/rax-simulator-renderer/src/utils/get-client-rects.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { isElement } from '@alilc/lowcode-utils'; - -// a range for test TextNode clientRect -const cycleRange = document.createRange(); - -export function getClientRects(node: Element | Text) { - if (isElement(node)) { - return [node.getBoundingClientRect()]; - } - - cycleRange.selectNode(node); - return Array.from(cycleRange.getClientRects()); -} diff --git a/packages/rax-simulator-renderer/src/utils/get-device-view.ts b/packages/rax-simulator-renderer/src/utils/get-device-view.ts deleted file mode 100644 index 9005562599..0000000000 --- a/packages/rax-simulator-renderer/src/utils/get-device-view.ts +++ /dev/null @@ -1,23 +0,0 @@ -function ucfirst(s: string) { - return s.charAt(0).toUpperCase() + s.substring(1); -} -function getDeviceView(view: any, device: string, mode: string) { - if (!view || typeof view === 'string') { - return view; - } - - // compatible vision Mobile | Preview - device = ucfirst(device); - if (device === 'Mobile' && view.hasOwnProperty(device)) { - view = view[device]; - } - mode = ucfirst(mode); - if (mode === 'Preview' && view.hasOwnProperty(mode)) { - view = view[mode]; - } - return view; -} - -export default { - getDeviceView, -}; diff --git a/packages/rax-simulator-renderer/src/utils/is-dom-node.ts b/packages/rax-simulator-renderer/src/utils/is-dom-node.ts deleted file mode 100644 index bfbeb79c1f..0000000000 --- a/packages/rax-simulator-renderer/src/utils/is-dom-node.ts +++ /dev/null @@ -1,4 +0,0 @@ -export function isDOMNode(node: any): node is Element | Text { - if (!node) return false; - return node.nodeType && (node.nodeType === Node.ELEMENT_NODE || node.nodeType === Node.TEXT_NODE); -} diff --git a/packages/rax-simulator-renderer/src/utils/loader.ts b/packages/rax-simulator-renderer/src/utils/loader.ts deleted file mode 100644 index 436e51c441..0000000000 --- a/packages/rax-simulator-renderer/src/utils/loader.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { load, evaluate } from './script'; -import StylePoint from './style'; -import { - Asset, - AssetLevel, - AssetLevels, - AssetType, - AssetList, - isAssetBundle, - isAssetItem, - assetItem, - AssetItem, - isCSSUrl, -} from '@alilc/lowcode-utils'; - -function parseAssetList(scripts: any, styles: any, assets: AssetList, level?: AssetLevel) { - for (const asset of assets) { - parseAsset(scripts, styles, asset, level); - } -} - -function parseAsset(scripts: any, styles: any, asset: Asset | undefined | null, level?: AssetLevel) { - if (!asset) { - return; - } - if (Array.isArray(asset)) { - return parseAssetList(scripts, styles, asset, level); - } - - if (isAssetBundle(asset)) { - if (asset.assets) { - if (Array.isArray(asset.assets)) { - parseAssetList(scripts, styles, asset.assets, asset.level || level); - } else { - parseAsset(scripts, styles, asset.assets, asset.level || level); - } - return; - } - return; - } - - if (!isAssetItem(asset)) { - asset = assetItem(isCSSUrl(asset) ? AssetType.CSSUrl : AssetType.JSUrl, asset, level)!; - } - - let lv = asset.level || level; - - if (!lv || AssetLevel[lv] == null) { - lv = AssetLevel.App; - } - - asset.level = lv; - if (asset.type === AssetType.CSSUrl || asset.type == AssetType.CSSText) { - styles[lv].push(asset); - } else { - scripts[lv].push(asset); - } -} - -export class AssetLoader { - async load(asset: Asset) { - const styles: any = {}; - const scripts: any = {}; - AssetLevels.forEach(lv => { - styles[lv] = []; - scripts[lv] = []; - }); - parseAsset(scripts, styles, asset); - const styleQueue: AssetItem[] = styles[AssetLevel.Environment].concat( - styles[AssetLevel.Library], - styles[AssetLevel.Theme], - styles[AssetLevel.Runtime], - styles[AssetLevel.App], - ); - const scriptQueue: AssetItem[] = scripts[AssetLevel.Environment].concat( - scripts[AssetLevel.Library], - scripts[AssetLevel.Theme], - scripts[AssetLevel.Runtime], - scripts[AssetLevel.App], - ); - await Promise.all( - styleQueue.map(({ content, level, type, id }) => this.loadStyle(content, level!, type === AssetType.CSSUrl, id)), - ); - await Promise.all(scriptQueue.map(({ content, type }) => this.loadScript(content, type === AssetType.JSUrl))); - } - - private stylePoints = new Map<string, StylePoint>(); - - private loadStyle(content: string | undefined | null, level: AssetLevel, isUrl?: boolean, id?: string) { - if (!content) { - return; - } - let point: StylePoint | undefined; - if (id) { - point = this.stylePoints.get(id); - if (!point) { - point = new StylePoint(level, id); - this.stylePoints.set(id, point); - } - } else { - point = new StylePoint(level); - } - return isUrl ? point.applyUrl(content) : point.applyText(content); - } - - private loadScript(content: string | undefined | null, isUrl?: boolean) { - if (!content) { - return; - } - return isUrl ? load(content) : evaluate(content); - } -} - -export default new AssetLoader(); diff --git a/packages/rax-simulator-renderer/src/utils/script.ts b/packages/rax-simulator-renderer/src/utils/script.ts deleted file mode 100644 index 81841ff6d2..0000000000 --- a/packages/rax-simulator-renderer/src/utils/script.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { createDefer } from './create-defer'; - -export function evaluate(script: string) { - const scriptEl = document.createElement('script'); - scriptEl.text = script; - document.head.appendChild(scriptEl); - document.head.removeChild(scriptEl); -} - -export function load(url: string) { - const node: any = document.createElement('script'); - - // node.setAttribute('crossorigin', 'anonymous'); - - node.onload = onload; - node.onerror = onload; - - const i = createDefer(); - - function onload(e: any) { - node.onload = null; - node.onerror = null; - if (e.type === 'load') { - i.resolve(); - } else { - i.reject(); - } - // document.head.removeChild(node); - // node = null; - } - - // node.async = true; - node.src = url; - - document.head.appendChild(node); - - return i.promise(); -} - -export function evaluateExpression(expr: string) { - // eslint-disable-next-line no-new-func - const fn = new Function(expr); - return fn(); -} - -export function newFunction(args: string, code: string) { - try { - // eslint-disable-next-line no-new-func - return new Function(args, code); - } catch (e) { - console.warn('Caught error, Cant init func'); - return null; - } -} diff --git a/packages/rax-simulator-renderer/src/utils/style.ts b/packages/rax-simulator-renderer/src/utils/style.ts deleted file mode 100644 index 91dbbc6345..0000000000 --- a/packages/rax-simulator-renderer/src/utils/style.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { createDefer } from './create-defer'; - -export default class StylePoint { - private lastContent: string | undefined; - - private lastUrl: string | undefined; - - private placeholder: Element | Text; - - constructor(readonly level: number, readonly id?: string) { - let placeholder: any; - if (id) { - placeholder = document.head.querySelector(`style[data-id="${id}"]`); - } - if (!placeholder) { - placeholder = document.createTextNode(''); - const meta = document.head.querySelector(`meta[level="${level}"]`); - if (meta) { - document.head.insertBefore(placeholder, meta); - } else { - document.head.appendChild(placeholder); - } - } - this.placeholder = placeholder; - } - - applyText(content: string) { - if (this.lastContent === content) { - return; - } - this.lastContent = content; - this.lastUrl = undefined; - const element = document.createElement('style'); - element.setAttribute('type', 'text/css'); - if (this.id) { - element.setAttribute('data-id', this.id); - } - element.appendChild(document.createTextNode(content)); - document.head.insertBefore(element, this.placeholder.parentNode === document.head ? this.placeholder.nextSibling : null); - document.head.removeChild(this.placeholder); - this.placeholder = element; - } - - applyUrl(url: string) { - if (this.lastUrl === url) { - return; - } - this.lastContent = undefined; - this.lastUrl = url; - const element = document.createElement('link'); - element.onload = onload; - element.onerror = onload; - - const i = createDefer(); - function onload(e: any) { - element.onload = null; - element.onerror = null; - if (e.type === 'load') { - i.resolve(); - } else { - i.reject(); - } - } - - element.href = url; - element.rel = 'stylesheet'; - if (this.id) { - element.setAttribute('data-id', this.id); - } - document.head.insertBefore(element, this.placeholder.parentNode === document.head ? this.placeholder.nextSibling : null); - document.head.removeChild(this.placeholder); - this.placeholder = element; - return i.promise(); - } -} diff --git a/packages/rax-simulator-renderer/src/utils/url.ts b/packages/rax-simulator-renderer/src/utils/url.ts deleted file mode 100644 index d720323b3a..0000000000 --- a/packages/rax-simulator-renderer/src/utils/url.ts +++ /dev/null @@ -1,74 +0,0 @@ -/** - * Parse queryString - * @param {String} str '?q=query&b=test' - * @return {Object} - */ -export function parseQuery(str: string): object { - const ret: any = {}; - - if (typeof str !== 'string') { - return ret; - } - - const s = str.trim().replace(/^(\?|#|&)/, ''); - - if (!s) { - return ret; - } - - s.split('&').forEach((param) => { - const parts = param.replace(/\+/g, ' ').split('='); - let key = parts.shift()!; - let val: any = parts.length > 0 ? parts.join('=') : undefined; - - key = decodeURIComponent(key); - - val = val === undefined ? null : decodeURIComponent(val); - - if (ret[key] === undefined) { - ret[key] = val; - } else if (Array.isArray(ret[key])) { - ret[key].push(val); - } else { - ret[key] = [ret[key], val]; - } - }); - - return ret; -} - -/** - * Stringify object to query parammeters - * @param {Object} obj - * @return {String} - */ -export function stringifyQuery(obj: any): string { - const param: string[] = []; - Object.keys(obj).forEach((key) => { - let value = obj[key]; - if (value && typeof value === 'object') { - value = JSON.stringify(value); - } - param.push(`${encodeURIComponent(key)}=${encodeURIComponent(value)}`); - }); - return param.join('&'); -} - -export function uriEncode(uri: string) { - return encodeURIComponent(uri); -} - -export function uriDecode(uri: string) { - return decodeURIComponent(uri); -} - -export function withQueryParams(url: string, params?: object) { - const queryStr = params ? stringifyQuery(params) : ''; - if (queryStr === '') { - return url; - } - const urlSplit = url.split('#'); - const hash = urlSplit[1] ? `#${urlSplit[1]}` : ''; - const urlWithoutHash = urlSplit[0]; - return `${urlWithoutHash}${~urlWithoutHash.indexOf('?') ? '&' : '?'}${queryStr}${hash}`; -} diff --git a/packages/react-renderer/README.md b/packages/react-renderer/README.md new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/react-renderer/build.json b/packages/react-renderer/build.json index e791d5b6b3..d0aec10385 100644 --- a/packages/react-renderer/build.json +++ b/packages/react-renderer/build.json @@ -1,6 +1,6 @@ { "plugins": [ - "build-plugin-component", + "@alilc/build-plugin-lce", "build-plugin-fusion", ["build-plugin-moment-locales", { "locales": ["zh-cn"] diff --git a/packages/react-renderer/build.test.json b/packages/react-renderer/build.test.json index dcdc891e93..9cc30d7463 100644 --- a/packages/react-renderer/build.test.json +++ b/packages/react-renderer/build.test.json @@ -1,6 +1,6 @@ { "plugins": [ - "build-plugin-component", + "@alilc/build-plugin-lce", "@alilc/lowcode-test-mate/plugin/index.ts" ] } diff --git a/packages/react-renderer/demo/compose.md b/packages/react-renderer/demo/compose.md index c432f3e971..b828cd6041 100644 --- a/packages/react-renderer/demo/compose.md +++ b/packages/react-renderer/demo/compose.md @@ -6,7 +6,7 @@ order: 2 ````jsx import React, { PureComponent } from 'react'; import ReactDOM from 'react-dom'; -import ReactRenderer from '@ali/lowcode-react-renderer'; +import ReactRenderer from '@alilc/lowcode-react-renderer'; import schema from './schemas/compose'; import components from './config/components/index'; import utils from './config/utils'; diff --git a/packages/react-renderer/demo/dataSource.md b/packages/react-renderer/demo/dataSource.md index 7167c80254..5ec00ae8b9 100644 --- a/packages/react-renderer/demo/dataSource.md +++ b/packages/react-renderer/demo/dataSource.md @@ -6,7 +6,7 @@ order: 4 ````jsx import React, { PureComponent } from 'react'; import ReactDOM from 'react-dom'; -import ReactRenderer from '@ali/lowcode-react-renderer'; +import ReactRenderer from '@alilc/lowcode-react-renderer'; import schema from './schemas/dataSource'; import components from './config/components/index'; import utils from './config/utils'; diff --git a/packages/react-renderer/demo/i18n.md b/packages/react-renderer/demo/i18n.md index ebca94fae3..bbea036d19 100644 --- a/packages/react-renderer/demo/i18n.md +++ b/packages/react-renderer/demo/i18n.md @@ -6,7 +6,7 @@ order: 5 ````jsx import React, { PureComponent } from 'react'; import ReactDOM from 'react-dom'; -import ReactRenderer from '@ali/lowcode-react-renderer'; +import ReactRenderer from '@alilc/lowcode-react-renderer'; import schema from './schemas/i18n'; import components from './config/components/index'; import utils from './config/utils'; diff --git a/packages/react-renderer/demo/list.md b/packages/react-renderer/demo/list.md index 5d5888f4f7..d0e34ee685 100644 --- a/packages/react-renderer/demo/list.md +++ b/packages/react-renderer/demo/list.md @@ -6,7 +6,7 @@ order: 1 ````jsx import React, { PureComponent } from 'react'; import ReactDOM from 'react-dom'; -import ReactRenderer from '@ali/lowcode-react-renderer'; +import ReactRenderer from '@alilc/lowcode-react-renderer'; import schema from './schemas/list'; import components from './config/components/index'; import utils from './config/utils'; diff --git a/packages/react-renderer/demo/table.md b/packages/react-renderer/demo/table.md index 9bdacf36cb..3c7cb307ca 100644 --- a/packages/react-renderer/demo/table.md +++ b/packages/react-renderer/demo/table.md @@ -6,7 +6,7 @@ order: 1 ````jsx import React, { PureComponent } from 'react'; import ReactDOM from 'react-dom'; -import ReactRenderer from '@ali/lowcode-react-renderer'; +import ReactRenderer from '@alilc/lowcode-react-renderer'; import schema from './schemas/table'; import components from './config/components/index'; import utils from './config/utils'; diff --git a/packages/react-renderer/jest.config.js b/packages/react-renderer/jest.config.js index 865d301160..df1400719b 100644 --- a/packages/react-renderer/jest.config.js +++ b/packages/react-renderer/jest.config.js @@ -1,11 +1,17 @@ -const esModules = ['@recore/obx-react'].join('|'); +const fs = require('fs'); +const { join } = require('path'); +const esModules = [].join('|'); +const pkgNames = fs.readdirSync(join('..')).filter(pkgName => !pkgName.startsWith('.')); -module.exports = { +const jestConfig = { // transform: { // '^.+\\.[jt]sx?$': 'babel-jest', // // '^.+\\.(ts|tsx)$': 'ts-jest', // // '^.+\\.(js|jsx)$': 'babel-jest', // }, + // testMatch: ['**/document/node/node.test.ts'], + // testMatch: ['**/designer/builtin-hotkey.test.ts'], + // testMatch: ['**/plugin/plugin-manager.test.ts'], // testMatch: ['(/tests?/.*(test))\\.[jt]s$'], transformIgnorePatterns: [ `/node_modules/(?!${esModules})/`, @@ -13,8 +19,14 @@ module.exports = { moduleFileExtensions: ['ts', 'tsx', 'js', 'json'], collectCoverage: true, collectCoverageFrom: [ - 'src/**/*.{ts,tsx}', + 'src/**/*.ts', + '!src/**/*.d.ts', '!**/node_modules/**', - '!**/vendor/**', ], }; + +// 只对本仓库内的 pkg 做 mapping +jestConfig.moduleNameMapper = {}; +jestConfig.moduleNameMapper[`^@alilc/lowcode\\-(${pkgNames.join('|')})$`] = '<rootDir>/../$1/src'; + +module.exports = jestConfig; \ No newline at end of file diff --git a/packages/react-renderer/package.json b/packages/react-renderer/package.json index e289f59a2b..625801bc25 100644 --- a/packages/react-renderer/package.json +++ b/packages/react-renderer/package.json @@ -1,6 +1,6 @@ { "name": "@alilc/lowcode-react-renderer", - "version": "1.0.3", + "version": "1.3.2", "description": "react renderer for ali lowcode engine", "main": "lib/index.js", "module": "es/index.js", @@ -12,7 +12,7 @@ "scripts": { "test": "build-scripts test --config build.test.json", "start": "build-scripts start", - "build": "build-scripts build --skip-demo", + "build": "build-scripts build", "build:umd": "NODE_OPTIONS=--max_old_space_size=8192 build-scripts build --config build.umd.json" }, "keywords": [ @@ -22,12 +22,11 @@ ], "dependencies": { "@alifd/next": "^1.21.16", - "@alilc/lowcode-renderer-core": "1.0.3" + "@alilc/lowcode-renderer-core": "1.3.2" }, "devDependencies": { "@alib/build-scripts": "^0.1.18", "@alifd/next": "^1.19.17", - "build-plugin-component": "^0.2.10", "build-plugin-fusion": "^0.1.0", "build-plugin-moment-locales": "^0.1.0", "react": "^16.4.1", @@ -42,6 +41,7 @@ "type": "http", "url": "https://github.com/alibaba/lowcode-engine/tree/main/packages/react-renderer" }, - "homepage": "https://unpkg.alibaba-inc.com/@alilc/lowcode-react-renderer@1.0.21/build/index.html", - "gitHead": "2669f179e6f899d395ce1942d0fe04f9c5ed48a6" + "homepage": "https://github.com/alibaba/lowcode-engine/#readme", + "gitHead": "2669f179e6f899d395ce1942d0fe04f9c5ed48a6", + "bugs": "https://github.com/alibaba/lowcode-engine/issues" } diff --git a/packages/react-simulator-renderer/.babelrc b/packages/react-simulator-renderer/.babelrc new file mode 100644 index 0000000000..469e76ee64 --- /dev/null +++ b/packages/react-simulator-renderer/.babelrc @@ -0,0 +1,6 @@ +{ + "plugins": [ + ["@babel/plugin-proposal-decorators", { "legacy": true }], + ["@babel/plugin-proposal-class-properties", { "loose": true }] + ] +} diff --git a/packages/react-simulator-renderer/babel.config.js b/packages/react-simulator-renderer/babel.config.js new file mode 100644 index 0000000000..c5986f2bc0 --- /dev/null +++ b/packages/react-simulator-renderer/babel.config.js @@ -0,0 +1 @@ +module.exports = require('../../babel.config'); \ No newline at end of file diff --git a/packages/react-simulator-renderer/build.json b/packages/react-simulator-renderer/build.json index b95a17aafe..e7ae1dcf7a 100644 --- a/packages/react-simulator-renderer/build.json +++ b/packages/react-simulator-renderer/build.json @@ -1,3 +1,3 @@ { - "plugins": ["build-plugin-component", "./build.plugin.js"] + "plugins": ["@alilc/build-plugin-lce", "./build.plugin.js"] } diff --git a/packages/react-simulator-renderer/build.test.json b/packages/react-simulator-renderer/build.test.json new file mode 100644 index 0000000000..9cc30d7463 --- /dev/null +++ b/packages/react-simulator-renderer/build.test.json @@ -0,0 +1,6 @@ +{ + "plugins": [ + "@alilc/build-plugin-lce", + "@alilc/lowcode-test-mate/plugin/index.ts" + ] +} diff --git a/packages/react-simulator-renderer/jest.config.js b/packages/react-simulator-renderer/jest.config.js new file mode 100644 index 0000000000..5378ef5380 --- /dev/null +++ b/packages/react-simulator-renderer/jest.config.js @@ -0,0 +1,33 @@ +const fs = require('fs'); +const { join } = require('path'); +const esModules = [].join('|'); +const pkgNames = fs.readdirSync(join('..')).filter(pkgName => !pkgName.startsWith('.')); + +const jestConfig = { + // transform: { + // '^.+\\.[jt]sx?$': 'babel-jest', + // // '^.+\\.(ts|tsx)$': 'ts-jest', + // // '^.+\\.(js|jsx)$': 'babel-jest', + // }, + // testMatch: ['**/document/node/node.test.ts'], + // testMatch: ['**/designer/builtin-hotkey.test.ts'], + // testMatch: ['**/plugin/plugin-manager.test.ts'], + // testMatch: ['(/tests?/.*(test))\\.[jt]s$'], + transformIgnorePatterns: [ + `/node_modules/(?!${esModules})/`, + ], + setupFiles: ['./test/utils/host.ts'], + moduleFileExtensions: ['ts', 'tsx', 'js', 'json'], + collectCoverage: true, + collectCoverageFrom: [ + 'src/**/*.ts', + '!src/**/*.d.ts', + '!**/node_modules/**', + ], +}; + +// 只对本仓库内的 pkg 做 mapping +jestConfig.moduleNameMapper = {}; +jestConfig.moduleNameMapper[`^@alilc/lowcode\\-(${pkgNames.join('|')})$`] = '<rootDir>/../$1/src'; + +module.exports = jestConfig; \ No newline at end of file diff --git a/packages/react-simulator-renderer/package.json b/packages/react-simulator-renderer/package.json index 4ceb1c1e0d..3c3950a124 100644 --- a/packages/react-simulator-renderer/package.json +++ b/packages/react-simulator-renderer/package.json @@ -1,6 +1,6 @@ { "name": "@alilc/lowcode-react-simulator-renderer", - "version": "1.0.3", + "version": "1.3.2", "description": "react simulator renderer for alibaba lowcode designer", "main": "lib/index.js", "module": "es/index.js", @@ -11,14 +11,16 @@ "dist" ], "scripts": { - "build": "NODE_OPTIONS=--max_old_space_size=8192 build-scripts build --skip-demo", - "build:umd": "NODE_OPTIONS=--max_old_space_size=8192 build-scripts build --config build.umd.json" + "test": "build-scripts test --config build.test.json", + "build": "NODE_OPTIONS=--max_old_space_size=8192 build-scripts build", + "build:umd": "NODE_OPTIONS=--max_old_space_size=8192 build-scripts build --config build.umd.json", + "test:cov": "build-scripts test --config build.test.json --jest-coverage" }, "dependencies": { - "@alilc/lowcode-designer": "1.0.3", - "@alilc/lowcode-react-renderer": "1.0.3", - "@alilc/lowcode-types": "1.0.3", - "@alilc/lowcode-utils": "1.0.3", + "@alilc/lowcode-designer": "1.3.2", + "@alilc/lowcode-react-renderer": "1.3.2", + "@alilc/lowcode-types": "1.3.2", + "@alilc/lowcode-utils": "1.3.2", "classnames": "^2.2.6", "mobx": "^6.3.0", "mobx-react": "^7.2.0", @@ -31,8 +33,7 @@ "@types/node": "^13.7.1", "@types/react": "^16", "@types/react-dom": "^16", - "@types/react-router": "^5.1.17", - "build-plugin-component": "^0.2.11" + "@types/react-router": "5.1.18" }, "publishConfig": { "access": "public", @@ -42,5 +43,7 @@ "type": "http", "url": "https://github.com/alibaba/lowcode-engine/tree/main/packages/react-simulator-renderer" }, - "gitHead": "2669f179e6f899d395ce1942d0fe04f9c5ed48a6" + "gitHead": "2669f179e6f899d395ce1942d0fe04f9c5ed48a6", + "bugs": "https://github.com/alibaba/lowcode-engine/issues", + "homepage": "https://github.com/alibaba/lowcode-engine/#readme" } diff --git a/packages/react-simulator-renderer/src/builtin-components/slot.tsx b/packages/react-simulator-renderer/src/builtin-components/slot.tsx index 164304cb77..2dd44b978f 100644 --- a/packages/react-simulator-renderer/src/builtin-components/slot.tsx +++ b/packages/react-simulator-renderer/src/builtin-components/slot.tsx @@ -51,7 +51,7 @@ class Slot extends Component { render() { const { children } = this.props; - return <div className="lc-container">{children}</div>; + return <>{children}</>; } } diff --git a/packages/react-simulator-renderer/src/locale/en-US.json b/packages/react-simulator-renderer/src/locale/en-US.json new file mode 100644 index 0000000000..ac864c0a29 --- /dev/null +++ b/packages/react-simulator-renderer/src/locale/en-US.json @@ -0,0 +1,4 @@ +{ + "Drag and drop components or templates here": "Drag and drop components or templates here", + "Locked elements and child elements cannot be edited": "Locked elements and child elements cannot be edited" +} \ No newline at end of file diff --git a/packages/react-simulator-renderer/src/locale/index.ts b/packages/react-simulator-renderer/src/locale/index.ts new file mode 100644 index 0000000000..5f4ef01505 --- /dev/null +++ b/packages/react-simulator-renderer/src/locale/index.ts @@ -0,0 +1,21 @@ +import { createElement } from 'react'; +import enUS from './en-US.json'; +import zhCN from './zh-CN.json'; + +const instance: Record<string, Record<string, string>> = { + 'zh-CN': zhCN as Record<string, string>, + 'en-US': enUS as Record<string, string>, +}; + +export function createIntl(locale: string = 'zh-CN') { + const intl = (id: string) => { + return instance[locale]?.[id] || id; + }; + + const intlNode = (id: string) => createElement('span', instance[locale]?.[id] || id); + + return { + intl, + intlNode, + }; +} diff --git a/packages/react-simulator-renderer/src/locale/zh-CN.json b/packages/react-simulator-renderer/src/locale/zh-CN.json new file mode 100644 index 0000000000..74bb821dd2 --- /dev/null +++ b/packages/react-simulator-renderer/src/locale/zh-CN.json @@ -0,0 +1,4 @@ +{ + "Drag and drop components or templates here": "拖拽组件或模板到这里", + "Locked elements and child elements cannot be edited": "锁定元素及子元素无法编辑" +} \ No newline at end of file diff --git a/packages/react-simulator-renderer/src/renderer-view.tsx b/packages/react-simulator-renderer/src/renderer-view.tsx index 5647a5447b..aa1683cd22 100644 --- a/packages/react-simulator-renderer/src/renderer-view.tsx +++ b/packages/react-simulator-renderer/src/renderer-view.tsx @@ -4,12 +4,13 @@ import cn from 'classnames'; import { Node } from '@alilc/lowcode-designer'; import LowCodeRenderer from '@alilc/lowcode-react-renderer'; import { observer } from 'mobx-react'; -import { getClosestNode, isFromVC } from '@alilc/lowcode-utils'; +import { getClosestNode, isFromVC, isReactComponent } from '@alilc/lowcode-utils'; import { GlobalEvent } from '@alilc/lowcode-types'; import { SimulatorRendererContainer, DocumentInstance } from './renderer'; import { host } from './host'; - +import { isRendererDetached } from './utils/misc'; import './renderer.less'; +import { createIntl } from './locale'; // patch cloneElement avoid lost keyProps const originCloneElement = window.React.cloneElement; @@ -130,6 +131,7 @@ class Renderer extends Component<{ documentInstance: DocumentInstance; }> { startTime: number | null = null; + schemaChangedSymbol = false; componentDidUpdate() { this.recordTime(); @@ -139,7 +141,7 @@ class Renderer extends Component<{ if (this.startTime) { const time = Date.now() - this.startTime; const nodeCount = host.designer.currentDocument?.getNodeCount?.(); - host.designer.editor?.emit(GlobalEvent.Node.Rerender, { + host.designer.editor?.eventBus.emit(GlobalEvent.Node.Rerender, { componentName: 'Renderer', type: 'All', time, @@ -152,8 +154,6 @@ class Renderer extends Component<{ this.recordTime(); } - schemaChangedSymbol = false; - getSchemaChangedSymbol = () => { return this.schemaChangedSymbol; }; @@ -170,14 +170,17 @@ class Renderer extends Component<{ this.startTime = Date.now(); this.schemaChangedSymbol = false; - if (!container.autoRender) return null; + if (!container.autoRender || isRendererDetached()) { + return null; + } + + const { intl } = createIntl(locale); + return ( <LowCodeRenderer locale={locale} messages={messages} schema={documentInstance.schema} - deltaData={documentInstance.deltaData} - deltaMode={documentInstance.deltaMode} components={container.components} appHelper={container.context} designMode={designMode} @@ -189,12 +192,16 @@ class Renderer extends Component<{ setSchemaChangedSymbol={this.setSchemaChangedSymbol} getNode={(id: string) => documentInstance.getNode(id) as Node} rendererName="PageRenderer" + thisRequiredInJSE={host.thisRequiredInJSE} + notFoundComponent={host.notFoundComponent} + faultComponent={host.faultComponent} + faultComponentMap={host.faultComponentMap} customCreateElement={(Component: any, props: any, children: any) => { const { __id, ...viewProps } = props; viewProps.componentId = __id; const leaf = documentInstance.getNode(__id) as Node; if (isFromVC(leaf?.componentMeta)) { - viewProps._leaf = leaf; + viewProps._leaf = leaf.internalToShellNode(); } viewProps._componentName = leaf?.componentName; // 如果是容器 && 无children && 高宽为空 增加一个占位容器,方便拖动 @@ -204,12 +211,12 @@ class Renderer extends Component<{ (children == null || (Array.isArray(children) && !children.length)) && (!viewProps.style || Object.keys(viewProps.style).length === 0) ) { - let defaultPlaceholder = '拖拽组件或模板到这里'; + let defaultPlaceholder = intl('Drag and drop components or templates here'); const lockedNode = getClosestNode(leaf, (node) => { return node?.getExtraProp('isLocked')?.getValue() === true; }); if (lockedNode) { - defaultPlaceholder = '锁定元素及子元素无法编辑'; + defaultPlaceholder = intl('Locked elements and child elements cannot be edited'); } children = ( <div className={cn('lc-container-placeholder', { 'lc-container-locked': !!lockedNode })} style={viewProps.placeholderStyle}> @@ -240,6 +247,11 @@ class Renderer extends Component<{ }); } + if (!isReactComponent(Component)) { + console.error(`${viewProps._componentName} is not a react component!`); + return null; + } + return createElement( getDeviceView(Component, device, designMode), viewProps, @@ -251,6 +263,7 @@ class Renderer extends Component<{ onCompGetRef={(schema: any, ref: ReactInstance | null) => { documentInstance.mountInstance(schema.id, ref); }} + enableStrictNotFoundMode={host.enableStrictNotFoundMode} /> ); } diff --git a/packages/react-simulator-renderer/src/renderer.ts b/packages/react-simulator-renderer/src/renderer.ts index b462a521e3..20f6e18c0b 100644 --- a/packages/react-simulator-renderer/src/renderer.ts +++ b/packages/react-simulator-renderer/src/renderer.ts @@ -4,7 +4,7 @@ import { host } from './host'; import SimulatorRendererView from './renderer-view'; import { computed, observable as obx, untracked, makeObservable, configure } from 'mobx'; import { getClientRects } from './utils/get-client-rects'; -import { reactFindDOMNodes, FIBER_KEY } from './utils/react-find-dom-nodes'; +import { reactFindDOMNodes, getReactInternalFiber } from './utils/react-find-dom-nodes'; import { Asset, isElement, @@ -17,31 +17,28 @@ import { AssetLoader, getProjectUtils, } from '@alilc/lowcode-utils'; -import { ComponentSchema, TransformStage, NodeSchema } from '@alilc/lowcode-types'; +import { IPublicTypeComponentSchema, IPublicEnumTransformStage, IPublicTypeNodeInstance, IPublicTypeProjectSchema } from '@alilc/lowcode-types'; // just use types -import { BuiltinSimulatorRenderer, NodeInstance, Component, DocumentModel, Node } from '@alilc/lowcode-designer'; +import { BuiltinSimulatorRenderer, Component, IDocumentModel, INode } from '@alilc/lowcode-designer'; import LowCodeRenderer from '@alilc/lowcode-react-renderer'; import { createMemoryHistory, MemoryHistory } from 'history'; import Slot from './builtin-components/slot'; import Leaf from './builtin-components/leaf'; import { withQueryParams, parseQuery } from './utils/url'; +import { merge } from 'lodash'; const loader = new AssetLoader(); configure({ enforceActions: 'never' }); export class DocumentInstance { - public instancesMap = new Map<string, ReactInstance[]>(); + instancesMap = new Map<string, ReactInstance[]>(); get schema(): any { - return this.document.export(TransformStage.Render); + return this.document.export(IPublicEnumTransformStage.Render); } private disposeFunctions: Array<() => void> = []; - constructor(readonly container: SimulatorRendererContainer, readonly document: DocumentModel) { - makeObservable(this); - } - @obx.ref private _components: any = {}; @computed get components(): object { @@ -50,24 +47,6 @@ export class DocumentInstance { return this._components; } - /** - * 本次的变更数据 - */ - @obx.ref private _deltaData: any = {}; - - @computed get deltaData(): any { - return this._deltaData; - } - - /** - * 是否使用增量模式 - */ - @obx.ref private _deltaMode: boolean = false; - - @computed get deltaMode(): boolean { - return this._deltaMode; - } - // context from: utils、constants、history、location、match @obx.ref private _appContext = {}; @@ -115,7 +94,11 @@ export class DocumentInstance { return this.document.id; } - private unmountIntance(id: string, instance: ReactInstance) { + constructor(readonly container: SimulatorRendererContainer, readonly document: IDocumentModel) { + makeObservable(this); + } + + private unmountInstance(id: string, instance: ReactInstance) { const instances = this.instancesMap.get(id); if (instances) { const i = instances.indexOf(instance); @@ -143,11 +126,11 @@ export class DocumentInstance { } return; } - const unmountIntance = this.unmountIntance.bind(this); + const unmountInstance = this.unmountInstance.bind(this); const origId = (instance as any)[SYMBOL_VNID]; if (origId && origId !== id) { // 另外一个节点的 instance 在此被复用了,需要从原来地方卸载 - unmountIntance(origId, instance); + unmountInstance(origId, instance); } if (isElement(instance)) { cacheReactKey(instance); @@ -159,7 +142,7 @@ export class DocumentInstance { } // hack! delete instance from map const newUnmount = function (this: any) { - unmountIntance(id, instance); + unmountInstance(id, instance); origUnmount && origUnmount.call(this); }; (newUnmount as any).origUnmount = origUnmount; @@ -187,11 +170,10 @@ export class DocumentInstance { host.setInstance(this.document.id, id, instances); } - mountContext(docId: string, id: string, ctx: object) { - // this.ctxMap.set(id, ctx); + mountContext() { } - getNode(id: string): Node | null { + getNode(id: string): INode | null { return this.document.getNode(id); } @@ -207,10 +189,65 @@ export class SimulatorRendererContainer implements BuiltinSimulatorRenderer { readonly history: MemoryHistory; @obx.ref private _documentInstances: DocumentInstance[] = []; + private _requestHandlersMap: any; get documentInstances() { return this._documentInstances; } + @obx private _layout: any = null; + + @computed get layout(): any { + // TODO: parse layout Component + return this._layout; + } + + set layout(value: any) { + this._layout = value; + } + + private _libraryMap: { [key: string]: string } = {}; + + private _components: Record<string, React.FC | React.ComponentClass> | null = {}; + + get components(): Record<string, React.FC | React.ComponentClass> { + // 根据 device 选择不同组件,进行响应式 + // 更好的做法是,根据 device 选择加载不同的组件资源,甚至是 simulatorUrl + return this._components || {}; + } + // context from: utils、constants、history、location、match + @obx.ref private _appContext: any = {}; + @computed get context(): any { + return this._appContext; + } + @obx.ref private _designMode: string = 'design'; + @computed get designMode(): any { + return this._designMode; + } + @obx.ref private _device: string = 'default'; + @computed get device() { + return this._device; + } + @obx.ref private _locale: string | undefined = undefined; + @computed get locale() { + return this._locale; + } + @obx.ref private _componentsMap = {}; + @computed get componentsMap(): any { + return this._componentsMap; + } + + /** + * 是否为画布自动渲染 + */ + autoRender = true; + + /** + * 画布是否自动监听事件来重绘节点 + */ + autoRepaintNode = true; + + private _running = false; + constructor() { makeObservable(this); this.autoRender = host.autoRender; @@ -220,7 +257,8 @@ export class SimulatorRendererContainer implements BuiltinSimulatorRenderer { this._layout = host.project.get('config').layout; // todo: split with others, not all should recompute - if (this._libraryMap !== host.libraryMap || this._componentsMap !== host.designer.componentsMap) { + if (this._libraryMap !== host.libraryMap + || this._componentsMap !== host.designer.componentsMap) { this._libraryMap = host.libraryMap || {}; this._componentsMap = host.designer.componentsMap; this.buildComponents(); @@ -263,8 +301,8 @@ export class SimulatorRendererContainer implements BuiltinSimulatorRenderer { initialEntries: [initialEntry], }); this.history = history; - history.listen((location, action) => { - const docId = location.pathname.substr(1); + history.listen((location) => { + const docId = location.pathname.slice(1); docId && host.project.open(docId); }); host.componentsConsumer.consume(async (componentsAsset) => { @@ -302,69 +340,37 @@ export class SimulatorRendererContainer implements BuiltinSimulatorRenderer { constants: {}, requestHandlersMap: this._requestHandlersMap, }; + host.injectionConsumer.consume((data) => { // TODO: sync utils, i18n, contants,... config const newCtx = { ...this._appContext, }; - newCtx.utils.i18n.messages = data.i18n || {}; + merge(newCtx, data.appHelper || {}); this._appContext = newCtx; }); - } - - @obx private _layout: any = null; - @computed get layout(): any { - // TODO: parse layout Component - return this._layout; - } - - set layout(value: any) { - this._layout = value; + host.i18nConsumer.consume((data) => { + const newCtx = { + ...this._appContext, + }; + newCtx.utils.i18n.messages = data || {}; + this._appContext = newCtx; + }); } - private _libraryMap: { [key: string]: string } = {}; - private buildComponents() { - this._components = buildComponents(this._libraryMap, this._componentsMap, this.createComponent.bind(this)); + this._components = buildComponents( + this._libraryMap, + this._componentsMap, + this.createComponent.bind(this), + ); this._components = { ...builtinComponents, ...this._components, }; } - private _components: any = {}; - - get components(): object { - // 根据 device 选择不同组件,进行响应式 - // 更好的做法是,根据 device 选择加载不同的组件资源,甚至是 simulatorUrl - return this._components; - } - // context from: utils、constants、history、location、match - @obx.ref private _appContext: any = {}; - @computed get context(): any { - return this._appContext; - } - @obx.ref private _designMode: string = 'design'; - @computed get designMode(): any { - return this._designMode; - } - @obx.ref private _device: string = 'default'; - @computed get device() { - return this._device; - } - @obx.ref private _locale: string | undefined = undefined; - @computed get locale() { - return this._locale; - } - @obx.ref private _componentsMap = {}; - @computed get componentsMap(): any { - return this._componentsMap; - } - /** - * 是否为画布自动渲染 - */ - autoRender = true; /** * 加载资源 */ @@ -382,7 +388,7 @@ export class SimulatorRendererContainer implements BuiltinSimulatorRenderer { const subs: string[] = []; while (true) { - const component = this._components[componentName]; + const component = this._components?.[componentName]; if (component) { return getSubComponent(component, subs); } @@ -396,7 +402,7 @@ export class SimulatorRendererContainer implements BuiltinSimulatorRenderer { } } - getClosestNodeInstance(from: ReactInstance, nodeId?: string): NodeInstance<ReactInstance> | null { + getClosestNodeInstance(from: ReactInstance, nodeId?: string): IPublicTypeNodeInstance<ReactInstance> | null { return getClosestNodeInstance(from, nodeId); } @@ -424,19 +430,20 @@ export class SimulatorRendererContainer implements BuiltinSimulatorRenderer { cursor.release(); } - createComponent(schema: NodeSchema): Component | null { - const _schema: any = { - ...compatibleLegaoSchema(schema), + createComponent(schema: IPublicTypeProjectSchema<IPublicTypeComponentSchema>): Component | null { + const _schema: IPublicTypeProjectSchema<IPublicTypeComponentSchema> = { + ...schema, + componentsTree: schema.componentsTree.map(compatibleLegaoSchema), }; - _schema.methods = {}; - _schema.lifeCycles = {}; - if (schema.componentName === 'Component' && (schema as ComponentSchema).css) { + const componentsTreeSchema = _schema.componentsTree[0]; + + if (componentsTreeSchema.componentName === 'Component' && componentsTreeSchema.css) { const doc = window.document; const s = doc.createElement('style'); s.setAttribute('type', 'text/css'); - s.setAttribute('id', `Component-${schema.id || ''}`); - s.appendChild(doc.createTextNode((schema as ComponentSchema).css || '')); + s.setAttribute('id', `Component-${componentsTreeSchema.id || ''}`); + s.appendChild(doc.createTextNode(componentsTreeSchema.css || '')); doc.getElementsByTagName('head')[0].appendChild(s); } @@ -448,12 +455,17 @@ export class SimulatorRendererContainer implements BuiltinSimulatorRenderer { return createElement(LowCodeRenderer, { ...extraProps, // 防止覆盖下面内置属性 // 使用 _schema 为了使低代码组件在页面设计中使用变量,同 react 组件使用效果一致 - schema: _schema, + schema: componentsTreeSchema, components: renderer.components, - designMode: renderer.designMode, + designMode: '', + locale: renderer.locale, + messages: _schema.i18n || {}, device: renderer.device, appHelper: renderer.context, rendererName: 'LowCodeRenderer', + thisRequiredInJSE: host.thisRequiredInJSE, + faultComponent: host.faultComponent, + faultComponentMap: host.faultComponentMap, customCreateElement: (Comp: any, props: any, children: any) => { const componentMeta = host.currentDocument?.getComponentMeta(Comp.displayName); if (componentMeta?.isModal) { @@ -464,6 +476,7 @@ export class SimulatorRendererContainer implements BuiltinSimulatorRenderer { // mock _leaf,减少性能开销 const _leaf = { isEmpty: () => false, + isMock: true, }; viewProps._leaf = _leaf; return createElement(Comp, viewProps, children); @@ -475,8 +488,6 @@ export class SimulatorRendererContainer implements BuiltinSimulatorRenderer { return LowCodeComp; } - private _running = false; - run() { if (this._running) { return; @@ -507,9 +518,17 @@ export class SimulatorRendererContainer implements BuiltinSimulatorRenderer { this._appContext = { ...this._appContext }; } + stopAutoRepaintNode() { + this.autoRepaintNode = false; + } + + enableAutoRepaintNode() { + this.autoRepaintNode = true; + } + dispose() { - this.disposeFunctions.forEach(fn => fn()); - this.documentInstances.forEach(docInst => docInst.dispose()); + this.disposeFunctions.forEach((fn) => fn()); + this.documentInstances.forEach((docInst) => docInst.dispose()); untracked(() => { this._componentsMap = {}; this._components = null; @@ -530,7 +549,10 @@ function cacheReactKey(el: Element): Element { if (REACT_KEY !== '') { return el; } - REACT_KEY = Object.keys(el).find((key) => key.startsWith('__reactInternalInstance$')) || ''; + // react17 采用 __reactFiber 开头 + REACT_KEY = Object.keys(el).find( + (key) => key.startsWith('__reactInternalInstance$') || key.startsWith('__reactFiber$'), + ) || ''; if (!REACT_KEY && (el as HTMLElement).parentElement) { return cacheReactKey((el as HTMLElement).parentElement!); } @@ -540,13 +562,16 @@ function cacheReactKey(el: Element): Element { const SYMBOL_VNID = Symbol('_LCNodeId'); const SYMBOL_VDID = Symbol('_LCDocId'); -function getClosestNodeInstance(from: ReactInstance, specId?: string): NodeInstance<ReactInstance> | null { +function getClosestNodeInstance( + from: ReactInstance, + specId?: string, + ): IPublicTypeNodeInstance<ReactInstance> | null { let el: any = from; if (el) { if (isElement(el)) { el = cacheReactKey(el); } else { - return getNodeInstance(el[FIBER_KEY], specId); + return getNodeInstance(getReactInternalFiber(el), specId); } } while (el) { @@ -570,7 +595,7 @@ function getClosestNodeInstance(from: ReactInstance, specId?: string): NodeInsta return null; } -function getNodeInstance(fiberNode: any, specId?: string): NodeInstance<ReactInstance> | null { +function getNodeInstance(fiberNode: any, specId?: string): IPublicTypeNodeInstance<ReactInstance> | null { const instance = fiberNode?.stateNode; if (instance && SYMBOL_VNID in instance) { const nodeId = instance[SYMBOL_VNID]; @@ -589,7 +614,7 @@ function getNodeInstance(fiberNode: any, specId?: string): NodeInstance<ReactIns function checkInstanceMounted(instance: any): boolean { if (isElement(instance)) { - return instance.parentElement != null; + return instance.parentElement != null && window.document.contains(instance); } return true; } @@ -599,12 +624,13 @@ function getLowCodeComponentProps(props: any) { return props; } const newProps: any = {}; - Object.keys(props).forEach(k => { + Object.keys(props).forEach((k) => { if (['children', 'componentId', '__designMode', '_componentName', '_leaf'].includes(k)) { return; } newProps[k] = props[k]; }); + newProps['componentName'] = props['_componentName']; return newProps; } diff --git a/packages/react-simulator-renderer/src/utils/misc.ts b/packages/react-simulator-renderer/src/utils/misc.ts index e53ac6ec11..a829a6e95b 100644 --- a/packages/react-simulator-renderer/src/utils/misc.ts +++ b/packages/react-simulator-renderer/src/utils/misc.ts @@ -24,3 +24,12 @@ export function getProjectUtils(librayMap: LibrayMap, utilsMetadata: UtilsMetada }); } } + +/** + * judges if current simulator renderer deteched or not + * @returns detached or not + */ +export function isRendererDetached() { + // if current iframe detached from host document, the `window.parent` will be undefined. + return !window.parent; +} \ No newline at end of file diff --git a/packages/react-simulator-renderer/src/utils/react-find-dom-nodes.ts b/packages/react-simulator-renderer/src/utils/react-find-dom-nodes.ts index eb1fb41d50..d7af90346f 100644 --- a/packages/react-simulator-renderer/src/utils/react-find-dom-nodes.ts +++ b/packages/react-simulator-renderer/src/utils/react-find-dom-nodes.ts @@ -3,7 +3,9 @@ import { findDOMNode } from 'react-dom'; import { isElement } from '@alilc/lowcode-utils'; import { isDOMNode } from './is-dom-node'; -export const FIBER_KEY = '_reactInternalFiber'; +export const getReactInternalFiber = (el: any) => { + return el._reactInternals || el._reactInternalFiber; +}; function elementsFromFiber(fiber: any, elements: Array<Element | Text>) { if (fiber) { @@ -28,7 +30,7 @@ export function reactFindDOMNodes(elem: ReactInstance | null): Array<Element | T return [elem]; } const elements: Array<Element | Text> = []; - const fiberNode = (elem as any)[FIBER_KEY]; + const fiberNode = getReactInternalFiber(elem); elementsFromFiber(fiberNode?.child, elements); if (elements.length > 0) return elements; try { diff --git a/packages/react-simulator-renderer/test/schema/basic.ts b/packages/react-simulator-renderer/test/schema/basic.ts new file mode 100644 index 0000000000..5dffd7267f --- /dev/null +++ b/packages/react-simulator-renderer/test/schema/basic.ts @@ -0,0 +1,81 @@ +export default { + id: 'node_ockvuu8u911', + css: 'body{background-color:#f2f3f5}', + flows: [], + props: { + className: 'page_kvuu9hym', + pageStyle: { + backgroundColor: '#f2f3f5', + }, + containerStyle: {}, + templateVersion: '1.0.0', + }, + state: {}, + title: '', + methods: { + __initMethods__: { + type: 'JSExpression', + value: "function (exports, module) { \"use strict\";\n\nexports.__esModule = true;\nexports.func1 = func1;\nexports.helloPage = helloPage;\n\nfunction func1() {\n console.info('hello, this is a page function');\n}\n\nfunction helloPage() {\n // 你可以这么调用其他函数\n this.func1(); // 你可以这么调用组件的函数\n // this.$('textField_xxx').getValue();\n // 你可以这么使用「数据源面板」定义的「变量」\n // this.state.xxx\n // 你可以这么发送一个在「数据源面板」定义的「远程 API」\n // this.dataSourceMap['xxx'].load(data)\n // API 详见:https://go.alibaba-inc.com/help3/API\n} \n}", + }, + }, + children: [ + { + id: 'node_ockvuu8u915', + props: { + fieldId: 'div_kvuu9gl1', + behavior: 'NORMAL', + __style__: {}, + customClassName: '', + useFieldIdAsDomId: false, + }, + title: '', + children: [ + { + id: 'node_ockvuu8u916', + props: { + content: { + use: 'zh-CN', + type: 'JSExpression', + 'en-US': 'Tips content', + value: '"我是一个简单的测试页面"', + 'zh-CN': '我是一个简单的测试页面', + extType: 'i18n', + }, + fieldId: 'text_kvuu9gl2', + maxLine: 0, + behavior: 'NORMAL', + __style__: {}, + showTitle: false, + }, + title: '', + condition: true, + componentName: 'Text', + }, + ], + condition: true, + componentName: 'Div', + }, + ], + condition: true, + dataSource: { + list: [], + sync: true, + online: [], + offline: [], + globalConfig: { + fit: { + type: 'JSExpression', + value: "function main(){\n 'use strict';\n\nvar __compiledFunc__ = function fit(response) {\n var content = response.content !== undefined ? response.content : response;\n var error = {\n message: response.errorMsg || response.errors && response.errors[0] && response.errors[0].msg || response.content || '远程数据源请求出错,success is false'\n };\n var success = true;\n if (response.success !== undefined) {\n success = response.success;\n } else if (response.hasError !== undefined) {\n success = !response.hasError;\n }\n return {\n content: content,\n success: success,\n error: error\n };\n};\n return __compiledFunc__.apply(this, arguments);\n}", + extType: 'function', + }, + }, + }, + lifeCycles: { + constructor: { + type: 'JSExpression', + value: "function constructor() {\nvar module = { exports: {} };\nvar _this = this;\nthis.__initMethods__(module.exports, module);\nObject.keys(module.exports).forEach(function(item) {\n if(typeof module.exports[item] === 'function'){\n _this[item] = module.exports[item];\n }\n});\n\n}", + extType: 'function', + }, + }, + componentName: 'Page', +}; diff --git a/packages/react-simulator-renderer/test/src/renderer/__snapshots__/demo.test.tsx.snap b/packages/react-simulator-renderer/test/src/renderer/__snapshots__/demo.test.tsx.snap new file mode 100644 index 0000000000..2f2d19f269 --- /dev/null +++ b/packages/react-simulator-renderer/test/src/renderer/__snapshots__/demo.test.tsx.snap @@ -0,0 +1,42 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Base should be render NotFoundComponent 1`] = ` +<div + className="lce-page page_kvuu9hym" + style={Object {}} +> + <div + componentName="Div" + > + <div + componentName="Text" + > + Text Component Not Found + </div> + </div> +</div> +`; + +exports[`Base should be render Text 1`] = ` +<div + className="lce-page page_kvuu9hym" + style={Object {}} +> + <div + componentName="Div" + > + <div + __designMode="design" + __style__={Object {}} + behavior="NORMAL" + componentId="node_ockvuu8u916" + fieldId="text_kvuu9gl2" + forwardRef={[Function]} + maxLine={0} + showTitle={false} + > + 我是一个简单的测试页面 + </div> + </div> +</div> +`; diff --git a/packages/react-simulator-renderer/test/src/renderer/demo.test.tsx b/packages/react-simulator-renderer/test/src/renderer/demo.test.tsx new file mode 100644 index 0000000000..b849678a38 --- /dev/null +++ b/packages/react-simulator-renderer/test/src/renderer/demo.test.tsx @@ -0,0 +1,31 @@ +import renderer from 'react-test-renderer'; +import rendererContainer from '../../../src/renderer'; +import SimulatorRendererView from '../../../src/renderer-view'; +import { Text } from '../../utils/components'; + +describe('Base', () => { + const component = renderer.create( + <SimulatorRendererView + rendererContainer={rendererContainer} + /> + ); + + it('should be render NotFoundComponent', () => { + let tree = component.toJSON(); + expect(tree).toMatchSnapshot(); + }); + + it('should be render Text', () => { + // 更新 _componentsMap 值 + (rendererContainer as any)._componentsMap.Text = Text;// = host.designer.componentsMap; + // 更新 components 列表 + (rendererContainer as any).buildComponents(); + + expect(!!(rendererContainer.components as any).Text).toBeTruthy(); + + rendererContainer.rerender(); + + let tree = component.toJSON(); + expect(tree).toMatchSnapshot(); + }); +}) \ No newline at end of file diff --git a/packages/react-simulator-renderer/test/utils/components.tsx b/packages/react-simulator-renderer/test/utils/components.tsx new file mode 100644 index 0000000000..0de3d3cd0e --- /dev/null +++ b/packages/react-simulator-renderer/test/utils/components.tsx @@ -0,0 +1,7 @@ +export const Text = ({ + __tag, + content, + ...props +}: any) => (<div {...props}>{content}</div>); + +export const Page = (props: any) => (<div>{props.children}</div>); \ No newline at end of file diff --git a/packages/react-simulator-renderer/test/utils/host.ts b/packages/react-simulator-renderer/test/utils/host.ts new file mode 100644 index 0000000000..f7ab343579 --- /dev/null +++ b/packages/react-simulator-renderer/test/utils/host.ts @@ -0,0 +1,79 @@ +import { Box, Breadcrumb, Form, Select, Input, Button, Table, Pagination, Dialog } from '@alifd/next'; +import defaultSchema from '../schema/basic'; +import { Page } from './components'; + +class Designer { + componentsMap = { + Box, + Breadcrumb, + 'Breadcrumb.Item': Breadcrumb.Item, + Form, + 'Form.Item': Form.Item, + Select, + Input, + Button, + 'Button.Group': Button.Group, + Table, + Pagination, + Dialog, + Page, + } +} + +class Host { + designer = new Designer(); + + connect = () => {} + + autorun = (fn: Function) => { + fn(); + } + + autoRender = true; + + componentsConsumer = { + consume() {} + } + + schema = defaultSchema; + + project = { + documents: [ + { + id: '1', + path: '/', + fileName: '', + export: () => { + return this.schema; + }, + getNode: () => {}, + } + ], + get: () => ({}), + } + + setInstance() {} + + designMode = 'design' + + get() {} + + injectionConsumer = { + consume() {} + } + + i18nConsumer = { + consume() {} + } + + /** 下列的函数或者方法是方便测试用 */ + mockSchema = (schema: any) => { + this.schema = schema; + }; +} + +if (!(window as any).LCSimulatorHost) { + (window as any).LCSimulatorHost = new Host(); +} + +export default (window as any).LCSimulatorHost; \ No newline at end of file diff --git a/packages/renderer-core/babel.config.js b/packages/renderer-core/babel.config.js new file mode 100644 index 0000000000..c5986f2bc0 --- /dev/null +++ b/packages/renderer-core/babel.config.js @@ -0,0 +1 @@ +module.exports = require('../../babel.config'); \ No newline at end of file diff --git a/packages/renderer-core/build.json b/packages/renderer-core/build.json index a8e42f1540..9140815c5e 100644 --- a/packages/renderer-core/build.json +++ b/packages/renderer-core/build.json @@ -1,7 +1,7 @@ { "plugins": [ [ - "build-plugin-component", + "@alilc/build-plugin-lce", { "babelPlugins": ["@babel/plugin-transform-typescript"] } diff --git a/packages/renderer-core/build.test.json b/packages/renderer-core/build.test.json new file mode 100644 index 0000000000..9cc30d7463 --- /dev/null +++ b/packages/renderer-core/build.test.json @@ -0,0 +1,6 @@ +{ + "plugins": [ + "@alilc/build-plugin-lce", + "@alilc/lowcode-test-mate/plugin/index.ts" + ] +} diff --git a/packages/renderer-core/jest.config.js b/packages/renderer-core/jest.config.js index 00b2654f90..1ea4204de5 100644 --- a/packages/renderer-core/jest.config.js +++ b/packages/renderer-core/jest.config.js @@ -1,29 +1,38 @@ -const esModules = [ - '@recore/obx-react', - '@alilc/lowcode-datasource-engine', -].join('|'); +const fs = require('fs'); +const { join } = require('path'); +const esModules = [].join('|'); +const pkgNames = fs.readdirSync(join('..')).filter(pkgName => !pkgName.startsWith('.')); -module.exports = { - transform: { - '^.+\\.(ts|tsx)$': 'ts-jest', - '^.+\\.(js|ts|tsx|jsx)$': 'babel-jest', - '^.+\\.(css|less|scss)$': './test/mock/styleMock.js', - }, - // testMatch: ['**/bugs/*.test.ts'], +const jestConfig = { + // transform: { + // // '^.+\\.[jt]sx?$': 'babel-jest', + // '^.+\\.(ts|tsx)$': 'ts-jest', + // // '^.+\\.(js|jsx)$': 'babel-jest', + // }, // testMatch: ['(/tests?/.*(test))\\.[jt]s$'], - // transformIgnorePatterns: [ - // `/node_modules/(?!${esModules})/`, - // ], - testEnvironment: 'jsdom', + // testMatch: ['**/*/base.test.tsx'], + // testMatch: ['**/utils/common.test.ts'], + // testMatch: ['**/*/leaf.test.tsx'], + // testMatch: ['**/*/is-use-loop.test.ts'], + transformIgnorePatterns: [ + `/node_modules/(?!${esModules})/`, + ], + setupFiles: [ + './tests/fixtures/unhandled-rejection.ts', + './tests/setup.ts', + ], moduleFileExtensions: ['ts', 'tsx', 'js', 'json'], - collectCoverage: false, + collectCoverage: true, collectCoverageFrom: [ - 'src/**/*.{ts,tsx}', - ], - moduleNameMapper: { - '^.+.(css|styl|less|sass|scss|png|jpg|ttf|woff|woff2)$': 'jest-transform-stub', - }, - setupFilesAfterEnv: [ - './test/setup.ts', + 'src/**/*.ts', + 'src/**/*.tsx', + '!src/utils/logger.ts', + '!src/types/index.ts', ], }; + +// 只对本仓库内的 pkg 做 mapping +jestConfig.moduleNameMapper = {}; +jestConfig.moduleNameMapper[`^@alilc/lowcode\\-(${pkgNames.join('|')})$`] = '<rootDir>/../$1/src'; + +module.exports = jestConfig; \ No newline at end of file diff --git a/packages/renderer-core/package.json b/packages/renderer-core/package.json index f0f8e89243..199eac1cac 100644 --- a/packages/renderer-core/package.json +++ b/packages/renderer-core/package.json @@ -1,6 +1,6 @@ { "name": "@alilc/lowcode-renderer-core", - "version": "1.0.3", + "version": "1.3.2", "description": "renderer core", "license": "MIT", "main": "lib/index.js", @@ -10,41 +10,42 @@ "es" ], "scripts": { - "build": "build-scripts build --skip-demo" + "build": "build-scripts build", + "test": "build-scripts test --config build.test.json", + "test:cov": "build-scripts test --config build.test.json --jest-coverage" }, "dependencies": { "@alilc/lowcode-datasource-engine": "^1.0.0", - "@alilc/lowcode-types": "1.0.3", - "@alilc/lowcode-utils": "1.0.3", + "@alilc/lowcode-types": "1.3.2", + "@alilc/lowcode-utils": "1.3.2", "classnames": "^2.2.6", "debug": "^4.1.1", "fetch-jsonp": "^1.1.3", "intl-messageformat": "^9.3.1", "jsonuri": "^2.1.2", "lodash": "^4.17.11", - "moment": "^2.24.0", "prop-types": "^15.7.2", "react-is": "^16.10.1", - "serialize-javascript": "^1.7.0", "socket.io-client": "^2.2.0", - "whatwg-fetch": "^3.0.0", - "zen-logger": "^1.1.4" + "whatwg-fetch": "^3.0.0" }, "devDependencies": { "@alib/build-scripts": "^0.1.18", + "@alifd/next": "^1.26.0", + "@alilc/lowcode-designer": "1.3.2", "@babel/plugin-transform-typescript": "^7.16.8", + "@testing-library/react": "^11.2.2", "@types/classnames": "^2.2.11", "@types/debug": "^4.1.5", + "@types/jest": "^26.0.16", "@types/lodash": "^4.14.167", "@types/node": "^13.7.1", "@types/prop-types": "^15.7.3", + "@types/react-is": "^17.0.3", "@types/react-test-renderer": "^17.0.1", - "@types/serialize-javascript": "^5.0.0", - "babel-jest": "^27.4.6", - "build-plugin-component": "^0.2.11", - "jest": "^27.4.7", + "jest": "^26.6.3", "react-test-renderer": "^17.0.2", - "ts-jest": "^27.1.3" + "ts-jest": "^26.5.0" }, "publishConfig": { "access": "public", @@ -54,5 +55,7 @@ "type": "http", "url": "https://github.com/alibaba/lowcode-engine/tree/main/packages/renderer-core" }, - "gitHead": "2669f179e6f899d395ce1942d0fe04f9c5ed48a6" + "gitHead": "2669f179e6f899d395ce1942d0fe04f9c5ed48a6", + "bugs": "https://github.com/alibaba/lowcode-engine/issues", + "homepage": "https://github.com/alibaba/lowcode-engine/#readme" } diff --git a/packages/renderer-core/src/adapter/index.ts b/packages/renderer-core/src/adapter/index.ts index b4e0eedec7..7a56bc039e 100644 --- a/packages/renderer-core/src/adapter/index.ts +++ b/packages/renderer-core/src/adapter/index.ts @@ -2,7 +2,6 @@ import { IRuntime, IRendererModules, IGeneralConstructor } from '../types'; export enum Env { React = 'react', - Rax = 'rax', } class Adapter { @@ -21,23 +20,23 @@ class Adapter { } initRuntime() { - const Component: IGeneralConstructor = class { + const Component: IGeneralConstructor = class <T = any, S = any> { + state: Readonly<S>; + props: Readonly<T> & Readonly<{ children?: any | undefined }>; + refs: Record<string, unknown>; + context: Record<string, unknown>; setState() {} forceUpdate() {} render() {} - state: {}; - props: {}; - refs: {}; - context: {}; }; - const PureComponent: IGeneralConstructor = class { + const PureComponent = class <T = any, S = any> { + state: Readonly<S>; + props: Readonly<T> & Readonly<{ children?: any | undefined }>; + refs: Record<string, unknown>; + context: Record<string, unknown>; setState() {} forceUpdate() {} render() {} - state: {}; - props: {}; - refs: {}; - context: {}; }; const createElement = () => {}; const createContext = () => {}; @@ -64,10 +63,10 @@ class Adapter { return false; } - return this.builtinModules.every(m => { - const flag = !!this.runtime[m]; + return this.builtinModules.every((m) => { + const flag = !!runtime[m]; if (!flag) { - throw new Error(`runtime is inValid, module '${m}' is not existed`); + throw new Error(`runtime is invalid, module '${m}' does not exist`); } return flag; }); @@ -85,10 +84,6 @@ class Adapter { return this.env === Env.React; } - isRax() { - return this.env === Env.Rax; - } - setRenderers(renderers: IRendererModules) { this.renderers = renderers; } diff --git a/packages/renderer-core/src/hoc/index.tsx b/packages/renderer-core/src/hoc/index.tsx index d4c877b871..4851ea486f 100644 --- a/packages/renderer-core/src/hoc/index.tsx +++ b/packages/renderer-core/src/hoc/index.tsx @@ -1,19 +1,88 @@ import { cloneEnumerableProperty } from '@alilc/lowcode-utils'; import adapter from '../adapter'; +import { IBaseRendererInstance, IRendererProps } from '../types'; -export function compWrapper(Comp: any) { +interface Options { + baseRenderer: IBaseRendererInstance; + schema: any; +} + +function patchDidCatch(Comp: any, { baseRenderer }: Options) { + if (Comp.patchedCatch) { + return; + } + Comp.patchedCatch = true; + const { PureComponent } = adapter.getRuntime(); + // Rax 的 getDerivedStateFromError 有 BUG,这里先用 componentDidCatch 来替代 + // @see https://github.com/alibaba/rax/issues/2211 + const originalDidCatch = Comp.prototype.componentDidCatch; + Comp.prototype.componentDidCatch = function didCatch(this: any, error: Error, errorInfo: any) { + this.setState({ engineRenderError: true, error }); + if (originalDidCatch && typeof originalDidCatch === 'function') { + originalDidCatch.call(this, error, errorInfo); + } + }; + + const { engine } = baseRenderer.context; + const originRender = Comp.prototype.render; + Comp.prototype.render = function () { + if (this.state && this.state.engineRenderError) { + this.state.engineRenderError = false; + return engine.createElement(engine.getFaultComponent(), { + ...this.props, + error: this.state.error, + componentName: this.props._componentName, + }); + } + return originRender.call(this); + }; + if (!(Comp.prototype instanceof PureComponent)) { + const originShouldComponentUpdate = Comp.prototype.shouldComponentUpdate; + Comp.prototype.shouldComponentUpdate = function (nextProps: IRendererProps, nextState: any) { + if (nextState && nextState.engineRenderError) { + return true; + } + return originShouldComponentUpdate + ? originShouldComponentUpdate.call(this, nextProps, nextState) + : true; + }; + } +} + +const cache = new Map<string, { Comp: any; WrapperComponent: any }>(); + +export function compWrapper(Comp: any, options: Options) { const { createElement, Component, forwardRef } = adapter.getRuntime(); - class Wrapper extends Component { - // constructor(props: any, context: any) { - // super(props, context); - // } + if ( + Comp?.prototype?.isReactComponent || // react + Comp?.prototype?.setState || // rax + Comp?.prototype instanceof Component + ) { + patchDidCatch(Comp, options); + return Comp; + } + + if (cache.has(options.schema.id) && cache.get(options.schema.id)?.Comp === Comp) { + return cache.get(options.schema.id)?.WrapperComponent; + } + class Wrapper extends Component { render() { - return createElement(Comp, this.props); + return createElement(Comp, { ...this.props, ref: this.props.forwardRef }); } } + (Wrapper as any).displayName = Comp.displayName; + + patchDidCatch(Wrapper, options); + + const WrapperComponent = cloneEnumerableProperty( + forwardRef((props: any, ref: any) => { + return createElement(Wrapper, { ...props, forwardRef: ref }); + }), + Comp, + ); + + cache.set(options.schema.id, { WrapperComponent, Comp }); - return cloneEnumerableProperty(forwardRef((props: any, ref: any) => { - return createElement(Wrapper, { ...props, forwardRef: ref }); - }), Comp); + return WrapperComponent; } diff --git a/packages/renderer-core/src/hoc/leaf.tsx b/packages/renderer-core/src/hoc/leaf.tsx index 8d96813629..2bb3c0b368 100644 --- a/packages/renderer-core/src/hoc/leaf.tsx +++ b/packages/renderer-core/src/hoc/leaf.tsx @@ -1,11 +1,10 @@ -import { BuiltinSimulatorHost, Node, PropChangeOptions } from '@alilc/lowcode-designer'; -import { GlobalEvent, TransformStage, NodeSchema } from '@alilc/lowcode-types'; +import { INode, IPublicTypePropChangeOptions } from '@alilc/lowcode-designer'; +import { GlobalEvent, IPublicEnumTransformStage, IPublicTypeNodeSchema, IPublicTypeEngineOptions } from '@alilc/lowcode-types'; import { isReactComponent, cloneEnumerableProperty } from '@alilc/lowcode-utils'; -import { EngineOptions } from '@alilc/lowcode-editor-core'; import { debounce } from '../utils/common'; import adapter from '../adapter'; import * as types from '../types/index'; -import { parseData } from '../utils'; +import logger from '../utils/logger'; export interface IComponentHocInfo { schema: any; @@ -18,21 +17,23 @@ export interface IComponentHocProps { __tag: any; componentId: any; _leaf: any; - forwardedRef: any; + forwardedRef?: any; } export interface IComponentHocState { childrenInState: boolean; nodeChildren: any; nodeCacheProps: any; + /** 控制是否显示隐藏 */ visible: boolean; + /** 控制是否渲染 */ condition: boolean; nodeProps: any; } -type DesignMode = Pick<EngineOptions, 'designMode'>['designMode']; +type DesignMode = Pick<IPublicTypeEngineOptions, 'designMode'>['designMode']; export interface IComponentHoc { designMode: DesignMode | DesignMode[]; @@ -42,15 +43,17 @@ export interface IComponentHoc { export type IComponentConstruct = (Comp: types.IBaseRenderComponent, info: IComponentHocInfo) => types.IGeneralConstructor; interface IProps { - _leaf: Node | undefined; + _leaf: INode | undefined; visible: boolean; - componentId?: number; + componentId: number; + + children?: INode[]; - children?: Node[]; + __tag: number; - __tag?: number; + forwardedRef?: any; } enum RerenderType { @@ -63,8 +66,7 @@ enum RerenderType { // 缓存 Leaf 层组件,防止重新渲染问题 class LeafCache { - constructor(public documentId: string, public device: string) { - } + /** 组件缓存 */ component = new Map(); @@ -79,6 +81,9 @@ class LeafCache { event = new Map(); ref = new Map(); + + constructor(public documentId: string, public device: string) { + } } let cache: LeafCache; @@ -98,21 +103,33 @@ function initRerenderEvent({ return; } cache.event.get(schema.id)?.dispose.forEach((disposeFn: any) => disposeFn && disposeFn()); + const debounceRerender = debounce(() => { + container.rerender(); + }, 20); cache.event.set(schema.id, { clear: false, leaf, dispose: [ leaf?.onPropChange?.(() => { + if (!container.autoRepaintNode) { + return; + } __debug(`${schema.componentName}[${schema.id}] leaf not render in SimulatorRendererView, leaf onPropsChange make rerender`); - container.rerender(); + debounceRerender(); }), leaf?.onChildrenChange?.(() => { + if (!container.autoRepaintNode) { + return; + } __debug(`${schema.componentName}[${schema.id}] leaf not render in SimulatorRendererView, leaf onChildrenChange make rerender`); - container.rerender(); + debounceRerender(); }) as Function, leaf?.onVisibleChange?.(() => { + if (!container.autoRepaintNode) { + return; + } __debug(`${schema.componentName}[${schema.id}] leaf not render in SimulatorRendererView, leaf onVisibleChange make rerender`); - container.rerender(); + debounceRerender(); }), ], }); @@ -141,17 +158,18 @@ export function leafWrapper(Comp: types.IBaseRenderComponent, { __debug, __getComponentProps: getProps, __getSchemaChildrenVirtualDom: getChildren, + __parseData, } = baseRenderer; const { engine } = baseRenderer.context; const host = baseRenderer.props?.__host; const curDocumentId = baseRenderer.props?.documentId ?? ''; const curDevice = baseRenderer.props?.device ?? ''; const getNode = baseRenderer.props?.getNode; - const container: BuiltinSimulatorHost = baseRenderer.props?.__container; + const container = baseRenderer.props?.__container; const setSchemaChangedSymbol = baseRenderer.props?.setSchemaChangedSymbol; const editor = host?.designer?.editor; const runtime = adapter.getRuntime(); - const { forwardRef } = runtime; + const { forwardRef, createElement } = runtime; const Component = runtime.Component as types.IGeneralConstructor< IComponentHocProps, IComponentHocState >; @@ -166,7 +184,7 @@ export function leafWrapper(Comp: types.IBaseRenderComponent, { } if (!isReactComponent(Comp)) { - console.error(`${schema.componentName} component may be has errors: `, Comp); + logger.error(`${schema.componentName} component may be has errors: `, Comp); } initRerenderEvent({ @@ -176,22 +194,72 @@ export function leafWrapper(Comp: types.IBaseRenderComponent, { getNode, }); - if (curDocumentId && cache.component.has(componentCacheId)) { - return cache.component.get(componentCacheId); + if (curDocumentId && cache.component.has(componentCacheId) && (cache.component.get(componentCacheId).Comp === Comp)) { + return cache.component.get(componentCacheId).LeafWrapper; } class LeafHoc extends Component { recordInfo: { startTime?: number | null; type?: string; - node?: Node; + node?: INode; } = {}; + + private curEventLeaf: INode | undefined; + static displayName = schema.componentName; disposeFunctions: Array<((() => void) | Function)> = []; __component_tag = 'leafWrapper'; + renderUnitInfo: { + minimalUnitId?: string; + minimalUnitName?: string; + singleRender?: boolean; + }; + + // 最小渲染单元做防抖处理 + makeUnitRenderDebounced = debounce(() => { + this.beforeRender(RerenderType.MinimalRenderUnit); + const schema = this.leaf?.export?.(IPublicEnumTransformStage.Render); + if (!schema) { + return; + } + const nextProps = getProps(schema, scope, Comp, componentInfo); + const children = getChildren(schema, scope, Comp); + const nextState = { + nodeProps: nextProps, + nodeChildren: children, + childrenInState: true, + }; + if ('children' in nextProps) { + nextState.nodeChildren = nextProps.children; + } + + __debug(`${this.leaf?.componentName}(${this.props.componentId}) MinimalRenderUnit Render!`); + this.setState(nextState); + }, 20); + + constructor(props: IProps, context: any) { + super(props, context); + // 监听以下事件,当变化时更新自己 + __debug(`${schema.componentName}[${this.props.componentId}] leaf render in SimulatorRendererView`); + clearRerenderEvent(componentCacheId); + this.curEventLeaf = this.leaf; + + cache.ref.set(componentCacheId, { + makeUnitRender: this.makeUnitRender, + }); + + let cacheState = cache.state.get(componentCacheId); + if (!cacheState || cacheState.__tag !== props.__tag) { + cacheState = this.getDefaultState(props); + } + + this.state = cacheState; + } + recordTime = () => { if (!this.recordInfo.startTime) { return; @@ -199,7 +267,7 @@ export function leafWrapper(Comp: types.IBaseRenderComponent, { const endTime = Date.now(); const nodeCount = host?.designer?.currentDocument?.getNodeCount?.(); const componentName = this.recordInfo.node?.componentName || this.leaf?.componentName || 'UnknownComponent'; - editor?.emit(GlobalEvent.Node.Rerender, { + editor?.eventBus.emit(GlobalEvent.Node.Rerender, { componentName, time: endTime - this.recordInfo.startTime, type: this.recordInfo.type, @@ -208,70 +276,43 @@ export function leafWrapper(Comp: types.IBaseRenderComponent, { this.recordInfo.startTime = null; }; + makeUnitRender = () => { + this.makeUnitRenderDebounced(); + }; + + get autoRepaintNode() { + return container?.autoRepaintNode; + } + componentDidUpdate() { this.recordTime(); } componentDidMount() { + const _leaf = this.leaf; + this.initOnPropsChangeEvent(_leaf); + this.initOnChildrenChangeEvent(_leaf); + this.initOnVisibleChangeEvent(_leaf); this.recordTime(); } - get childrenMap(): any { - const map = new Map(); - - if (!this.hasChildren) { - return map; - } - - this.children.forEach((d: any) => { - if (Array.isArray(d)) { - map.set(d[0].props.componentId, d); - return; - } - map.set(d.props.componentId, d); - }); - - return map; - } - - get defaultState() { + getDefaultState(nextProps: any) { const { hidden = false, condition = true, - } = this.leaf?.schema || {}; + } = nextProps.__inner__ || this.leaf?.export?.(IPublicEnumTransformStage.Render) || {}; return { nodeChildren: null, childrenInState: false, visible: !hidden, - condition: parseData(condition, scope), + condition: __parseData?.(condition, scope), nodeCacheProps: {}, nodeProps: {}, }; } - constructor(props: IProps, context: any) { - super(props, context); - // 监听以下事件,当变化时更新自己 - __debug(`${schema.componentName}[${this.props.componentId}] leaf render in SimulatorRendererView`); - clearRerenderEvent(this.props.componentId); - const _leaf = this.leaf; - this.initOnPropsChangeEvent(_leaf); - this.initOnChildrenChangeEvent(_leaf); - this.initOnVisibleChangeEvent(_leaf); - this.curEventLeaf = _leaf; - - let cacheState = cache.state.get(props.componentId); - if (!cacheState || cacheState.__tag !== props.__tag) { - cacheState = this.defaultState; - } - - this.state = cacheState; - } - - private curEventLeaf: Node | undefined; - setState(state: any) { - cache.state.set(this.props.componentId, { + cache.state.set(componentCacheId, { ...this.state, ...state, __tag: this.props.__tag, @@ -280,19 +321,13 @@ export function leafWrapper(Comp: types.IBaseRenderComponent, { } /** 由于内部属性变化,在触发渲染前,会执行该函数 */ - beforeRender(type: string, node?: Node): void { + beforeRender(type: string, node?: INode): void { this.recordInfo.startTime = Date.now(); this.recordInfo.type = type; this.recordInfo.node = node; setSchemaChangedSymbol?.(true); } - renderUnitInfo: { - minimalUnitId?: string; - minimalUnitName?: string; - singleRender?: boolean; - }; - judgeMiniUnitRender() { if (!this.renderUnitInfo) { this.getRenderUnitInfo(); @@ -310,7 +345,7 @@ export function leafWrapper(Comp: types.IBaseRenderComponent, { if (!ref) { __debug('Cant find minimalRenderUnit ref! This make rerender!'); - container.rerender(); + container?.rerender(); return; } __debug(`${this.leaf?.componentName}(${this.props.componentId}) need render, make its minimalRenderUnit ${renderUnitInfo.minimalUnitName}(${renderUnitInfo.minimalUnitId})`); @@ -323,7 +358,7 @@ export function leafWrapper(Comp: types.IBaseRenderComponent, { return; } - if (leaf.isRoot()) { + if (leaf.isRootNode) { this.renderUnitInfo = { singleRender: true, ...(this.renderUnitInfo || {}), @@ -349,39 +384,13 @@ export function leafWrapper(Comp: types.IBaseRenderComponent, { } } - // 最小渲染单元做防抖处理 - makeUnitRenderDebounced = debounce(() => { - this.beforeRender(RerenderType.MinimalRenderUnit); - const schema = this.leaf?.export?.(TransformStage.Render); - if (!schema) { - return; - } - const nextProps = getProps(schema, scope, Comp, componentInfo); - const children = getChildren(schema, scope, Comp); - const nextState = { - nodeProps: nextProps, - nodeChildren: children, - childrenInState: true, - }; - if ('children' in nextProps) { - nextState.nodeChildren = nextProps.children; - } - - __debug(`${this.leaf?.componentName}(${this.props.componentId}) MinimalRenderUnit Render!`); - this.setState(nextState); - }, 20); - - makeUnitRender = () => { - this.makeUnitRenderDebounced(); - }; - componentWillReceiveProps(nextProps: any) { - let { _leaf, componentId } = nextProps; + let { componentId } = nextProps; if (nextProps.__tag === this.props.__tag) { return null; } - _leaf = _leaf || getNode(componentId); + const _leaf = getNode?.(componentId); if (_leaf && this.curEventLeaf && _leaf !== this.curEventLeaf) { this.disposeFunctions.forEach((fn) => fn()); this.disposeFunctions = []; @@ -394,13 +403,13 @@ export function leafWrapper(Comp: types.IBaseRenderComponent, { const { visible, ...resetState - } = this.defaultState; + } = this.getDefaultState(nextProps); this.setState(resetState); } /** 监听参数变化 */ initOnPropsChangeEvent(leaf = this.leaf): void { - const dispose = leaf?.onPropChange?.((propChangeInfo: PropChangeOptions) => { + const handlePropsChange = debounce((propChangeInfo: IPublicTypePropChangeOptions) => { const { key, newValue = null, @@ -408,11 +417,12 @@ export function leafWrapper(Comp: types.IBaseRenderComponent, { const node = leaf; if (key === '___condition___') { - const condition = parseData(newValue, scope); + const { condition = true } = this.leaf?.export(IPublicEnumTransformStage.Render) || {}; + const conditionValue = __parseData?.(condition, scope); __debug(`key is ___condition___, change condition value to [${condition}]`); // 条件表达式改变 this.setState({ - condition, + condition: conditionValue, }); return; } @@ -421,7 +431,7 @@ export function leafWrapper(Comp: types.IBaseRenderComponent, { // 目前多层循坏无法判断需要从哪一层开始渲染,故先粗暴解决 if (key === '___loop___') { __debug('key is ___loop___, render a page!'); - container.rerender(); + container?.rerender(); // 由于 scope 变化,需要清空缓存,使用新的 scope cache.component.delete(componentCacheId); return; @@ -429,7 +439,7 @@ export function leafWrapper(Comp: types.IBaseRenderComponent, { this.beforeRender(RerenderType.PropsChanged); const { state } = this; const { nodeCacheProps } = state; - const nodeProps = getProps(node?.export?.(TransformStage.Render) as NodeSchema, scope, Comp, componentInfo); + const nodeProps = getProps(node?.export?.(IPublicEnumTransformStage.Render) as IPublicTypeNodeSchema, scope, Comp, componentInfo); if (key && !(key in nodeProps) && (key in this.props)) { // 当 key 在 this.props 中时,且不存在在计算值中,需要用 newValue 覆盖掉 this.props 的取值 nodeCacheProps[key] = newValue; @@ -447,6 +457,12 @@ export function leafWrapper(Comp: types.IBaseRenderComponent, { this.judgeMiniUnitRender(); }); + const dispose = leaf?.onPropChange?.((propChangeInfo: IPublicTypePropChangeOptions) => { + if (!this.autoRepaintNode) { + return; + } + handlePropsChange(propChangeInfo); + }); dispose && this.disposeFunctions.push(dispose); } @@ -456,6 +472,9 @@ export function leafWrapper(Comp: types.IBaseRenderComponent, { */ initOnVisibleChangeEvent(leaf = this.leaf) { const dispose = leaf?.onVisibleChange?.((flag: boolean) => { + if (!this.autoRepaintNode) { + return; + } if (this.state.visible === flag) { return; } @@ -476,6 +495,9 @@ export function leafWrapper(Comp: types.IBaseRenderComponent, { */ initOnChildrenChangeEvent(leaf = this.leaf) { const dispose = leaf?.onChildrenChange?.((param): void => { + if (!this.autoRepaintNode) { + return; + } const { type, node, @@ -484,7 +506,7 @@ export function leafWrapper(Comp: types.IBaseRenderComponent, { // TODO: 缓存同级其他元素的 children。 // 缓存二级 children Next 查询筛选组件有问题 // 缓存一级 children Next Tab 组件有问题 - const nextChild = getChildren(leaf?.export?.(TransformStage.Render) as types.ISchema, scope, Comp); // this.childrenMap + const nextChild = getChildren(leaf?.export?.(IPublicEnumTransformStage.Render) as types.ISchema, scope, Comp); __debug(`${schema.componentName}[${this.props.componentId}] component trigger onChildrenChange event`, nextChild); this.setState({ nodeChildren: nextChild, @@ -500,16 +522,11 @@ export function leafWrapper(Comp: types.IBaseRenderComponent, { } get hasChildren(): boolean { - let { children } = this.props; - if (this.state.childrenInState) { - children = this.state.nodeChildren; - } - - if (Array.isArray(children)) { - return Boolean(children && children.length); + if (!this.state.childrenInState) { + return 'children' in this.props; } - return Boolean(children); + return true; } get children(): any { @@ -522,11 +539,16 @@ export function leafWrapper(Comp: types.IBaseRenderComponent, { if (this.props.children && this.props.children.length) { return this.props.children; } - return []; + return this.props.children; } - get leaf(): Node | undefined { - return this.props._leaf || getNode(this.props.componentId); + get leaf(): INode | undefined { + if (this.props._leaf?.isMock) { + // 低代码组件作为一个整体更新,其内部的组件不需要监听相关事件 + return undefined; + } + + return getNode?.(componentCacheId); } render() { @@ -548,24 +570,31 @@ export function leafWrapper(Comp: types.IBaseRenderComponent, { ref: forwardedRef, }; - return engine.createElement(Comp, compProps, this.hasChildren ? this.children : null); + delete compProps.__inner__; + + if (this.hasChildren) { + return engine.createElement(Comp, compProps, this.children); + } + + return engine.createElement(Comp, compProps); } } - let LeafWrapper = forwardRef((props: any, ref: any) => ( - // @ts-ignore - <LeafHoc - {...props} - forwardedRef={ref} - ref={(_ref: any) => cache.ref.set(props.componentId, _ref)} - /> - )); + let LeafWrapper = forwardRef((props: any, ref: any) => { + return createElement(LeafHoc, { + ...props, + forwardedRef: ref, + }); + }); LeafWrapper = cloneEnumerableProperty(LeafWrapper, Comp); LeafWrapper.displayName = (Comp as any).displayName; - cache.component.set(componentCacheId, LeafWrapper); + cache.component.set(componentCacheId, { + LeafWrapper, + Comp, + }); return LeafWrapper; } \ No newline at end of file diff --git a/packages/renderer-core/src/renderer/addon.tsx b/packages/renderer-core/src/renderer/addon.tsx index 66bdd44f68..211ec182f2 100644 --- a/packages/renderer-core/src/renderer/addon.tsx +++ b/packages/renderer-core/src/renderer/addon.tsx @@ -1,12 +1,13 @@ import PropTypes from 'prop-types'; import baseRendererFactory from './base'; -import { isEmpty, goldlog } from '../utils'; +import { isEmpty } from '../utils'; import { IRendererAppHelper, IBaseRendererProps, IBaseRenderComponent } from '../types'; +import logger from '../utils/logger'; export default function addonRendererFactory(): IBaseRenderComponent { const BaseRenderer = baseRendererFactory(); return class AddonRenderer extends BaseRenderer { - static dislayName = 'addon-renderer'; + static displayName = 'AddonRenderer'; __namespace = 'addon'; @@ -32,7 +33,7 @@ export default function addonRendererFactory(): IBaseRenderComponent { const schema = props.__schema || {}; this.state = this.__parseData(schema.state || {}); if (isEmpty(props.config) || !props.config?.addonKey) { - console.warn('lce addon has wrong config'); + logger.warn('lce addon has wrong config'); this.setState({ __hasError: true, }); @@ -45,7 +46,7 @@ export default function addonRendererFactory(): IBaseRenderComponent { this.__initDataSource(props); this.open = this.open || (() => { }); this.close = this.close || (() => { }); - this.__setLifeCycleMethods('constructor', [...arguments]); + this.__executeLifeCycleMethod('constructor', [...arguments]); } async componentWillUnmount() { @@ -57,21 +58,6 @@ export default function addonRendererFactory(): IBaseRenderComponent { } } - goldlog = (goKey: string, params: any) => { - const { addonKey, addonConfig = {} } = this.props.config || {}; - goldlog( - goKey, - { - addonKey, - package: addonConfig.package, - version: addonConfig.version, - ...this.appHelper.logParams, - ...params, - }, - 'addon', - ); - }; - get utils() { const { utils = {} } = this.context.config || {}; return { ...this.appHelper.utils, ...utils }; @@ -84,7 +70,7 @@ export default function addonRendererFactory(): IBaseRenderComponent { return '插件 schema 结构异常!'; } - this.__debug(`${AddonRenderer.dislayName} render - ${__schema.fileName}`); + this.__debug(`${AddonRenderer.displayName} render - ${__schema.fileName}`); this.__generateCtx({ component: this, }); diff --git a/packages/renderer-core/src/renderer/base.tsx b/packages/renderer-core/src/renderer/base.tsx index 82d2884e18..d240095604 100644 --- a/packages/renderer-core/src/renderer/base.tsx +++ b/packages/renderer-core/src/renderer/base.tsx @@ -1,7 +1,10 @@ +/* eslint-disable no-console */ +/* eslint-disable max-len */ /* eslint-disable react/prop-types */ import classnames from 'classnames'; import { create as createDataSourceEngine } from '@alilc/lowcode-datasource-engine/interpret'; -import { isI18nData, isJSExpression, isJSFunction, NodeSchema, NodeData, JSONValue, CompositeValue } from '@alilc/lowcode-types'; +import { IPublicTypeNodeSchema, IPublicTypeNodeData, IPublicTypeJSONValue, IPublicTypeCompositeValue } from '@alilc/lowcode-types'; +import { checkPropTypes, isI18nData, isJSExpression, isJSFunction } from '@alilc/lowcode-utils'; import adapter from '../adapter'; import divFactory from '../components/Div'; import visualDomFactory from '../components/VisualDom'; @@ -11,27 +14,88 @@ import { getValue, parseData, parseExpression, + parseThisRequiredExpression, parseI18n, isEmpty, isSchema, isFileSchema, transformArrayToMap, transformStringToFunction, - checkPropTypes, getI18n, - canAcceptsRef, getFileCssName, capitalizeFirstLetter, DataHelper, isVariable, isJSSlot, } from '../utils'; -import { IBaseRendererProps, IInfo, IBaseRenderComponent, IBaseRendererContext, IGeneralConstructor, IRendererAppHelper, DataSource } from '../types'; +import { IBaseRendererProps, INodeInfo, IBaseRenderComponent, IBaseRendererContext, IRendererAppHelper, DataSource } from '../types'; import { compWrapper } from '../hoc'; -import { IComponentConstruct, IComponentHoc, leafWrapper } from '../hoc/leaf'; +import { IComponentConstruct, leafWrapper } from '../hoc/leaf'; import logger from '../utils/logger'; import isUseLoop from '../utils/is-use-loop'; +/** + * execute method in schema.lifeCycles with context + * @PRIVATE + */ +export function executeLifeCycleMethod(context: any, schema: IPublicTypeNodeSchema, method: string, args: any, thisRequiredInJSE: boolean | undefined): any { + if (!context || !isSchema(schema) || !method) { + return; + } + const lifeCycleMethods = getValue(schema, 'lifeCycles', {}); + let fn = lifeCycleMethods[method]; + + if (!fn) { + return; + } + + // TODO: cache + if (isJSExpression(fn) || isJSFunction(fn)) { + fn = thisRequiredInJSE ? parseThisRequiredExpression(fn, context) : parseExpression(fn, context); + } + + if (typeof fn !== 'function') { + logger.error(`生命周期${method}类型不符`, fn); + return; + } + + try { + return fn.apply(context, args); + } catch (e) { + logger.error(`[${schema.componentName}]生命周期${method}出错`, e); + } +} + +/** + * get children from a node schema + * @PRIVATE + */ +export function getSchemaChildren(schema: IPublicTypeNodeSchema | undefined) { + if (!schema) { + return; + } + + if (!schema.props) { + return schema.children; + } + + if (!schema.children) { + return schema.props.children; + } + + if (!schema.props.children) { + return schema.children; + } + + let result = ([] as IPublicTypeNodeData[]).concat(schema.children); + if (Array.isArray(schema.props.children)) { + result = result.concat(schema.props.children); + } else { + result.push(schema.props.children); + } + return result; +} + export default function baseRendererFactory(): IBaseRenderComponent { const { BaseRenderer: customBaseRenderer } = adapter.getRenderers(); @@ -39,13 +103,7 @@ export default function baseRendererFactory(): IBaseRenderComponent { return customBaseRenderer; } - const runtime = adapter.getRuntime(); - const Component = runtime.Component as IGeneralConstructor< - IBaseRendererProps, - Record<string, any>, - any - >; - const createElement = runtime.createElement; + const { Component, createElement } = adapter.getRuntime(); const Div = divFactory(); const VisualDom = visualDomFactory(); const AppContext = contextFactory(); @@ -56,10 +114,14 @@ export default function baseRendererFactory(): IBaseRenderComponent { PREVIEW: 'preview', }; const OVERLAY_LIST = ['Dialog', 'Overlay', 'Animate', 'ConfigProvider']; + const DEFAULT_LOOP_ARG_ITEM = 'item'; + const DEFAULT_LOOP_ARG_INDEX = 'index'; let scopeIdx = 0; - return class BaseRenderer extends Component { - static displayName = 'base-renderer'; + return class BaseRenderer extends Component<IBaseRendererProps, Record<string, any>> { + [key: string]: any; + + static displayName = 'BaseRenderer'; static defaultProps = { __schema: {}, @@ -67,108 +129,102 @@ export default function baseRendererFactory(): IBaseRenderComponent { static contextType = AppContext; - __namespace = 'base'; + i18n: any; + getLocale: any; + setLocale: any; + dataSourceMap: Record<string, any> = {}; - _self: any = null; - appHelper?: IRendererAppHelper; + __namespace = 'base'; __compScopes: Record<string, any> = {}; __instanceMap: Record<string, any> = {}; __dataHelper: any; - __showPlaceholder: boolean = false; + + /** + * keep track of customMethods added to this context + * + * @type {any} + */ __customMethodsList: any[] = []; - dataSourceMap: Record<string, any> = {}; + __parseExpression: any; __ref: any; - i18n: any; - getLocale: any; - setLocale: any; - styleElement: any; - [key: string]: any; + + /** + * reference of style element contains schema.css + * + * @type {any} + */ + __styleElement: any; constructor(props: IBaseRendererProps, context: IBaseRendererContext) { super(props, context); this.context = context; + this.__parseExpression = (str: string, self: any) => { + return parseExpression({ str, self, thisRequired: props?.thisRequiredInJSE, logScope: props.componentName }); + }; this.__beforeInit(props); this.__init(props); this.__afterInit(props); this.__debug(`constructor - ${props?.__schema?.fileName}`); } + // eslint-disable-next-line @typescript-eslint/no-unused-vars __beforeInit(_props: IBaseRendererProps) { } __init(props: IBaseRendererProps) { - this.appHelper = props.__appHelper; this.__compScopes = {}; this.__instanceMap = {}; this.__bindCustomMethods(props); this.__initI18nAPIs(); } + // eslint-disable-next-line @typescript-eslint/no-unused-vars __afterInit(_props: IBaseRendererProps) { } static getDerivedStateFromProps(props: IBaseRendererProps, state: any) { - logger.log('getDerivedStateFromProps'); - const func = props?.__schema?.lifeCycles?.getDerivedStateFromProps; - - if (func) { - if (isJSExpression(func) || isJSFunction(func)) { - const fn = parseExpression(func, this); - return fn(props, state); - } - - if (typeof func === 'function') { - return (func as Function)(props, state); - } - } - return null; + const result = executeLifeCycleMethod(this, props?.__schema, 'getDerivedStateFromProps', [props, state], props.thisRequiredInJSE); + return result === undefined ? null : result; } - async getSnapshotBeforeUpdate() { - this.__setLifeCycleMethods('getSnapshotBeforeUpdate', arguments); + async getSnapshotBeforeUpdate(...args: any[]) { + this.__executeLifeCycleMethod('getSnapshotBeforeUpdate', args); this.__debug(`getSnapshotBeforeUpdate - ${this.props?.__schema?.fileName}`); } - async componentDidMount() { + async componentDidMount(...args: any[]) { this.reloadDataSource(); - this.__setLifeCycleMethods('componentDidMount', arguments); + this.__executeLifeCycleMethod('componentDidMount', args); this.__debug(`componentDidMount - ${this.props?.__schema?.fileName}`); } - async componentDidUpdate(...args: any) { - this.__setLifeCycleMethods('componentDidUpdate', args); + async componentDidUpdate(...args: any[]) { + this.__executeLifeCycleMethod('componentDidUpdate', args); this.__debug(`componentDidUpdate - ${this.props.__schema.fileName}`); } - async componentWillUnmount(...args: any) { - this.__setLifeCycleMethods('componentWillUnmount', args); + async componentWillUnmount(...args: any[]) { + this.__executeLifeCycleMethod('componentWillUnmount', args); this.__debug(`componentWillUnmount - ${this.props?.__schema?.fileName}`); } - async componentDidCatch(e: any) { - this.__setLifeCycleMethods('componentDidCatch', arguments); - console.warn(e); + async componentDidCatch(...args: any[]) { + this.__executeLifeCycleMethod('componentDidCatch', args); + logger.warn(args); } reloadDataSource = () => new Promise((resolve, reject) => { this.__debug('reload data source'); if (!this.__dataHelper) { - this.__showPlaceholder = false; return resolve({}); } - this.__dataHelper - .getInitData() + this.__dataHelper.getInitData() .then((res: any) => { - this.__showPlaceholder = false; if (isEmpty(res)) { this.forceUpdate(); return resolve({}); } - this.setState(res, resolve); + this.setState(res, resolve as () => void); }) .catch((err: Error) => { - if (this.__showPlaceholder) { - this.__showPlaceholder = false; - this.forceUpdate(); - } reject(err); }); }); @@ -187,45 +243,45 @@ export default function baseRendererFactory(): IBaseRenderComponent { } } - __setLifeCycleMethods = (method: string, args?: any) => { - const lifeCycleMethods = getValue(this.props.__schema, 'lifeCycles', {}); - let fn = lifeCycleMethods[method]; - if (fn) { - // TODO, cache - if (isJSExpression(fn) || isJSFunction(fn)) { - fn = parseExpression(fn, this); - } - if (typeof fn !== 'function') { - console.error(`生命周期${method}类型不符`, fn); - return; - } - try { - return fn.apply(this, args); - } catch (e) { - console.error(`[${this.props.__schema.componentName}]生命周期${method}出错`, e); - } + /** + * execute method in schema.lifeCycles + * @PRIVATE + */ + __executeLifeCycleMethod = (method: string, args?: any) => { + executeLifeCycleMethod(this, this.props.__schema, method, args, this.props.thisRequiredInJSE); + }; + + /** + * this method is for legacy purpose only, which used _ prefix instead of __ as private for some historical reasons + * @LEGACY + */ + _getComponentView = (componentName: string) => { + const { __components } = this.props; + if (!__components) { + return; } + return __components[componentName]; }; - __bindCustomMethods = (props = this.props) => { + __bindCustomMethods = (props: IBaseRendererProps) => { const { __schema } = props; const customMethodsList = Object.keys(__schema.methods || {}) || []; - this.__customMethodsList - && this.__customMethodsList.forEach((item: any) => { - if (!customMethodsList.includes(item)) { - delete this[item]; - } - }); + (this.__customMethodsList || []).forEach((item: any) => { + if (!customMethodsList.includes(item)) { + delete this[item]; + } + }); this.__customMethodsList = customMethodsList; forEach(__schema.methods, (val: any, key: string) => { - if (isJSExpression(val) || isJSFunction(val)) { - val = parseExpression(val, this); + let value = val; + if (isJSExpression(value) || isJSFunction(value)) { + value = this.__parseExpression(value, this); } - if (typeof val !== 'function') { - console.error(`自定义函数${key}类型不符`, val); + if (typeof value !== 'function') { + logger.error(`custom method ${key} can not be parsed to a valid function`, value); return; } - this[key] = val.bind(this); + this[key] = value.bind(this); }); }; @@ -242,33 +298,34 @@ export default function baseRendererFactory(): IBaseRenderComponent { }; __parseData = (data: any, ctx?: Record<string, any>) => { - const { __ctx } = this.props; - return parseData(data, ctx || __ctx || this); + const { __ctx, thisRequiredInJSE, componentName } = this.props; + return parseData(data, ctx || __ctx || this, { thisRequiredInJSE, logScope: componentName }); }; - __initDataSource = (props = this.props) => { + __initDataSource = (props: IBaseRendererProps) => { + if (!props) { + return; + } const schema = props.__schema || {}; const defaultDataSource: DataSource = { list: [], }; - const dataSource = (schema && schema?.dataSource) || defaultDataSource; + const dataSource = schema.dataSource || defaultDataSource; // requestHandlersMap 存在才走数据源引擎方案 - if (props?.__appHelper?.requestHandlersMap) { + // TODO: 下面if else 抽成独立函数 + const useDataSourceEngine = !!(props.__appHelper?.requestHandlersMap); + if (useDataSourceEngine) { this.__dataHelper = { updateConfig: (updateDataSource: any) => { const { dataSourceMap, reloadDataSource } = createDataSourceEngine( - updateDataSource, + updateDataSource ?? {}, this, props.__appHelper.requestHandlersMap ? { requestHandlersMap: props.__appHelper.requestHandlersMap } : undefined, ); this.reloadDataSource = () => new Promise((resolve) => { this.__debug('reload data source'); - // this.__showPlaceholder = true; reloadDataSource().then(() => { - // this.__showPlaceholder = false; - // @TODO 是否需要 forceUpate - // this.forceUpdate(); resolve({}); }); }); @@ -283,57 +340,60 @@ export default function baseRendererFactory(): IBaseRenderComponent { this.reloadDataSource = () => new Promise((resolve, reject) => { this.__debug('reload data source'); if (!this.__dataHelper) { - // this.__showPlaceholder = false; return resolve({}); } - this.__dataHelper - .getInitData() + this.__dataHelper.getInitData() .then((res: any) => { - // this.__showPlaceholder = false; if (isEmpty(res)) { - this.forceUpdate(); return resolve({}); } - this.setState(res, resolve); + this.setState(res, resolve as () => void); }) .catch((err: Error) => { - if (this.__showPlaceholder) { - this.__showPlaceholder = false; - this.forceUpdate(); - } reject(err); }); }); } - // 设置容器组件占位,若设置占位则在初始异步请求完成之前用loading占位且不渲染容器组件内部内容 - // @TODO __showPlaceholder 的逻辑一旦开启就关不掉,先注释掉了 - /* this.__showPlaceholder = this.__parseData(schema.props && schema.props.autoLoading) && (dataSource.list || []).some( - (item) => !!this.__parseData(item.isInit), - ); */ }; + /** + * init i18n apis + * @PRIVATE + */ __initI18nAPIs = () => { this.i18n = (key: string, values = {}) => { const { locale, messages } = this.props; return getI18n(key, values, locale, messages); }; this.getLocale = () => this.props.locale; - this.setLocale = (loc: string) => this.appHelper?.utils?.i18n?.setLocale && this.appHelper?.utils?.i18n?.setLocale(loc); + this.setLocale = (loc: string) => { + const setLocaleFn = this.appHelper?.utils?.i18n?.setLocale; + if (!setLocaleFn || typeof setLocaleFn !== 'function') { + logger.warn('initI18nAPIs Failed, i18n only works when appHelper.utils.i18n.setLocale() exists'); + return undefined; + } + return setLocaleFn(loc); + }; }; - __writeCss = () => { - const css = getValue(this.props.__schema, 'css', ''); - let style = this.styleElement; - if (!this.styleElement) { + /** + * write props.__schema.css to document as a style element, + * which will be added once and only once. + * @PRIVATE + */ + __writeCss = (props: IBaseRendererProps) => { + const css = getValue(props.__schema, 'css', ''); + this.__debug('create this.styleElement with css', css); + let style = this.__styleElement; + if (!this.__styleElement) { style = document.createElement('style'); style.type = 'text/css'; style.setAttribute('from', 'style-sheet'); - if (style.firstChild) { - style.removeChild(style.firstChild); - } + const head = document.head || document.getElementsByTagName('head')[0]; head.appendChild(style); - this.styleElement = style; + this.__styleElement = style; + this.__debug('this.styleElement is created', this.__styleElement); } if (style.innerHTML === css) { @@ -345,16 +405,16 @@ export default function baseRendererFactory(): IBaseRenderComponent { __render = () => { const schema = this.props.__schema; - this.__setLifeCycleMethods('render'); - this.__writeCss(); + this.__executeLifeCycleMethod('render'); + this.__writeCss(this.props); const { engine } = this.context; if (engine) { engine.props.onCompGetCtx(schema, this); // 画布场景才需要每次渲染bind自定义方法 - if (engine.props.designMode) { - this.__bindCustomMethods(); - this.dataSourceMap = this.__dataHelper && this.__dataHelper.updateConfig(schema.dataSource); + if (this.__designModeIsDesign) { + this.__bindCustomMethods(this.props); + this.dataSourceMap = this.__dataHelper?.updateConfig(schema.dataSource); } } }; @@ -366,56 +426,54 @@ export default function baseRendererFactory(): IBaseRenderComponent { this.__ref = ref; }; - getSchemaChildren = (schema: NodeSchema | undefined) => { - if (!schema || !schema.props) { - return schema?.children; - } - if (!schema.children) return schema.props.children; - if (!schema.props.children) return schema.children; - let _children = ([] as NodeData[]).concat(schema.children); - if (Array.isArray(schema.props.children)) { - _children = _children.concat(schema.props.children); - } else { - _children.push(schema.props.children); - } - return _children; - }; - __createDom = () => { const { __schema, __ctx, __components = {} } = this.props; - const scope: any = {}; + // merge defaultProps + const scopeProps = { + ...__schema.defaultProps, + ...this.props, + }; + const scope: any = { + props: scopeProps, + }; scope.__proto__ = __ctx || this; - if (!this._self) { - this._self = scope; - } - const _children = this.getSchemaChildren(__schema); + + const _children = getSchemaChildren(__schema); let Comp = __components[__schema.componentName]; if (!Comp) { this.__debug(`${__schema.componentName} is invalid!`); } - - return this.__createVirtualDom(_children, scope, ({ + const parentNodeInfo = ({ schema: __schema, - Comp: this.__getHocComp(Comp, __schema, scope), - } as IInfo)); + Comp: this.__getHOCWrappedComponent(Comp, __schema, scope), + } as INodeInfo); + return this.__createVirtualDom(_children, scope, parentNodeInfo); }; - - // 将模型结构转换成react Element - // schema 模型结构 - // self 为每个渲染组件构造的上下文,self是自上而下继承的 - // parentInfo 父组件的信息,包含schema和Comp - // idx 若为循环渲染的循环Index - __createVirtualDom = (schema: NodeData | NodeData[] | undefined, scope: any, parentInfo: IInfo, idx: string | number = ''): any => { + /** + * 将模型结构转换成react Element + * @param originalSchema schema + * @param originalScope scope + * @param parentInfo 父组件的信息,包含schema和Comp + * @param idx 为循环渲染的循环Index + */ + __createVirtualDom = (originalSchema: IPublicTypeNodeData | IPublicTypeNodeData[] | undefined, originalScope: any, parentInfo: INodeInfo, idx: string | number = ''): any => { + if (originalSchema === null || originalSchema === undefined) { + return null; + } + let scope = originalScope; + let schema = originalSchema; const { engine } = this.context || {}; + if (!engine) { + this.__debug('this.context.engine is invalid!'); + return null; + } try { - if (!schema) return null; - const { __appHelper: appHelper, __components: components = {} } = this.props || {}; if (isJSExpression(schema)) { - return parseExpression(schema, scope); + return this.__parseExpression(schema, scope); } if (isI18nData(schema)) { return parseI18n(schema, scope); @@ -423,33 +481,47 @@ export default function baseRendererFactory(): IBaseRenderComponent { if (isJSSlot(schema)) { return this.__createVirtualDom(schema.value, scope, parentInfo); } - if (typeof schema === 'string') return schema; + + if (typeof schema === 'string') { + return schema; + } + if (typeof schema === 'number' || typeof schema === 'boolean') { return String(schema); } + if (Array.isArray(schema)) { - if (schema.length === 1) return this.__createVirtualDom(schema[0], scope, parentInfo); - return schema.map((item, idy) => this.__createVirtualDom(item, scope, parentInfo, (item as NodeSchema)?.__ctx?.lceKey ? '' : String(idy))); + if (schema.length === 1) { + return this.__createVirtualDom(schema[0], scope, parentInfo); + } + return schema.map((item, idy) => this.__createVirtualDom(item, scope, parentInfo, (item as IPublicTypeNodeSchema)?.__ctx?.lceKey ? '' : String(idy))); + } + + // @ts-expect-error 如果直接转换好了,可以返回 + if (schema.$$typeof) { + return schema; + } + + const _children = getSchemaChildren(schema); + if (!schema.componentName) { + logger.error('The componentName in the schema is invalid, please check the schema: ', schema); + return; } - // FIXME - const _children = this.getSchemaChildren(schema); // 解析占位组件 - if (schema?.componentName === 'Fragment' && _children) { - const tarChildren = isJSExpression(_children) ? parseExpression(_children, scope) : _children; + if (schema.componentName === 'Fragment' && _children) { + const tarChildren = isJSExpression(_children) ? this.__parseExpression(_children, scope) : _children; return this.__createVirtualDom(tarChildren, scope, parentInfo); } - if (schema?.componentName === 'Text' && typeof schema?.props?.text === 'string') { - const text: string = schema?.props?.text; + if (schema.componentName === 'Text' && typeof schema.props?.text === 'string') { + const text: string = schema.props?.text; schema = { ...schema }; schema.children = [text]; } - // @ts-expect-error 如果直接转换好了,可以返回 - if (schema?.$$typeof) { - return schema; + if (!isSchema(schema)) { + return null; } - if (!isSchema(schema)) return null; let Comp = components[schema.componentName] || this.props.__container?.components?.[schema.componentName]; // 容器类组件的上下文通过props传递,避免context传递带来的嵌套问题 @@ -462,21 +534,25 @@ export default function baseRendererFactory(): IBaseRenderComponent { : {}; if (!Comp) { - console.error(`${schema.componentName} component is not found in components list! component list is:`, components || this.props.__container?.components); - Comp = engine.getNotFoundComponent(); - otherProps.__componentName = schema.componentName; - } - - // DesignMode 为 design 情况下,需要进入 leaf Hoc,进行相关事件注册 - const displayInHook = engine?.props?.designMode === 'design'; - - if (schema.hidden && !displayInHook) { - return null; + logger.error(`${schema.componentName} component is not found in components list! component list is:`, components || this.props.__container?.components); + return engine.createElement( + engine.getNotFoundComponent(), + { + componentName: schema.componentName, + componentId: schema.id, + enableStrictNotFoundMode: engine.props.enableStrictNotFoundMode, + ref: (ref: any) => { + ref && engine.props?.onCompGetRef(schema, ref); + }, + }, + this.__getSchemaChildrenVirtualDom(schema, scope, Comp), + ); } if (schema.loop != null) { - const loop = parseData(schema.loop, scope); - const useLoop = isUseLoop(loop, this._designModeIsDesign); + const loop = this.__parseData(schema.loop, scope); + if (Array.isArray(loop) && loop.length === 0) return null; + const useLoop = isUseLoop(loop, this.__designModeIsDesign); if (useLoop) { return this.__createLoopVirtualDom( { @@ -489,13 +565,18 @@ export default function baseRendererFactory(): IBaseRenderComponent { ); } } - const condition = schema.condition == null ? true : parseData(schema.condition, scope); - if (!condition && !displayInHook) return null; + const condition = schema.condition == null ? true : this.__parseData(schema.condition, scope); + + // DesignMode 为 design 情况下,需要进入 leaf Hoc,进行相关事件注册 + const displayInHook = this.__designModeIsDesign; + if (!condition && !displayInHook) { + return null; + } let scopeKey = ''; // 判断组件是否需要生成scope,且只生成一次,挂在this.__compScopes上 if (Comp.generateScope) { - const key = parseExpression(schema.props?.key, scope); + const key = this.__parseExpression(schema.props?.key, scope); if (key) { // 如果组件自己设置key则使用组件自己的key scopeKey = key; @@ -520,10 +601,10 @@ export default function baseRendererFactory(): IBaseRenderComponent { scope = compSelf; } - if (engine?.props?.designMode) { + if (engine.props?.designMode) { otherProps.__designMode = engine.props.designMode; } - if (this._designModeIsDesign) { + if (this.__designModeIsDesign) { otherProps.__tag = Math.random(); } const componentInfo: any = {}; @@ -532,7 +613,7 @@ export default function baseRendererFactory(): IBaseRenderComponent { props: transformArrayToMap(componentInfo.props, 'name'), }) || {}; - this.componentHoc.forEach((ComponentConstruct: IComponentConstruct) => { + this.__componentHOCs.forEach((ComponentConstruct: IComponentConstruct) => { Comp = ComponentConstruct(Comp, { schema, componentInfo, @@ -541,19 +622,13 @@ export default function baseRendererFactory(): IBaseRenderComponent { }); }); - // 对于不可以接收到 ref 的组件需要做特殊处理 - if (!canAcceptsRef(Comp)) { - Comp = compWrapper(Comp); - components[schema.componentName] = Comp; - } - otherProps.ref = (ref: any) => { this.$(props.fieldId || props.ref, ref); // 收集ref const refProps = props.ref; if (refProps && typeof refProps === 'string') { this[refProps] = ref; } - ref && engine?.props?.onCompGetRef(schema, ref); + ref && engine.props?.onCompGetRef(schema, ref); }; // scope需要传入到组件上 @@ -562,10 +637,10 @@ export default function baseRendererFactory(): IBaseRenderComponent { } if (schema?.__ctx?.lceKey) { if (!isFileSchema(schema)) { - engine?.props?.onCompGetCtx(schema, scope); + engine.props?.onCompGetCtx(schema, scope); } props.key = props.key || `${schema.__ctx.lceKey}_${schema.__ctx.idx || 0}_${idx !== undefined ? idx : ''}`; - } else if (typeof idx === 'number' && !props.key) { + } else if ((typeof idx === 'number' || typeof idx === 'string') && !props.key) { // 仅当循环场景走这里 props.key = idx; } @@ -575,8 +650,8 @@ export default function baseRendererFactory(): IBaseRenderComponent { props.key = props.__id; } - let child = this.__getSchemaChildrenVirtualDom(schema, scope, Comp); - const renderComp = (props: any) => engine.createElement(Comp, props, child); + let child = this.__getSchemaChildrenVirtualDom(schema, scope, Comp, condition); + const renderComp = (innerProps: any) => engine.createElement(Comp, innerProps, child); // 设计模式下的特殊处理 if (engine && [DESIGN_MODE.EXTEND, DESIGN_MODE.BORDER].includes(engine.props.designMode)) { // 对于overlay,dialog等组件为了使其在设计模式下显示,外层需要增加一个div容器 @@ -603,7 +678,14 @@ export default function baseRendererFactory(): IBaseRenderComponent { } } } - return renderComp({ ...props, ...otherProps }); + return renderComp({ + ...props, + ...otherProps, + __inner__: { + hidden: schema.hidden, + condition, + }, + }); } catch (e) { return engine.createElement(engine.getFaultComponent(), { error: e, @@ -615,38 +697,32 @@ export default function baseRendererFactory(): IBaseRenderComponent { } }; - get componentHoc(): IComponentConstruct[] { - const componentHoc: IComponentHoc[] = [ - { - designMode: 'design', - hoc: leafWrapper, - }, - ]; - - return componentHoc - .filter((d: IComponentHoc) => { - if (Array.isArray(d.designMode)) { - return d.designMode.includes(this.props.designMode); - } - - return d.designMode === this.props.designMode; - }) - .map((d: IComponentHoc) => d.hoc); + /** + * get Component HOCs + * + * @readonly + * @type {IComponentConstruct[]} + */ + get __componentHOCs(): IComponentConstruct[] { + if (this.__designModeIsDesign) { + return [leafWrapper, compWrapper]; + } + return [compWrapper]; } - __getSchemaChildrenVirtualDom = (schema: NodeSchema | undefined, scope: any, Comp: any) => { - let _children = this.getSchemaChildren(schema); + __getSchemaChildrenVirtualDom = (schema: IPublicTypeNodeSchema | undefined, scope: any, Comp: any, condition = true) => { + let children = condition ? getSchemaChildren(schema) : null; // @todo 补完这里的 Element 定义 @承虎 - let children: any = []; - if (/*! isFileSchema(schema) && */_children) { - if (!Array.isArray(_children)) { - _children = [_children]; + let result: any = []; + if (children) { + if (!Array.isArray(children)) { + children = [children]; } - _children.forEach((_child: any) => { - const _childVirtualDom = this.__createVirtualDom( - isJSExpression(_child) ? parseExpression(_child, scope) : _child, + children.forEach((child: any) => { + const childVirtualDom = this.__createVirtualDom( + isJSExpression(child) ? this.__parseExpression(child, scope) : child, scope, { schema, @@ -654,17 +730,17 @@ export default function baseRendererFactory(): IBaseRenderComponent { }, ); - children.push(_childVirtualDom); + result.push(childVirtualDom); }); } - if (children && children.length) { - return children; + if (result && result.length > 0) { + return result; } return null; }; - __getComponentProps = (schema: NodeSchema | undefined, scope: any, Comp: any, componentInfo?: any) => { + __getComponentProps = (schema: IPublicTypeNodeSchema | undefined, scope: any, Comp: any, componentInfo?: any) => { if (!schema) { return {}; } @@ -678,16 +754,18 @@ export default function baseRendererFactory(): IBaseRenderComponent { }) || {}; }; - __createLoopVirtualDom = (schema: NodeSchema, scope: any, parentInfo: IInfo, idx: number | string) => { + __createLoopVirtualDom = (schema: IPublicTypeNodeSchema, scope: any, parentInfo: INodeInfo, idx: number | string) => { if (isFileSchema(schema)) { - console.warn('file type not support Loop'); + logger.warn('file type not support Loop'); + return null; + } + if (!Array.isArray(schema.loop)) { return null; } - if (!Array.isArray(schema.loop)) return null; - const itemArg = (schema.loopArgs && schema.loopArgs[0]) || 'item'; - const indexArg = (schema.loopArgs && schema.loopArgs[1]) || 'index'; - const loop: (JSONValue| CompositeValue)[] = schema.loop; - return loop.map((item: JSONValue | CompositeValue, i: number) => { + const itemArg = (schema.loopArgs && schema.loopArgs[0]) || DEFAULT_LOOP_ARG_ITEM; + const indexArg = (schema.loopArgs && schema.loopArgs[1]) || DEFAULT_LOOP_ARG_INDEX; + const { loop } = schema; + return loop.map((item: IPublicTypeJSONValue | IPublicTypeCompositeValue, i: number) => { const loopSelf: any = { [itemArg]: item, [indexArg]: i, @@ -697,6 +775,11 @@ export default function baseRendererFactory(): IBaseRenderComponent { { ...schema, loop: undefined, + props: { + ...schema.props, + // 循环下 key 不能为常量,这样会造成 key 值重复,渲染异常 + key: isJSExpression(schema.props?.key) ? schema.props?.key : null, + }, }, loopSelf, parentInfo, @@ -705,64 +788,59 @@ export default function baseRendererFactory(): IBaseRenderComponent { }); }; - get _designModeIsDesign() { + get __designModeIsDesign() { const { engine } = this.context || {}; return engine?.props?.designMode === 'design'; } - __parseProps = (props: any, scope: any, path: string, info: IInfo): any => { + __parseProps = (originalProps: any, scope: any, path: string, info: INodeInfo): any => { + let props = originalProps; const { schema, Comp, componentInfo = {} } = info; const propInfo = getValue(componentInfo.props, path); - // FIXME! 将这行逻辑外置,解耦,线上环境不要验证参数,调试环境可以有,通过传参自定义 + // FIXME: 将这行逻辑外置,解耦,线上环境不要验证参数,调试环境可以有,通过传参自定义 const propType = propInfo?.extra?.propType; - const ignoreParse = schema?.__ignoreParse || []; + const checkProps = (value: any) => { - if (!propType) return value; + if (!propType) { + return value; + } return checkPropTypes(value, path, propType, componentInfo.name) ? value : undefined; }; const parseReactNode = (data: any, params: any) => { if (isEmpty(params)) { - return checkProps(this.__createVirtualDom(data, scope, ({ schema, Comp } as IInfo))); + const virtualDom = this.__createVirtualDom(data, scope, ({ schema, Comp } as INodeInfo)); + return checkProps(virtualDom); } - return checkProps(function () { + return checkProps((...argValues: any[]) => { const args: any = {}; if (Array.isArray(params) && params.length) { params.forEach((item, idx) => { if (typeof item === 'string') { - args[item] = arguments[idx]; + args[item] = argValues[idx]; } else if (item && typeof item === 'object') { - args[item.name] = arguments[idx]; + args[item.name] = argValues[idx]; } }); } args.__proto__ = scope; - return scope.__createVirtualDom(data, args, { schema, Comp }); + return scope.__createVirtualDom(data, args, ({ schema, Comp } as INodeInfo)); }); }; - // 判断是否需要解析变量 - if ( - ignoreParse.some((item: any) => { - if (item instanceof RegExp) { - return item.test(path); - } - return item === path; - }) - ) { - return checkProps(props); - } if (isJSExpression(props)) { - props = parseExpression(props, scope); + props = this.__parseExpression(props, scope); // 只有当变量解析出来为模型结构的时候才会继续解析 - if (!isSchema(props) && !isJSSlot(props)) return checkProps(props); + if (!isSchema(props) && !isJSSlot(props)) { + return checkProps(props); + } } - const handleLegaoI18n = (props: any) => props[props.use || 'zh_CN']; + const handleI18nData = (innerProps: any) => innerProps[innerProps.use || (this.getLocale && this.getLocale()) || 'zh-CN']; - // 兼容乐高设计态 i18n 数据 + // @LEGACY 兼容老平台设计态 i18n 数据 if (isI18nData(props)) { - const i18nProp = handleLegaoI18n(props); + const i18nProp = handleI18nData(props); if (i18nProp) { props = i18nProp; } else { @@ -770,11 +848,11 @@ export default function baseRendererFactory(): IBaseRenderComponent { } } - // 兼容乐高设计态的变量绑定 + // @LEGACY 兼容老平台设计态的变量绑定 if (isVariable(props)) { props = props.value; if (isI18nData(props)) { - props = handleLegaoI18n(props); + props = handleI18nData(props); } } @@ -783,28 +861,31 @@ export default function baseRendererFactory(): IBaseRenderComponent { } if (isJSSlot(props)) { const { params, value } = props; - if (!isSchema(value) || isEmpty(value)) return undefined; + if (!isSchema(value) || isEmpty(value)) { + return undefined; + } return parseReactNode(value, params); } + // 兼容通过componentInfo判断的情况 if (isSchema(props)) { - const isReactNodeFunction = !!( - propInfo?.type === 'ReactNode' - && propInfo?.props?.type === 'function' - ); + const isReactNodeFunction = !!(propInfo?.type === 'ReactNode' && propInfo?.props?.type === 'function'); const isMixinReactNodeFunction = !!( propInfo?.type === 'Mixin' && propInfo?.props?.types?.indexOf('ReactNode') > -1 && propInfo?.props?.reactNodeProps?.type === 'function' ); + + let params = null; + if (isReactNodeFunction) { + params = propInfo?.props?.params; + } else if (isMixinReactNodeFunction) { + params = propInfo?.props?.reactNodeProps?.params; + } return parseReactNode( props, - isReactNodeFunction - ? propInfo.props.params - : isMixinReactNodeFunction - ? propInfo.props.reactNodeProps.params - : null, + params, ); } if (Array.isArray(props)) { @@ -814,7 +895,9 @@ export default function baseRendererFactory(): IBaseRenderComponent { return checkProps(props.bind(scope)); } if (props && typeof props === 'object') { - if (props.$$typeof) return checkProps(props); + if (props.$$typeof) { + return checkProps(props); + } const res: any = {}; forEach(props, (val: any, key: string) => { if (key.startsWith('__')) { @@ -825,9 +908,6 @@ export default function baseRendererFactory(): IBaseRenderComponent { }); return checkProps(res); } - if (typeof props === 'string') { - return checkProps(props.trim()); - } return checkProps(props); }; @@ -842,18 +922,16 @@ export default function baseRendererFactory(): IBaseRenderComponent { return this.__instanceMap[filedId]; } - __debug = logger.log; + __debug = (...args: any[]) => { logger.debug(...args); }; __renderContextProvider = (customProps?: object, children?: any) => { - customProps = customProps || {}; - children = children || this.__createDom(); return createElement(AppContext.Provider, { value: { ...this.context, blockContext: this, - ...customProps, + ...(customProps || {}), }, - children, + children: children || this.__createDom(), }); }; @@ -861,8 +939,9 @@ export default function baseRendererFactory(): IBaseRenderComponent { return createElement(AppContext.Consumer, {}, children); }; - __getHocComp(Comp: any, schema: any, scope: any) { - this.componentHoc.forEach((ComponentConstruct: IComponentConstruct) => { + __getHOCWrappedComponent(OriginalComp: any, schema: any, scope: any) { + let Comp = OriginalComp; + this.__componentHOCs.forEach((ComponentConstruct: IComponentConstruct) => { Comp = ComponentConstruct(Comp || Div, { schema, componentInfo: {}, @@ -874,12 +953,12 @@ export default function baseRendererFactory(): IBaseRenderComponent { return Comp; } - __renderComp(Comp: any, ctxProps: object) { - const { __schema } = this.props; - const { __ctx } = this.props; + __renderComp(OriginalComp: any, ctxProps: object) { + let Comp = OriginalComp; + const { __schema, __ctx } = this.props; const scope: any = {}; scope.__proto__ = __ctx || this; - Comp = this.__getHocComp(Comp, __schema, scope); + Comp = this.__getHOCWrappedComponent(Comp, __schema, scope); const data = this.__parseProps(__schema?.props, scope, '', { schema: __schema, Comp, @@ -892,7 +971,7 @@ export default function baseRendererFactory(): IBaseRenderComponent { return null; } - if (this._designModeIsDesign) { + if (this.__designModeIsDesign) { otherProps.__tag = Math.random(); } @@ -913,26 +992,33 @@ export default function baseRendererFactory(): IBaseRenderComponent { __renderContent(children: any) { const { __schema } = this.props; - const props = this.__parseData(__schema.props); - const { id, className, style = {} } = props; + const parsedProps = this.__parseData(__schema.props); + const className = classnames(`lce-${this.__namespace}`, getFileCssName(__schema.fileName), parsedProps.className, this.props.className); + const style = { ...(parsedProps.style || {}), ...(typeof this.props.style === 'object' ? this.props.style : {}) }; + const id = this.props.id || parsedProps.id; return createElement('div', { ref: this.__getRef, - className: classnames(`lce-${this.__namespace}`, getFileCssName(__schema.fileName), className, this.props.className), - id: this.props.id || id, - style: { ...style, ...(typeof this.props.style === 'object' ? this.props.style : {}) }, + className, + id, + style, }, children); } - __checkSchema = (schema: NodeSchema | undefined, extraComponents: string | string[] = []) => { + __checkSchema = (schema: IPublicTypeNodeSchema | undefined, originalExtraComponents: string | string[] = []) => { + let extraComponents = originalExtraComponents; if (typeof extraComponents === 'string') { extraComponents = [extraComponents]; } - const buitin = capitalizeFirstLetter(this.__namespace); - const componentNames = [buitin, ...extraComponents]; - return !isSchema(schema, true) || !componentNames.includes(schema?.componentName ?? ''); + const builtin = capitalizeFirstLetter(this.__namespace); + const componentNames = [builtin, ...extraComponents]; + return !isSchema(schema) || !componentNames.includes(schema?.componentName ?? ''); }; + get appHelper(): IRendererAppHelper { + return this.props.__appHelper; + } + get requestHandlersMap() { return this.appHelper?.requestHandlersMap; } diff --git a/packages/renderer-core/src/renderer/block.tsx b/packages/renderer-core/src/renderer/block.tsx index 17daccaa35..5132997f05 100644 --- a/packages/renderer-core/src/renderer/block.tsx +++ b/packages/renderer-core/src/renderer/block.tsx @@ -4,7 +4,7 @@ import { IBaseRendererProps, IBaseRenderComponent } from '../types'; export default function blockRendererFactory(): IBaseRenderComponent { const BaseRenderer = baseRendererFactory(); return class BlockRenderer extends BaseRenderer { - static dislayName = 'block-renderer'; + static displayName = 'BlockRenderer'; __namespace = 'block'; @@ -13,7 +13,7 @@ export default function blockRendererFactory(): IBaseRenderComponent { const schema = props.__schema || {}; this.state = this.__parseData(schema.state || {}); this.__initDataSource(props); - this.__setLifeCycleMethods('constructor', [...arguments]); + this.__executeLifeCycleMethod('constructor', [...arguments]); } render() { @@ -23,7 +23,7 @@ export default function blockRendererFactory(): IBaseRenderComponent { return '区块 schema 结构异常!'; } - this.__debug(`${BlockRenderer.dislayName} render - ${__schema?.fileName}`); + this.__debug(`${BlockRenderer.displayName} render - ${__schema?.fileName}`); this.__generateCtx({}); this.__render(); diff --git a/packages/renderer-core/src/renderer/component.tsx b/packages/renderer-core/src/renderer/component.tsx index 1e06bcc5f6..3dfc1df33f 100644 --- a/packages/renderer-core/src/renderer/component.tsx +++ b/packages/renderer-core/src/renderer/component.tsx @@ -4,7 +4,7 @@ import { IBaseRendererProps, IBaseRenderComponent } from '../types'; export default function componentRendererFactory(): IBaseRenderComponent { const BaseRenderer = baseRendererFactory(); return class CompRenderer extends BaseRenderer { - static dislayName = 'comp-renderer'; + static displayName = 'CompRenderer'; __namespace = 'component'; @@ -15,7 +15,7 @@ export default function componentRendererFactory(): IBaseRenderComponent { const schema = props.__schema || {}; this.state = this.__parseData(schema.state || {}); this.__initDataSource(props); - this.__setLifeCycleMethods('constructor', arguments as any); + this.__executeLifeCycleMethod('constructor', arguments as any); } render() { @@ -23,14 +23,16 @@ export default function componentRendererFactory(): IBaseRenderComponent { if (this.__checkSchema(__schema)) { return '自定义组件 schema 结构异常!'; } - this.__debug(`${CompRenderer.dislayName} render - ${__schema.fileName}`); + this.__debug(`${CompRenderer.displayName} render - ${__schema.fileName}`); this.__generateCtx({ component: this, }); this.__render(); - const { noContainer } = this.__parseData(__schema.props); + const noContainer = this.__parseData(__schema.props?.noContainer); + + this.__bindCustomMethods(this.props); if (noContainer) { return this.__renderContextProvider({ compContext: this }); diff --git a/packages/renderer-core/src/renderer/page.tsx b/packages/renderer-core/src/renderer/page.tsx index 8446d332dd..16d55e01be 100644 --- a/packages/renderer-core/src/renderer/page.tsx +++ b/packages/renderer-core/src/renderer/page.tsx @@ -1,11 +1,13 @@ +import { getLogger } from '@alilc/lowcode-utils'; import baseRendererFactory from './base'; -import { parseData } from '../utils'; import { IBaseRendererProps, IBaseRenderComponent } from '../types'; +const logger = getLogger({ level: 'warn', bizName: 'renderer-core:page' }); + export default function pageRendererFactory(): IBaseRenderComponent { const BaseRenderer = baseRendererFactory(); return class PageRenderer extends BaseRenderer { - static dislayName = 'page-renderer'; + static displayName = 'PageRenderer'; __namespace = 'page'; @@ -16,39 +18,40 @@ export default function pageRendererFactory(): IBaseRenderComponent { const schema = props.__schema || {}; this.state = this.__parseData(schema.state || {}); this.__initDataSource(props); - this.__setLifeCycleMethods('constructor', [props, ...rest]); + this.__executeLifeCycleMethod('constructor', [props, ...rest]); } async componentDidUpdate(prevProps: IBaseRendererProps, _prevState: {}, snapshot: unknown) { const { __ctx } = this.props; - const prevState = parseData(prevProps.__schema.state, __ctx); - const newState = parseData(this.props.__schema.state, __ctx); - // 当编排的时候修改schema.state值,需要将最新schema.state值setState - if (JSON.stringify(newState) != JSON.stringify(prevState)) { + // 当编排的时候修改 schema.state 值,需要将最新 schema.state 值 setState + if (JSON.stringify(prevProps.__schema.state) != JSON.stringify(this.props.__schema.state)) { + const newState = this.__parseData(this.props.__schema.state, __ctx); this.setState(newState); } super.componentDidUpdate?.(prevProps, _prevState, snapshot); } + setState(state: any, callback?: () => void) { + logger.info('page set state', state); + super.setState(state, callback); + } + render() { const { __schema, __components } = this.props; if (this.__checkSchema(__schema)) { return '页面schema结构异常!'; } - this.__debug(`${PageRenderer.dislayName} render - ${__schema.fileName}`); + this.__debug(`${PageRenderer.displayName} render - ${__schema.fileName}`); this.__bindCustomMethods(this.props); this.__initDataSource(this.props); - // this.__setLifeCycleMethods('constructor', arguments); - this.__generateCtx({ page: this, }); this.__render(); - const { Page } = __components; if (Page) { return this.__renderComp(Page, { pageContext: this }); diff --git a/packages/renderer-core/src/renderer/renderer.tsx b/packages/renderer-core/src/renderer/renderer.tsx index 17738beff8..300b1cd164 100644 --- a/packages/renderer-core/src/renderer/renderer.tsx +++ b/packages/renderer-core/src/renderer/renderer.tsx @@ -1,17 +1,15 @@ import Debug from 'debug'; import adapter from '../adapter'; import contextFactory from '../context'; -import { isFileSchema, goldlog, isEmpty } from '../utils'; +import { isFileSchema, isEmpty } from '../utils'; import baseRendererFactory from './base'; import divFactory from '../components/Div'; -import { IGeneralConstructor, IRenderComponent, IRendererProps, IRendererState } from '../types'; -import { RootSchema } from '@alilc/lowcode-types'; +import { IRenderComponent, IRendererProps, IRendererState } from '../types'; +import { IPublicTypeNodeSchema, IPublicTypeRootSchema } from '@alilc/lowcode-types'; +import logger from '../utils/logger'; export default function rendererFactory(): IRenderComponent { - const runtime = adapter.getRuntime(); - const Component = runtime.Component as IGeneralConstructor<IRendererProps, Record<string, any>>; - const PureComponent = runtime.PureComponent as IGeneralConstructor<IRendererProps, Record<string, any>>; - const { createElement, findDOMNode } = runtime; + const { PureComponent, Component, createElement, findDOMNode } = adapter.getRuntime(); const RENDERER_COMPS: any = adapter.getRenderers(); const BaseRenderer = baseRendererFactory(); const AppContext = contextFactory(); @@ -21,10 +19,9 @@ export default function rendererFactory(): IRenderComponent { const debug = Debug('renderer:entry'); - class FaultComponent extends PureComponent { + class FaultComponent extends PureComponent<IPublicTypeNodeSchema | any> { render() { - // FIXME: errorlog - console.error('render error', this.props); + logger.error(`%c${this.props.componentName || ''} 组件渲染异常, 异常原因: ${this.props.error?.message || this.props.error || '未知'}`, 'color: #ff0000;'); return createElement(Div, { style: { width: '100%', @@ -35,18 +32,23 @@ export default function rendererFactory(): IRenderComponent { color: '#ff0000', border: '2px solid #ff0000', }, - }, '组件渲染异常,请查看控制台日志'); + }, `${this.props.componentName || ''} 组件渲染异常,请查看控制台日志`); } } - class NotFoundComponent extends PureComponent { + class NotFoundComponent extends PureComponent<{ + componentName: string; + } & IRendererProps> { render() { - return createElement(Div, this.props, this.props.children || 'Component Not Found'); + if (this.props.enableStrictNotFoundMode) { + return `${this.props.componentName || ''} Component Not Found`; + } + return createElement(Div, this.props, this.props.children || `${this.props.componentName || ''} Component Not Found`); } } - return class Renderer extends Component { - static dislayName = 'renderer'; + return class Renderer extends Component<IRendererProps> { + static displayName = 'Renderer'; state: Partial<IRendererState> = {}; @@ -57,9 +59,10 @@ export default function rendererFactory(): IRenderComponent { components: {}, designMode: '', suspended: false, - schema: {} as RootSchema, + schema: {} as IPublicTypeRootSchema, onCompGetRef: () => { }, onCompGetCtx: () => { }, + thisRequiredInJSE: true, }; static findDOMNode = findDOMNode; @@ -71,14 +74,6 @@ export default function rendererFactory(): IRenderComponent { } async componentDidMount() { - goldlog( - 'EXP', - { - action: 'appear', - value: !!this.props.designMode, - }, - 'renderer', - ); debug(`entry.componentDidMount - ${this.props.schema && this.props.schema.componentName}`); } @@ -90,8 +85,9 @@ export default function rendererFactory(): IRenderComponent { debug(`entry.componentWillUnmount - ${this.props?.schema?.componentName}`); } - async componentDidCatch(e: any) { - console.warn(e); + componentDidCatch(error: Error) { + this.state.engineRenderError = true; + this.state.error = error; } shouldComponentUpdate(nextProps: IRendererProps) { @@ -109,49 +105,7 @@ export default function rendererFactory(): IRenderComponent { return SetComponent; } - patchDidCatch(SetComponent: any) { - if (!this.isValidComponent(SetComponent)) { - return; - } - if (SetComponent.patchedCatch) { - return; - } - SetComponent.patchedCatch = true; - - // Rax 的 getDerivedStateFromError 有 BUG,这里先用 componentDidCatch 来替代 - // @see https://github.com/alibaba/rax/issues/2211 - const originalDidCatch = SetComponent.prototype.componentDidCatch; - SetComponent.prototype.componentDidCatch = function didCatch(this: any, error: Error, errorInfo: any) { - this.setState({ engineRenderError: true, error }); - if (originalDidCatch && typeof originalDidCatch === 'function') { - originalDidCatch.call(this, error, errorInfo); - } - }; - - const engine = this; - const originRender = SetComponent.prototype.render; - SetComponent.prototype.render = function () { - if (this.state && this.state.engineRenderError) { - this.state.engineRenderError = false; - return engine.createElement(engine.getFaultComponent(), { - ...this.props, - error: this.state.error, - }); - } - return originRender.call(this); - }; - const originShouldComponentUpdate = SetComponent.prototype.shouldComponentUpdate; - SetComponent.prototype.shouldComponentUpdate = function (nextProps: IRendererProps, nextState: any) { - if (nextState && nextState.engineRenderError) { - return true; - } - return originShouldComponentUpdate ? originShouldComponentUpdate.call(this, nextProps, nextState) : true; - }; - } - createElement(SetComponent: any, props: any, children?: any) { - // TODO: enable in runtime mode? - this.patchDidCatch(SetComponent); return (this.props.customCreateElement || createElement)(SetComponent, props, children); } @@ -160,7 +114,25 @@ export default function rendererFactory(): IRenderComponent { } getFaultComponent() { - return this.props.faultComponent || FaultComponent; + const { faultComponent, faultComponentMap, schema } = this.props; + if (faultComponentMap) { + const { componentName } = schema; + return faultComponentMap[componentName] || faultComponent || FaultComponent; + } + return faultComponent || FaultComponent; + } + + getComp() { + const { schema, components } = this.props; + const { componentName } = schema; + const allComponents = { ...RENDERER_COMPS, ...components }; + let Comp = allComponents[componentName] || RENDERER_COMPS[`${componentName}Renderer`]; + if (Comp && Comp.prototype) { + if (!(Comp.prototype instanceof BaseRenderer)) { + Comp = RENDERER_COMPS[`${componentName}Renderer`]; + } + } + return Comp; } render() { @@ -170,24 +142,28 @@ export default function rendererFactory(): IRenderComponent { } // 兼容乐高区块模板 if (schema.componentName !== 'Div' && !isFileSchema(schema)) { + logger.error('The root component name needs to be one of Page、Block、Component, please check the schema: ', schema); return '模型结构异常'; } debug('entry.render'); - const { componentName } = schema; const allComponents = { ...RENDERER_COMPS, ...components }; - let Comp = allComponents[componentName] || RENDERER_COMPS[`${componentName}Renderer`]; - if (Comp && Comp.prototype) { - if (!(Comp.prototype instanceof BaseRenderer)) { - Comp = RENDERER_COMPS[`${componentName}Renderer`]; - } + let Comp = this.getComp(); + + if (this.state && this.state.engineRenderError) { + return createElement(this.getFaultComponent(), { + ...this.props, + error: this.state.error, + }); } if (Comp) { - return createElement(AppContext.Provider, { value: { - appHelper, - components: allComponents, - engine: this, - } }, createElement(ConfigProvider, { + return createElement(AppContext.Provider, { + value: { + appHelper, + components: allComponents, + engine: this, + }, + }, createElement(ConfigProvider, { device: this.props.device, locale: this.props.locale, }, createElement(Comp, { diff --git a/packages/renderer-core/src/renderer/temp.tsx b/packages/renderer-core/src/renderer/temp.tsx index 79d825d092..1432da5fd2 100644 --- a/packages/renderer-core/src/renderer/temp.tsx +++ b/packages/renderer-core/src/renderer/temp.tsx @@ -1,11 +1,12 @@ import { IBaseRenderComponent } from '../types'; +import logger from '../utils/logger'; import baseRendererFactory from './base'; export default function tempRendererFactory(): IBaseRenderComponent { const BaseRenderer = baseRendererFactory(); return class TempRenderer extends BaseRenderer { - static dislayName = 'temp-renderer'; + static displayName = 'TempRenderer'; __namespace = 'temp'; @@ -41,7 +42,7 @@ export default function tempRendererFactory(): IBaseRenderComponent { } async componentDidCatch(e: any) { - console.warn(e); + logger.warn(e); this.__debug(`componentDidCatch - ${this.props.__schema.fileName}`); } @@ -51,7 +52,7 @@ export default function tempRendererFactory(): IBaseRenderComponent { return '下钻编辑 schema 结构异常!'; } - this.__debug(`${TempRenderer.dislayName} render - ${__schema?.fileName}`); + this.__debug(`${TempRenderer.displayName} render - ${__schema?.fileName}`); return this.__renderContent(this.__renderContextProvider({ __ctx })); } diff --git a/packages/renderer-core/src/types/index.ts b/packages/renderer-core/src/types/index.ts index e85602c7b2..afbec272ab 100644 --- a/packages/renderer-core/src/types/index.ts +++ b/packages/renderer-core/src/types/index.ts @@ -1,30 +1,32 @@ import type { ComponentLifecycle, CSSProperties } from 'react'; -import { BuiltinSimulatorHost } from '@alilc/lowcode-designer'; -import { RequestHandler, NodeSchema, NodeData, RootSchema, JSONObject } from '@alilc/lowcode-types'; +import { BuiltinSimulatorHost, BuiltinSimulatorRenderer } from '@alilc/lowcode-designer'; +import { RequestHandler, IPublicTypeNodeSchema, IPublicTypeRootSchema, IPublicTypeJSONObject } from '@alilc/lowcode-types'; + +export type ISchema = IPublicTypeNodeSchema | IPublicTypeRootSchema; /* ** Duck typed component type supporting both react and rax */ interface IGeneralComponent<P = {}, S = {}, SS = any> extends ComponentLifecycle<P, S, SS> { + readonly props: Readonly<P> & Readonly<{ children?: any | undefined }>; + state: Readonly<S>; + refs: Record<string, any>; + context: any; setState<K extends keyof S>( state: ((prevState: Readonly<S>, props: Readonly<P>) => (Pick<S, K> | S | null)) | (Pick<S, K> | S | null), callback?: () => void ): void; forceUpdate(callback?: () => void): void; render(): any; - readonly props: Readonly<P> & Readonly<{ children?: any | undefined }>; - state: Readonly<S>; - refs: Record<string, any>; - context: any; } export type IGeneralConstructor< - P = { + T = { [key: string]: any; }, S = { [key: string]: any; - }, SS = any -> = new (props: any, context: any) => IGeneralComponent<P, S, SS>; + }, D = any +> = new <TT = T, SS = S, DD = D>(props: TT, context: any) => IGeneralComponent<TT, SS, DD>; /** * duck-typed History @@ -58,20 +60,28 @@ export interface ILocationLike { } export type IRendererAppHelper = Partial<{ + /** 全局公共函数 */ utils: Record<string, any>; + /** 全局常量 */ constants: Record<string, any>; + /** react-router 的 location 实例 */ location: ILocationLike; + /** react-router 的 history 实例 */ history: IHistoryLike; + /** @deprecated 已无业务使用 */ match: any; + /** @experimental 内部使用 */ logParams: Record<string, any>; + /** @experimental 内部使用 */ addons: Record<string, any>; + /** @experimental 内部使用 */ requestHandlersMap: Record<string, RequestHandler<{ data: unknown; @@ -84,48 +94,91 @@ export type IRendererAppHelper = Partial<{ * @see @todo @承虎 */ export interface IRendererProps { + /** 符合低代码搭建协议的数据 */ - schema: RootSchema | NodeSchema; + schema: IPublicTypeRootSchema | IPublicTypeNodeSchema; + /** 组件依赖的实例 */ components: Record<string, IGeneralComponent>; + /** CSS 类名 */ className?: string; + /** style */ style?: CSSProperties; + /** id */ id?: string | number; + /** 语言 */ locale?: string; + + /** + * 多语言语料 + * 配置规范参见《低代码搭建组件描述协议》https://lowcode-engine.cn/lowcode 中 2.6 国际化多语言支持 + * */ + messages?: Record<string, any>; + /** 主要用于设置渲染模块的全局上下文,里面定义的内容可以在低代码中通过 this 来访问,比如 this.utils */ appHelper?: IRendererAppHelper; + /** - * 配置规范参见《中后台搭建组件描述协议》,主要在搭建场景中使用,用于提升用户搭建体验。 + * 配置规范参见《低代码搭建组件描述协议》https://lowcode-engine.cn/lowcode + * 主要在搭建场景中使用,用于提升用户搭建体验。 * * > 在生产环境下不需要设置 */ componentsMap?: { [key: string]: any }; + /** 设计模式,可选值:live、design */ designMode?: string; + /** 渲染模块是否挂起,当设置为 true 时,渲染模块最外层容器的 shouldComponentUpdate 将始终返回false,在下钻编辑或者多引擎渲染的场景会用到该参数。 */ suspended?: boolean; + /** 组件获取 ref 时触发的钩子 */ - onCompGetRef?: (schema: NodeSchema, ref: any) => void; + onCompGetRef?: (schema: IPublicTypeNodeSchema, ref: any) => void; + /** 组件 ctx 更新回调 */ - onCompGetCtx?: (schema: NodeSchema, ref: any) => void; + onCompGetCtx?: (schema: IPublicTypeNodeSchema, ref: any) => void; + /** 传入的 schema 是否有变更 */ getSchemaChangedSymbol?: () => boolean; + /** 设置 schema 是否有变更 */ setSchemaChangedSymbol?: (symbol: boolean) => void; + /** 自定义创建 element 的钩子 */ customCreateElement?: (Component: any, props: any, children: any) => any; + /** 渲染类型,标识当前模块是以什么类型进行渲染的 */ rendererName?: 'LowCodeRenderer' | 'PageRenderer' | string; + /** 当找不到组件时,显示的组件 */ notFoundComponent?: IGeneralComponent; + /** 当组件渲染异常时,显示的组件 */ faultComponent?: IGeneralComponent; + + /** */ + faultComponentMap?: { + [prop: string]: IGeneralComponent; + }; + /** 设备信息 */ device?: string; + + /** + * @default true + * JSExpression 是否只支持使用 this 来访问上下文变量 + */ + thisRequiredInJSE?: boolean; + + /** + * @default false + * 当开启组件未找到严格模式时,渲染模块不会默认给一个容器组件 + */ + enableStrictNotFoundMode?: boolean; } export interface IRendererState { @@ -142,29 +195,29 @@ export interface IBaseRendererProps { __appHelper: IRendererAppHelper; __components: Record<string, any>; __ctx: Record<string, any>; - __schema: RootSchema; + __schema: IPublicTypeRootSchema; __host?: BuiltinSimulatorHost; - __container?: any; + __container?: BuiltinSimulatorRenderer; config?: Record<string, any>; - /** - * @see https://yuque.antfin.com/ali-lowcode/docs/hk2ogo#designMode - */ - designMode?: 'live' | 'design'; + designMode?: 'design'; className?: string; style?: CSSProperties; id?: string | number; getSchemaChangedSymbol?: () => boolean; setSchemaChangedSymbol?: (symbol: boolean) => void; + thisRequiredInJSE?: boolean; documentId?: string; getNode?: any; + /** * 设备类型,默认值:'default' */ device?: 'default' | 'mobile' | string; + componentName?: string; } -export interface IInfo { - schema?: NodeSchema; +export interface INodeInfo { + schema?: IPublicTypeNodeSchema; Comp: any; componentInfo?: any; componentChildren?: any; @@ -181,7 +234,7 @@ export interface DataSourceItem { type?: string; options?: { uri: string | JSExpression; - params?: JSONObject | JSExpression; + params?: IPublicTypeJSONObject | JSExpression; method?: string | JSExpression; shouldFetch?: string; willFetch?: string; @@ -197,13 +250,13 @@ export interface DataSource { } export interface IRuntime { + [key: string]: any; Component: IGeneralConstructor; PureComponent: IGeneralConstructor; createElement: (...args: any) => any; createContext: (...args: any) => any; forwardRef: (...args: any) => any; findDOMNode: (...args: any) => any; - [key: string]: any; } export interface IRendererModules { @@ -231,11 +284,10 @@ export type IBaseRendererInstance = IGeneralComponent< > & { reloadDataSource(): Promise<any>; - getSchemaChildren(schema: NodeSchema | undefined): NodeData | NodeData[] | undefined; __beforeInit(props: IBaseRendererProps): void; __init(props: IBaseRendererProps): void; __afterInit(props: IBaseRendererProps): void; - __setLifeCycleMethods(method: string, args?: any[]): void; + __executeLifeCycleMethod(method: string, args?: any[]): void; __bindCustomMethods(props: IBaseRendererProps): void; __generateCtx(ctx: Record<string, any>): void; __parseData(data: any, ctx?: any): any; @@ -243,21 +295,21 @@ export type IBaseRendererInstance = IGeneralComponent< __render(): void; __getRef(ref: any): void; __getSchemaChildrenVirtualDom( - schema: NodeSchema | undefined, + schema: IPublicTypeNodeSchema | undefined, Comp: any, nodeChildrenMap?: any ): any; - __getComponentProps(schema: NodeSchema | undefined, scope: any, Comp: any, componentInfo?: any): any; + __getComponentProps(schema: IPublicTypeNodeSchema | undefined, scope: any, Comp: any, componentInfo?: any): any; __createDom(): any; - __createVirtualDom(schema: any, self: any, parentInfo: IInfo, idx: string | number): any; - __createLoopVirtualDom(schema: any, self: any, parentInfo: IInfo, idx: number | string): any; - __parseProps(props: any, self: any, path: string, info: IInfo): any; + __createVirtualDom(schema: any, self: any, parentInfo: INodeInfo, idx: string | number): any; + __createLoopVirtualDom(schema: any, self: any, parentInfo: INodeInfo, idx: number | string): any; + __parseProps(props: any, self: any, path: string, info: INodeInfo): any; __initDebug?(): void; __debug(...args: any[]): void; __renderContextProvider(customProps?: object, children?: any): any; __renderContextConsumer(children: any): any; __renderContent(children: any): any; - __checkSchema(schema: NodeSchema | undefined, extraComponents?: string | string[]): any; + __checkSchema(schema: IPublicTypeNodeSchema | undefined, extraComponents?: string | string[]): any; __renderComp(Comp: any, ctxProps: object): any; $(filedId: string, instance?: any): any; }; @@ -270,21 +322,21 @@ export interface IBaseRenderComponent { } export interface IRenderComponent { + displayName: string; + defaultProps: IRendererProps; + findDOMNode: (...args: any) => any; + new(props: IRendererProps, context: any): IGeneralComponent<IRendererProps, IRendererState> & { [x: string]: any; - componentDidMount(): Promise<void>; - componentDidUpdate(): Promise<void>; - componentWillUnmount(): Promise<void>; - componentDidCatch(e: any): Promise<void>; - shouldComponentUpdate(nextProps: IRendererProps): boolean; __getRef: (ref: any) => void; + componentDidMount(): Promise<void> | void; + componentDidUpdate(): Promise<void> | void; + componentWillUnmount(): Promise<void> | void; + componentDidCatch(e: any): Promise<void> | void; + shouldComponentUpdate(nextProps: IRendererProps): boolean; isValidComponent(SetComponent: any): any; - patchDidCatch(SetComponent: any): void; createElement(SetComponent: any, props: any, children?: any): any; getNotFoundComponent(): any; getFaultComponent(): any; }; - dislayName: string; - defaultProps: IRendererProps; - findDOMNode: (...args: any) => any; } diff --git a/packages/renderer-core/src/utils/common.ts b/packages/renderer-core/src/utils/common.ts index b2c72de474..0462d358a7 100644 --- a/packages/renderer-core/src/utils/common.ts +++ b/packages/renderer-core/src/utils/common.ts @@ -1,31 +1,15 @@ +/* eslint-disable no-console */ /* eslint-disable no-new-func */ -import Debug from 'debug'; -import { isI18nData, RootSchema, NodeSchema, isJSExpression, JSSlot } from '@alilc/lowcode-types'; -// moment对象配置 -import _moment from 'moment'; -import 'moment/locale/zh-cn'; -import pkg from '../../package.json'; - +import logger from './logger'; +import { IPublicTypeRootSchema, IPublicTypeNodeSchema, IPublicTypeJSSlot } from '@alilc/lowcode-types'; +import { isI18nData, isJSExpression } from '@alilc/lowcode-utils'; import { isEmpty } from 'lodash'; - -import _serialize from 'serialize-javascript'; -import * as _jsonuri from 'jsonuri'; - import IntlMessageFormat from 'intl-messageformat'; +import pkg from '../../package.json'; -export const moment = _moment; -moment.locale('zh-cn'); (window as any).sdkVersion = pkg.version; export { pick, isEqualWith as deepEqual, cloneDeep as clone, isEmpty, throttle, debounce } from 'lodash'; -export const jsonuri = _jsonuri; -export const serialize = _serialize; - -const ReactIs = require('react-is'); -const ReactPropTypesSecret = require('prop-types/lib/ReactPropTypesSecret'); -const factoryWithTypeCheckers = require('prop-types/factoryWithTypeCheckers'); - -const PropTypes2 = factoryWithTypeCheckers(ReactIs.isElement, true); const EXPRESSION_TYPE = { JSEXPRESSION: 'JSExpression', @@ -35,35 +19,51 @@ const EXPRESSION_TYPE = { I18N: 'i18n', }; -const hasSymbol = typeof Symbol === 'function' && Symbol.for; -const REACT_FORWARD_REF_TYPE = hasSymbol ? Symbol.for('react.forward_ref') : 0xead0; -const debug = Debug('utils:index'); - -const ENV = { - TBE: 'TBE', - WEBIDE: 'WEB-IDE', - VSCODE: 'VSCODE', - WEB: 'WEB', -}; - /** + * check if schema passed in is a valid schema * @name isSchema - * @description 判断是否是模型结构 + * @returns boolean */ -export function isSchema(schema: any, ignoreArr = false): schema is NodeSchema { - if (isEmpty(schema)) return false; - // Leaf 组件也返回 true - if (schema.componentName === 'Leaf' || schema.componentName === 'Slot') return true; - if (!ignoreArr && Array.isArray(schema)) return schema.every((item) => isSchema(item)); - return !!(schema.componentName && schema.props && (typeof schema.props === 'object' || isJSExpression(schema.props))); +export function isSchema(schema: any): schema is IPublicTypeNodeSchema { + if (isEmpty(schema)) { + return false; + } + // Leaf and Slot should be valid + if (schema.componentName === 'Leaf' || schema.componentName === 'Slot') { + return true; + } + if (Array.isArray(schema)) { + return schema.every((item) => isSchema(item)); + } + // check if props is valid + const isValidProps = (props: any) => { + if (!props) { + return false; + } + if (isJSExpression(props)) { + return true; + } + return (typeof schema.props === 'object' && !Array.isArray(props)); + }; + return !!(schema.componentName && isValidProps(schema.props)); } -export function isFileSchema(schema: NodeSchema): schema is RootSchema { - if (isEmpty(schema)) return false; - return ['Page', 'Block', 'Component', 'Addon', 'Temp'].includes(schema.componentName); +/** + * check if schema passed in is a container type, including : Component Block Page + * @param schema + * @returns boolean + */ +export function isFileSchema(schema: IPublicTypeNodeSchema): schema is IPublicTypeRootSchema { + if (!isSchema(schema)) { + return false; + } + return ['Page', 'Block', 'Component'].includes(schema.componentName); } -// 判断当前页面是否被嵌入到同域的页面中 +/** + * check if current page is nested within another page with same host + * @returns boolean + */ export function inSameDomain() { try { return window.parent !== window && window.parent.location.host === window.location.host; @@ -72,8 +72,15 @@ export function inSameDomain() { } } +/** + * get css styled name from schema`s fileName + * FileName -> lce-file-name + * @returns string + */ export function getFileCssName(fileName: string) { - if (!fileName) return; + if (!fileName) { + return; + } const name = fileName.replace(/([A-Z])/g, '-$1').toLowerCase(); return (`lce-${name}`) .split('-') @@ -81,79 +88,45 @@ export function getFileCssName(fileName: string) { .join('-'); } -// 兼容乐高设计态 JSBlock 的老协议 -export function isJSSlot(obj: any): obj is JSSlot { - return obj && typeof obj === 'object' && ([EXPRESSION_TYPE.JSSLOT, EXPRESSION_TYPE.JSBLOCK].includes(obj.type)); -} - /** - * @name wait - * @description 等待函数 + * check if a object is type of JSSlot + * @returns string */ -export function wait(ms: number) { - return new Promise((resolve) => setTimeout(() => resolve(true), ms)); -} +export function isJSSlot(obj: any): obj is IPublicTypeJSSlot { + if (!obj) { + return false; + } + if (typeof obj !== 'object' || Array.isArray(obj)) { + return false; + } -export function curry(Comp: any, hocs = []) { - return hocs.reverse().reduce((pre, cur: (pre: any) => any) => { - return cur(pre); - }, Comp); + // Compatible with the old protocol JSBlock + return [EXPRESSION_TYPE.JSSLOT, EXPRESSION_TYPE.JSBLOCK].includes(obj.type); } +/** + * get value from an object + * @returns string + */ export function getValue(obj: any, path: string, defaultValue = {}) { - if (isEmpty(obj) || typeof obj !== 'object') return defaultValue; + // array is not valid type, return default value + if (Array.isArray(obj)) { + return defaultValue; + } + + if (isEmpty(obj) || typeof obj !== 'object') { + return defaultValue; + } + const res = path.split('.').reduce((pre, cur) => { return pre && pre[cur]; }, obj); - if (res === undefined) return defaultValue; + if (res === undefined) { + return defaultValue; + } return res; } -// 更新obj的内容但不改变obj的指针 -export function fillObj(receiver: any = {}, ...suppliers: any) { - Object.keys(receiver).forEach((item) => { - delete receiver[item]; - }); - Object.assign(receiver, ...suppliers); - return receiver; -} - -// 中划线转驼峰 -export function toHump(name: string) { - // eslint-disable-next-line no-useless-escape - return name.replace(/\-(\w)/g, (_: any, letter: string) => { - return letter.toUpperCase(); - }); -} - -// 驼峰转中划线 -export function toLine(name: string) { - return name.replace(/([A-Z])/g, '-$1').toLowerCase(); -} - -// 获取当前环境 -export function getEnv() { - const { userAgent } = navigator; - const isVscode = /Electron\//.test(userAgent); - if (isVscode) return ENV.VSCODE; - const isTheia = (window as any).is_theia === true; - if (isTheia) return ENV.WEBIDE; - return ENV.WEB; -} - -/** - * 用于构造国际化字符串处理函数 - * @param {*} locale 国际化标识,例如 zh-CN、en-US - * @param {*} messages 国际化语言包 - */ -export function generateI18n(locale = 'zh-CN', messages: any = {}) { - return (key: string, values = {}) => { - if (!messages || !messages[key]) return ''; - const formater = new IntlMessageFormat(messages[key], locale); - return formater.format(values); - }; -} - /** * 用于处理国际化字符串 * @param {*} key 语料标识 @@ -162,7 +135,9 @@ export function generateI18n(locale = 'zh-CN', messages: any = {}) { * @param {*} messages 国际化语言包 */ export function getI18n(key: string, values = {}, locale = 'zh-CN', messages: Record<string, any> = {}) { - if (!messages || !messages[locale] || !messages[locale][key]) return ''; + if (!messages || !messages[locale] || !messages[locale][key]) { + return ''; + } const formater = new IntlMessageFormat(messages[locale][key], locale); return formater.format(values); } @@ -172,178 +147,46 @@ export function getI18n(key: string, values = {}, locale = 'zh-CN', messages: Re * @param {*} Comp 需要判断的组件 */ export function canAcceptsRef(Comp: any) { + const hasSymbol = typeof Symbol === 'function' && Symbol.for; + const REACT_FORWARD_REF_TYPE = hasSymbol ? Symbol.for('react.forward_ref') : 0xead0; + // eslint-disable-next-line max-len return Comp?.$$typeof === REACT_FORWARD_REF_TYPE || Comp?.prototype?.isReactComponent || Comp?.prototype?.setState || Comp._forwardRef; } /** - * 黄金令箭埋点 - * @param {String} gmKey 为黄金令箭业务类型 - * @param {Object} params 参数 - * @param {String} logKey 属性串 + * transform array to a object + * @param arr array to be transformed + * @param key key of array item, which`s value will be used as key in result map + * @param overwrite overwrite existing item in result or not + * @returns object result map */ -export function goldlog(gmKey: string, params = {}, logKey = 'other') { - // vscode 黄金令箭API - const sendIDEMessage = (window as any).sendIDEMessage || (inSameDomain() && (window.parent as any).sendIDEMessage); - const goKey = serializeParams({ - sdkVersion: pkg.version, - env: getEnv(), - ...params, - }); - if (sendIDEMessage) { - sendIDEMessage({ - action: 'goldlog', - data: { - logKey: `/lce.core.${logKey}`, - gmKey, - goKey, - }, - }); - } - (window as any)?.goldlog?.record(`/lce.core.${logKey}`, gmKey, goKey, 'POST'); -} - -// utils为编辑器打包生成的utils文件内容,utilsConfig为数据库存放的utils配置 -export function generateUtils(utils: any, utilsConfig: Array<{ name: string; type: string; content: any }>) { - if (!Array.isArray(utilsConfig)) return { ...utils }; - const res: any = {}; - utilsConfig.forEach((item) => { - if (!item.name || !item.type || !item.content) return; - if (item.type === 'function' && typeof item.content === 'function') { - res[item.name] = item.content; - } else if (item.type === 'npm' && utils[item.name]) { - res[item.name] = utils[item.name]; - } - }); - return res; -} - -// 将函数返回结果转成promise形式,如果函数有返回值则根据返回值的bool类型判断是reject还是resolve,若函数无返回值默认执行resolve -export function transformToPromise(input: any) { - if (input instanceof Promise) return input; - return new Promise((resolve, reject) => { - if (input || input === undefined) { - resolve({}); - } else { - reject(); - } - }); -} - -export function moveArrayItem(arr: any[], sourceIdx: number, distIdx: number, direction: 'after' | 'before') { - if ( - !Array.isArray(arr) || - sourceIdx === distIdx || - sourceIdx < 0 || - sourceIdx >= arr.length || - distIdx < 0 || - distIdx >= arr.length - ) return arr; - const item = arr[sourceIdx]; - if (direction === 'after') { - arr.splice(distIdx + 1, 0, item); - } else { - arr.splice(distIdx, 0, item); - } - if (sourceIdx < distIdx) { - arr.splice(sourceIdx, 1); - } else { - arr.splice(sourceIdx + 1, 1); - } - return arr; -} - export function transformArrayToMap(arr: any[], key: string, overwrite = true) { - if (isEmpty(arr) || !Array.isArray(arr)) return {}; + if (isEmpty(arr) || !Array.isArray(arr)) { + return {}; + } const res: any = {}; arr.forEach((item) => { const curKey = item[key]; - if (item[key] === undefined) return; - if (res[curKey] && !overwrite) return; + if (item[key] === undefined) { + return; + } + if (res[curKey] && !overwrite) { + return; + } res[curKey] = item; }); return res; } -export function checkPropTypes(value: any, name: string, rule: any, componentName: string) { - if (typeof rule === 'string') { - rule = new Function(`"use strict"; const PropTypes = arguments[0]; return ${rule}`)(PropTypes2); - } - if (!rule || typeof rule !== 'function') { - console.warn('checkPropTypes should have a function type rule argument'); - return true; - } - const err = rule( - { - [name]: value, - }, - name, - componentName, - 'prop', - null, - ReactPropTypesSecret, - ); - if (err) { - console.warn(err); - } - return !err; -} - -export function transformSchemaToPure(obj: any) { - const pureObj = (obj: any): any => { - if (Array.isArray(obj)) { - return obj.map((item) => pureObj(item)); - } else if (typeof obj === 'object') { - // 对于undefined及null直接返回 - if (!obj) return obj; - const res: any = {}; - forEach(obj, (val: any, key: string) => { - if (key.startsWith('__') && key !== '__ignoreParse') return; - res[key] = pureObj(val); - }); - return res; - } - return obj; - }; - return pureObj(obj); -} - -export function transformSchemaToStandard(obj: any) { - const standardObj = (obj: any): any => { - if (Array.isArray(obj)) { - return obj.map((item) => standardObj(item)); - } else if (typeof obj === 'object') { - // 对于undefined及null直接返回 - if (!obj) return obj; - const res: any = {}; - forEach(obj, (val: any, key: string) => { - if (key.startsWith('__') && key !== '__ignoreParse') return; - if (isSchema(val) && key !== 'children' && obj.type !== 'JSSlot') { - res[key] = { - type: 'JSSlot', - value: standardObj(val), - }; - // table特殊处理 - if (key === 'cell') { - res[key].params = ['value', 'index', 'record']; - } - } else { - res[key] = standardObj(val); - } - }); - return res; - } else if (typeof obj === 'function') { - return { - type: 'JSFunction', - value: obj.toString(), - }; - } - return obj; - }; - return standardObj(obj); -} - +/** + * transform string to a function + * @param str function in string form + * @returns funtion + */ export function transformStringToFunction(str: string) { - if (typeof str !== 'string') return str; + if (typeof str !== 'string') { + return str; + } if (inSameDomain() && (window.parent as any).__newFunc) { return (window.parent as any).__newFunc(`"use strict"; return ${str}`)(); } else { @@ -351,62 +194,102 @@ export function transformStringToFunction(str: string) { } } -export function parseData(schema: unknown, self: any): any { - if (isJSExpression(schema)) { - return parseExpression(schema, self); - } else if (isI18nData(schema)) { - return parseI18n(schema, self); - } else if (typeof schema === 'string') { - return schema.trim(); - } else if (Array.isArray(schema)) { - return schema.map((item) => parseData(item, self)); - } else if (typeof schema === 'function') { - return schema.bind(self); - } else if (typeof schema === 'object') { - // 对于undefined及null直接返回 - if (!schema) return schema; - const res: any = {}; - forEach(schema, (val: any, key: string) => { - if (key.startsWith('__')) return; - res[key] = parseData(val, self); - }); - return res; - } - return schema; -} +/** + * 对象类型JSExpression,支持省略this + * @param str expression in string form + * @param self scope object + * @returns funtion + */ -/* 全匹配{{开头,}}结尾的变量表达式,或者对象类型JSExpression,支持省略this */ -export function parseExpression(str: any, self: any) { +function parseExpression(options: { + str: any; self: any; thisRequired?: boolean; logScope?: string; +}): any; +function parseExpression(str: any, self: any, thisRequired?: boolean): any; +function parseExpression(a: any, b?: any, c = false) { + let str; + let self; + let thisRequired; + let logScope; + if (typeof a === 'object' && b === undefined) { + str = a.str; + self = a.self; + thisRequired = a.thisRequired; + logScope = a.logScope; + } else { + str = a; + self = b; + thisRequired = c; + } try { const contextArr = ['"use strict";', 'var __self = arguments[0];']; contextArr.push('return '); - let tarStr; + let tarStr: string; tarStr = (str.value || '').trim(); + + // NOTE: use __self replace 'this' in the original function str + // may be wrong in extreme case which contains '__self' already tarStr = tarStr.replace(/this(\W|$)/g, (_a: any, b: any) => `__self${b}`); tarStr = contextArr.join('\n') + tarStr; - // 默认调用顶层窗口的parseObj,保障new Function的window对象是顶层的window对象 + + // 默认调用顶层窗口的parseObj, 保障new Function的window对象是顶层的window对象 if (inSameDomain() && (window.parent as any).__newFunc) { return (window.parent as any).__newFunc(tarStr)(self); } - const code = `with($scope || {}) { ${tarStr} }`; + const code = `with(${thisRequired ? '{}' : '$scope || {}'}) { ${tarStr} }`; return new Function('$scope', code)(self); } catch (err) { - debug('parseExpression.error', err, str, self); + logger.error(`${logScope || ''} parseExpression.error`, err, str, self?.__self ?? self); return undefined; } } -// 首字母大写 +export { + parseExpression, +}; + +export function parseThisRequiredExpression(str: any, self: any) { + return parseExpression(str, self, true); +} + +/** + * capitalize first letter + * @param word string to be proccessed + * @returns string capitalized string + */ export function capitalizeFirstLetter(word: string) { + if (!word || !isString(word) || word.length === 0) { + return word; + } return word[0].toUpperCase() + word.slice(1); } +/** + * check str passed in is a string type of not + * @param str obj to be checked + * @returns boolean + */ +export function isString(str: any): boolean { + return {}.toString.call(str) === '[object String]'; +} + +/** + * check if obj is type of variable structure + * @param obj object to be checked + * @returns boolean + */ export function isVariable(obj: any) { - return obj && typeof obj === 'object' && obj?.type === 'variable'; + if (!obj || Array.isArray(obj)) { + return false; + } + return typeof obj === 'object' && obj?.type === 'variable'; } -/* 将 i18n 结构,降级解释为对 i18n 接口的调用 */ +/** + * 将 i18n 结构,降级解释为对 i18n 接口的调用 + * @param i18nInfo object + * @param self context + */ export function parseI18n(i18nInfo: any, self: any) { return parseExpression({ type: EXPRESSION_TYPE.JSEXPRESSION, @@ -414,40 +297,74 @@ export function parseI18n(i18nInfo: any, self: any) { }, self); } -export function forEach(obj: any, fn: any, context?: any) { - obj = obj || {}; - Object.keys(obj).forEach(key => fn.call(context, obj[key], key)); -} - -export function shallowEqual(objA: any, objB: any) { - if (objA === objB) { - return true; +/** + * for each key in targetObj, run fn with the value of the value, and the context paased in. + * @param targetObj object that keys will be for each + * @param fn function that process each item + * @param context + */ +export function forEach(targetObj: any, fn: any, context?: any) { + if (!targetObj || Array.isArray(targetObj) || isString(targetObj) || typeof targetObj !== 'object') { + return; } - if (typeof objA !== 'object' || objA === null || typeof objB !== 'object' || objB === null) { - return false; - } + Object.keys(targetObj).forEach((key) => fn.call(context, targetObj[key], key)); +} - const keysA = Object.keys(objA); - if (keysA.length !== Object.keys(objB).length) { - return false; - } +interface IParseOptions { + thisRequiredInJSE?: boolean; + logScope?: string; +} - for (let i = 0, key; i < keysA.length; i++) { - key = keysA[i]; - if (!objB.hasOwnProperty(key) || objA[key] !== objB[key]) { - return false; +export function parseData(schema: unknown, self: any, options: IParseOptions = {}): any { + if (isJSExpression(schema)) { + return parseExpression({ + str: schema, + self, + thisRequired: options.thisRequiredInJSE, + logScope: options.logScope, + }); + } else if (isI18nData(schema)) { + return parseI18n(schema, self); + } else if (typeof schema === 'string') { + return schema.trim(); + } else if (Array.isArray(schema)) { + return schema.map((item) => parseData(item, self, options)); + } else if (typeof schema === 'function') { + return schema.bind(self); + } else if (typeof schema === 'object') { + // 对于undefined及null直接返回 + if (!schema) { + return schema; } + const res: any = {}; + forEach(schema, (val: any, key: string) => { + if (key.startsWith('__')) { + return; + } + res[key] = parseData(val, self, options); + }); + return res; } - return true; + return schema; } +/** + * process params for using in a url query + * @param obj params to be processed + * @returns string + */ export function serializeParams(obj: any) { - let rst: any = []; + let result: any = []; forEach(obj, (val: any, key: any) => { - if (val === null || val === undefined || val === '') return; - if (typeof val === 'object') rst.push(`${key}=${encodeURIComponent(JSON.stringify(val))}`); - else rst.push(`${key}=${encodeURIComponent(val)}`); + if (val === null || val === undefined || val === '') { + return; + } + if (typeof val === 'object') { + result.push(`${key}=${encodeURIComponent(JSON.stringify(val))}`); + } else { + result.push(`${key}=${encodeURIComponent(val)}`); + } }); - return rst.join('&'); -} \ No newline at end of file + return result.join('&'); +} diff --git a/packages/renderer-core/src/utils/data-helper.ts b/packages/renderer-core/src/utils/data-helper.ts index 50564937ae..41bcb9bfa0 100644 --- a/packages/renderer-core/src/utils/data-helper.ts +++ b/packages/renderer-core/src/utils/data-helper.ts @@ -1,8 +1,11 @@ +/* eslint-disable no-console */ +/* eslint-disable max-len */ /* eslint-disable object-curly-newline */ -import { isJSFunction } from '@alilc/lowcode-types'; -import { transformArrayToMap, transformStringToFunction, clone } from './common'; +import { isJSFunction } from '@alilc/lowcode-utils'; +import { transformArrayToMap, transformStringToFunction } from './common'; import { jsonp, request, get, post } from './request'; -import { DataSource, DataSourceItem } from '../types'; +import logger from './logger'; +import { DataSource, DataSourceItem, IRendererAppHelper } from '../types'; const DS_STATUS = { INIT: 'init', @@ -11,22 +14,77 @@ const DS_STATUS = { ERROR: 'error', }; +type DataSourceType = 'fetch' | 'jsonp'; + +/** + * do request for standard DataSourceType + * @param {DataSourceType} type type of DataSourceItem + * @param {any} options + */ +export function doRequest(type: DataSourceType, options: any) { + // eslint-disable-next-line prefer-const + let { uri, url, method = 'GET', headers, params, ...otherProps } = options; + otherProps = otherProps || {}; + if (type === 'jsonp') { + return jsonp(uri, params, otherProps); + } + + if (type === 'fetch') { + switch (method.toUpperCase()) { + case 'GET': + return get(uri, params, headers, otherProps); + case 'POST': + return post(uri, params, headers, otherProps); + default: + return request(uri, method, params, headers, otherProps); + } + } + + logger.log(`Engine default dataSource does not support type:[${type}] dataSource request!`, options); +} + +// TODO: according to protocol, we should implement errorHandler/shouldFetch/willFetch/requestHandler and isSync controll. export class DataHelper { + /** + * host object that will be "this" object when excuting dataHandler + * + * @type {*} + * @memberof DataHelper + */ host: any; + /** + * data source config + * + * @type {DataSource} + * @memberof DataHelper + */ config: DataSource; + /** + * a parser function which will be called to process config data + * which eventually will call common/utils.processData() to process data + * (originalConfig) => parsedConfig + * @type {*} + * @memberof DataHelper + */ parser: any; + /** + * config.list + * + * @type {any[]} + * @memberof DataHelper + */ ajaxList: any[]; ajaxMap: any; dataSourceMap: any; - appHelper: any; + appHelper: IRendererAppHelper; - constructor(comp: any, config: DataSource, appHelper: any, parser: any) { + constructor(comp: any, config: DataSource, appHelper: IRendererAppHelper, parser: any) { this.host = comp; this.config = config || {}; this.parser = parser; @@ -36,15 +94,6 @@ export class DataHelper { this.appHelper = appHelper; } - // 重置config,dataSourceMap状态会被重置; - resetConfig(config = {}) { - this.config = config as DataSource; - this.ajaxList = (config as DataSource)?.list || []; - this.ajaxMap = transformArrayToMap(this.ajaxList, 'id'); - this.dataSourceMap = this.generateDataSourceMap(); - return this.dataSourceMap; - } - // 更新config,只会更新配置,状态保存; updateConfig(config = {}) { this.config = config as DataSource; @@ -78,6 +127,7 @@ export class DataHelper { res[item.id] = { status: DS_STATUS.INIT, load: (...args: any) => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore return this.getDataSource(item.id, ...args); }, @@ -92,41 +142,54 @@ export class DataHelper { this.dataSourceMap[id].status = error ? DS_STATUS.ERROR : DS_STATUS.LOADED; } - getInitData() { - const initSyncData = this.parser(this.ajaxList).filter((item: DataSourceItem) => { - if (item.isInit) { + /** + * get all dataSourceItems which marked as isInit === true + * @private + * @returns + * @memberof DataHelper + */ + getInitDataSourseConfigs() { + const initConfigs = this.parser(this.ajaxList).filter((item: DataSourceItem) => { + // according to [spec](https://lowcode-engine.cn/lowcode), isInit should be boolean true to be working + if (item.isInit === true) { this.dataSourceMap[item.id].status = DS_STATUS.LOADING; return true; } return false; }); + return initConfigs; + } + + /** + * process all dataSourceItems which marked as isInit === true, and get dataSource request results. + * @public + * @returns + * @memberof DataHelper + */ + getInitData() { + const initSyncData = this.getInitDataSourseConfigs(); // 所有 datasource 的 datahandler return this.asyncDataHandler(initSyncData).then((res) => { - let { dataHandler } = this.config; - if (isJSFunction(dataHandler)) { - dataHandler = transformStringToFunction(dataHandler.value); - } - if (!dataHandler || typeof dataHandler !== 'function') return res; - try { - return (dataHandler as any).call(this.host, res); - } catch (e) { - console.error('请求数据处理函数运行出错', e); - } + const { dataHandler } = this.config; + return this.handleData(null, dataHandler, res, null); }); } getDataSource(id: string, params: any, otherOptions: any, callback: any) { const req = this.parser(this.ajaxMap[id]); const options = req.options || {}; + let callbackFn = callback; + let otherOptionsObj = otherOptions; if (typeof otherOptions === 'function') { - callback = otherOptions; - otherOptions = {}; + callbackFn = otherOptions; + otherOptionsObj = {}; } - const { headers, ...otherProps } = otherOptions || {}; + const { headers, ...otherProps } = otherOptionsObj || {}; if (!req) { - console.warn(`getDataSource API named ${id} not exist`); + logger.warn(`getDataSource API named ${id} not exist`); return; } + return this.asyncDataHandler([ { ...req, @@ -148,170 +211,99 @@ export class DataHelper { }, }, ]) - .then((res: any) => { - try { - callback && callback(res && res[id]); - } catch (e) { - console.error('load请求回调函数报错', e); - } - - return res && res[id]; - }) - .catch((err) => { - try { - callback && callback(null, err); - } catch (e) { - console.error('load请求回调函数报错', e); - } - - return err; - }); + .then((res: any) => { + try { + callbackFn && callbackFn(res && res[id]); + } catch (e) { + logger.error('load请求回调函数报错', e); + } + return res && res[id]; + }) + .catch((err) => { + try { + callbackFn && callbackFn(null, err); + } catch (e) { + logger.error('load请求回调函数报错', e); + } + return err; + }); } asyncDataHandler(asyncDataList: any[]) { return new Promise((resolve, reject) => { - const allReq = []; - const doserReq: Array<{name: string; package: string; params: any }> = []; - const doserList: string[] = []; - const beforeRequest = this.appHelper && this.appHelper.utils && this.appHelper.utils.beforeRequest; - const afterRequest = this.appHelper && this.appHelper.utils && this.appHelper.utils.afterRequest; - const csrfInput = document.getElementById('_csrf_token'); - const _tb_token_ = (csrfInput as any)?.value; + const allReq: any[] = []; asyncDataList.forEach((req) => { - const { id, type, options } = req; - if (!id || !type || type === 'legao') return; - if (type === 'doServer') { - const { uri, params } = options || {}; - if (!uri) return; - doserList.push(id); - doserReq.push({ name: uri, package: 'cms', params }); - } else { - allReq.push(req); + const { id, type } = req; + // TODO: need refactoring to remove 'legao' related logic + if (!id || !type || type === 'legao') { + return; } + allReq.push(req); }); - if (doserReq.length > 0) { - allReq.push({ - type: 'doServer', - options: { - uri: '/nrsService.do', - cors: true, - method: 'POST', - params: { - data: JSON.stringify(doserReq), - _tb_token_, - }, - }, - }); + if (allReq.length === 0) { + resolve({}); } - if (allReq.length === 0) resolve({}); const res: any = {}; - // todo: Promise.all( allReq.map((item: any) => { - return new Promise((resolve) => { + return new Promise((innerResolve) => { const { type, id, dataHandler, options } = item; - const doFetch = (type: string, options: any) => { - this.fetchOne(type as any, options) + + const fetchHandler = (data: any, error: any) => { + res[id] = this.handleData(id, dataHandler, data, error); + this.updateDataSourceMap(id, res[id], error); + innerResolve({}); + }; + + const doFetch = (innerType: string, innerOptions: any) => { + doRequest(innerType as any, innerOptions) ?.then((data: any) => { - if (afterRequest) { - this.appHelper.utils.afterRequest(item, data, undefined, (data: any, error: any) => { - fetchHandler(data, error); - }); - } else { - fetchHandler(data, undefined); - } + fetchHandler(data, undefined); }) .catch((err: Error) => { - if (afterRequest) { - // 必须要这么调用,否则beforeRequest中的this会丢失 - this.appHelper.utils.afterRequest(item, undefined, err, (data: any, error: any) => { - fetchHandler(data, error); - }); - } else { - fetchHandler(undefined, err); - } - }); - }; - const fetchHandler = (data: any, error: any) => { - if (type === 'doServer') { - if (!Array.isArray(data)) { - data = [data]; - } - doserList.forEach((id, idx) => { - const req: any = this.ajaxMap[id]; - if (req) { - res[id] = this.dataHandler(id, req.dataHandler, data && data[idx], error); - this.updateDataSourceMap(id, res[id], error); - } + fetchHandler(undefined, err); }); - } else { - res[id] = this.dataHandler(id, dataHandler, data, error); - this.updateDataSourceMap(id, res[id], error); - } - resolve({}); }; - if (type === 'doServer') { - doserList.forEach((item) => { - this.dataSourceMap[item].status = DS_STATUS.LOADING; - }); - } else { - this.dataSourceMap[id].status = DS_STATUS.LOADING; - } - // 请求切片 - if (beforeRequest) { - // 必须要这么调用,否则beforeRequest中的this会丢失 - this.appHelper.utils.beforeRequest(item, clone(options), (options: any) => doFetch(type, options)); - } else { - doFetch(type, options); - } + this.dataSourceMap[id].status = DS_STATUS.LOADING; + doFetch(type, options); }); }), - ) - .then(() => { - resolve(res); - }) - .catch((e) => { - reject(e); - }); + ).then(() => { + resolve(res); + }).catch((e) => { + reject(e); + }); }); } - // dataHandler todo: - dataHandler(id: string, dataHandler: any, data: any, error: any) { + /** + * process data using dataHandler + * + * @param {(string | null)} id request id, will be used in error message, can be null + * @param {*} dataHandler + * @param {*} data + * @param {*} error + * @returns + * @memberof DataHelper + */ + handleData(id: string | null, dataHandler: any, data: any, error: any) { + let dataHandlerFun = dataHandler; if (isJSFunction(dataHandler)) { - dataHandler = transformStringToFunction(dataHandler.value); + dataHandlerFun = transformStringToFunction(dataHandler.value); + } + if (!dataHandlerFun || typeof dataHandlerFun !== 'function') { + return data; } - if (!dataHandler || typeof dataHandler !== 'function') return data; try { - return dataHandler.call(this.host, data, error); + return dataHandlerFun.call(this.host, data, error); } catch (e) { - console.error(`[${ id }]单个请求数据处理函数运行出错`, e); - } - } - - fetchOne(type: DataSourceType, options: any) { - // eslint-disable-next-line prefer-const - let { uri, url, method = 'GET', headers, params, ...otherProps } = options; - otherProps = otherProps || {}; - if (type === 'jsonp') { - return jsonp(uri, params, otherProps); - } - - if (type === 'fetch') { - switch (method.toUpperCase()) { - case 'GET': - return get(uri, params, headers, otherProps); - case 'POST': - return post(uri, params, headers, otherProps); - default: - return request(uri, method, params, headers, otherProps); + if (id) { + logger.error(`[${id}]单个请求数据处理函数运行出错`, e); + } else { + logger.error('请求数据处理函数运行出错', e); } } - - console.error(`Engine default dataSource not support type:[${type}] dataSource request!`); } } - -type DataSourceType = 'fetch' | 'jsonp'; \ No newline at end of file diff --git a/packages/renderer-core/src/utils/is-use-loop.ts b/packages/renderer-core/src/utils/is-use-loop.ts index 913480f638..b6d67a802a 100644 --- a/packages/renderer-core/src/utils/is-use-loop.ts +++ b/packages/renderer-core/src/utils/is-use-loop.ts @@ -1,19 +1,20 @@ -import { isJSExpression, JSExpression } from '@alilc/lowcode-types'; +import { IPublicTypeJSExpression } from '@alilc/lowcode-types'; +import { isJSExpression } from '@alilc/lowcode-utils'; // 1.渲染模式下,loop 是数组,则按照数组长度渲染组件 // 2.设计模式下,loop 需要长度大于 0,按照循环模式渲染,防止无法设计的情况 -export default function isUseLoop(loop: null | any[] | JSExpression, isDesignMode: boolean): boolean { +export default function isUseLoop(loop: null | any[] | IPublicTypeJSExpression, isDesignMode: boolean): boolean { if (isJSExpression(loop)) { return true; } - if (!Array.isArray(loop)) { - return false; - } - if (!isDesignMode) { return true; } + if (!Array.isArray(loop)) { + return false; + } + return loop.length > 0; } diff --git a/packages/renderer-core/src/utils/logger.ts b/packages/renderer-core/src/utils/logger.ts index 1feb9f6c8f..5b7a276eb6 100644 --- a/packages/renderer-core/src/utils/logger.ts +++ b/packages/renderer-core/src/utils/logger.ts @@ -1,2 +1,3 @@ -import Logger from 'zen-logger'; +import { Logger } from '@alilc/lowcode-utils'; + export default new Logger({ level: 'warn', bizName: 'renderer' }); \ No newline at end of file diff --git a/packages/renderer-core/src/utils/request.ts b/packages/renderer-core/src/utils/request.ts index 9a88068c28..dde5ca87e9 100644 --- a/packages/renderer-core/src/utils/request.ts +++ b/packages/renderer-core/src/utils/request.ts @@ -2,7 +2,15 @@ import 'whatwg-fetch'; import fetchJsonp from 'fetch-jsonp'; import { serializeParams } from '.'; -function buildUrl(dataAPI: any, params: any) { +/** + * this is a private method, export for testing purposes only. + * + * @export + * @param {*} dataAPI + * @param {*} params + * @returns + */ +export function buildUrl(dataAPI: any, params: any) { const paramStr = serializeParams(params); if (paramStr) { return dataAPI.indexOf('?') > 0 ? `${dataAPI}&${paramStr}` : `${dataAPI}?${paramStr}`; @@ -10,43 +18,75 @@ function buildUrl(dataAPI: any, params: any) { return dataAPI; } -export function get(dataAPI: any, params = {}, headers = {}, otherProps = {}) { - headers = { +/** + * do Get request + * + * @export + * @param {*} dataAPI + * @param {*} [params={}] + * @param {*} [headers={}] + * @param {*} [otherProps={}] + * @returns + */ + export function get(dataAPI: any, params = {}, headers = {}, otherProps = {}) { + const processedHeaders = { Accept: 'application/json', ...headers, }; - dataAPI = buildUrl(dataAPI, params); - return request(dataAPI, 'GET', null, headers, otherProps); + const url = buildUrl(dataAPI, params); + return request(url, 'GET', null, processedHeaders, otherProps); } +/** + * do Post request + * + * @export + * @param {*} dataAPI + * @param {*} [params={}] + * @param {*} [headers={}] + * @param {*} [otherProps={}] + * @returns + */ export function post(dataAPI: any, params = {}, headers: any = {}, otherProps = {}) { - headers = { + const processedHeaders = { Accept: 'application/json', 'Content-Type': 'application/x-www-form-urlencoded', ...headers, }; + const body = processedHeaders['Content-Type'].indexOf('application/json') > -1 || Array.isArray(params) + ? JSON.stringify(params) + : serializeParams(params); + return request( dataAPI, 'POST', - headers['Content-Type'].indexOf('application/json') > -1 || Array.isArray(params) - ? JSON.stringify(params) - : serializeParams(params), - headers, + body, + processedHeaders, otherProps, ); } +/** + * do request + * + * @export + * @param {*} dataAPI + * @param {string} [method='GET'] + * @param {*} data + * @param {*} [headers={}] + * @param {*} [otherProps={}] + * @returns + */ export function request(dataAPI: any, method = 'GET', data: any, headers = {}, otherProps: any = {}) { - switch (method) { - case 'PUT': - case 'DELETE': - headers = { - Accept: 'application/json', - 'Content-Type': 'application/json', - ...headers, - }; - data = JSON.stringify(data || {}); - break; + let processedHeaders = headers || {}; + let payload = data; + if (method === 'PUT' || method === 'DELETE') { + processedHeaders = { + Accept: 'application/json', + 'Content-Type': 'application/json', + ...processedHeaders, + }; + payload = JSON.stringify(payload || {}); } return new Promise((resolve, reject) => { if (otherProps.timeout) { @@ -57,8 +97,8 @@ export function request(dataAPI: any, method = 'GET', data: any, headers = {}, o fetch(dataAPI, { method, credentials: 'include', - headers, - body: data, + headers: processedHeaders, + body: payload, ...otherProps, }) .then((response) => { @@ -101,13 +141,19 @@ export function request(dataAPI: any, method = 'GET', data: any, headers = {}, o code: response.status, }; }); + default: } return null; }) .then((json) => { - if (json && json.__success !== false) { + if (!json) { + reject(json); + return; + } + if (json.__success !== false) { resolve(json); } else { + // eslint-disable-next-line no-param-reassign delete json.__success; reject(json); } @@ -118,14 +164,26 @@ export function request(dataAPI: any, method = 'GET', data: any, headers = {}, o }); } +/** + * do jsonp request + * + * @export + * @param {*} dataAPI + * @param {*} [params={}] + * @param {*} [otherProps={}] + * @returns + */ export function jsonp(dataAPI: any, params = {}, otherProps = {}) { return new Promise((resolve, reject) => { - otherProps = { + const processedOtherProps = { timeout: 5000, ...otherProps, }; - fetchJsonp(buildUrl(dataAPI, params), otherProps) - .then((response) => response.json()) + const url = buildUrl(dataAPI, params); + fetchJsonp(url, processedOtherProps) + .then((response) => { + response.json(); + }) .then((json) => { if (json) { resolve(json); diff --git a/packages/renderer-core/test/renderer/__snapshots__/base.test.tsx.snap b/packages/renderer-core/test/renderer/__snapshots__/base.test.tsx.snap deleted file mode 100644 index cdd695efbe..0000000000 --- a/packages/renderer-core/test/renderer/__snapshots__/base.test.tsx.snap +++ /dev/null @@ -1,51 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`notFountComponent not found snapshot 1`] = ` -<div - className="lce-page _css_pseudo_node_ockyigdqxl1" - style={Object {}} -> - <div - __componentName="RootHeader" - __id="node_ockyigdqxl2" - > - Component Not Found - </div> - <div - __componentName="RootContent" - __id="node_ockyigdqxl3" - contentBgColor="white" - contentMargin="20" - contentPadding="20" - > - <div - __componentName="Button" - __id="node_ockyigdqxl5" - __style__={Object {}} - baseIcon="" - behavior="NORMAL" - className="_css_pseudo_node_ockyigdqxl5" - content="按 钮" - events={ - Object { - "ignored": true, - } - } - fieldId="button_kyige3yf" - loading={false} - otherIcon="" - size="medium" - triggerEventsWhenLoading={false} - type="primary" - > - Component Not Found - </div> - </div> - <div - __componentName="RootFooter" - __id="node_ockyigdqxl4" - > - Component Not Found - </div> -</div> -`; diff --git a/packages/renderer-core/test/renderer/base.test.tsx b/packages/renderer-core/test/renderer/base.test.tsx deleted file mode 100644 index ca268e2075..0000000000 --- a/packages/renderer-core/test/renderer/base.test.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import renderer from 'react-test-renderer'; -import React from 'react'; -import '../utils/react-env-init'; -import pageRendererFactory from '../../src/renderer/renderer'; -import { sampleSchema } from '../mock/sample'; - -describe('notFountComponent', () => { - const Render = pageRendererFactory(); - - const component = renderer.create( - // @ts-ignore - <Render - schema={sampleSchema as any} - components={{}} - appHelper={{}} - />, - ); - - it('not found snapshot', () => { - let tree = component.toJSON(); - expect(tree).toMatchSnapshot(); - }); -}) \ No newline at end of file diff --git a/packages/renderer-core/test/setup.ts b/packages/renderer-core/test/setup.ts deleted file mode 100644 index a1b5e73289..0000000000 --- a/packages/renderer-core/test/setup.ts +++ /dev/null @@ -1,12 +0,0 @@ -jest.mock('zen-logger', () => { - class Logger { - log() {} - error() {} - warn() {} - debug() {} - } - return { - __esModule: true, - default: Logger, - }; -}); \ No newline at end of file diff --git a/packages/renderer-core/test/utils/is-use-loop.test.ts b/packages/renderer-core/test/utils/is-use-loop.test.ts deleted file mode 100644 index 5f502a2e5b..0000000000 --- a/packages/renderer-core/test/utils/is-use-loop.test.ts +++ /dev/null @@ -1,25 +0,0 @@ -// @ts-nocheck -import isUseLoop from '../../src/utils/is-use-loop'; - -describe('base test', () => { - it('designMode is true', () => { - expect(isUseLoop([], true)).toBeFalsy(); - expect(isUseLoop([{}], true)).toBeTruthy(); - }); - - it('loop is expression', () => { - expect(isUseLoop({ - "type": "JSExpression", - "value": "function() { console.log('componentDidMount'); }" - }, true)).toBeTruthy(); - expect(isUseLoop({ - "type": "JSExpression", - "value": "function() { console.log('componentDidMount'); }" - }, false)).toBeTruthy(); - }); - - it('designMode is false', () => { - expect(isUseLoop([], false)).toBeTruthy(); - expect(isUseLoop([{}], false)).toBeTruthy(); - }); -}); diff --git a/packages/renderer-core/tests/adapter/adapter.test.ts b/packages/renderer-core/tests/adapter/adapter.test.ts new file mode 100644 index 0000000000..57d92d1d42 --- /dev/null +++ b/packages/renderer-core/tests/adapter/adapter.test.ts @@ -0,0 +1,101 @@ +// @ts-nocheck +import adapter, { Env } from '../../src/adapter'; + + + +describe('test src/adapter ', () => { + + it('adapter basic use works', () => { + expect(adapter).toBeTruthy(); + + }); + + it('isValidRuntime works', () => { + expect(adapter.isValidRuntime([] as any)).toBeFalsy(); + + expect(adapter.isValidRuntime('' as any)).toBeFalsy(); + + let invalidRuntime = {}; + expect(() => adapter.isValidRuntime(invalidRuntime as any)).toThrowError(/Component/); + invalidRuntime = { + Component: {}, + }; + expect(() => adapter.isValidRuntime(invalidRuntime as any)).toThrowError(/PureComponent/); + invalidRuntime = { + Component: {}, + PureComponent: {}, + }; + expect(() => adapter.isValidRuntime(invalidRuntime as any)).toThrowError(/createElement/); + invalidRuntime = { + Component: {}, + PureComponent: {}, + createElement: {}, + }; + expect(() => adapter.isValidRuntime(invalidRuntime as any)).toThrowError(/createContext/); + invalidRuntime = { + Component: {}, + PureComponent: {}, + createElement: {}, + createContext: {}, + }; + expect(() => adapter.isValidRuntime(invalidRuntime as any)).toThrowError(/forwardRef/); + invalidRuntime = { + Component: {}, + PureComponent: {}, + createElement: {}, + createContext: {}, + forwardRef: {}, + }; + expect(() => adapter.isValidRuntime(invalidRuntime as any)).toThrowError(/findDOMNode/); + const validRuntime = { + Component: {}, + PureComponent: {}, + createElement: {}, + createContext: {}, + forwardRef: {}, + findDOMNode: {}, + }; + + expect(adapter.isValidRuntime(validRuntime as any)).toBeTruthy(); + }); + + it('setRuntime/getRuntime works', () => { + const validRuntime = { + Component: {}, + PureComponent: {}, + createElement: {}, + createContext: {}, + forwardRef: {}, + findDOMNode: {}, + }; + + adapter.setRuntime(validRuntime as any); + expect(adapter.getRuntime()).toBe(validRuntime); + + // won`t work when invalid runtime paased in. + adapter.setRuntime([] as any); + expect(adapter.getRuntime()).toBe(validRuntime); + + + }); + + it('setEnv/.env/isReact works', () => { + adapter.setEnv(Env.React); + expect(adapter.env).toBe(Env.React); + expect(adapter.isReact()).toBeTruthy(); + }); + + it('setRenderers/getRenderers works', () => { + const mockRenderers = { BaseRenderer: {} as IBaseRenderComponent}; + adapter.setRenderers(mockRenderers); + expect(adapter.getRenderers()).toBe(mockRenderers); + adapter.setRenderers(undefined); + expect(adapter.getRenderers()).toStrictEqual({}); + }); + + it('setConfigProvider/getConfigProvider works', () => { + const mockConfigProvider = { a: 111 }; + adapter.setConfigProvider(mockConfigProvider); + expect(adapter.getConfigProvider()).toBe(mockConfigProvider); + }); +}); \ No newline at end of file diff --git a/packages/renderer-core/tests/fixtures/schema/basic.ts b/packages/renderer-core/tests/fixtures/schema/basic.ts new file mode 100644 index 0000000000..cc587163ab --- /dev/null +++ b/packages/renderer-core/tests/fixtures/schema/basic.ts @@ -0,0 +1,567 @@ +export default { + componentName: 'Page', + id: 'node_dockcviv8fo1', + props: { + ref: 'outterView', + autoLoading: true, + style: { + padding: '0 5px 0 5px', + }, + }, + fileName: 'test', + dataSource: { + list: [], + }, + state: { + text: 'outter', + isShowDialog: false, + }, + css: 'body {font-size: 12px;} .botton{width:100px;color:#ff00ff}', + lifeCycles: { + componentDidMount: { + type: 'JSFunction', + value: "function() {\n console.log('did mount');\n }", + }, + componentWillUnmount: { + type: 'JSFunction', + value: "function() {\n console.log('will umount');\n }", + }, + }, + methods: { + testFunc: { + type: 'JSFunction', + value: "function() {\n console.log('test func');\n }", + }, + onClick: { + type: 'JSFunction', + value: 'function() {\n this.setState({\n isShowDialog: true\n })\n }', + }, + closeDialog: { + type: 'JSFunction', + value: 'function() {\n this.setState({\n isShowDialog: false\n })\n }', + }, + }, + children: [ + { + componentName: 'Box', + id: 'node_dockcy8n9xed', + props: { + style: { + backgroundColor: 'rgba(31,56,88,0.1)', + padding: '12px 12px 12px 12px', + }, + }, + children: [ + { + componentName: 'Box', + id: 'node_dockcy8n9xee', + props: { + style: { + padding: '12px 12px 12px 12px', + backgroundColor: '#ffffff', + }, + }, + children: [ + { + componentName: 'Breadcrumb', + id: 'node_dockcy8n9xef', + props: { + prefix: 'next-', + maxNode: 100, + component: 'nav', + }, + children: [ + { + componentName: 'Breadcrumb.Item', + id: 'node_dockcy8n9xeg', + props: { + prefix: 'next-', + children: '首页', + }, + }, + { + componentName: 'Breadcrumb.Item', + id: 'node_dockcy8n9xei', + props: { + prefix: 'next-', + children: '品质中台', + }, + }, + { + componentName: 'Breadcrumb.Item', + id: 'node_dockcy8n9xek', + props: { + prefix: 'next-', + children: '商家品质页面管理', + }, + }, + { + componentName: 'Breadcrumb.Item', + id: 'node_dockcy8n9xem', + props: { + prefix: 'next-', + children: '质检知识条配置', + }, + }, + ], + }, + ], + }, + { + componentName: 'Box', + id: 'node_dockcy8n9xeo', + props: { + style: { + marginTop: '12px', + backgroundColor: '#ffffff', + }, + }, + children: [ + { + componentName: 'Form', + id: 'node_dockcy8n9xep', + props: { + inline: true, + style: { + marginTop: '12px', + marginRight: '12px', + marginLeft: '12px', + }, + __events: [], + }, + children: [ + { + componentName: 'Form.Item', + id: 'node_dockcy8n9xeq', + props: { + style: { + marginBottom: '0', + }, + label: '类目名:', + }, + children: [ + { + componentName: 'Select', + id: 'node_dockcy8n9xer', + props: { + mode: 'single', + hasArrow: true, + cacheValue: true, + style: { + width: '150px', + }, + }, + }, + ], + }, + { + componentName: 'Form.Item', + id: 'node_dockcy8n9xes', + props: { + style: { + marginBottom: '0', + }, + label: '项目类型:', + }, + children: [ + { + componentName: 'Select', + id: 'node_dockcy8n9xet', + props: { + mode: 'single', + hasArrow: true, + cacheValue: true, + style: { + width: '200px', + }, + }, + }, + ], + }, + { + componentName: 'Form.Item', + id: 'node_dockcy8n9xeu', + props: { + style: { + marginBottom: '0', + }, + label: '项目 ID:', + }, + children: [ + { + componentName: 'Input', + id: 'node_dockcy8n9xev', + props: { + hasBorder: true, + size: 'medium', + autoComplete: 'off', + style: { + width: '200px', + }, + }, + }, + ], + }, + { + componentName: 'Button.Group', + id: 'node_dockcy8n9xew', + props: {}, + children: [ + { + componentName: 'Button', + id: 'node_dockcy8n9xex', + props: { + type: 'primary', + style: { + margin: '0 5px 0 5px', + }, + htmlType: 'submit', + children: '搜索', + }, + }, + { + componentName: 'Button', + id: 'node_dockcy8n9xe10', + props: { + type: 'normal', + style: { + margin: '0 5px 0 5px', + }, + htmlType: 'reset', + children: '清空', + }, + }, + ], + }, + ], + }, + ], + }, + { + componentName: 'Box', + id: 'node_dockcy8n9xe1f', + props: { + style: { + backgroundColor: '#ffffff', + paddingBottom: '24px', + display: 'flex', + flexDirection: 'row', + justifyContent: 'flex-end', + }, + }, + children: [ + { + componentName: 'Button', + id: 'node_dockd5nrh9p4', + props: { + type: 'primary', + size: 'medium', + htmlType: 'button', + component: 'button', + children: '新建配置', + style: {}, + __events: [ + { + type: 'componentEvent', + name: 'onClick', + relatedEventName: 'onClick', + }, + ], + onClick: { + type: 'JSFunction', + value: 'function(){ this.onClick() }', + }, + }, + }, + ], + }, + { + componentName: 'Box', + id: 'node_dockd5nrh9p5', + props: {}, + children: [ + { + componentName: 'Table', + id: 'node_dockjielosj1', + props: { + showMiniPager: true, + showActionBar: true, + actionBar: [ + { + title: '新增', + type: 'primary', + }, + { + title: '编辑', + }, + ], + columns: [ + { + dataKey: 'name', + width: 200, + align: 'center', + title: '姓名', + editType: 'text', + }, + { + dataKey: 'age', + width: 200, + align: 'center', + title: '年龄', + }, + { + dataKey: 'email', + width: 200, + align: 'center', + title: '邮箱', + }, + ], + data: [ + { + name: '王小', + id: '1', + age: 15000, + email: 'aaa@abc.com', + }, + { + name: '王中', + id: '2', + age: 25000, + email: 'bbb@abc.com', + }, + { + name: '王大', + id: '3', + age: 35000, + email: 'ccc@abc.com', + }, + ], + actionTitle: '操作', + actionWidth: 180, + actionType: 'link', + actionFixed: 'right', + actionHidden: false, + maxWebShownActionCount: 2, + actionColumn: [ + { + title: '编辑', + callback: { + type: 'JSFunction', + value: '(rowData, action, table) => {\n return table.editRow(rowData).then((row) => {\n console.log(row);\n });\n }', + }, + device: [ + 'desktop', + ], + }, + { + title: '保存', + callback: { + type: 'JSFunction', + value: '(rowData, action, table) => { \nreturn table.saveRow(rowData).then((row) => { \nconsole.log(row); \n}); \n}', + }, + mode: 'EDIT', + }, + ], + }, + }, + { + componentName: 'Box', + id: 'node_dockd5nrh9pg', + props: { + style: { + display: 'flex', + flexDirection: 'row', + justifyContent: 'flex-end', + }, + }, + children: [ + { + componentName: 'Pagination', + id: 'node_dockd5nrh9pf', + props: { + prefix: 'next-', + type: 'normal', + shape: 'normal', + size: 'medium', + defaultCurrent: 1, + total: 100, + pageShowCount: 5, + pageSize: 10, + pageSizePosition: 'start', + showJump: true, + style: {}, + }, + }, + ], + }, + ], + }, + ], + }, + { + componentName: 'Dialog', + id: 'node_dockcy8n9xe1h', + props: { + prefix: 'next-', + footerAlign: 'right', + footerActions: [ + 'ok', + 'cancel', + ], + closeable: 'esc,close', + hasMask: true, + align: 'cc cc', + minMargin: 40, + visible: { + type: 'JSExpression', + value: 'this.state.isShowDialog', + }, + title: '标题', + events: [], + __events: [ + { + type: 'componentEvent', + name: 'onCancel', + relatedEventName: 'closeDialog', + }, + { + type: 'componentEvent', + name: 'onClose', + relatedEventName: 'closeDialog', + }, + { + type: 'componentEvent', + name: 'onOk', + relatedEventName: 'testFunc', + }, + ], + onCancel: { + type: 'JSFunction', + value: 'function(){ this.closeDialog() }', + }, + onClose: { + type: 'JSFunction', + value: 'function(){ this.closeDialog() }', + }, + onOk: { + type: 'JSFunction', + value: 'function(){ this.testFunc() }', + }, + }, + children: [ + { + componentName: 'Form', + id: 'node_dockd5nrh9pi', + props: { + inline: false, + labelAlign: 'top', + labelTextAlign: 'right', + size: 'medium', + }, + children: [ + { + componentName: 'Form.Item', + id: 'node_dockd5nrh9pj', + props: { + style: { + marginBottom: '0', + minWidth: '200px', + minHeight: '28px', + }, + label: '商品类目', + }, + children: [ + { + componentName: 'Select', + id: 'node_dockd5nrh9pk', + props: { + mode: 'single', + hasArrow: true, + cacheValue: true, + }, + }, + ], + }, + { + componentName: 'Form.Item', + id: 'node_dockd5nrh9pl', + props: { + style: { + marginBottom: '0', + minWidth: '200px', + minHeight: '28px', + }, + label: '商品类目', + }, + children: [ + { + componentName: 'Select', + id: 'node_dockd5nrh9pm', + props: { + mode: 'single', + hasArrow: true, + cacheValue: true, + }, + }, + ], + }, + { + componentName: 'Form.Item', + id: 'node_dockd5nrh9pn', + props: { + style: { + marginBottom: '0', + minWidth: '200px', + minHeight: '28px', + }, + label: '商品类目', + asterisk: true, + }, + children: [ + { + componentName: 'Select', + id: 'node_dockd5nrh9po', + props: { + mode: 'single', + hasArrow: true, + cacheValue: true, + }, + }, + ], + }, + { + componentName: 'Form.Item', + id: 'node_dockd5nrh9pp', + props: { + style: { + marginBottom: '0', + minWidth: '200px', + minHeight: '28px', + }, + label: '商品类目', + }, + children: [ + { + componentName: 'Input', + id: 'node_dockd5nrh9pr', + props: { + hasBorder: true, + size: 'medium', + autoComplete: 'off', + }, + }, + ], + }, + ], + }, + ], + }, + { + componentName: 'ErrorComponent', + id: 'node_dockd5nrh9pr', + props: { + name: 'error', + }, + }, + ], +}; diff --git a/packages/renderer-core/tests/fixtures/unhandled-rejection.ts b/packages/renderer-core/tests/fixtures/unhandled-rejection.ts new file mode 100644 index 0000000000..d2ab20b0b1 --- /dev/null +++ b/packages/renderer-core/tests/fixtures/unhandled-rejection.ts @@ -0,0 +1,7 @@ +if (!process.env.LISTENING_TO_UNHANDLED_REJECTION) { + process.on('unhandledRejection', reason => { + throw reason; + }); + // Avoid memory leak by adding too many listeners + process.env.LISTENING_TO_UNHANDLED_REJECTION = 'true'; +} diff --git a/packages/renderer-core/tests/hoc/__snapshots__/leaf.test.tsx.snap b/packages/renderer-core/tests/hoc/__snapshots__/leaf.test.tsx.snap new file mode 100644 index 0000000000..e0ddfa8c29 --- /dev/null +++ b/packages/renderer-core/tests/hoc/__snapshots__/leaf.test.tsx.snap @@ -0,0 +1,148 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`children this.props.children is array 1`] = ` +<div> + <div + content="content" + > + content + </div> + <div + content="content" + > + content + </div> +</div> +`; + +exports[`lifecycle leaf change and make componentWillReceiveProps 1`] = ` +<div> + <div + __id="text6" + __tag="222" + componentId="text6" + content="content new leaf" + > + content new leaf + </div> +</div> +`; + +exports[`lifecycle props change and make componentWillReceiveProps 1`] = ` +<div> + <div + content="content" + > + content + </div> +</div> +`; + +exports[`lifecycle props change and make componentWillReceiveProps 2`] = ` +<div> + <div + content="content 123" + > + content 123 + </div> +</div> +`; + +exports[`lifecycle props change and make componentWillReceiveProps 3`] = ` +<div> + <div + __tag="111" + content="content 123" + > + content 123 + </div> +</div> +`; + +exports[`mini unit render leaf has a loop, render from parent 1`] = ` +<div> + this is a new children +</div> +`; + +exports[`mini unit render make text props change 1`] = ` +<div> + <div + content="content" + > + content + </div> +</div> +`; + +exports[`mini unit render make text props change 2`] = ` +<div + newPropKey="newPropValue" +/> +`; + +exports[`mini unit render parent is a mock leaf 1`] = ` +<div> + <div + content="new content to mock" + > + new content to mock + </div> +</div> +`; + +exports[`mini unit render props has new children 1`] = ` +<div> + children 01 + children 02 +</div> +`; + +exports[`onChildrenChange children is array string 1`] = ` +<div> + onChildrenChange content 01 + onChildrenChange content 02 +</div> +`; + +exports[`onPropChange change textNode [key:___condition___] props, but not hidden component 1`] = ` +<div> + <div + content="content" + > + content + </div> +</div> +`; + +exports[`onPropChange change textNode [key:___condition___] props, hide textNode component 1`] = `<div />`; + +exports[`onPropChange change textNode [key:content], content in this.props but not in leaf.export result 1`] = ` +<div> + <div + content="content" + > + content + </div> +</div> +`; + +exports[`onPropChange change textNode [key:content], content in this.props but not in leaf.export result 2`] = ` +<div> + <div + content={null} + /> +</div> +`; + +exports[`onVisibleChange visible is false 1`] = `<div />`; + +exports[`onVisibleChange visible is true 1`] = ` +<div> + <div + content="content" + > + content + </div> +</div> +`; diff --git a/packages/renderer-core/tests/hoc/leaf.test.tsx b/packages/renderer-core/tests/hoc/leaf.test.tsx new file mode 100644 index 0000000000..c21a10be92 --- /dev/null +++ b/packages/renderer-core/tests/hoc/leaf.test.tsx @@ -0,0 +1,604 @@ +import renderer from 'react-test-renderer'; +import React from 'react'; +import { createElement } from 'react'; +import '../utils/react-env-init'; +import { leafWrapper } from '../../src/hoc/leaf'; +import components from '../utils/components'; +import Node from '../utils/node'; +import { parseData } from '../../src/utils'; + +let rerenderCount = 0; + +const nodeMap = new Map(); + +const makeSnapshot = (component) => { + let tree = component.toJSON(); + expect(tree).toMatchSnapshot(); +} + +const baseRenderer: any = { + __debug () {}, + __getComponentProps (schema: any) { + return schema.props; + }, + __getSchemaChildrenVirtualDom (schema: any) { + return schema.children; + }, + context: { + engine: { + createElement, + } + }, + props: { + __host: {}, + getNode: (id) => nodeMap.get(id), + __container: { + rerender: () => { + rerenderCount = 1 + rerenderCount; + }, + autoRepaintNode: true, + }, + documentId: '01' + }, + __parseData (data, scope) { + return parseData(data, scope, {}); + } +} + +let Div, DivNode, Text, TextNode, component, textSchema, divSchema; +let id = 0; + +beforeEach(() => { + textSchema = { + id: 'text' + id, + props: { + content: 'content' + }, + }; + + divSchema = { + id: 'div' + id, + }; + + id++; + + Div = leafWrapper(components.Div as any, { + schema: divSchema, + baseRenderer, + componentInfo: {}, + scope: {}, + }); + + DivNode = new Node(divSchema); + TextNode = new Node(textSchema); + + nodeMap.set(divSchema.id, DivNode); + nodeMap.set(textSchema.id, TextNode); + + Text = leafWrapper(components.Text as any, { + schema: textSchema, + baseRenderer, + componentInfo: {}, + scope: {}, + }); + + component = renderer.create( + <Div _leaf={DivNode}> + <Text _leaf={TextNode} content="content"></Text> + </Div> + ); +}); + +afterEach(() => { + component.unmount(component); +}); + +describe('onPropChange', () => { + it('change textNode [key:content] props', () => { + TextNode.emitPropChange({ + key: 'content', + newValue: 'new content', + } as any); + + const root = component.root; + expect(root.findByType(components.Text).props.content).toEqual('new content') + }); + + it('change textNode [key:___condition___] props, hide textNode component', () => { + // mock leaf?.export result + TextNode.schema.condition = false; + TextNode.emitPropChange({ + key: '___condition___', + newValue: false, + } as any); + + makeSnapshot(component); + }); + + it('change textNode [key:___condition___] props, but not hidden component', () => { + TextNode.schema.condition = true; + TextNode.emitPropChange({ + key: '___condition___', + newValue: false, + } as any); + + makeSnapshot(component); + }); + + it('change textNode [key:content], content in this.props but not in leaf.export result', () => { + makeSnapshot(component); + + delete TextNode.schema.props.content; + TextNode.emitPropChange({ + key: 'content', + newValue: null, + } as any, true); + + makeSnapshot(component); + + const root = component.root; + + const TextInst = root.findByType(components.Text); + + expect(TextInst.props.content).toBeNull(); + }); + + it('change textNode [key:___loop___], make rerender', () => { + expect(leafWrapper(components.Text as any, { + schema: textSchema, + baseRenderer, + componentInfo: {}, + scope: {}, + })).toEqual(Text); + + const nextRerenderCount = rerenderCount + 1; + + TextNode.emitPropChange({ + key: '___loop___', + newValue: 'new content', + } as any); + + expect(rerenderCount).toBe(nextRerenderCount); + expect(leafWrapper(components.Text as any, { + schema: textSchema, + baseRenderer, + componentInfo: {}, + scope: {}, + })).not.toEqual(Text); + }); +}); + +describe('lifecycle', () => { + it('props change and make componentWillReceiveProps', () => { + makeSnapshot(component); + + // 没有 __tag 标识 + component.update(( + <Div _leaf={DivNode}> + <Text _leaf={TextNode} content="content 123"></Text> + </Div> + )); + + makeSnapshot(component); + + // 有 __tag 标识 + component.update(( + <Div _leaf={DivNode}> + <Text _leaf={TextNode} __tag="111" content="content 123"></Text> + </Div> + )); + + makeSnapshot(component); + }); + + it('leaf change and make componentWillReceiveProps', () => { + const newTextNodeLeaf = new Node(textSchema); + nodeMap.set(textSchema.id, newTextNodeLeaf); + component.update(( + <Div _leaf={DivNode}> + <Text componentId={textSchema.id} __tag="222" content="content 123"></Text> + </Div> + )); + + newTextNodeLeaf.emitPropChange({ + key: 'content', + newValue: 'content new leaf', + }); + + makeSnapshot(component); + }); +}); + +describe('mini unit render', () => { + let miniRenderSchema, MiniRenderDiv, MiniRenderDivNode; + beforeEach(() => { + miniRenderSchema = { + id: 'miniDiv' + id, + }; + + MiniRenderDiv = leafWrapper(components.MiniRenderDiv as any, { + schema: miniRenderSchema, + baseRenderer, + componentInfo: {}, + scope: {}, + }); + + MiniRenderDivNode = new Node(miniRenderSchema, { + componentMeta: { + isMinimalRenderUnit: true, + }, + }); + + TextNode = new Node(textSchema, { + parent: MiniRenderDivNode, + }); + + nodeMap.set(miniRenderSchema.id, MiniRenderDivNode); + nodeMap.set(textSchema.id, TextNode); + + component = renderer.create( + <MiniRenderDiv _leaf={MiniRenderDivNode}> + <Text _leaf={TextNode} content="content"></Text> + </MiniRenderDiv> + ); + }) + + it('make text props change', () => { + if (!MiniRenderDivNode.schema.props) { + MiniRenderDivNode.schema.props = {}; + } + MiniRenderDivNode.schema.props['newPropKey'] = 'newPropValue'; + + makeSnapshot(component); + + const inst = component.root; + + const TextInst = inst.findByType(Text).children[0]; + + TextNode.emitPropChange({ + key: 'content', + newValue: 'new content', + } as any); + + expect((TextInst as any)?._fiber.stateNode.renderUnitInfo).toEqual({ + singleRender: false, + minimalUnitId: 'miniDiv' + id, + minimalUnitName: undefined, + }); + + makeSnapshot(component); + }); + + it('dont render mini render component', () => { + const TextNode = new Node(textSchema, { + parent: new Node({ + id: 'random', + }, { + componentMeta: { + isMinimalRenderUnit: true, + }, + }), + }); + + nodeMap.set(textSchema.id, TextNode); + + renderer.create( + <div> + <Text _leaf={TextNode} content="content"></Text> + </div> + ); + + const nextCount = rerenderCount + 1; + + TextNode.emitPropChange({ + key: 'content', + newValue: 'new content', + } as any); + + expect(rerenderCount).toBe(nextCount); + }); + + it('leaf is a mock function', () => { + const TextNode = new Node(textSchema, { + parent: { + isEmpty: () => false, + } + }); + + renderer.create( + <div> + <Text _leaf={TextNode} content="content"></Text> + </div> + ); + + TextNode.emitPropChange({ + key: 'content', + newValue: 'new content', + } as any); + }); + + it('change component leaf isRoot is true', () => { + const TextNode = new Node(textSchema, { + isRoot: true, + isRootNode: true, + }); + + nodeMap.set(textSchema.id, TextNode); + + const component = renderer.create( + <Text _leaf={TextNode} content="content"></Text> + ); + + const inst = component.root; + + TextNode.emitPropChange({ + key: 'content', + newValue: 'new content', + } as any); + + expect((inst.children[0] as any)?._fiber.stateNode.renderUnitInfo).toEqual({ + singleRender: true, + }); + }); + + it('change component leaf parent isRoot is true', () => { + const TextNode = new Node(textSchema, { + parent: new Node({ + id: 'first-parent', + }, { + componentMeta: { + isMinimalRenderUnit: true, + }, + parent: new Node({ + id: 'rootId', + }, { + isRoot: true, + isRootNode: true + }), + }) + }); + + nodeMap.set(textSchema.id, TextNode); + + const component = renderer.create( + <Text _leaf={TextNode} content="content"></Text> + ); + + const inst = component.root; + + TextNode.emitPropChange({ + key: 'content', + newValue: 'new content', + } as any); + + expect((inst.children[0] as any)?._fiber.stateNode.renderUnitInfo).toEqual({ + singleRender: false, + minimalUnitId: 'first-parent', + minimalUnitName: undefined, + }); + }); + + it('parent is a mock leaf', () => { + const MiniRenderDivNode = { + isMock: true, + }; + + const component = renderer.create( + <MiniRenderDiv _leaf={MiniRenderDivNode}> + <Text _leaf={TextNode} content="content"></Text> + </MiniRenderDiv> + ); + + TextNode.emitPropChange({ + key: 'content', + newValue: 'new content to mock', + } as any); + + makeSnapshot(component); + }); + + it('props has new children', () => { + MiniRenderDivNode.schema.props.children = [ + 'children 01', + 'children 02', + ]; + + TextNode.emitPropChange({ + key: 'content', + newValue: 'props' + }); + + makeSnapshot(component); + }); + + it('leaf has a loop, render from parent', () => { + MiniRenderDivNode = new Node(miniRenderSchema, {}); + + TextNode = new Node(textSchema, { + parent: MiniRenderDivNode, + hasLoop: true, + }); + + nodeMap.set(textSchema.id, TextNode); + nodeMap.set(miniRenderSchema.id, MiniRenderDivNode); + + component = renderer.create( + <MiniRenderDiv _leaf={MiniRenderDivNode}> + <Text _leaf={TextNode} content="content"></Text> + </MiniRenderDiv> + ); + + MiniRenderDivNode.schema.children = ['this is a new children']; + + TextNode.emitPropChange({ + key: 'content', + newValue: '1', + }); + + makeSnapshot(component); + }); +}); + +describe('component cache', () => { + it('get different component with same is and different doc id', () => { + const baseRenderer02 = { + ...baseRenderer, + props: { + ...baseRenderer.props, + documentId: '02', + } + } + const Div3 = leafWrapper(components.Div as any, { + schema: divSchema, + baseRenderer: baseRenderer02, + componentInfo: {}, + scope: {}, + }); + + expect(Div).not.toEqual(Div3); + }); + + it('get component again and get ths cache component', () => { + const Div2 = leafWrapper(components.Div as any, { + schema: divSchema, + baseRenderer, + componentInfo: {}, + scope: {}, + }); + + expect(Div).toEqual(Div2); + }); +}); + +describe('onVisibleChange', () => { + it('visible is false', () => { + TextNode.emitVisibleChange(false); + makeSnapshot(component); + }); + + it('visible is true', () => { + TextNode.emitVisibleChange(true); + makeSnapshot(component); + }); +}); + +describe('children', () => { + it('this.props.children is array', () => { + const component = renderer.create( + <Div _leaf={DivNode}> + <Text _leaf={TextNode} content="content"></Text> + <Text _leaf={TextNode} content="content"></Text> + </Div> + ); + + makeSnapshot(component); + }); +}); + +describe('onChildrenChange', () => { + it('children is array string', () => { + DivNode.schema.children = [ + 'onChildrenChange content 01', + 'onChildrenChange content 02' + ] + DivNode.emitChildrenChange(); + makeSnapshot(component); + }); + + it('children is 0', () => { + DivNode.schema.children = 0 + DivNode.emitChildrenChange(); + const componentInstance = component.root; + expect(componentInstance.findByType(components.Div).props.children).toEqual(0); + }); + + it('children is false', () => { + DivNode.schema.children = false + DivNode.emitChildrenChange(); + const componentInstance = component.root; + expect(componentInstance.findByType(components.Div).props.children).toEqual(false); + }); + + it('children is []', () => { + DivNode.schema.children = [] + DivNode.emitChildrenChange(); + const componentInstance = component.root; + expect(componentInstance.findByType(components.Div).props.children).toEqual([]); + }); + + it('children is null', () => { + DivNode.schema.children = null + DivNode.emitChildrenChange(); + const componentInstance = component.root; + expect(componentInstance.findByType(components.Div).props.children).toEqual(null); + }); + + it('children is undefined', () => { + DivNode.schema.children = undefined; + DivNode.emitChildrenChange(); + const componentInstance = component.root; + expect(componentInstance.findByType(components.Div).props.children).toEqual(undefined); + }); +}); + +describe('not render leaf', () => { + let miniRenderSchema, MiniRenderDiv, MiniRenderDivNode; + beforeEach(() => { + miniRenderSchema = { + id: 'miniDiv' + id, + }; + + MiniRenderDivNode = new Node(miniRenderSchema, { + componentMeta: { + isMinimalRenderUnit: true, + }, + }); + + nodeMap.set(miniRenderSchema.id, MiniRenderDivNode); + + MiniRenderDiv = leafWrapper(components.MiniRenderDiv as any, { + schema: miniRenderSchema, + baseRenderer, + componentInfo: {}, + scope: {}, + }); + + TextNode = new Node(textSchema, { + parent: MiniRenderDivNode, + }); + + component = renderer.create( + <Text _leaf={TextNode} content="content"></Text> + ); + }); + + it('onPropsChange', () => { + const nextCount = rerenderCount + 1; + + MiniRenderDivNode.emitPropChange({ + key: 'any', + newValue: 'any', + }); + + expect(rerenderCount).toBe(nextCount); + }); + + it('onChildrenChange', () => { + const nextCount = rerenderCount + 1; + + MiniRenderDivNode.emitChildrenChange({ + key: 'any', + newValue: 'any', + }); + + expect(rerenderCount).toBe(nextCount); + }); + + it('onVisibleChange', () => { + const nextCount = rerenderCount + 1; + + MiniRenderDivNode.emitVisibleChange(true); + + expect(rerenderCount).toBe(nextCount); + }); +}); diff --git a/packages/renderer-core/tests/mock/loop.ts b/packages/renderer-core/tests/mock/loop.ts new file mode 100644 index 0000000000..60e74378de --- /dev/null +++ b/packages/renderer-core/tests/mock/loop.ts @@ -0,0 +1,221 @@ +const schema = { + "componentName": "Page", + "id": "node_ocl1djd9o41", + "docId": "docl1djd9o4", + "props": { + "templateVersion": "1.0.0", + "containerStyle": {}, + "pageStyle": { + "backgroundColor": "#f2f3f5" + }, + "className": "_css_pseudo_node_ocl1djd9o41" + }, + "dataSource": { + "offline": [], + "globalConfig": {}, + "online": [ + { + "gmtModified": 1639385418000, + "initialData": "", + "globalUid": "AY866BC1ERSVK0BE55NU364515LH3NM0RF4XK61", + "formUuid": "FORM-3KYJN7RV-J47BPFK63W2PHAGPO1VC3-B4H1WE5K-131", + "name": "locale", + "description": "当前语种(在 window.g_config 中设置)", + "id": "AY866BC1ERSVK0BE55NU364515LH3NM0RF4XK61", + "protocal": "VALUE", + "shareType": "APP" + }, + { + "gmtModified": 1639385418000, + "initialData": "", + "globalUid": "AY866BC1ERSVK0BE55NU364515LH3SM0RF4XK71", + "formUuid": "FORM-RFYJTWKV-D47BWO6R0QHA74R062FN2-R5IPXK4K-0H", + "name": "appType", + "description": "应用的唯一 code", + "id": "AY866BC1ERSVK0BE55NU364515LH3SM0RF4XK71", + "protocal": "VALUE", + "shareType": "APP" + }, + { + "gmtModified": 1639385418000, + "initialData": "", + "globalUid": "AY866BC1ERSVK0BE55NU364515LH3XM0RF4XK81", + "formUuid": "FORM-RFYJTWKV-D47BWO6R0QHA74R062FN2-R5IPXK4K-0H", + "name": "version", + "description": "应该版本,默认 0.1.0", + "id": "AY866BC1ERSVK0BE55NU364515LH3XM0RF4XK81", + "protocal": "VALUE", + "shareType": "APP" + }, + { + "gmtModified": 1639385418000, + "initialData": "", + "globalUid": "AY866BC1ERSVK0BE55NU364515LH33N0RF4XK91", + "formUuid": "FORM-RFYJTWKV-D47BWO6R0QHA74R062FN2-R5IPXK4K-0H", + "name": "apiPrefix", + "description": "", + "id": "AY866BC1ERSVK0BE55NU364515LH33N0RF4XK91", + "protocal": "VALUE", + "shareType": "APP" + } + ], + "sync": true, + "list": [ + { + "gmtModified": 1639385418000, + "initialData": "", + "globalUid": "AY866BC1ERSVK0BE55NU364515LH3NM0RF4XK61", + "formUuid": "FORM-3KYJN7RV-J47BPFK63W2PHAGPO1VC3-B4H1WE5K-131", + "name": "locale", + "description": "当前语种(在 window.g_config 中设置)", + "id": "AY866BC1ERSVK0BE55NU364515LH3NM0RF4XK61", + "protocal": "VALUE", + "shareType": "APP" + }, + { + "gmtModified": 1639385418000, + "initialData": "", + "globalUid": "AY866BC1ERSVK0BE55NU364515LH3SM0RF4XK71", + "formUuid": "FORM-RFYJTWKV-D47BWO6R0QHA74R062FN2-R5IPXK4K-0H", + "name": "appType", + "description": "应用的唯一 code", + "id": "AY866BC1ERSVK0BE55NU364515LH3SM0RF4XK71", + "protocal": "VALUE", + "shareType": "APP" + }, + { + "gmtModified": 1639385418000, + "initialData": "", + "globalUid": "AY866BC1ERSVK0BE55NU364515LH3XM0RF4XK81", + "formUuid": "FORM-RFYJTWKV-D47BWO6R0QHA74R062FN2-R5IPXK4K-0H", + "name": "version", + "description": "应该版本,默认 0.1.0", + "id": "AY866BC1ERSVK0BE55NU364515LH3XM0RF4XK81", + "protocal": "VALUE", + "shareType": "APP" + }, + { + "gmtModified": 1639385418000, + "initialData": "", + "globalUid": "AY866BC1ERSVK0BE55NU364515LH33N0RF4XK91", + "formUuid": "FORM-RFYJTWKV-D47BWO6R0QHA74R062FN2-R5IPXK4K-0H", + "name": "apiPrefix", + "description": "", + "id": "AY866BC1ERSVK0BE55NU364515LH33N0RF4XK91", + "protocal": "VALUE", + "shareType": "APP" + } + ] + }, + "methods": {}, + "hidden": false, + "title": "", + "isLocked": false, + "condition": true, + "conditionGroup": "", + "children": [ + { + "componentName": "RootHeader", + "id": "node_ocl1djd9o42", + "docId": "docl1djd9o4", + "props": {}, + "hidden": false, + "title": "", + "isLocked": false, + "condition": true, + "conditionGroup": "" + }, + { + "componentName": "RootContent", + "id": "node_ocl1djd9o43", + "docId": "docl1djd9o4", + "props": { + "contentMargin": "20", + "contentPadding": "20", + "contentBgColor": "white" + }, + "hidden": false, + "title": "", + "isLocked": false, + "condition": true, + "conditionGroup": "", + "children": [ + { + "componentName": "Div", + "id": "node_ocl1djd9o45", + "docId": "docl1djd9o4", + "props": { + "behavior": "NORMAL", + "__style__": {}, + "fieldId": "div_l1djdj1n", + "events": { + "ignored": true + }, + "useFieldIdAsDomId": false, + "customClassName": "", + "className": "_css_pseudo_node_ocl1djd9o45" + }, + "hidden": false, + "title": "", + "isLocked": false, + "condition": true, + "conditionGroup": "", + "loop": [ + 1, + 2, + 3 + ], + "loopArgs": [ + null, + null + ], + "children": [ + { + "componentName": "Div", + "id": "node_ocl1djd9o46", + "docId": "docl1djd9o4", + "props": { + "behavior": "NORMAL", + "__style__": {}, + "fieldId": "div_l1djdj1o", + "events": { + "ignored": true + }, + "useFieldIdAsDomId": false, + "customClassName": "", + "className": "_css_pseudo_node_ocl1djd9o46" + }, + "hidden": false, + "title": "", + "isLocked": false, + "condition": true, + "conditionGroup": "", + "loop": [ + 1, + 2, + 3 + ], + "loopArgs": [ + null, + null + ] + } + ] + } + ] + }, + { + "componentName": "RootFooter", + "id": "node_ocl1djd9o44", + "docId": "docl1djd9o4", + "props": {}, + "hidden": false, + "title": "", + "isLocked": false, + "condition": true, + "conditionGroup": "" + } + ] +}; + +export default schema; diff --git a/packages/renderer-core/test/mock/sample.ts b/packages/renderer-core/tests/mock/sample.ts similarity index 100% rename from packages/renderer-core/test/mock/sample.ts rename to packages/renderer-core/tests/mock/sample.ts diff --git a/packages/renderer-core/test/mock/styleMock.js b/packages/renderer-core/tests/mock/styleMock.js similarity index 100% rename from packages/renderer-core/test/mock/styleMock.js rename to packages/renderer-core/tests/mock/styleMock.js diff --git a/packages/renderer-core/tests/renderer/__snapshots__/renderer.test.tsx.snap b/packages/renderer-core/tests/renderer/__snapshots__/renderer.test.tsx.snap new file mode 100644 index 0000000000..79c5f0f085 --- /dev/null +++ b/packages/renderer-core/tests/renderer/__snapshots__/renderer.test.tsx.snap @@ -0,0 +1,1398 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Base Render renderComp 1`] = ` +<div + className="lce-page lce-test" + style={ + Object { + "padding": "0 5px 0 5px", + } + } +> + <div + __id="node_dockcy8n9xed" + __inner__={ + Object { + "condition": true, + "hidden": undefined, + } + } + className="next-box" + style={ + Object { + "backgroundColor": "rgba(31,56,88,0.1)", + "flexDirection": "column", + "flexWrap": "nowrap", + "msFlexDirection": "column", + "msFlexWrap": "none", + "padding": "12px 12px 12px 12px", + } + } + > + <div + __id="node_dockcy8n9xee" + __inner__={ + Object { + "condition": true, + "hidden": undefined, + } + } + className="next-box" + style={ + Object { + "backgroundColor": "#ffffff", + "flexDirection": "column", + "flexWrap": "nowrap", + "msFlexDirection": "column", + "msFlexWrap": "none", + "padding": "12px 12px 12px 12px", + } + } + > + <nav + __id="node_dockcy8n9xef" + __inner__={ + Object { + "condition": true, + "hidden": undefined, + } + } + aria-label="Breadcrumb" + style={ + Object { + "position": "relative", + } + } + > + <ul + className="next-breadcrumb" + > + <li + className="next-breadcrumb-item" + dir={null} + > + <span + __id="node_dockcy8n9xeg" + __inner__={ + Object { + "condition": true, + "hidden": undefined, + } + } + className="next-breadcrumb-text" + > + 首页 + </span> + <span + className="next-breadcrumb-separator" + > + <i + className="next-icon next-icon-arrow-right next-medium next-breadcrumb-icon-sep" + style={Object {}} + /> + </span> + </li> + <li + className="next-breadcrumb-item" + dir={null} + > + <span + __id="node_dockcy8n9xei" + __inner__={ + Object { + "condition": true, + "hidden": undefined, + } + } + className="next-breadcrumb-text" + > + 品质中台 + </span> + <span + className="next-breadcrumb-separator" + > + <i + className="next-icon next-icon-arrow-right next-medium next-breadcrumb-icon-sep" + style={Object {}} + /> + </span> + </li> + <li + className="next-breadcrumb-item" + dir={null} + > + <span + __id="node_dockcy8n9xek" + __inner__={ + Object { + "condition": true, + "hidden": undefined, + } + } + className="next-breadcrumb-text" + > + 商家品质页面管理 + </span> + <span + className="next-breadcrumb-separator" + > + <i + className="next-icon next-icon-arrow-right next-medium next-breadcrumb-icon-sep" + style={Object {}} + /> + </span> + </li> + <li + className="next-breadcrumb-item" + dir={null} + > + <span + __id="node_dockcy8n9xem" + __inner__={ + Object { + "condition": true, + "hidden": undefined, + } + } + aria-current="page" + className="next-breadcrumb-text activated" + > + 质检知识条配置 + </span> + </li> + </ul> + </nav> + </div> + <div + __id="node_dockcy8n9xeo" + __inner__={ + Object { + "condition": true, + "hidden": undefined, + } + } + className="next-box" + style={ + Object { + "backgroundColor": "#ffffff", + "flexDirection": "column", + "flexWrap": "nowrap", + "marginTop": "12px", + "msFlexDirection": "column", + "msFlexWrap": "none", + } + } + > + <form + __events={Array []} + __id="node_dockcy8n9xep" + __inner__={ + Object { + "condition": true, + "hidden": undefined, + } + } + className="next-form next-inline next-medium" + onSubmit={[Function]} + role="grid" + style={ + Object { + "marginLeft": "12px", + "marginRight": "12px", + "marginTop": "12px", + } + } + > + <div + __id="node_dockcy8n9xeq" + __inner__={ + Object { + "condition": true, + "hidden": undefined, + } + } + className="next-form-item next-left next-medium" + style={ + Object { + "marginBottom": "0", + } + } + > + <div + className="next-form-item-label" + > + <label> + 类目名: + </label> + </div> + <div + className="next-form-item-control" + > + <span + aria-haspopup={true} + className="next-select next-select-trigger next-select-single next-medium next-inactive next-no-search" + onClick={[Function]} + onKeyDown={[Function]} + onMouseDown={[Function]} + onMouseEnter={[Function]} + onMouseLeave={[Function]} + style={ + Object { + "width": "150px", + } + } + > + <span + className="next-input next-medium next-select-inner" + > + <span + className="next-select-values next-input-text-field" + > + <span + className="next-select-trigger-search" + > + <input + __id="node_dockcy8n9xer" + __inner__={ + Object { + "condition": true, + "hidden": undefined, + } + } + autoComplete="off" + disabled={false} + height="100%" + maxLength={null} + onBlur={[Function]} + onChange={[Function]} + onCompositionEnd={[Function]} + onCompositionStart={[Function]} + onFocus={[Function]} + onKeyDown={[Function]} + placeholder="请选择" + readOnly={true} + role="combobox" + size="1" + tabIndex={0} + value="" + /> + <span + aria-hidden={true} + > + <span> + 请选择 + </span> + <span + style={ + Object { + "display": "inline-block", + "width": 1, + } + } + > +   + </span> + </span> + </span> + </span> + <span + className="next-input-control" + onClick={[Function]} + > + <span + aria-hidden={true} + className="next-select-arrow" + onClick={[Function]} + > + <i + className="next-icon next-icon-arrow-down next-medium next-select-symbol-fold" + style={Object {}} + /> + </span> + </span> + </span> + <span + aria-live="polite" + className="next-sr-only" + > + + </span> + </span> + + + </div> + </div> + <div + __id="node_dockcy8n9xes" + __inner__={ + Object { + "condition": true, + "hidden": undefined, + } + } + className="next-form-item next-left next-medium" + style={ + Object { + "marginBottom": "0", + } + } + > + <div + className="next-form-item-label" + > + <label> + 项目类型: + </label> + </div> + <div + className="next-form-item-control" + > + <span + aria-haspopup={true} + className="next-select next-select-trigger next-select-single next-medium next-inactive next-no-search" + onClick={[Function]} + onKeyDown={[Function]} + onMouseDown={[Function]} + onMouseEnter={[Function]} + onMouseLeave={[Function]} + style={ + Object { + "width": "200px", + } + } + > + <span + className="next-input next-medium next-select-inner" + > + <span + className="next-select-values next-input-text-field" + > + <span + className="next-select-trigger-search" + > + <input + __id="node_dockcy8n9xet" + __inner__={ + Object { + "condition": true, + "hidden": undefined, + } + } + autoComplete="off" + disabled={false} + height="100%" + maxLength={null} + onBlur={[Function]} + onChange={[Function]} + onCompositionEnd={[Function]} + onCompositionStart={[Function]} + onFocus={[Function]} + onKeyDown={[Function]} + placeholder="请选择" + readOnly={true} + role="combobox" + size="1" + tabIndex={0} + value="" + /> + <span + aria-hidden={true} + > + <span> + 请选择 + </span> + <span + style={ + Object { + "display": "inline-block", + "width": 1, + } + } + > +   + </span> + </span> + </span> + </span> + <span + className="next-input-control" + onClick={[Function]} + > + <span + aria-hidden={true} + className="next-select-arrow" + onClick={[Function]} + > + <i + className="next-icon next-icon-arrow-down next-medium next-select-symbol-fold" + style={Object {}} + /> + </span> + </span> + </span> + <span + aria-live="polite" + className="next-sr-only" + > + + </span> + </span> + + + </div> + </div> + <div + __id="node_dockcy8n9xeu" + __inner__={ + Object { + "condition": true, + "hidden": undefined, + } + } + className="next-form-item next-left next-medium" + style={ + Object { + "marginBottom": "0", + } + } + > + <div + className="next-form-item-label" + > + <label> + 项目 ID: + </label> + </div> + <div + className="next-form-item-control" + > + <span + className="next-input next-medium" + style={ + Object { + "width": "200px", + } + } + > + <input + __id="node_dockcy8n9xev" + __inner__={ + Object { + "condition": true, + "hidden": undefined, + } + } + autoComplete="off" + disabled={false} + height="100%" + maxLength={null} + onBlur={[Function]} + onChange={[Function]} + onCompositionEnd={[Function]} + onCompositionStart={[Function]} + onFocus={[Function]} + onKeyDown={[Function]} + readOnly={false} + value="" + /> + </span> + + + </div> + </div> + <div + __id="node_dockcy8n9xew" + __inner__={ + Object { + "condition": true, + "hidden": undefined, + } + } + className="next-btn-group" + > + <button + __id="node_dockcy8n9xex" + __inner__={ + Object { + "condition": true, + "hidden": undefined, + } + } + className="next-btn next-medium next-btn-primary" + disabled={false} + onClick={[Function]} + onMouseUp={[Function]} + style={ + Object { + "margin": "0 5px 0 5px", + } + } + type="submit" + > + <span + className="next-btn-helper" + > + 搜索 + </span> + </button> + <button + __id="node_dockcy8n9xe10" + __inner__={ + Object { + "condition": true, + "hidden": undefined, + } + } + className="next-btn next-medium next-btn-normal" + disabled={false} + onClick={[Function]} + onMouseUp={[Function]} + style={ + Object { + "margin": "0 5px 0 5px", + } + } + type="reset" + > + <span + className="next-btn-helper" + > + 清空 + </span> + </button> + </div> + </form> + </div> + <div + __id="node_dockcy8n9xe1f" + __inner__={ + Object { + "condition": true, + "hidden": undefined, + } + } + className="next-box" + style={ + Object { + "backgroundColor": "#ffffff", + "display": "flex", + "flexDirection": "row", + "flexWrap": "nowrap", + "justifyContent": "flex-end", + "msFlexDirection": "column", + "msFlexWrap": "none", + "paddingBottom": "24px", + } + } + > + <button + __events={ + Array [ + Object { + "name": "onClick", + "relatedEventName": "onClick", + "type": "componentEvent", + }, + ] + } + __id="node_dockd5nrh9p4" + __inner__={ + Object { + "condition": true, + "hidden": undefined, + } + } + className="next-btn next-medium next-btn-primary" + disabled={false} + onClick={[Function]} + onMouseUp={[Function]} + style={Object {}} + type="button" + > + <span + className="next-btn-helper" + > + 新建配置 + </span> + </button> + </div> + <div + __id="node_dockd5nrh9p5" + __inner__={ + Object { + "condition": true, + "hidden": undefined, + } + } + className="next-box" + style={ + Object { + "flexDirection": "column", + "flexWrap": "nowrap", + "msFlexDirection": "column", + "msFlexWrap": "none", + } + } + > + <div + __id="node_dockjielosj1" + __inner__={ + Object { + "condition": true, + "hidden": undefined, + } + } + actionBar={ + Array [ + Object { + "title": "新增", + "type": "primary", + }, + Object { + "title": "编辑", + }, + ] + } + actionColumn={ + Array [ + Object { + "callback": [Function], + "device": Array [ + "desktop", + ], + "title": "编辑", + }, + Object { + "callback": [Function], + "mode": "EDIT", + "title": "保存", + }, + ] + } + actionFixed="right" + actionHidden={false} + actionTitle="操作" + actionType="link" + actionWidth={180} + className="next-table next-table-medium" + data={ + Array [ + Object { + "age": 15000, + "email": "aaa@abc.com", + "id": "1", + "name": "王小", + }, + Object { + "age": 25000, + "email": "bbb@abc.com", + "id": "2", + "name": "王中", + }, + Object { + "age": 35000, + "email": "ccc@abc.com", + "id": "3", + "name": "王大", + }, + ] + } + maxWebShownActionCount={2} + showActionBar={true} + showMiniPager={true} + style={Object {}} + > + <div + className="next-table-column-resize-proxy" + /> + <table + role="table" + style={ + Object { + "width": undefined, + } + } + > + <colgroup> + <col + style={ + Object { + "width": 200, + } + } + /> + <col + style={ + Object { + "width": 200, + } + } + /> + <col + style={ + Object { + "width": 200, + } + } + /> + </colgroup> + <thead + className="next-table-header" + > + <tr> + <th + className="next-table-cell next-table-header-node" + role="gridcell" + rowSpan={1} + style={ + Object { + "textAlign": "center", + } + } + > + <div + className="next-table-cell-wrapper" + data-next-table-col={0} + > + 姓名 + </div> + </th> + <th + className="next-table-cell next-table-header-node" + role="gridcell" + rowSpan={1} + style={ + Object { + "textAlign": "center", + } + } + > + <div + className="next-table-cell-wrapper" + data-next-table-col={1} + > + 年龄 + </div> + </th> + <th + className="next-table-cell next-table-header-node" + role="gridcell" + rowSpan={1} + style={ + Object { + "textAlign": "center", + } + } + > + <div + className="next-table-cell-wrapper" + data-next-table-col={2} + > + 邮箱 + </div> + </th> + </tr> + </thead> + <tbody + className="next-table-body" + > + <tr> + <td + colSpan={3} + > + <div + className="next-table-empty" + style={ + Object { + "left": 0, + "overflow": "hidden", + "position": "sticky", + "width": -1, + } + } + > + 没有数据 + </div> + </td> + </tr> + </tbody> + </table> + </div> + <div + __id="node_dockd5nrh9pg" + __inner__={ + Object { + "condition": true, + "hidden": undefined, + } + } + className="next-box" + style={ + Object { + "display": "flex", + "flexDirection": "row", + "flexWrap": "nowrap", + "justifyContent": "flex-end", + "msFlexDirection": "column", + "msFlexWrap": "none", + } + } + > + <div + __id="node_dockd5nrh9pf" + __inner__={ + Object { + "condition": true, + "hidden": undefined, + } + } + className="next-pagination next-medium next-normal" + style={Object {}} + > + <div + className="next-pagination-pages" + > + <button + aria-label="上一页,当前第1页" + className="next-btn next-medium next-btn-normal next-pagination-item next-prev" + disabled={true} + onClick={[Function]} + onMouseUp={[Function]} + type="button" + > + <i + className="next-icon next-icon-arrow-left next-xs next-btn-icon next-icon-first next-pagination-icon-prev" + style={Object {}} + /> + <span + className="next-btn-helper" + > + 上一页 + </span> + </button> + <div + className="next-pagination-list" + > + <button + aria-label="第1页,共10页" + className="next-btn next-medium next-btn-normal next-pagination-item next-current" + disabled={false} + onClick={[Function]} + onMouseUp={[Function]} + type="button" + > + <span + className="next-btn-helper" + > + 1 + </span> + </button> + <button + aria-label="第2页,共10页" + className="next-btn next-medium next-btn-normal next-pagination-item" + disabled={false} + onClick={[Function]} + onMouseUp={[Function]} + type="button" + > + <span + className="next-btn-helper" + > + 2 + </span> + </button> + <button + aria-label="第3页,共10页" + className="next-btn next-medium next-btn-normal next-pagination-item" + disabled={false} + onClick={[Function]} + onMouseUp={[Function]} + type="button" + > + <span + className="next-btn-helper" + > + 3 + </span> + </button> + <button + aria-label="第4页,共10页" + className="next-btn next-medium next-btn-normal next-pagination-item" + disabled={false} + onClick={[Function]} + onMouseUp={[Function]} + type="button" + > + <span + className="next-btn-helper" + > + 4 + </span> + </button> + <i + className="next-icon next-icon-ellipsis next-medium next-pagination-ellipsis next-pagination-icon-ellipsis" + style={Object {}} + /> + <button + aria-label="第10页,共10页" + className="next-btn next-medium next-btn-normal next-pagination-item" + disabled={false} + onClick={[Function]} + onMouseUp={[Function]} + type="button" + > + <span + className="next-btn-helper" + > + 10 + </span> + </button> + </div> + <button + aria-label="下一页,当前第1页" + className="next-btn next-medium next-btn-normal next-pagination-item next-next" + disabled={false} + onClick={[Function]} + onMouseUp={[Function]} + type="button" + > + <span + className="next-btn-helper" + > + 下一页 + </span> + <i + className="next-icon next-icon-arrow-right next-xs next-btn-icon next-icon-last next-pagination-icon-next" + style={Object {}} + /> + </button> + <span + className="next-pagination-display" + > + <em> + 1 + </em> + / + 10 + </span> + <span + className="next-pagination-jump-text" + > + 到第 + </span> + <span + className="next-input next-medium next-pagination-jump-input" + > + <input + aria-label="请输入跳转到第几页" + autoComplete="off" + disabled={false} + height="100%" + maxLength={null} + onBlur={[Function]} + onChange={[Function]} + onCompositionEnd={[Function]} + onCompositionStart={[Function]} + onFocus={[Function]} + onKeyDown={[Function]} + readOnly={false} + value="" + /> + </span> + <span + className="next-pagination-jump-text" + > + 页 + </span> + <button + className="next-btn next-medium next-btn-normal next-pagination-jump-go" + disabled={false} + onClick={[Function]} + onMouseUp={[Function]} + type="button" + > + <span + className="next-btn-helper" + > + 确定 + </span> + </button> + </div> + </div> + </div> + </div> + </div> + <span + aria-haspopup={true} + className="next-select next-select-trigger next-select-single next-medium next-inactive next-no-search" + onClick={[Function]} + onKeyDown={[Function]} + onMouseDown={[Function]} + onMouseEnter={[Function]} + onMouseLeave={[Function]} + > + <span + className="next-input next-medium next-select-inner" + > + <span + className="next-select-values next-input-text-field" + > + <span + className="next-select-trigger-search" + > + <input + __id="node_dockd5nrh9pr" + __inner__={ + Object { + "condition": true, + "hidden": undefined, + } + } + autoComplete="off" + disabled={false} + height="100%" + maxLength={null} + name="error" + onBlur={[Function]} + onChange={[Function]} + onCompositionEnd={[Function]} + onCompositionStart={[Function]} + onFocus={[Function]} + onKeyDown={[Function]} + placeholder="请选择" + readOnly={true} + role="combobox" + size="1" + tabIndex={0} + value="" + /> + <span + aria-hidden={true} + > + <span> + 请选择 + </span> + <span + style={ + Object { + "display": "inline-block", + "width": 1, + } + } + > +   + </span> + </span> + </span> + </span> + <span + className="next-input-control" + onClick={[Function]} + > + <span + aria-hidden={true} + className="next-select-arrow" + onClick={[Function]} + > + <i + className="next-icon next-icon-arrow-down next-medium next-select-symbol-fold" + style={Object {}} + /> + </span> + </span> + </span> + <span + aria-live="polite" + className="next-sr-only" + > + + </span> + </span> +</div> +`; + +exports[`JSExpression JSExpression props 1`] = ` +<div + className="lce-page" + style={Object {}} +> + <div + __inner__={ + Object { + "condition": true, + "hidden": undefined, + } + } + className="div-ut" + forwardRef={[Function]} + visible={true} + /> +</div> +`; + +exports[`JSExpression JSExpression props with loop 1`] = ` +<div + className="lce-page" + style={Object {}} +> + <div + __inner__={ + Object { + "condition": true, + "hidden": undefined, + } + } + className="div-ut" + forwardRef={[Function]} + name1="1" + name2="1" + /> + <div + __inner__={ + Object { + "condition": true, + "hidden": undefined, + } + } + className="div-ut" + forwardRef={[Function]} + name1="2" + name2="2" + /> +</div> +`; + +exports[`JSExpression JSExpression props with loop, and thisRequiredInJSE is true 1`] = ` +<div + className="lce-page" + style={Object {}} +> + <div + __inner__={ + Object { + "condition": true, + "hidden": undefined, + } + } + className="div-ut" + forwardRef={[Function]} + name1="1" + /> + <div + __inner__={ + Object { + "condition": true, + "hidden": undefined, + } + } + className="div-ut" + forwardRef={[Function]} + name1="2" + /> +</div> +`; + +exports[`JSExpression JSFunction props 1`] = ` +<div + className="lce-page" + style={Object {}} +> + <div + __inner__={ + Object { + "condition": true, + "hidden": undefined, + } + } + className="div-ut" + forwardRef={[Function]} + onClick={[Function]} + /> +</div> +`; + +exports[`JSExpression JSSlot has loop 1`] = ` +<div + className="lce-page" + style={Object {}} +> + <div + __id="node_ocl1ao1o7w3" + __inner__={ + Object { + "condition": true, + "hidden": false, + } + } + __style__=":root { + padding: 12px; + background: #f2f2f2; + border: 1px solid #ddd; +}" + behavior="NORMAL" + className="div_l1ao7pfc" + customClassName="" + fieldId="div_l1ao7lvq" + forwardRef={[Function]} + useFieldIdAsDomId={false} + > + <div + __id="node_ocl1ao1o7w4" + __inner__={ + Object { + "condition": true, + "hidden": false, + } + } + __style__=":root { + font-size: 14px; + color: #666; +}" + behavior="NORMAL" + className="text_l1ao7pfb" + content="这是一个低代码业务组件~" + fieldId="text_l1ao7lvp" + forwardRef={[Function]} + maxLine={0} + showTitle={false} + > + 这是一个低代码业务组件~ + </div> + </div> + <div + __id="node_ocl1ao1o7w3" + __inner__={ + Object { + "condition": true, + "hidden": false, + } + } + __style__=":root { + padding: 12px; + background: #f2f2f2; + border: 1px solid #ddd; +}" + behavior="NORMAL" + className="div_l1ao7pfc" + customClassName="" + fieldId="div_l1ao7lvq" + forwardRef={[Function]} + useFieldIdAsDomId={false} + > + <div + __id="node_ocl1ao1o7w4" + __inner__={ + Object { + "condition": true, + "hidden": false, + } + } + __style__=":root { + font-size: 14px; + color: #666; +}" + behavior="NORMAL" + className="text_l1ao7pfb" + content="这是一个低代码业务组件~" + fieldId="text_l1ao7lvp" + forwardRef={[Function]} + maxLine={0} + showTitle={false} + > + 这是一个低代码业务组件~ + </div> + </div> + <div + __id="node_ocl1ao1o7w3" + __inner__={ + Object { + "condition": true, + "hidden": false, + } + } + __style__=":root { + padding: 12px; + background: #f2f2f2; + border: 1px solid #ddd; +}" + behavior="NORMAL" + className="div_l1ao7pfc" + customClassName="" + fieldId="div_l1ao7lvq" + forwardRef={[Function]} + useFieldIdAsDomId={false} + > + <div + __id="node_ocl1ao1o7w4" + __inner__={ + Object { + "condition": true, + "hidden": false, + } + } + __style__=":root { + font-size: 14px; + color: #666; +}" + behavior="NORMAL" + className="text_l1ao7pfb" + content="这是一个低代码业务组件~" + fieldId="text_l1ao7lvp" + forwardRef={[Function]} + maxLine={0} + showTitle={false} + > + 这是一个低代码业务组件~ + </div> + </div> +</div> +`; + +exports[`JSExpression base props 1`] = ` +<div + className="lce-page" + style={Object {}} +> + <div + __inner__={ + Object { + "condition": true, + "hidden": undefined, + } + } + className="div-ut" + forwardRef={[Function]} + text="123" + visible={true} + /> +</div> +`; + +exports[`designMode designMode:default 1`] = ` +<div + className="lce-page" + style={Object {}} +> + <div + __inner__={ + Object { + "condition": true, + "hidden": undefined, + } + } + className="div-ut" + forwardRef={[Function]} + > + <div + __inner__={ + Object { + "condition": true, + "hidden": undefined, + } + } + className="div-ut-children" + forwardRef={[Function]} + /> + </div> +</div> +`; diff --git a/packages/renderer-core/tests/renderer/base.test.tsx b/packages/renderer-core/tests/renderer/base.test.tsx new file mode 100644 index 0000000000..3faa2bcf44 --- /dev/null +++ b/packages/renderer-core/tests/renderer/base.test.tsx @@ -0,0 +1,222 @@ + +import React, { Component, createElement, forwardRef, PureComponent, createContext } from 'react'; +const mockGetRenderers = jest.fn(); +const mockGetRuntime = jest.fn(); +const mockParseExpression = jest.fn(); +jest.mock('../../src/adapter', () => { + return { + getRenderers: () => { return mockGetRenderers();}, + getRuntime: () => { return mockGetRuntime();}, + }; +}); +jest.mock('../../src/utils', () => { + const originalUtils = jest.requireActual('../../src/utils'); + return { + ...originalUtils, + parseExpression: (...args) => { mockParseExpression(args);}, + }; +}); + + +import baseRendererFactory from '../../src/renderer/base'; +import { IBaseRendererProps } from '../../src/types'; +import TestRenderer from 'react-test-renderer'; +import components from '../utils/components'; +import schema from '../fixtures/schema/basic'; + + +describe('Base Render factory', () => { + it('customBaseRenderer logic works', () => { + mockGetRenderers.mockReturnValue({BaseRenderer: {}}); + const baseRenderer = baseRendererFactory(); + expect(mockGetRenderers).toBeCalledTimes(1); + expect(baseRenderer).toStrictEqual({}); + mockGetRenderers.mockClear(); + }); +}); + +describe('Base Render methods', () => { + let RendererClass; + const mockRendererFactory = () => { + return class extends Component { + constructor(props: IBaseRendererProps, context: any) { + super(props, context); + } + } + } + beforeEach(() => { + const mockRnederers = { + PageRenderer: mockRendererFactory(), + ComponentRenderer: mockRendererFactory(), + BlockRenderer: mockRendererFactory(), + AddonRenderer: mockRendererFactory(), + TempRenderer: mockRendererFactory(), + DivRenderer: mockRendererFactory(), + }; + mockGetRenderers.mockReturnValue(mockRnederers); + mockGetRuntime.mockReturnValue({ + Component, + createElement, + PureComponent, + createContext, + forwardRef, + }); + RendererClass = baseRendererFactory(); + }) + + afterEach(() => { + mockGetRenderers.mockClear(); + }) + + it('should excute lifecycle.getDerivedStateFromProps when defined', () => { + const mockGetDerivedStateFromProps = { + type: 'JSFunction', + value: 'function() {\n console.log(\'did mount\');\n }', + }; + const mockSchema = schema; + (mockSchema.lifeCycles as any).getDerivedStateFromProps = mockGetDerivedStateFromProps; + + // const originalUtils = jest.requireActual('../../src/utils'); + // mockParseExpression.mockImplementation(originalUtils.parseExpression); + const component = TestRenderer.create( + <RendererClass + __schema={mockSchema} + components={components as any} + thisRequiredInJSE={false} + a='1' + />); + // console.log(component.root.props.a); + // component.update(<RendererClasssnippets + // schema={mockSchema} + // components={components as any} + // thisRequiredInJSE={false} + // a='2' + // />); + // console.log(component.root.props.a); + // expect(mockParseExpression).toHaveBeenCalledWith(mockGetDerivedStateFromProps, expect.anything()) + // test lifecycle.getDerivedStateFromProps is null + + // test lifecycle.getDerivedStateFromProps is JSExpression + + // test lifecycle.getDerivedStateFromProps is JSFunction + + // test lifecycle.getDerivedStateFromProps is function + + }); + + + // it('should excute lifecycle.getSnapshotBeforeUpdate when defined', () => { + // }); + + // it('should excute lifecycle.componentDidMount when defined', () => { + // }); + + // it('should excute lifecycle.componentDidUpdate when defined', () => { + // }); + + // it('should excute lifecycle.componentWillUnmount when defined', () => { + // }); + + // it('should excute lifecycle.componentDidCatch when defined', () => { + // }); + + // it('__executeLifeCycleMethod should work', () => { + // }); + + // it('reloadDataSource should work', () => { + // }); + + // it('shouldComponentUpdate should work', () => { + // }); + + + // it('_getComponentView should work', () => { + // }); + + // it('__bindCustomMethods should work', () => { + // }); + + // it('__generateCtx should work', () => { + // }); + + // it('__parseData should work', () => { + // }); + + // it('__initDataSource should work', () => { + // }); + + // it('__initI18nAPIs should work', () => { + // }); + + // it('__writeCss should work', () => { + // }); + + // it('__render should work', () => { + // }); + + // it('getSchemaChildren should work', () => { + // }); + + // it('__createDom should work', () => { + // }); + + // it('__createVirtualDom should work', () => { + // }); + + // it('__componentHOCs should work', () => { + // }); + + // it('__getSchemaChildrenVirtualDom should work', () => { + // }); + + // it('__getComponentProps should work', () => { + // }); + + // it('__createLoopVirtualDom should work', () => { + // }); + + // it('__designModeIsDesign should work', () => { + // }); + + // it('__parseProps should work', () => { + // }); + + // it('$ should work', () => { + // }); + + // it('__renderContextProvider should work', () => { + // }); + + // it('__renderContextConsumer should work', () => { + // }); + + // it('__getHOCWrappedComponent should work', () => { + // }); + + // it('__renderComp should work', () => { + // }); + + // it('__renderContent should work', () => { + // }); + + // it('__checkSchema should work', () => { + // }); + + // it('requestHandlersMap should work', () => { + // }); + + // it('utils should work', () => { + // }); + + // it('constants should work', () => { + // }); + + // it('history should work', () => { + // }); + + // it('location should work', () => { + // }); + + // it('match should work', () => { + // }); +}); \ No newline at end of file diff --git a/packages/renderer-core/tests/renderer/renderer.test.tsx b/packages/renderer-core/tests/renderer/renderer.test.tsx new file mode 100644 index 0000000000..081cede8ab --- /dev/null +++ b/packages/renderer-core/tests/renderer/renderer.test.tsx @@ -0,0 +1,447 @@ +import React from 'react'; +import renderer from 'react-test-renderer'; +import schema from '../fixtures/schema/basic'; +import '../utils/react-env-init'; +import rendererFactory from '../../src/renderer/renderer'; +import components from '../utils/components'; + +const Renderer = rendererFactory(); + +function getComp(schema, comp = null, others = {}): Promise<{ + component, + inst, +}> { + return new Promise((resolve, reject) => { + const component = renderer.create( + <Renderer + schema={schema} + components={components as any} + {...others} + />); + + const componentInstance = component.root; + + setTimeout(() => { + resolve({ + inst: comp ? componentInstance.findAllByType(comp) : null, + component, + }); + }, 20); + }) +} + +beforeEach(() => { + +}); + +let componentSnapshot; + +afterEach(() => { + if (componentSnapshot) { + let tree = componentSnapshot.toJSON(); + expect(tree).toMatchSnapshot(); + componentSnapshot = null; + } +}); + +describe('Base Render', () => { + it('renderComp', () => { + const content = ( + <Renderer + schema={schema as any} + components={components as any} + />); + const tree = renderer.create(content).toJSON(); + expect(tree).toMatchSnapshot(); + }); +}); + +describe('JSExpression', () => { + it('base props', (done) => { + const schema = { + componentName: 'Page', + props: {}, + children: [ + { + componentName: "Div", + props: { + className: 'div-ut', + text: "123", + visible: true, + } + } + ] + }; + + getComp(schema, components.Div).then(({ component, inst }) => { + expect(inst[0].props.text).toBe('123'); + expect(inst[0].props.visible).toBeTruthy(); + + componentSnapshot = component; + done(); + }); + }); + + it('JSExpression props', (done) => { + const schema = { + componentName: 'Page', + props: {}, + state: { + isShowDialog: true, + }, + children: [ + { + componentName: "Div", + props: { + className: "div-ut", + visible: { + type: 'JSExpression', + value: 'this.state.isShowDialog', + }, + } + } + ] + }; + + getComp(schema, components.Div).then(({ component, inst }) => { + expect(inst[0].props.visible).toBeTruthy(); + componentSnapshot = component; + done(); + }); + }); + + it('JSExpression props with loop', (done) => { + const schema = { + componentName: 'Page', + props: {}, + state: { + isShowDialog: true, + }, + children: [ + { + componentName: "Div", + loop: [ + { + name: '1', + }, + { + name: '2' + } + ], + props: { + className: "div-ut", + name1: { + type: 'JSExpression', + value: 'this.item.name', + }, + name2: { + type: 'JSExpression', + value: 'item.name', + }, + } + } + ] + }; + + getComp(schema, components.Div, { + thisRequiredInJSE: false, + }).then(({ component, inst }) => { + // expect(inst[0].props.visible).toBeTruthy(); + expect(inst.length).toEqual(2); + [1, 2].forEach((i) => { + expect(inst[0].props[`name${i}`]).toBe('1'); + expect(inst[1].props[`name${i}`]).toBe('2'); + }) + componentSnapshot = component; + done(); + }); + }); + + it('JSExpression props with loop, and thisRequiredInJSE is true', (done) => { + const schema = { + componentName: 'Page', + props: {}, + state: { + isShowDialog: true, + }, + children: [ + { + componentName: "Div", + loop: [ + { + name: '1', + }, + { + name: '2' + } + ], + props: { + className: "div-ut", + name1: { + type: 'JSExpression', + value: 'this.item.name', + }, + name2: { + type: 'JSExpression', + value: 'item.name', + }, + } + } + ] + }; + + getComp(schema, components.Div).then(({ component, inst }) => { + expect(inst.length).toEqual(2); + [0, 1].forEach((i) => { + expect(inst[i].props[`name1`]).toBe(i + 1 + ''); + expect(inst[i].props[`name2`]).toBe(undefined); + }) + componentSnapshot = component; + done(); + }); + }); + + // it('JSFunction props with loop', (done) => { + // const schema = { + // componentName: 'Page', + // props: {}, + // state: { + // isShowDialog: true, + // }, + // children: [ + // { + // componentName: "Div", + // loop: [ + // { + // name: '1', + // }, + // { + // name: '2' + // } + // ], + // props: { + // className: "div-ut", + // onClick1: { + // type: 'JSFunction', + // value: '() => this.item.name', + // }, + // onClick2: { + // type: 'JSFunction', + // value: 'function(){ return this.item.name }', + // }, + // onClick3: { + // type: 'JSFunction', + // value: 'function(){ return item.name }', + // }, + // onClick4: { + // type: 'JSFunction', + // value: '() => item.name', + // } + // } + // } + // ] + // }; + + // getComp(schema, components.Div).then(({ component, inst }) => { + // // expect(inst[0].props.visible).toBeTruthy(); + // expect(inst.length).toEqual(2); + // [1, 2, 3, 4].forEach((i) => { + // expect(inst[0].props[`onClick${i}`]()).toBe('1'); + // expect(inst[1].props[`onClick${i}`]()).toBe('2'); + // }) + // componentSnapshot = component; + // done(); + // }); + // }); + + it('JSFunction props', (done) => { + const schema = { + componentName: 'Page', + props: {}, + state: { + isShowDialog: true, + }, + children: [ + { + componentName: "Div", + props: { + className: "div-ut", + onClick: { + type: 'JSFunction', + value: 'function() {return this.state.isShowDialog}', + }, + } + } + ] + }; + + getComp(schema, components.Div).then(({ component, inst }) => { + expect(!!inst[0].props.onClick).toBeTruthy(); + expect(inst[0].props.onClick()).toBeTruthy(); + + componentSnapshot = component; + done(); + }); + }); + + it('JSSlot has loop', (done) => { + const schema = { + componentName: "Page", + props: {}, + children: [ + { + componentName: "SlotComponent", + id: "node_k8bnubvz", + props: { + mobileSlot: { + type: "JSSlot", + title: "mobile容器", + name: "mobileSlot", + value: [ + { + condition: true, + hidden: false, + children: [ + { + condition: true, + hidden: false, + loopArgs: [ + "item", + "index" + ], + isLocked: false, + conditionGroup: "", + componentName: "Text", + id: "node_ocl1ao1o7w4", + title: "", + props: { + maxLine: 0, + showTitle: false, + className: "text_l1ao7pfb", + behavior: "NORMAL", + content: "这是一个低代码业务组件~", + __style__: ":root {\n font-size: 14px;\n color: #666;\n}", + fieldId: "text_l1ao7lvp" + } + } + ], + loop: { + type: "JSExpression", + value: "this.state.content" + }, + loopArgs: [ + "item", + "index" + ], + isLocked: false, + conditionGroup: "", + componentName: "Div", + id: "node_ocl1ao1o7w3", + title: "", + props: { + useFieldIdAsDomId: false, + customClassName: "", + className: "div_l1ao7pfc", + behavior: "NORMAL", + __style__: ":root {\n padding: 12px;\n background: #f2f2f2;\n border: 1px solid #ddd;\n}", + fieldId: "div_l1ao7lvq" + } + } + ] + }, + }, + } + ], + state: { + content: { + type: "JSExpression", + value: "[{}, {}, {}]", + }, + }, + }; + + getComp(schema, components.Div).then(({ component, inst }) => { + expect(inst.length).toBe(3); + componentSnapshot = component; + done(); + }); + }) +}); + +describe("designMode", () => { + it('designMode:default', (done) => { + const schema = { + componentName: 'Page', + props: {}, + children: [ + { + componentName: "Div", + props: { + className: 'div-ut', + children: [ + { + componentName: "Div", + visible: true, + props: { + className: 'div-ut-children', + } + } + ] + } + } + ] + }; + + getComp(schema, components.Div).then(({ component, inst }) => { + expect(inst.length).toBe(2); + expect(inst[0].props.className).toBe('div-ut'); + expect(inst[1].props.className).toBe('div-ut-children'); + componentSnapshot = component; + done(); + }); + }); + it('designMode:design', (done) => { + const schema = { + componentName: 'Page', + props: {}, + children: [ + { + componentName: "Div", + id: '0', + props: { + className: 'div-ut', + children: [ + { + componentName: "Div", + id: 'hiddenId', + hidden: true, + props: { + className: 'div-ut-children', + } + } + ] + } + } + ] + }; + + getComp(schema, components.Div, { + designMode: 'design', + getNode: (id) => { + if (id === 'hiddenId') { + return { + export() { + return { + hidden: true, + }; + } + } + } + } + }).then(({ component, inst }) => { + expect(inst.length).toBe(1); + expect(inst[0].props.className).toBe('div-ut'); + done(); + }); + }); +}) \ No newline at end of file diff --git a/packages/renderer-core/tests/setup.ts b/packages/renderer-core/tests/setup.ts new file mode 100644 index 0000000000..0d51f6bb5a --- /dev/null +++ b/packages/renderer-core/tests/setup.ts @@ -0,0 +1,14 @@ +jest.mock('lodash', () => { + const original = jest.requireActual('lodash'); + + return { + ...original, + debounce: (fn) => (...args: any[]) => fn.apply(this, args), + throttle: (fn) => (...args: any[]) => fn.apply(this, args), + } +}) + +export const mockConsoleWarn = jest.fn(); +console.warn = mockConsoleWarn; + +process.env.NODE_ENV = 'production'; \ No newline at end of file diff --git a/packages/renderer-core/tests/utils/common.test.ts b/packages/renderer-core/tests/utils/common.test.ts new file mode 100644 index 0000000000..13b6908d50 --- /dev/null +++ b/packages/renderer-core/tests/utils/common.test.ts @@ -0,0 +1,463 @@ +import { + isSchema, + isFileSchema, + inSameDomain, + getFileCssName, + isJSSlot, + getValue, + getI18n, + transformArrayToMap, + transformStringToFunction, + isVariable, + capitalizeFirstLetter, + forEach, + isString, + serializeParams, + parseExpression, + parseThisRequiredExpression, + parseI18n, + parseData, +} from '../../src/utils/common'; +import logger from '../../src/utils/logger'; + +describe('test isSchema', () => { + it('should be false when empty value is passed', () => { + expect(isSchema(null)).toBeFalsy(); + expect(isSchema(undefined)).toBeFalsy(); + expect(isSchema('')).toBeFalsy(); + expect(isSchema({})).toBeFalsy(); + }); + + it('should be true when componentName is Leaf or Slot ', () => { + expect(isSchema({ componentName: 'Leaf' })).toBeTruthy(); + expect(isSchema({ componentName: 'Slot' })).toBeTruthy(); + }); + + it('should check each item of an array', () => { + const validArraySchema = [ + { componentName: 'Button', props: {}}, + { componentName: 'Button', props: { type: 'JSExpression' }}, + { componentName: 'Leaf' }, + { componentName: 'Slot'}, + ]; + const invalidArraySchema = [ + ...validArraySchema, + { componentName: 'ComponentWithoutProps'}, + ]; + expect(isSchema(validArraySchema)).toBeTruthy(); + expect(isSchema(invalidArraySchema)).toBeFalsy(); + }); + + it('normal valid schema should contains componentName, and props of type object or JSExpression', () => { + expect(isSchema({ componentName: 'Button', props: {}})).toBeTruthy(); + expect(isSchema({ componentName: 'Button', props: { type: 'JSExpression' }})).toBeTruthy(); + expect(isSchema({ xxxName: 'Button'})).toBeFalsy(); + expect(isSchema({ componentName: 'Button', props: null})).toBeFalsy(); + expect(isSchema({ componentName: 'Button', props: []})).toBeFalsy(); + expect(isSchema({ componentName: 'Button', props: 'props string'})).toBeFalsy(); + }); +}); + +describe('test isFileSchema ', () => { + it('should be false when invalid schema is passed', () => { + expect(isFileSchema({ xxxName: 'Button'})).toBeFalsy(); + expect(isFileSchema({ componentName: 'Button', props: null})).toBeFalsy(); + expect(isFileSchema({ componentName: 'Button', props: []})).toBeFalsy(); + expect(isFileSchema({ componentName: 'Button', props: 'props string'})).toBeFalsy(); + }); + it('should be true only when schema with root named Page || Block || Component is passed', () => { + expect(isFileSchema({ componentName: 'Page', props: {}})).toBeTruthy(); + expect(isFileSchema({ componentName: 'Block', props: {}})).toBeTruthy(); + expect(isFileSchema({ componentName: 'Component', props: {}})).toBeTruthy(); + expect(isFileSchema({ componentName: 'Button', props: {}})).toBeFalsy(); + }); +}); + +describe('test inSameDomain ', () => { + let windowSpy; + + beforeEach(() => { + windowSpy = jest.spyOn(window, "window", "get"); + }); + + afterEach(() => { + windowSpy.mockRestore(); + }); + it('should work', () => { + + windowSpy.mockImplementation(() => ({ + parent: { + location: { + host: "example.com" + }, + }, + location: { + host: "example.com" + } + })); + expect(inSameDomain()).toBeTruthy(); + + windowSpy.mockImplementation(() => ({ + parent: { + location: { + host: "example.com" + }, + }, + location: { + host: "another.com" + } + })); + expect(inSameDomain()).toBeFalsy(); + + windowSpy.mockImplementation(() => ({ + parent: null, + location: { + host: "example.com" + } + })); + + expect(inSameDomain()).toBeFalsy(); + }); +}); + + +describe('test getFileCssName ', () => { + it('should work', () => { + expect(getFileCssName(null)).toBe(undefined); + expect(getFileCssName(undefined)).toBe(undefined); + expect(getFileCssName('')).toBe(undefined); + expect(getFileCssName('FileName')).toBe('lce-file-name'); + expect(getFileCssName('Page1_abc')).toBe('lce-page1_abc'); + }); +}); + + +describe('test isJSSlot ', () => { + it('should work', () => { + expect(isJSSlot(null)).toBeFalsy(); + expect(isJSSlot(undefined)).toBeFalsy(); + expect(isJSSlot('stringValue')).toBeFalsy(); + expect(isJSSlot([1, 2, 3])).toBeFalsy(); + expect(isJSSlot({ type: 'JSSlot' })).toBeTruthy(); + expect(isJSSlot({ type: 'JSBlock' })).toBeTruthy(); + expect(isJSSlot({ type: 'anyOtherType' })).toBeFalsy(); + }); +}); + +describe('test getValue ', () => { + it('should check params', () => { + expect(getValue(null, 'somePath')).toStrictEqual({}); + expect(getValue(undefined, 'somePath')).toStrictEqual({}); + // array is not valid input, return default + expect(getValue([], 'somePath')).toStrictEqual({}); + expect(getValue([], 'somePath', 'aaa')).toStrictEqual('aaa'); + expect(getValue([1, 2, 3], 'somePath', 'aaa')).toStrictEqual('aaa'); + + expect(getValue({}, 'somePath')).toStrictEqual({}); + expect(getValue({}, 'somePath', 'default')).toStrictEqual('default'); + }); + it('should work normally', () => { + // single segment path + expect(getValue({ a: 'aValue' }, 'a')).toStrictEqual('aValue'); + expect(getValue({ a: 'aValue', f:null }, 'f')).toBeNull(); + expect(getValue({ a: { b: 'bValue' } }, 'a.b')).toStrictEqual('bValue'); + expect(getValue({ a: { b: 'bValue', c: { d: 'dValue' } } }, 'a.c.d')).toStrictEqual('dValue'); + expect(getValue({ a: { b: 'bValue', c: { d: 'dValue' } } }, 'e')).toStrictEqual({}); + }); +}); + +describe('test getI18n ', () => { + it('should work', () => { + const messages = { + 'zh-CN': { + 'key1': '啊啊啊', + 'key2': '哈哈哈', + }, + }; + expect(getI18n('keyString', {}, 'zh-CN')).toStrictEqual(''); + expect(getI18n('keyString', {}, 'zh-CN', null)).toStrictEqual(''); + expect(getI18n('keyString', {}, 'en-US', messages)).toStrictEqual(''); + expect(getI18n('key3', {}, 'zh-CN', messages)).toStrictEqual(''); + }); +}); + + +describe('test transformArrayToMap ', () => { + it('should work', () => { + expect(transformArrayToMap([])).toStrictEqual({}); + expect(transformArrayToMap('not a array')).toStrictEqual({}); + expect(transformArrayToMap({'not Array': 1})).toStrictEqual({}); + + let mockArray = [ + { + name: 'jack', + age: 2, + }, + { + name: 'jack', + age: 20, + } + ]; + // test override + expect(transformArrayToMap(mockArray, 'name', true).jack.age).toBe(20); + expect(transformArrayToMap(mockArray, 'name').jack.age).toBe(20); + expect(transformArrayToMap(mockArray, 'name', false).jack.age).toBe(2); + + mockArray = [ + { + name: 'jack', + age: 2, + }, + { + name: 'rose', + age: 20, + } + ]; + // normal case + expect(transformArrayToMap(mockArray, 'name').jack.age).toBe(2); + expect(transformArrayToMap(mockArray, 'name').jack.name).toBe('jack'); + expect(transformArrayToMap(mockArray, 'name').rose.age).toBe(20); + // key not exists + expect(transformArrayToMap(mockArray, 'nameEn')).toStrictEqual({}); + }); +}); + + + +describe('test transformStringToFunction ', () => { + it('should work', () => { + const mockFun = jest.fn(); + expect(transformStringToFunction(mockFun)).toBe(mockFun); + expect(transformStringToFunction(111)).toBe(111); + + let mockFnStr = 'function(){return 111;}'; + let fn = transformStringToFunction(mockFnStr); + expect(fn()).toBe(111); + + mockFnStr = '() => { return 222; }'; + fn = transformStringToFunction(mockFnStr); + expect(fn()).toBe(222); + + mockFnStr = 'function getValue() { return 333; }'; + fn = transformStringToFunction(mockFnStr); + expect(fn()).toBe(333); + + mockFnStr = 'function getValue(aaa) {\ + return aaa; \ + }'; + fn = transformStringToFunction(mockFnStr); + expect(fn(123)).toBe(123); + }); +}); + + +describe('test isVariable ', () => { + it('should work', () => { + expect(isVariable(null)).toBeFalsy(); + expect(isVariable(undefined)).toBeFalsy(); + expect(isVariable([1, 2, 3])).toBeFalsy(); + expect(isVariable({})).toBeFalsy(); + expect(isVariable({ type: 'any other type' })).toBeFalsy(); + expect(isVariable({ type: 'variable' })).toBeTruthy(); + }); +}); + +describe('test capitalizeFirstLetter ', () => { + it('should work', () => { + expect(capitalizeFirstLetter(null)).toBeNull(); + expect(capitalizeFirstLetter()).toBeUndefined(); + expect(capitalizeFirstLetter([1, 2, 3])).toStrictEqual([1, 2, 3]); + expect(capitalizeFirstLetter({ a: 1 })).toStrictEqual({ a: 1 }); + expect(capitalizeFirstLetter('')).toStrictEqual(''); + expect(capitalizeFirstLetter('a')).toStrictEqual('A'); + expect(capitalizeFirstLetter('abcd')).toStrictEqual('Abcd'); + }); +}); + +describe('test forEach ', () => { + it('should work', () => { + const mockFn = jest.fn(); + + forEach(null, mockFn); + expect(mockFn).toBeCalledTimes(0); + + forEach(undefined, mockFn); + expect(mockFn).toBeCalledTimes(0); + + forEach([1, 2, 3], mockFn); + expect(mockFn).toBeCalledTimes(0); + + forEach('stringValue', mockFn); + expect(mockFn).toBeCalledTimes(0); + + forEach({ a: 1, b: 2, c: 3 }, mockFn); + expect(mockFn).toBeCalledTimes(3); + + const mockFn2 = jest.fn(); + forEach({ a: 1 }, mockFn2, { b: 'bbb' }); + expect(mockFn2).toHaveBeenCalledWith(1, 'a'); + + let sum = 0; + const mockFn3 = function(value, key) { sum = value + this.b; }; + forEach({ a: 1 }, mockFn3, { b: 10 }); + expect(sum).toEqual(11); + }); +}); + +describe('test isString ', () => { + it('should work', () => { + expect(isString(123)).toBeFalsy(); + expect(isString([])).toBeFalsy(); + expect(isString({})).toBeFalsy(); + expect(isString(null)).toBeFalsy(); + expect(isString(undefined)).toBeFalsy(); + expect(isString(true)).toBeFalsy(); + expect(isString('111')).toBeTruthy(); + expect(isString(new String('111'))).toBeTruthy(); + }); +}); + +describe('test serializeParams ', () => { + it('should work', () => { + const mockParams = { a: 1, b: 2, c: 'cvalue', d:[1, 'a', {}], e: {e1: 'value1', e2: 'value2'}}; + const result = serializeParams(mockParams); + const decodedParams = decodeURIComponent(result); + expect(result).toBe('a=1&b=2&c=cvalue&d=%5B1%2C%22a%22%2C%7B%7D%5D&e=%7B%22e1%22%3A%22value1%22%2C%22e2%22%3A%22value2%22%7D'); + expect(decodedParams).toBe('a=1&b=2&c=cvalue&d=[1,"a",{}]&e={"e1":"value1","e2":"value2"}'); + }); +}); + +describe('test parseExpression ', () => { + it('can handle JSExpression', () => { + const mockExpression = { + "type": "JSExpression", + "value": "function (params) { return this.scopeValue + params.param1 + 5;}" + }; + const result = parseExpression(mockExpression, { scopeValue: 1 }); + expect(result({ param1: 2 })).toBe((1 + 2 + 5)); + }); + + it('[success] JSExpression handle without this use scopeValue', () => { + const mockExpression = { + "type": "JSExpression", + "value": "state" + }; + const result = parseExpression(mockExpression, { state: 1 }); + expect(result).toBe((1)); + }); + + it('[success] JSExpression handle without this use scopeValue', () => { + const mockExpression = { + "type": "JSExpression", + "value": "this.state" + }; + const result = parseExpression(mockExpression, { state: 1 }); + expect(result).toBe((1)); + }); +}); + +describe('test parseThisRequiredExpression', () => { + it('can handle JSExpression', () => { + const mockExpression = { + "type": "JSExpression", + "value": "function (params) { return this.scopeValue + params.param1 + 5;}" + }; + const result = parseThisRequiredExpression(mockExpression, { scopeValue: 1 }); + expect(result({ param1: 2 })).toBe((1 + 2 + 5)); + }); + + it('[error] JSExpression handle without this use scopeValue', () => { + const mockExpression = { + "type": "JSExpression", + "value": "state.text" + }; + const fn = logger.error = jest.fn(); + parseThisRequiredExpression(mockExpression, { state: { text: 'text' } }); + expect(fn).toBeCalledWith(' parseExpression.error', new ReferenceError('state is not defined'), {"type": "JSExpression", "value": "state.text"}, {"state": {"text": "text"}}); + }); + + it('[success] JSExpression handle without this use scopeValue', () => { + const mockExpression = { + "type": "JSExpression", + "value": "this.state" + }; + const result = parseThisRequiredExpression(mockExpression, { state: 1 }); + expect(result).toBe((1)); + }); +}) + +describe('test parseI18n ', () => { + it('can handle normal parseI18n', () => { + const mockI18n = { + "type": "i18n", + "key": "keyA" + }; + const mockI18nFun = (key) => { return 'hahaha' + key;}; + const result = parseI18n(mockI18n, { i18n: mockI18nFun }); + expect(result).toBe('hahahakeyA'); + }); +}); + +describe('test parseData ', () => { + it('should work when isJSExpression === true', () => { + const mockExpression = { + "type": "JSExpression", + "value": "function (params) { return this.scopeValue + params.param1 + 5;}" + }; + const result = parseData(mockExpression, { scopeValue: 1 }); + expect(result({ param1: 2 })).toBe((1 + 2 + 5)); + }); + it('should work when isI18nData === true', () => { + const mockI18n = { + "type": "i18n", + "key": "keyA" + }; + const mockI18nFun = (key) => { return 'hahaha' + key;}; + const result = parseData(mockI18n, { i18n: mockI18nFun }); + expect(result).toBe('hahahakeyA'); + }); + it('should work when schema is string', () => { + expect(parseData(' this is a normal string, will be trimmed only ')).toStrictEqual('this is a normal string, will be trimmed only'); + }); + + it('should work when schema is array', () => { + const mockData = [ + { + "type": "i18n", + "key": "keyA" + }, + ' this is a normal string, will be trimmed only ', + ]; + + const mockI18nFun = (key) => { return 'hahaha' + key;}; + const result = parseData(mockData, { i18n: mockI18nFun }); + + expect(result[0]).toStrictEqual('hahahakeyA'); + expect(result[1]).toStrictEqual('this is a normal string, will be trimmed only'); + }); + it('should work when schema is function', () => { + const mockFn = function() { return this.a; }; + const result = parseData(mockFn, { a: 111 }); + expect(result()).toBe(111); + }); + it('should work when schema is null or undefined', () => { + expect(parseData(null)).toBe(null); + expect(parseData(undefined)).toBe(undefined); + }); + it('should work when schema is normal object', () => { + expect(parseData({})).toStrictEqual({}); + const mockI18nFun = (key) => { return 'hahaha' + key;}; + const result = parseData({ + key1: { + "type": "i18n", + "key": "keyA" + }, + key2: ' this is a normal string, will be trimmed only ', + __privateKey: 'any value', + }, { i18n: mockI18nFun }); + expect(result.key1).toStrictEqual('hahahakeyA'); + expect(result.key2).toStrictEqual('this is a normal string, will be trimmed only'); + expect(result.__privateKey).toBeUndefined(); + + }); +}); diff --git a/packages/renderer-core/tests/utils/components.tsx b/packages/renderer-core/tests/utils/components.tsx new file mode 100644 index 0000000000..639151612f --- /dev/null +++ b/packages/renderer-core/tests/utils/components.tsx @@ -0,0 +1,38 @@ +import React from 'react'; +import { Box, Breadcrumb, Form, Select, Input, Button, Table, Pagination, Dialog } from '@alifd/next'; + +const Div = ({_leaf, ...rest}: any) => (<div {...rest}>{rest.children}</div>); + +const MiniRenderDiv = ({_leaf, ...rest}: any) => { + return ( + <div {...rest}> + {rest.children} + </div> + ); +}; + +const Text = ({_leaf, ...rest}: any) => (<div {...rest}>{rest.content}</div>); + +const SlotComponent = (props: any) => props.mobileSlot; + +const components = { + Box, + Breadcrumb, + 'Breadcrumb.Item': Breadcrumb.Item, + Form, + 'Form.Item': Form.Item, + Select, + Input, + Button, + 'Button.Group': Button.Group, + Table, + Pagination, + Dialog, + ErrorComponent: Select, + Div, + SlotComponent, + Text, + MiniRenderDiv, +}; + +export default components; diff --git a/packages/renderer-core/tests/utils/data-helper.test.ts b/packages/renderer-core/tests/utils/data-helper.test.ts new file mode 100644 index 0000000000..f4b388ce92 --- /dev/null +++ b/packages/renderer-core/tests/utils/data-helper.test.ts @@ -0,0 +1,559 @@ +// @ts-nocheck +const mockJsonp = jest.fn(); +const mockRequest = jest.fn(); +const mockGet = jest.fn(); +const mockPost = jest.fn(); +jest.mock('../../src/utils/request', () => { + return { + jsonp: (uri, params, headers, otherProps) => { + return new Promise((resolve, reject) => { + resolve(mockJsonp(uri, params, headers, otherProps)); + }); + }, + request: (uri, params, headers, otherProps) => { + return new Promise((resolve, reject) => { + resolve(mockRequest(uri, params, headers, otherProps)); + }); + }, + get: (uri, params, headers, otherProps) => { + return new Promise((resolve, reject) => { + resolve(mockGet(uri, params, headers, otherProps)); + }); + }, + post: (uri, params, headers, otherProps) => { + return new Promise((resolve, reject) => { + resolve(mockPost(uri, params, headers, otherProps)); + }); + }, + }; + }); + +import { DataHelper, doRequest } from '../../src/utils/data-helper'; +import { parseData } from '../../src/utils/common'; + +describe('test DataHelper ', () => { + beforeEach(() => { + jest.resetModules(); + }) + it('can be inited', () => { + const mockHost = {}; + let mockDataSourceConfig = {}; + const mockAppHelper = {}; + const mockParser = (config: any) => parseData(config); + let dataHelper = new DataHelper(mockHost, mockDataSourceConfig, mockAppHelper, mockParser); + + expect(dataHelper).toBeTruthy(); + expect(dataHelper.host).toBe(mockHost); + expect(dataHelper.config).toBe(mockDataSourceConfig); + expect(dataHelper.appHelper).toBe(mockAppHelper); + expect(dataHelper.parser).toBe(mockParser); + + + dataHelper = new DataHelper(mockHost, undefined, mockAppHelper, mockParser); + expect(dataHelper.config).toStrictEqual({}); + expect(dataHelper.ajaxList).toStrictEqual([]); + + mockDataSourceConfig = { + list: [ + { + id: 'ds1', + }, { + id: 'ds2', + }, + ] + }; + dataHelper = new DataHelper(mockHost, mockDataSourceConfig, mockAppHelper, mockParser); + expect(dataHelper.config).toBe(mockDataSourceConfig); + expect(dataHelper.ajaxList.length).toBe(2); + expect(dataHelper.ajaxMap.ds1).toStrictEqual({ + id: 'ds1', + }); + }); + it('should handle generateDataSourceMap properly in constructor', () => { + const mockHost = {}; + let mockDataSourceConfig = {}; + const mockAppHelper = {}; + const mockParser = (config: any) => parseData(config); + let dataHelper = new DataHelper(mockHost, mockDataSourceConfig, mockAppHelper, mockParser); + + // test generateDataSourceMap logic + mockDataSourceConfig = { + list: [ + { + id: 'getInfo', + isInit: true, + type: 'fetch', // fetch/mtop/jsonp/custom + options: { + uri: 'mock/info.json', + method: 'GET', + params: { a: 1 }, + timeout: 5000, + }, + }, { + id: 'postInfo', + isInit: true, + type: 'fetch', + options: { + uri: 'mock/info.json', + method: 'POST', + params: { a: 1 }, + timeout: 5000, + }, + }, + ] + }; + dataHelper = new DataHelper(mockHost, mockDataSourceConfig, mockAppHelper, mockParser); + expect(Object.keys(dataHelper.dataSourceMap).length).toBe(2); + expect(dataHelper.dataSourceMap.getInfo.status).toBe('init'); + expect(typeof dataHelper.dataSourceMap.getInfo.load).toBe('function'); + }); + + it('getInitDataSourseConfigs should work', () => { + const mockHost = {}; + let mockDataSourceConfig = {}; + const mockAppHelper = {}; + const mockParser = (config: any) => parseData(config); + + // test generateDataSourceMap logic + mockDataSourceConfig = { + list: [ + { + id: 'getInfo', + isInit: true, + type: 'fetch', // fetch/mtop/jsonp/custom + options: { + uri: 'mock/info.json', + method: 'GET', + params: { a: 1 }, + timeout: 5000, + }, + }, + { + id: 'postInfo', + isInit: false, + type: 'fetch', + options: { + uri: 'mock/info.json', + method: 'POST', + params: { a: 1 }, + timeout: 5000, + }, + }, + { + id: 'getInfoLater', + isInit: false, + type: 'fetch', + options: { + uri: 'mock/info.json', + method: 'POST', + params: { a: 1 }, + timeout: 5000, + }, + }, + { + id: 'getInfoLater2', + isInit: 'not a valid boolean', + type: 'fetch', + options: { + uri: 'mock/info.json', + method: 'POST', + params: { a: 1 }, + timeout: 5000, + }, + }, + ], + }; + + const dataHelper = new DataHelper(mockHost, mockDataSourceConfig, mockAppHelper, mockParser); + expect(dataHelper.getInitDataSourseConfigs().length).toBe(1); + expect(dataHelper.getInitDataSourseConfigs()[0].id).toBe('getInfo'); + }); + it('util function doRequest should work', () => { + doRequest('jsonp', { + uri: 'https://www.baidu.com', + params: { a: 1 }, + otherStuff1: 'aaa', + }); + expect(mockJsonp).toBeCalled(); + + // test GET + doRequest('fetch', { + uri: 'https://www.baidu.com', + method: 'get', + params: { a: 1 }, + otherStuff1: 'aaa', + }); + expect(mockGet).toBeCalled(); + + mockGet.mockClear(); + doRequest('fetch', { + uri: 'https://www.baidu.com', + method: 'Get', + params: { a: 1 }, + otherStuff1: 'aaa', + }); + expect(mockGet).toBeCalled(); + + mockGet.mockClear(); + doRequest('fetch', { + uri: 'https://www.baidu.com', + method: 'GET', + params: { a: 1 }, + otherStuff1: 'aaa', + }); + expect(mockGet).toBeCalled(); + + mockGet.mockClear(); + + // test POST + doRequest('fetch', { + uri: 'https://www.baidu.com', + method: 'post', + params: { a: 1 }, + otherStuff1: 'aaa', + }); + expect(mockPost).toBeCalled(); + mockPost.mockClear(); + + doRequest('fetch', { + uri: 'https://www.baidu.com', + method: 'POST', + params: { a: 1 }, + otherStuff1: 'aaa', + }); + expect(mockPost).toBeCalled(); + mockPost.mockClear(); + doRequest('fetch', { + uri: 'https://www.baidu.com', + method: 'Post', + params: { a: 1 }, + otherStuff1: 'aaa', + }); + expect(mockPost).toBeCalled(); + mockPost.mockClear(); + + // test default + doRequest('fetch', { + uri: 'https://www.baidu.com', + method: 'whatever', + params: { a: 1 }, + otherStuff1: 'aaa', + }); + expect(mockRequest).toBeCalled(); + mockRequest.mockClear(); + mockGet.mockClear(); + + // method will be GET when not provided + doRequest('fetch', { + uri: 'https://www.baidu.com', + params: { a: 1 }, + otherStuff1: 'aaa', + }); + expect(mockRequest).toBeCalledTimes(0); + expect(mockGet).toBeCalledTimes(1); + + mockRequest.mockClear(); + mockGet.mockClear(); + mockPost.mockClear(); + mockJsonp.mockClear(); + + doRequest('someOtherType', { + uri: 'https://www.baidu.com', + params: { a: 1 }, + otherStuff1: 'aaa', + }); + expect(mockRequest).toBeCalledTimes(0); + expect(mockGet).toBeCalledTimes(0); + expect(mockPost).toBeCalledTimes(0); + expect(mockJsonp).toBeCalledTimes(0); + }); + it('updateDataSourceMap should work', () => { + const mockHost = {}; + const mockDataSourceConfig = { + list: [ + { + id: 'ds1', + }, { + id: 'ds2', + }, + ] + }; + const mockAppHelper = {}; + const mockParser = (config: any) => parseData(config); + const dataHelper = new DataHelper(mockHost, mockDataSourceConfig, mockAppHelper, mockParser); + dataHelper.updateDataSourceMap('ds1', { a: 1 }, null); + expect(dataHelper.dataSourceMap['ds1']).toBeTruthy(); + expect(dataHelper.dataSourceMap['ds1'].data).toStrictEqual({ a: 1 }); + expect(dataHelper.dataSourceMap['ds1'].error).toBeUndefined(); + expect(dataHelper.dataSourceMap['ds1'].status).toBe('loaded'); + dataHelper.updateDataSourceMap('ds2', { b: 2 }, new Error()); + expect(dataHelper.dataSourceMap['ds2']).toBeTruthy(); + expect(dataHelper.dataSourceMap['ds2'].data).toStrictEqual({ b: 2 }); + expect(dataHelper.dataSourceMap['ds2'].status).toBe('error'); + expect(dataHelper.dataSourceMap['ds2'].error).toBeTruthy(); + }); + + it('handleData should work', () => { + const mockHost = { stateA: 'aValue'}; + const mockDataSourceConfig = { + list: [ + { + id: 'fullConfigGet', + isInit: true, + options: { + params: {}, + method: 'GET', + isCors: true, + timeout: 5000, + headers: {}, + uri: 'mock/info.json', + }, + shouldFetch: { + type: 'JSFunction', + value: 'function() { return true; }', + }, + dataHandler: { + type: 'JSFunction', + value: 'function(res) { return res.data; }', + }, + errorHandler: { + type: 'JSFunction', + value: 'function(error) {}', + }, + willFetch: { + type: 'JSFunction', + value: 'function(options) { return options; }', + }, + }, + ] + }; + const mockAppHelper = {}; + const mockParser = (config: any) => parseData(config); + const dataHelper = new DataHelper(mockHost, mockDataSourceConfig, mockAppHelper, mockParser); + // test valid case + let mockDataHandler = { + type: 'JSFunction', + value: 'function(res) { return res.data + \'+\' + this.stateA; }', + }; + let result = dataHelper.handleData('fullConfigGet', mockDataHandler, { data: 'mockDataValue' }, null); + expect(result).toBe('mockDataValue+aValue'); + + // test invalid datahandler + mockDataHandler = { + type: 'not a JSFunction', + value: 'function(res) { return res.data + \'+\' + this.stateA; }', + }; + result = dataHelper.handleData('fullConfigGet', mockDataHandler, { data: 'mockDataValue' }, null); + expect(result).toStrictEqual({ data: 'mockDataValue' }); + + // exception with id + mockDataHandler = { + type: 'JSFunction', + value: 'function(res) { return res.data + \'+\' + JSON.parse({a:1}); }', + }; + result = dataHelper.handleData('fullConfigGet', mockDataHandler, { data: 'mockDataValue' }, null); + expect(result).toBeUndefined(); + + // exception without id + mockDataHandler = { + type: 'JSFunction', + value: 'function(res) { return res.data + \'+\' + JSON.parse({a:1}); }', + }; + result = dataHelper.handleData(null, mockDataHandler, { data: 'mockDataValue' }, null); + expect(result).toBeUndefined(); + }); + + it('updateConfig should work', () => { + const mockHost = { stateA: 'aValue'}; + const mockDataSourceConfig = { + list: [ + { + id: 'ds1', + }, { + id: 'ds2', + }, + { + id: 'fullConfigGet', + isInit: true, + options: { + params: {}, + method: 'GET', + isCors: true, + timeout: 5000, + headers: {}, + uri: 'mock/info.json', + }, + shouldFetch: { + type: 'JSFunction', + value: 'function() { return true; }', + }, + dataHandler: { + type: 'JSFunction', + value: 'function(res) { return res.data; }', + }, + errorHandler: { + type: 'JSFunction', + value: 'function(error) {}', + }, + willFetch: { + type: 'JSFunction', + value: 'function(options) { return options; }', + }, + }, + ] + }; + const mockAppHelper = {}; + const mockParser = (config: any) => parseData(config); + const dataHelper = new DataHelper(mockHost, mockDataSourceConfig, mockAppHelper, mockParser); + + expect(dataHelper.ajaxList.length).toBe(3); + + let updatedConfig = { + list: [ + { + id: 'ds2', + }, + { + id: 'fullConfigGet', + }, + ] + }; + dataHelper.updateConfig(updatedConfig); + + expect(dataHelper.ajaxList.length).toBe(2); + expect(dataHelper.dataSourceMap.ds1).toBeUndefined(); + + updatedConfig = { + list: [ + { + id: 'ds2', + }, + { + id: 'fullConfigGet', + }, + { + id: 'ds3', + }, + ] + }; + dataHelper.updateConfig(updatedConfig); + expect(dataHelper.ajaxList.length).toBe(3); + expect(dataHelper.dataSourceMap.ds3).toBeTruthy(); + }); + + it('getInitData should work', () => { + const mockHost = { stateA: 'aValue'}; + const mockDataSourceConfig = { + list: [ + { + id: 'ds1', + }, { + id: 'ds2', + }, + { + id: 'fullConfigGet', + isInit: true, + type: 'fetch', + options: { + params: {}, + method: 'GET', + isCors: true, + timeout: 5000, + headers: { + headerA: 1, + }, + uri: 'mock/info.json', + }, + shouldFetch: { + type: 'JSFunction', + value: 'function() { return true; }', + }, + dataHandler: { + type: 'JSFunction', + value: 'function(res) { return 123; }', + }, + errorHandler: { + type: 'JSFunction', + value: 'function(error) {}', + }, + willFetch: { + type: 'JSFunction', + value: 'function(options) { return options; }', + }, + }, + ] + }; + const mockAppHelper = {}; + const mockParser = (config: any) => parseData(config); + const dataHelper = new DataHelper(mockHost, mockDataSourceConfig, mockAppHelper, mockParser); + + expect(dataHelper.ajaxList.length).toBe(3); + expect(mockGet).toBeCalledTimes(0); + dataHelper.getInitData().then(res => { + expect(mockGet).toBeCalledTimes(1); + expect(mockGet).toBeCalledWith('mock/info.json', {}, { + headerA: 1, + }, expect.anything()); + mockGet.mockClear(); + }); + }); + + it('getDataSource should work', () => { + const mockHost = { stateA: 'aValue'}; + const mockDataSourceConfig = { + list: [ + { + id: 'ds1', + }, { + id: 'ds2', + }, + { + id: 'fullConfigGet', + isInit: true, + type: 'fetch', + options: { + params: {}, + method: 'GET', + isCors: true, + timeout: 5000, + headers: { + headerA: 1, + }, + uri: 'mock/info.json', + }, + shouldFetch: { + type: 'JSFunction', + value: 'function() { return true; }', + }, + dataHandler: { + type: 'JSFunction', + value: 'function(res) { return 123; }', + }, + errorHandler: { + type: 'JSFunction', + value: 'function(error) {}', + }, + willFetch: { + type: 'JSFunction', + value: 'function(options) { return options; }', + }, + }, + ] + }; + const mockAppHelper = {}; + const mockParser = (config: any) => parseData(config); + const dataHelper = new DataHelper(mockHost, mockDataSourceConfig, mockAppHelper, mockParser); + + expect(dataHelper.ajaxList.length).toBe(3); + expect(mockGet).toBeCalledTimes(0); + const callbackFn = jest.fn(); + dataHelper.getDataSource('fullConfigGet', { param1: 'value1' }, {}, callbackFn).then(res => { + expect(mockGet).toBeCalledTimes(1); + expect(mockGet).toBeCalledWith('mock/info.json', { param1: 'value1' }, { + headerA: 1, + }, expect.anything()); + mockGet.mockClear(); + expect(callbackFn).toBeCalledTimes(1); + }); + }); +}); diff --git a/packages/renderer-core/tests/utils/is-use-loop.test.ts b/packages/renderer-core/tests/utils/is-use-loop.test.ts new file mode 100644 index 0000000000..b0a614f2ee --- /dev/null +++ b/packages/renderer-core/tests/utils/is-use-loop.test.ts @@ -0,0 +1,31 @@ +// @ts-nocheck +import isUseLoop from '../../src/utils/is-use-loop'; + +describe('base test', () => { + it('designMode is true', () => { + expect(isUseLoop([], true)).toBeFalsy(); + expect(isUseLoop([{}], true)).toBeTruthy(); + expect(isUseLoop(null, true)).toBeFalsy(); + expect(isUseLoop(undefined, true)).toBeFalsy(); + expect(isUseLoop(0, true)).toBeFalsy(); + }); + + it('loop is expression', () => { + expect(isUseLoop({ + "type": "JSExpression", + "value": "function() { console.log('componentDidMount'); }" + }, true)).toBeTruthy(); + expect(isUseLoop({ + "type": "JSExpression", + "value": "function() { console.log('componentDidMount'); }" + }, false)).toBeTruthy(); + }); + + it('designMode is false', () => { + expect(isUseLoop([], false)).toBeTruthy(); + expect(isUseLoop([{}], false)).toBeTruthy(); + expect(isUseLoop(null, false)).toBeTruthy(); + expect(isUseLoop(undefined, false)).toBeTruthy(); + expect(isUseLoop(0, false)).toBeTruthy(); + }); +}); diff --git a/packages/renderer-core/tests/utils/node.ts b/packages/renderer-core/tests/utils/node.ts new file mode 100644 index 0000000000..01c6ab507c --- /dev/null +++ b/packages/renderer-core/tests/utils/node.ts @@ -0,0 +1,97 @@ +import { IPublicTypePropChangeOptions } from "@ali/lowcode-designer"; +import EventEmitter from "events"; + +export default class Node { + private emitter: EventEmitter; + schema: any = { + props: {}, + }; + + componentMeta = {}; + + parent; + + hasLoop = () => this._hasLoop; + + id; + + _isRoot: false; + + _hasLoop: false; + + constructor(schema: any, info: any = {}) { + this.emitter = new EventEmitter(); + const { + componentMeta, + parent, + isRoot, + hasLoop, + } = info; + this.schema = { + props: {}, + ...schema, + }; + this.componentMeta = componentMeta || {}; + this.parent = parent; + this.id = schema.id; + this._isRoot = isRoot; + this._hasLoop = hasLoop; + } + + isRoot = () => this._isRoot; + + get isRootNode () { + return this._isRoot; + }; + + // componentMeta() { + // return this.componentMeta; + // } + + // mockLoop() { + // // this.hasLoop = true; + // } + + onChildrenChange(fn: any) { + this.emitter.on('onChildrenChange', fn); + return () => { + this.emitter.off('onChildrenChange', fn); + } + } + + emitChildrenChange() { + this.emitter?.emit('onChildrenChange', {}); + } + + onPropChange(fn: any) { + this.emitter.on('onPropChange', fn); + return () => { + this.emitter.off('onPropChange', fn); + } + } + + emitPropChange(val: IPublicTypePropChangeOptions, skip?: boolean) { + if (!skip) { + this.schema.props = { + ...this.schema.props, + [val.key + '']: val.newValue, + } + } + + this.emitter?.emit('onPropChange', val); + } + + onVisibleChange(fn: any) { + this.emitter.on('onVisibleChange', fn); + return () => { + this.emitter.off('onVisibleChange', fn); + } + } + + emitVisibleChange(val: boolean) { + this.emitter?.emit('onVisibleChange', val); + } + export() { + return this.schema; + } +} \ No newline at end of file diff --git a/packages/renderer-core/test/utils/react-env-init.ts b/packages/renderer-core/tests/utils/react-env-init.ts similarity index 100% rename from packages/renderer-core/test/utils/react-env-init.ts rename to packages/renderer-core/tests/utils/react-env-init.ts diff --git a/packages/renderer-core/tests/utils/request.test.ts b/packages/renderer-core/tests/utils/request.test.ts new file mode 100644 index 0000000000..d0bfeb5aaa --- /dev/null +++ b/packages/renderer-core/tests/utils/request.test.ts @@ -0,0 +1,159 @@ +// @ts-nocheck +const mockSerializeParams = jest.fn(); +jest.mock('../../src/utils/common', () => { + return { + serializeParams: (params) => { + return mockSerializeParams(params); + }, + }; + }); +const mockFetchJsonp = jest.fn(); +jest.mock('fetch-jsonp', () => { + return (uri, otherProps) => { + mockFetchJsonp(uri, otherProps); + return Promise.resolve({ + json: () => { + return Promise.resolve({ data: [1, 2, 3]}); + } , + ok: true, + }); + } +}); + +import { get, post, buildUrl, request, jsonp } from '../../src/utils/request'; + + +describe('test utils/request.ts ', () => { + + it('buildUrl should be working properly', () => { + mockSerializeParams.mockImplementation((params) => { + return 'serializedParams=serializedParams'; + }); + expect(buildUrl('mockDataApi', { a: 1, b: 'a', c: []})).toBe('mockDataApi?serializedParams=serializedParams'); + expect(buildUrl('mockDataApi?existingParamA=valueA', { a: 1, b: 'a', c: []})).toBe('mockDataApi?existingParamA=valueA&serializedParams=serializedParams'); + mockSerializeParams.mockClear(); + + mockSerializeParams.mockImplementation((params) => { + return undefined; + }); + expect(buildUrl('mockDataApi', { a: 1, b: 'a', c: []})).toBe('mockDataApi'); + mockSerializeParams.mockClear(); + }); + + it('request should be working properly', () => { + const fetchMock = jest + .spyOn(global, 'fetch') + .mockImplementation(() => + Promise.resolve({ + json: () => Promise.resolve([]) , + status: 200, + }) + ); + + request('https://someradomurl/api/list', 'GET', {}, {}, {}).then((response) => { + expect(fetchMock).toBeCalledWith('https://someradomurl/api/list', { body: {}, credentials: 'include', headers: {}, method: 'GET'}); + }).catch((error) => { + console.error(error); + }); + + }); + + it('get should be working properly', () => { + const fetchMock = jest + .spyOn(global, 'fetch') + .mockImplementation(() => + Promise.resolve({ + json: () => Promise.resolve([]) , + status: 200, + }) + ); + + get('https://someradomurl/api/list', {}, {}, {}).then((response) => { + expect(fetchMock).toBeCalledWith( + 'https://someradomurl/api/list', + { + body: null, + headers: { Accept: 'application/json' }, + method: 'GET', + credentials: 'include', + }); + }).catch((error) => { + console.error(error); + }); + + }); + + it('post should be working properly', () => { + const fetchMock = jest + .spyOn(global, 'fetch') + .mockImplementation(() => + Promise.resolve({ + json: () => Promise.resolve([]) , + status: 200, + }) + ); + + post('https://someradomurl/api/list', { a: 1, b: 'a', c: [] }, { 'Content-Type': 'application/json' }, {}).then((response) => { + expect(fetchMock).toBeCalledWith( + 'https://someradomurl/api/list', + { + body: '{"a":1,"b":"a","c":[]}', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + method: 'POST', + credentials: 'include', + }); + }).catch((error) => { + console.error(error); + }); + + + post('https://someradomurl/api/list', [ 1, 2, 3, 4 ], {}, {}).then((response) => { + expect(fetchMock).toBeCalledWith( + 'https://someradomurl/api/list', + { + body: '[1,2,3,4]', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/x-www-form-urlencoded', + }, + method: 'POST', + credentials: 'include', + }); + }).catch((error) => { + console.error(error); + }); + + mockSerializeParams.mockImplementation((params) => { + return 'serializedParams=serializedParams'; + }); + post('https://someradomurl/api/list', { a: 1, b: 'a', c: [] }, {}, {}).then((response) => { + expect(fetchMock).toBeCalledWith( + 'https://someradomurl/api/list', + { + body: 'serializedParams=serializedParams', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/x-www-form-urlencoded', + }, + method: 'POST', + credentials: 'include', + }); + mockSerializeParams.mockClear(); + }).catch((error) => { + console.error(error); + }); + + }); + it('jsonp should be working properly', () => { + mockSerializeParams.mockImplementation((params) => { + return 'params'; + }); + jsonp('https://someradomurl/api/list', {}, { otherParam1: '123'}).catch(() => { + expect(mockFetchJsonp).toBeCalledWith('https://someradomurl/api/list?params', { timeout: 5000, otherParam1: '123' }); + mockSerializeParams.mockClear(); + }); + }); +}); diff --git a/packages/shell/build.json b/packages/shell/build.json index bd5cf18dde..3e92600554 100644 --- a/packages/shell/build.json +++ b/packages/shell/build.json @@ -1,5 +1,5 @@ { "plugins": [ - "build-plugin-component" + "@alilc/build-plugin-lce" ] } diff --git a/packages/shell/build.test.json b/packages/shell/build.test.json index dcdc891e93..9cc30d7463 100644 --- a/packages/shell/build.test.json +++ b/packages/shell/build.test.json @@ -1,6 +1,6 @@ { "plugins": [ - "build-plugin-component", + "@alilc/build-plugin-lce", "@alilc/lowcode-test-mate/plugin/index.ts" ] } diff --git a/packages/shell/package.json b/packages/shell/package.json index bbc9d2082c..c2b62e2270 100644 --- a/packages/shell/package.json +++ b/packages/shell/package.json @@ -1,6 +1,6 @@ { "name": "@alilc/lowcode-shell", - "version": "1.0.3", + "version": "1.3.2", "description": "Shell Layer for AliLowCodeEngine", "main": "lib/index.js", "module": "es/index.js", @@ -9,27 +9,24 @@ "es" ], "scripts": { - "build": "build-scripts build --skip-demo", - "test": "build-scripts test --config build.test.json", - "test:cov": "build-scripts test --config build.test.json --jest-coverage" + "build": "build-scripts build" }, "license": "MIT", "dependencies": { - "@alilc/lowcode-designer": "1.0.3", - "@alilc/lowcode-editor-core": "1.0.3", - "@alilc/lowcode-editor-skeleton": "1.0.3", - "@alilc/lowcode-types": "1.0.3", - "@alilc/lowcode-utils": "1.0.3", + "@alilc/lowcode-designer": "1.3.2", + "@alilc/lowcode-editor-core": "1.3.2", + "@alilc/lowcode-editor-skeleton": "1.3.2", + "@alilc/lowcode-types": "1.3.2", + "@alilc/lowcode-utils": "1.3.2", + "@alilc/lowcode-workspace": "1.3.2", "classnames": "^2.2.6", "enzyme": "^3.11.0", "enzyme-adapter-react-16": "^1.15.5", "react": "^16", - "react-dom": "^16.7.0", - "zen-logger": "^1.1.0" + "react-dom": "^16.7.0" }, "devDependencies": { "@alib/build-scripts": "^0.1.29", - "@alilc/lowcode-test-mate": "^1.0.1", "@testing-library/react": "^11.2.2", "@types/classnames": "^2.2.7", "@types/jest": "^26.0.16", @@ -38,9 +35,6 @@ "@types/node": "^13.7.1", "@types/react": "^16", "@types/react-dom": "^16", - "babel-jest": "^26.5.2", - "build-plugin-component": "^0.2.10", - "build-scripts-config": "^0.1.8", "jest": "^26.6.3", "lodash": "^4.17.20", "moment": "^2.29.1", @@ -50,12 +44,11 @@ "access": "public", "registry": "https://registry.npmjs.org/" }, - "resolutions": { - "@builder/babel-preset-ice": "1.0.1" - }, "repository": { "type": "http", "url": "https://github.com/alibaba/lowcode-engine/tree/main/packages/shell" }, - "gitHead": "2669f179e6f899d395ce1942d0fe04f9c5ed48a6" + "gitHead": "2669f179e6f899d395ce1942d0fe04f9c5ed48a6", + "bugs": "https://github.com/alibaba/lowcode-engine/issues", + "homepage": "https://github.com/alibaba/lowcode-engine/#readme" } diff --git a/packages/shell/src/api/canvas.ts b/packages/shell/src/api/canvas.ts new file mode 100644 index 0000000000..48acbc487a --- /dev/null +++ b/packages/shell/src/api/canvas.ts @@ -0,0 +1,82 @@ +import { + IPublicApiCanvas, + IPublicModelDropLocation, + IPublicModelScrollTarget, + IPublicTypeScrollable, + IPublicModelScroller, + IPublicTypeLocationData, + IPublicModelEditor, + IPublicModelDragon, + IPublicModelActiveTracker, + IPublicModelClipboard, +} from '@alilc/lowcode-types'; +import { + ScrollTarget as InnerScrollTarget, + IDesigner, +} from '@alilc/lowcode-designer'; +import { editorSymbol, designerSymbol, nodeSymbol } from '../symbols'; +import { + Dragon as ShellDragon, + DropLocation as ShellDropLocation, + ActiveTracker as ShellActiveTracker, + Clipboard as ShellClipboard, + DropLocation, +} from '../model'; + +const clipboardInstanceSymbol = Symbol('clipboardInstace'); + +export class Canvas implements IPublicApiCanvas { + private readonly [editorSymbol]: IPublicModelEditor; + private readonly [clipboardInstanceSymbol]: IPublicModelClipboard; + + private get [designerSymbol](): IDesigner { + return this[editorSymbol].get('designer') as IDesigner; + } + + get dragon(): IPublicModelDragon | null { + return ShellDragon.create(this[designerSymbol].dragon, this.workspaceMode); + } + + get activeTracker(): IPublicModelActiveTracker | null { + const activeTracker = new ShellActiveTracker(this[designerSymbol].activeTracker); + return activeTracker; + } + + get isInLiveEditing(): boolean { + return Boolean(this[editorSymbol].get('designer')?.project?.simulator?.liveEditing?.editing); + } + + get clipboard(): IPublicModelClipboard { + return this[clipboardInstanceSymbol]; + } + + constructor(editor: IPublicModelEditor, readonly workspaceMode: boolean = false) { + this[editorSymbol] = editor; + this[clipboardInstanceSymbol] = new ShellClipboard(); + } + + createScrollTarget(shell: HTMLDivElement): IPublicModelScrollTarget { + return new InnerScrollTarget(shell); + } + + createScroller(scrollable: IPublicTypeScrollable): IPublicModelScroller { + return this[designerSymbol].createScroller(scrollable); + } + + /** + * 创建插入位置,考虑放到 dragon 中 + */ + createLocation(locationData: IPublicTypeLocationData): IPublicModelDropLocation { + return new DropLocation(this[designerSymbol].createLocation({ + ...locationData, + target: (locationData.target as any)[nodeSymbol], + })); + } + + /** + * @deprecated + */ + get dropLocation() { + return ShellDropLocation.create((this[designerSymbol] as any).dropLocation || null); + } +} diff --git a/packages/shell/src/api/command.ts b/packages/shell/src/api/command.ts new file mode 100644 index 0000000000..ebab4a9ff5 --- /dev/null +++ b/packages/shell/src/api/command.ts @@ -0,0 +1,46 @@ +import { IPublicApiCommand, IPublicModelPluginContext, IPublicTypeCommand, IPublicTypeCommandHandlerArgs, IPublicTypeListCommand } from '@alilc/lowcode-types'; +import { commandSymbol, pluginContextSymbol } from '../symbols'; +import { ICommand, ICommandOptions } from '@alilc/lowcode-editor-core'; + +const optionsSymbol = Symbol('options'); +const commandScopeSet = new Set<string>(); + +export class Command implements IPublicApiCommand { + [commandSymbol]: ICommand; + [optionsSymbol]?: ICommandOptions; + [pluginContextSymbol]?: IPublicModelPluginContext; + + constructor(innerCommand: ICommand, pluginContext?: IPublicModelPluginContext, options?: ICommandOptions) { + this[commandSymbol] = innerCommand; + this[optionsSymbol] = options; + this[pluginContextSymbol] = pluginContext; + const commandScope = options?.commandScope; + if (commandScope && commandScopeSet.has(commandScope)) { + throw new Error(`Command scope "${commandScope}" has been registered.`); + } + } + + registerCommand(command: IPublicTypeCommand): void { + this[commandSymbol].registerCommand(command, this[optionsSymbol]); + } + + batchExecuteCommand(commands: { name: string; args: IPublicTypeCommandHandlerArgs }[]): void { + this[commandSymbol].batchExecuteCommand(commands, this[pluginContextSymbol]); + } + + executeCommand(name: string, args: IPublicTypeCommandHandlerArgs): void { + this[commandSymbol].executeCommand(name, args); + } + + listCommands(): IPublicTypeListCommand[] { + return this[commandSymbol].listCommands(); + } + + unregisterCommand(name: string): void { + this[commandSymbol].unregisterCommand(name); + } + + onCommandError(callback: (name: string, error: Error) => void): void { + this[commandSymbol].onCommandError(callback); + } +} diff --git a/packages/shell/src/api/common.tsx b/packages/shell/src/api/common.tsx new file mode 100644 index 0000000000..8ce07153ad --- /dev/null +++ b/packages/shell/src/api/common.tsx @@ -0,0 +1,468 @@ +import { editorSymbol, skeletonSymbol, designerCabinSymbol, designerSymbol, settingFieldSymbol, editorCabinSymbol, skeletonCabinSymbol } from '../symbols'; +import { + isFormEvent as innerIsFormEvent, + compatibleLegaoSchema as innerCompatibleLegaoSchema, + getNodeSchemaById as innerGetNodeSchemaById, + transactionManager, + isNodeSchema as innerIsNodeSchema, + isDragNodeDataObject as innerIsDragNodeDataObject, + isDragNodeObject as innerIsDragNodeObject, + isDragAnyObject as innerIsDragAnyObject, + isLocationChildrenDetail as innerIsLocationChildrenDetail, + isNode as innerIsNode, + isSettingField, + isSettingField as innerIsSettingField, +} from '@alilc/lowcode-utils'; +import { + IPublicTypeNodeSchema, + IPublicEnumTransitionType, + IPublicEnumTransformStage as InnerTransitionStage, + IPublicApiCommonDesignerCabin, + IPublicApiCommonSkeletonCabin, + IPublicApiCommonUtils, + IPublicApiCommon, + IPublicEnumDragObjectType as InnerDragObjectType, + IPublicTypeLocationDetailType as InnerLocationDetailType, + IPublicApiCommonEditorCabin, + IPublicModelDragon, + IPublicModelSettingField, + IPublicTypeI18nData, +} from '@alilc/lowcode-types'; +import { + SettingField as InnerSettingField, + LiveEditing as InnerLiveEditing, + isShaken as innerIsShaken, + contains as innerContains, + ScrollTarget as InnerScrollTarget, + getConvertedExtraKey as innerGetConvertedExtraKey, + getOriginalExtraKey as innerGetOriginalExtraKey, + IDesigner, + DropLocation as InnerDropLocation, + Designer as InnerDesigner, + Node as InnerNode, + LowCodePluginManager as InnerLowCodePluginManager, + DesignerView as InnerDesignerView, +} from '@alilc/lowcode-designer'; +import { + Skeleton as InnerSkeleton, + createSettingFieldView as innerCreateSettingFieldView, + PopupContext as InnerPopupContext, + PopupPipe as InnerPopupPipe, + Workbench as InnerWorkbench, + SettingsPrimaryPane as InnerSettingsPrimaryPane, + registerDefaults as InnerRegisterDefaults, +} from '@alilc/lowcode-editor-skeleton'; +import { + Editor, + Title as InnerTitle, + Tip as InnerTip, + shallowIntl as innerShallowIntl, + createIntl as innerCreateIntl, + intl as innerIntl, + globalLocale as innerGlobalLocale, + obx as innerObx, + observable as innerObservable, + makeObservable as innerMakeObservable, + untracked as innerUntracked, + computed as innerComputed, + observer as innerObserver, + action as innerAction, + runInAction as innerRunInAction, + engineConfig as innerEngineConfig, + globalContext, +} from '@alilc/lowcode-editor-core'; +import { Dragon as ShellDragon } from '../model'; +import { ReactNode } from 'react'; + +class DesignerCabin implements IPublicApiCommonDesignerCabin { + private readonly [editorSymbol]: Editor; + + /** + * @deprecated + */ + readonly [designerCabinSymbol]: any; + + private get [designerSymbol](): IDesigner { + return this[editorSymbol].get('designer') as IDesigner; + } + + constructor(editor: Editor) { + this[editorSymbol] = editor; + this[designerCabinSymbol] = { + isDragNodeObject: innerIsDragNodeObject, + isDragAnyObject: innerIsDragAnyObject, + isShaken: innerIsShaken, + contains: innerContains, + LocationDetailType: InnerLocationDetailType, + isLocationChildrenDetail: innerIsLocationChildrenDetail, + ScrollTarget: InnerScrollTarget, + isSettingField: innerIsSettingField, + TransformStage: InnerTransitionStage, + SettingField: InnerSettingField, + LiveEditing: InnerLiveEditing, + DragObjectType: InnerDragObjectType, + isDragNodeDataObject: innerIsDragNodeDataObject, + isNode: innerIsNode, + DropLocation: InnerDropLocation, + Designer: InnerDesigner, + Node: InnerNode, + LowCodePluginManager: InnerLowCodePluginManager, + DesignerView: InnerDesignerView, + }; + } + + /** + * 是否是 SettingField 实例 + * @deprecated use same function from @alilc/lowcode-utils directly + */ + isSettingField(obj: any): boolean { + return isSettingField(obj); + } + + /** + * 转换类型枚举对象,包含 init / upgrade / render 等类型 + * [参考](https://github.com/alibaba/lowcode-engine/blob/main/packages/types/src/transform-stage.ts) + * @deprecated use { TransformStage } from '@alilc/lowcode-types' instead + */ + get TransformStage() { + return InnerTransitionStage; + } + + /** + * @deprecated + */ + get SettingField() { + return InnerSettingField; + } + + /** + * @deprecated + */ + get LiveEditing() { + return InnerLiveEditing; + } + + /** + * @deprecated + */ + get DragObjectType() { + return InnerDragObjectType; + } + + /** + * @deprecated + */ + isDragNodeDataObject(obj: any): boolean { + return innerIsDragNodeDataObject(obj); + } + + /** + * @deprecated + */ + isNode(node: any): boolean { + return innerIsNode(node); + } + + /** + * @deprecated please use canvas.dragon + */ + get dragon(): IPublicModelDragon | null { + return ShellDragon.create(this[designerSymbol].dragon, false); + } +} + +class SkeletonCabin implements IPublicApiCommonSkeletonCabin { + private readonly [skeletonSymbol]: InnerSkeleton; + + readonly [skeletonCabinSymbol]: any; + + constructor(skeleton: InnerSkeleton) { + this[skeletonSymbol] = skeleton; + this[skeletonCabinSymbol] = { + Workbench: InnerWorkbench, + createSettingFieldView: this.createSettingFieldView, + PopupContext: InnerPopupContext, + PopupPipe: InnerPopupPipe, + SettingsPrimaryPane: InnerSettingsPrimaryPane, + registerDefaults: InnerRegisterDefaults, + Skeleton: InnerSkeleton, + }; + } + + get Workbench(): any { + const innerSkeleton = this[skeletonSymbol]; + return (props: any) => <InnerWorkbench {...props} skeleton={innerSkeleton} />; + } + + /** + * @deprecated + */ + createSettingFieldView(field: IPublicModelSettingField, fieldEntry: any) { + return innerCreateSettingFieldView((field as any)[settingFieldSymbol] || field, fieldEntry); + } + + /** + * @deprecated + */ + get PopupContext(): any { + return InnerPopupContext; + } + + /** + * @deprecated + */ + get PopupPipe(): any { + return InnerPopupPipe; + } +} + +class Utils implements IPublicApiCommonUtils { + isNodeSchema(data: any): data is IPublicTypeNodeSchema { + return innerIsNodeSchema(data); + } + + isFormEvent(e: KeyboardEvent | MouseEvent): boolean { + return innerIsFormEvent(e); + } + + /** + * @deprecated this is a legacy api, do not use this if not using is already + */ + compatibleLegaoSchema(props: any): any { + return innerCompatibleLegaoSchema(props); + } + + getNodeSchemaById( + schema: IPublicTypeNodeSchema, + nodeId: string, + ): IPublicTypeNodeSchema | undefined { + return innerGetNodeSchemaById(schema, nodeId); + } + + getConvertedExtraKey(key: string): string { + return innerGetConvertedExtraKey(key); + } + + getOriginalExtraKey(key: string): string { + return innerGetOriginalExtraKey(key); + } + + executeTransaction( + fn: () => void, + type: IPublicEnumTransitionType = IPublicEnumTransitionType.REPAINT, + ): void { + transactionManager.executeTransaction(fn, type); + } + + createIntl(instance: string | object): { + intlNode(id: string, params?: object): ReactNode; + intl(id: string, params?: object): string; + getLocale(): string; + setLocale(locale: string): void; + } { + return innerCreateIntl(instance); + } + + intl(data: IPublicTypeI18nData | string, params?: object): any { + return innerIntl(data, params); + } +} + +class EditorCabin implements IPublicApiCommonEditorCabin { + private readonly [editorSymbol]: Editor; + + /** + * @deprecated + */ + readonly [editorCabinSymbol]: any; + + constructor(editor: Editor) { + this[editorSymbol] = editor; + this[editorCabinSymbol] = { + Editor, + globalContext, + runInAction: innerRunInAction, + Title: InnerTitle, + Tip: InnerTip, + shallowIntl: innerShallowIntl, + createIntl: innerCreateIntl, + intl: innerIntl, + createSetterContent: this.createSetterContent.bind(this), + globalLocale: innerGlobalLocale, + obx: innerObx, + action: innerAction, + engineConfig: innerEngineConfig, + observable: innerObservable, + makeObservable: innerMakeObservable, + untracked: innerUntracked, + computed: innerComputed, + observer: innerObserver, + }; + } + + /** + * Title 组件 + * @experimental unstable API, pay extra caution when trying to use this + */ + get Title() { + return InnerTitle; + } + + /** + * Tip 组件 + * @experimental unstable API, pay extra caution when trying to use this + */ + get Tip() { + return InnerTip; + } + + /** + * @deprecated + */ + shallowIntl(data: any): any { + return innerShallowIntl(data); + } + + /** + * @deprecated use common.utils.createIntl instead + */ + createIntl(instance: any): any { + return innerCreateIntl(instance); + } + + /** + * @deprecated + */ + intl(data: any, params?: object): any { + return innerIntl(data, params); + } + + /** + * @deprecated + */ + createSetterContent = (setter: any, props: Record<string, any>): ReactNode => { + const setters = this[editorSymbol].get('setters'); + return setters.createSetterContent(setter, props); + }; + + /** + * @deprecated use common.utils.createIntl instead + */ + get globalLocale(): any { + return innerGlobalLocale; + } + + /** + * @deprecated + */ + get obx() { + return innerObx; + } + + /** + * @deprecated + */ + get action() { + return innerAction; + } + + /** + * @deprecated + */ + get engineConfig() { + return innerEngineConfig; + } + + /** + * @deprecated + */ + get runInAction() { + return innerRunInAction; + } + + /** + * @deprecated + */ + get observable() { + return innerObservable; + } + + /** + * @deprecated + */ + makeObservable(target: any, annotations: any, options: any) { + return innerMakeObservable(target, annotations, options); + } + + /** + * @deprecated + */ + untracked(action: any) { + return innerUntracked(action); + } + + /** + * @deprecated + */ + get computed() { + return innerComputed; + } + + /** + * @deprecated + */ + observer(component: any) { + return innerObserver(component); + } +} + +export class Common implements IPublicApiCommon { + private readonly __designerCabin: any; + private readonly __skeletonCabin: any; + private readonly __editorCabin: any; + private readonly __utils: Utils; + + constructor(editor: Editor, skeleton: InnerSkeleton) { + this.__designerCabin = new DesignerCabin(editor); + this.__skeletonCabin = new SkeletonCabin(skeleton); + this.__editorCabin = new EditorCabin(editor); + this.__utils = new Utils(); + } + + get utils(): any { + return this.__utils; + } + + /** + * 历史原因导致此处设计不合理,慎用。 + * this load of crap will be removed in some future versions, don`t use it. + * @deprecated + */ + get editorCabin(): any { + return this.__editorCabin; + } + + /** + * 历史原因导致此处设计不合理,慎用。 + * this load of crap will be removed in some future versions, don`t use it. + * @deprecated use canvas api instead + */ + get designerCabin(): any { + return this.__designerCabin; + } + + get skeletonCabin(): any { + return this.__skeletonCabin; + } + + /** + * 历史原因导致此处设计不合理,慎用。 + * this load of crap will be removed in some future versions, don`t use it. + * @deprecated use { TransformStage } from '@alilc/lowcode-types' instead + */ + get objects(): any { + return { + TransformStage: InnerTransitionStage, + }; + } +} \ No newline at end of file diff --git a/packages/shell/src/api/commonUI.tsx b/packages/shell/src/api/commonUI.tsx new file mode 100644 index 0000000000..69dd104b2a --- /dev/null +++ b/packages/shell/src/api/commonUI.tsx @@ -0,0 +1,78 @@ +import { IPublicApiCommonUI, IPublicModelPluginContext, IPublicTypeContextMenuAction } from '@alilc/lowcode-types'; +import { + HelpTip, + IEditor, + Tip as InnerTip, + Title as InnerTitle, + } from '@alilc/lowcode-editor-core'; +import { Balloon, Breadcrumb, Button, Card, Checkbox, DatePicker, Dialog, Dropdown, Form, Icon, Input, Loading, Message, Overlay, Pagination, Radio, Search, Select, SplitButton, Step, Switch, Tab, Table, Tree, TreeSelect, Upload, Divider } from '@alifd/next'; +import { ContextMenu } from '../components/context-menu'; +import { editorSymbol } from '../symbols'; +import { ReactElement } from 'react'; + +export class CommonUI implements IPublicApiCommonUI { + [editorSymbol]: IEditor; + + Balloon = Balloon; + Breadcrumb = Breadcrumb; + Button = Button; + Card = Card; + Checkbox = Checkbox; + DatePicker = DatePicker; + Dialog = Dialog; + Dropdown = Dropdown; + Form = Form; + Icon = Icon; + Input = Input; + Loading = Loading as any; + Message = Message; + Overlay = Overlay; + Pagination = Pagination; + Radio = Radio; + Search = Search; + Select = Select; + SplitButton = SplitButton; + Step = Step; + Switch = Switch; + Tab = Tab; + Table = Table; + Tree = Tree; + TreeSelect = TreeSelect; + Upload = Upload; + Divider = Divider; + + ContextMenu: ((props: { + menus: IPublicTypeContextMenuAction[]; + children: React.ReactElement[] | React.ReactElement; + }) => ReactElement) & { + create(menus: IPublicTypeContextMenuAction[], event: MouseEvent | React.MouseEvent): void; + }; + + constructor(editor: IEditor) { + this[editorSymbol] = editor; + + const innerContextMenu = (props: any) => { + const pluginContext: IPublicModelPluginContext = editor.get('pluginContext') as IPublicModelPluginContext; + return <ContextMenu {...props} pluginContext={pluginContext} />; + }; + + innerContextMenu.create = (menus: IPublicTypeContextMenuAction[], event: MouseEvent) => { + const pluginContext: IPublicModelPluginContext = editor.get('pluginContext') as IPublicModelPluginContext; + return ContextMenu.create(pluginContext, menus, event); + }; + + this.ContextMenu = innerContextMenu; + } + + get Tip() { + return InnerTip; + } + + get HelpTip() { + return HelpTip; + } + + get Title() { + return InnerTitle; + } +} diff --git a/packages/shell/src/api/config.ts b/packages/shell/src/api/config.ts new file mode 100644 index 0000000000..d841208780 --- /dev/null +++ b/packages/shell/src/api/config.ts @@ -0,0 +1,39 @@ +import { IPublicModelEngineConfig, IPublicModelPreference, IPublicTypeDisposable } from '@alilc/lowcode-types'; +import { configSymbol } from '../symbols'; +import { IEngineConfig } from '@alilc/lowcode-editor-core'; + +export class Config implements IPublicModelEngineConfig { + private readonly [configSymbol]: IEngineConfig; + + constructor(innerEngineConfig: IEngineConfig) { + this[configSymbol] = innerEngineConfig; + } + + has(key: string): boolean { + return this[configSymbol].has(key); + } + + get(key: string, defaultValue?: any): any { + return this[configSymbol].get(key, defaultValue); + } + + set(key: string, value: any): void { + this[configSymbol].set(key, value); + } + + setConfig(config: { [key: string]: any }): void { + this[configSymbol].setConfig(config); + } + + onceGot(key: string): Promise<any> { + return this[configSymbol].onceGot(key); + } + + onGot(key: string, fn: (data: any) => void): IPublicTypeDisposable { + return this[configSymbol].onGot(key, fn); + } + + getPreference(): IPublicModelPreference { + return this[configSymbol].getPreference(); + } +} diff --git a/packages/shell/src/api/event.ts b/packages/shell/src/api/event.ts new file mode 100644 index 0000000000..f2adca98c1 --- /dev/null +++ b/packages/shell/src/api/event.ts @@ -0,0 +1,88 @@ +import { IEditor, IEventBus } from '@alilc/lowcode-editor-core'; +import { getLogger, isPluginEventName } from '@alilc/lowcode-utils'; +import { IPublicApiEvent, IPublicTypeDisposable } from '@alilc/lowcode-types'; + +const logger = getLogger({ level: 'warn', bizName: 'shell-event' }); + +type EventOptions = { + prefix: string; +}; + +const eventBusSymbol = Symbol('eventBus'); + +export class Event implements IPublicApiEvent { + private readonly [eventBusSymbol]: IEventBus; + private readonly options: EventOptions; + + constructor(eventBus: IEventBus, options: EventOptions, public workspaceMode = false) { + this[eventBusSymbol] = eventBus; + this.options = options; + if (!this.options.prefix) { + logger.warn('prefix is required while initializing Event'); + } + } + + /** + * 监听事件 + * @param event 事件名称 + * @param listener 事件回调 + */ + on(event: string, listener: (...args: any[]) => void): IPublicTypeDisposable { + if (isPluginEventName(event)) { + return this[eventBusSymbol].on(event, listener); + } else { + logger.warn(`fail to monitor on event ${event}, event should have a prefix like 'somePrefix:eventName'`); + return () => {}; + } + } + + /** + * 监听事件,会在其他回调函数之前执行 + * @param event 事件名称 + * @param listener 事件回调 + */ + prependListener(event: string, listener: (...args: any[]) => void): IPublicTypeDisposable { + if (isPluginEventName(event)) { + return this[eventBusSymbol].prependListener(event, listener); + } else { + logger.warn(`fail to prependListener event ${event}, event should have a prefix like 'somePrefix:eventName'`); + return () => {}; + } + } + + /** + * 取消监听事件 + * @param event 事件名称 + * @param listener 事件回调 + */ + off(event: string, listener: (...args: any[]) => void) { + this[eventBusSymbol].off(event, listener); + } + + /** + * 触发事件 + * @param event 事件名称 + * @param args 事件参数 + * @returns + */ + emit(event: string, ...args: any[]) { + if (!this.options.prefix) { + logger.warn('Event#emit has been forbidden while prefix is not specified'); + return; + } + this[eventBusSymbol].emit(`${this.options.prefix}:${event}`, ...args); + } + + /** + * DO NOT USE if u fully understand what this method does. + * @param event + * @param args + */ + __internalEmit__(event: string, ...args: unknown[]) { + this[eventBusSymbol].emit(event, ...args); + } +} + +export function getEvent(editor: IEditor, options: any = { prefix: 'common' }) { + return new Event(editor.eventBus, options); +} diff --git a/packages/shell/src/api/hotkey.ts b/packages/shell/src/api/hotkey.ts new file mode 100644 index 0000000000..4e65844ceb --- /dev/null +++ b/packages/shell/src/api/hotkey.ts @@ -0,0 +1,53 @@ +import { globalContext, Hotkey as InnerHotkey } from '@alilc/lowcode-editor-core'; +import { hotkeySymbol } from '../symbols'; +import { IPublicTypeDisposable, IPublicTypeHotkeyCallback, IPublicTypeHotkeyCallbacks, IPublicApiHotkey } from '@alilc/lowcode-types'; + +const innerHotkeySymbol = Symbol('innerHotkey'); + +export class Hotkey implements IPublicApiHotkey { + private readonly [innerHotkeySymbol]: InnerHotkey; + get [hotkeySymbol](): InnerHotkey { + if (this.workspaceMode) { + return this[innerHotkeySymbol]; + } + const workspace = globalContext.get('workspace'); + if (workspace.isActive) { + return workspace.window.innerHotkey; + } + + return this[innerHotkeySymbol]; + } + + constructor(hotkey: InnerHotkey, readonly workspaceMode: boolean = false) { + this[innerHotkeySymbol] = hotkey; + } + + get callbacks(): IPublicTypeHotkeyCallbacks { + return this[hotkeySymbol].callBacks; + } + + /** + * @deprecated + */ + get callBacks() { + return this.callbacks; + } + + /** + * 绑定快捷键 + * @param combos 快捷键,格式如:['command + s'] 、['ctrl + shift + s'] 等 + * @param callback 回调函数 + * @param action + * @returns + */ + bind( + combos: string[] | string, + callback: IPublicTypeHotkeyCallback, + action?: string, + ): IPublicTypeDisposable { + this[hotkeySymbol].bind(combos, callback, action); + return () => { + this[hotkeySymbol].unbind(combos, callback, action); + }; + } +} \ No newline at end of file diff --git a/packages/shell/src/api/index.ts b/packages/shell/src/api/index.ts new file mode 100644 index 0000000000..79340f6777 --- /dev/null +++ b/packages/shell/src/api/index.ts @@ -0,0 +1,15 @@ +export * from './common'; +export * from './event'; +export * from './hotkey'; +export * from './logger'; +export * from './material'; +export * from './plugins'; +export * from './project'; +export * from './setters'; +export * from './simulator-host'; +export * from './skeleton'; +export * from './canvas'; +export * from './workspace'; +export * from './config'; +export * from './commonUI'; +export * from './command'; \ No newline at end of file diff --git a/packages/shell/src/api/logger.ts b/packages/shell/src/api/logger.ts new file mode 100644 index 0000000000..54fee7a660 --- /dev/null +++ b/packages/shell/src/api/logger.ts @@ -0,0 +1,48 @@ + +import { getLogger } from '@alilc/lowcode-utils'; +import { IPublicApiLogger, ILoggerOptions } from '@alilc/lowcode-types'; + +const innerLoggerSymbol = Symbol('logger'); + +export class Logger implements IPublicApiLogger { + private readonly [innerLoggerSymbol]: any; + + constructor(options: ILoggerOptions) { + this[innerLoggerSymbol] = getLogger(options as any); + } + + /** + * debug info + */ + debug(...args: any | any[]): void { + this[innerLoggerSymbol].debug(...args); + } + + /** + * normal info output + */ + info(...args: any | any[]): void { + this[innerLoggerSymbol].info(...args); + } + + /** + * warning info output + */ + warn(...args: any | any[]): void { + this[innerLoggerSymbol].warn(...args); + } + + /** + * error info output + */ + error(...args: any | any[]): void { + this[innerLoggerSymbol].error(...args); + } + + /** + * normal log output + */ + log(...args: any | any[]): void { + this[innerLoggerSymbol].log(...args); + } +} \ No newline at end of file diff --git a/packages/shell/src/api/material.ts b/packages/shell/src/api/material.ts new file mode 100644 index 0000000000..284b88fbbf --- /dev/null +++ b/packages/shell/src/api/material.ts @@ -0,0 +1,213 @@ +import { globalContext } from '@alilc/lowcode-editor-core'; +import { + IDesigner, + isComponentMeta, +} from '@alilc/lowcode-designer'; +import { IPublicTypeAssetsJson, getLogger } from '@alilc/lowcode-utils'; +import { + IPublicTypeComponentAction, + IPublicTypeComponentMetadata, + IPublicApiMaterial, + IPublicTypeMetadataTransducer, + IPublicModelComponentMeta, + IPublicTypeNpmInfo, + IPublicModelEditor, + IPublicTypeDisposable, + IPublicTypeContextMenuAction, + IPublicTypeContextMenuItem, +} from '@alilc/lowcode-types'; +import { Workspace as InnerWorkspace } from '@alilc/lowcode-workspace'; +import { editorSymbol, designerSymbol } from '../symbols'; +import { ComponentMeta as ShellComponentMeta } from '../model'; +import { ComponentType } from 'react'; + +const logger = getLogger({ level: 'warn', bizName: 'shell-material' }); + +const innerEditorSymbol = Symbol('editor'); +export class Material implements IPublicApiMaterial { + private readonly [innerEditorSymbol]: IPublicModelEditor; + + get [editorSymbol](): IPublicModelEditor { + if (this.workspaceMode) { + return this[innerEditorSymbol]; + } + const workspace: InnerWorkspace = globalContext.get('workspace'); + if (workspace.isActive) { + if (!workspace.window.editor) { + logger.error('Material api 调用时机出现问题,请检查'); + return this[innerEditorSymbol]; + } + return workspace.window.editor; + } + + return this[innerEditorSymbol]; + } + + get [designerSymbol](): IDesigner { + return this[editorSymbol].get('designer')!; + } + + constructor(editor: IPublicModelEditor, readonly workspaceMode: boolean = false) { + this[innerEditorSymbol] = editor; + } + + /** + * 获取组件 map 结构 + */ + get componentsMap(): { [key: string]: IPublicTypeNpmInfo | ComponentType<any> | object } { + return this[designerSymbol].componentsMap; + } + + /** + * 设置「资产包」结构 + * @param assets + * @returns + */ + async setAssets(assets: IPublicTypeAssetsJson) { + return await this[editorSymbol].setAssets(assets); + } + + /** + * 获取「资产包」结构 + * @returns + */ + getAssets(): IPublicTypeAssetsJson | undefined { + return this[editorSymbol].get('assets'); + } + + /** + * 加载增量的「资产包」结构,该增量包会与原有的合并 + * @param incrementalAssets + * @returns + */ + loadIncrementalAssets(incrementalAssets: IPublicTypeAssetsJson) { + return this[designerSymbol].loadIncrementalAssets(incrementalAssets); + } + + /** + * 注册物料元数据管道函数 + * @param transducer + * @param level + * @param id + */ + registerMetadataTransducer = ( + transducer: IPublicTypeMetadataTransducer, + level?: number, + id?: string | undefined, + ) => { + this[designerSymbol].componentActions.registerMetadataTransducer(transducer, level, id); + }; + + /** + * 获取所有物料元数据管道函数 + * @returns + */ + getRegisteredMetadataTransducers() { + return this[designerSymbol].componentActions.getRegisteredMetadataTransducers(); + } + + /** + * 获取指定名称的物料元数据 + * @param componentName + * @returns + */ + getComponentMeta(componentName: string): IPublicModelComponentMeta | null { + const innerMeta = this[designerSymbol].getComponentMeta(componentName); + return ShellComponentMeta.create(innerMeta); + } + + /** + * create an instance of ComponentMeta by given metadata + * @param metadata + * @returns + */ + createComponentMeta(metadata: IPublicTypeComponentMetadata) { + return ShellComponentMeta.create(this[designerSymbol].createComponentMeta(metadata)); + } + + /** + * test if the given object is a ComponentMeta instance or not + * @param obj + * @returns + */ + isComponentMeta(obj: any) { + return isComponentMeta(obj); + } + + /** + * 获取所有已注册的物料元数据 + * @returns + */ + getComponentMetasMap(): Map<string, IPublicModelComponentMeta> { + const map = new Map<string, IPublicModelComponentMeta>(); + const originalMap = this[designerSymbol].getComponentMetasMap(); + for (let componentName of originalMap.keys()) { + map.set(componentName, this.getComponentMeta(componentName)!); + } + return map; + } + + /** + * 在设计器辅助层增加一个扩展 action + * @param action + */ + addBuiltinComponentAction = (action: IPublicTypeComponentAction) => { + this[designerSymbol].componentActions.addBuiltinComponentAction(action); + }; + + /** + * 刷新 componentMetasMap,可触发模拟器里的 components 重新构建 + */ + refreshComponentMetasMap = () => { + this[designerSymbol].refreshComponentMetasMap(); + }; + + /** + * 移除设计器辅助层的指定 action + * @param name + */ + removeBuiltinComponentAction(name: string) { + this[designerSymbol].componentActions.removeBuiltinComponentAction(name); + } + + /** + * 修改已有的设计器辅助层的指定 action + * @param actionName + * @param handle + */ + modifyBuiltinComponentAction( + actionName: string, + handle: (action: IPublicTypeComponentAction) => void, + ) { + this[designerSymbol].componentActions.modifyBuiltinComponentAction(actionName, handle); + } + + /** + * 监听 assets 变化的事件 + * @param fn + */ + onChangeAssets(fn: () => void): IPublicTypeDisposable { + const dispose = [ + // 设置 assets,经过 setAssets 赋值 + this[editorSymbol].onChange('assets', fn), + // 增量设置 assets,经过 loadIncrementalAssets 赋值 + this[editorSymbol].eventBus.on('designer.incrementalAssetsReady', fn), + ]; + + return () => { + dispose.forEach(d => d && d()); + }; + } + + addContextMenuOption(option: IPublicTypeContextMenuAction) { + this[designerSymbol].contextMenuActions.addMenuAction(option); + } + + removeContextMenuOption(name: string) { + this[designerSymbol].contextMenuActions.removeMenuAction(name); + } + + adjustContextMenuLayout(fn: (actions: IPublicTypeContextMenuItem[]) => IPublicTypeContextMenuItem[]) { + this[designerSymbol].contextMenuActions.adjustMenuLayout(fn); + } +} diff --git a/packages/shell/src/api/plugins.ts b/packages/shell/src/api/plugins.ts new file mode 100644 index 0000000000..b6f5e63717 --- /dev/null +++ b/packages/shell/src/api/plugins.ts @@ -0,0 +1,88 @@ +import { + ILowCodePluginManager, +} from '@alilc/lowcode-designer'; +import { globalContext } from '@alilc/lowcode-editor-core'; +import { + IPublicApiPlugins, + IPublicModelPluginInstance, + IPublicTypePlugin, + IPublicTypePluginRegisterOptions, + IPublicTypePreferenceValueType, +} from '@alilc/lowcode-types'; +import { PluginInstance as ShellPluginInstance } from '../model'; +import { pluginsSymbol } from '../symbols'; + +const innerPluginsSymbol = Symbol('plugin'); +export class Plugins implements IPublicApiPlugins { + private readonly [innerPluginsSymbol]: ILowCodePluginManager; + get [pluginsSymbol](): ILowCodePluginManager { + if (this.workspaceMode) { + return this[innerPluginsSymbol]; + } + const workspace = globalContext.get('workspace'); + if (workspace.isActive) { + return workspace.window.innerPlugins; + } + + return this[innerPluginsSymbol]; + } + + constructor(plugins: ILowCodePluginManager, public workspaceMode: boolean = false) { + this[innerPluginsSymbol] = plugins; + } + + async register( + pluginModel: IPublicTypePlugin, + options?: any, + registerOptions?: IPublicTypePluginRegisterOptions, + ): Promise<void> { + await this[pluginsSymbol].register(pluginModel, options, registerOptions); + } + + async init(registerOptions: any) { + await this[pluginsSymbol].init(registerOptions); + } + + getPluginPreference( + pluginName: string, + ): Record<string, IPublicTypePreferenceValueType> | null | undefined { + return this[pluginsSymbol].getPluginPreference(pluginName); + } + + get(pluginName: string): IPublicModelPluginInstance | null { + const instance = this[pluginsSymbol].get(pluginName); + if (instance) { + return new ShellPluginInstance(instance); + } + + return null; + } + + getAll() { + return this[pluginsSymbol].getAll()?.map((d) => new ShellPluginInstance(d)); + } + + has(pluginName: string) { + return this[pluginsSymbol].has(pluginName); + } + + async delete(pluginName: string) { + return await this[pluginsSymbol].delete(pluginName); + } + + toProxy() { + return new Proxy(this, { + get(target, prop, receiver) { + const _target = target[pluginsSymbol]; + if (_target.pluginsMap.has(prop as string)) { + // 禁用态的插件,直接返回 undefined + if (_target.pluginsMap.get(prop as string)!.disabled) { + return undefined; + } + return _target.pluginsMap.get(prop as string)?.toProxy(); + } + return Reflect.get(target, prop, receiver); + }, + }); + } +} diff --git a/packages/shell/src/api/project.ts b/packages/shell/src/api/project.ts new file mode 100644 index 0000000000..f005d0af0c --- /dev/null +++ b/packages/shell/src/api/project.ts @@ -0,0 +1,246 @@ +import { + BuiltinSimulatorHost, + IProject as InnerProject, +} from '@alilc/lowcode-designer'; +import { globalContext } from '@alilc/lowcode-editor-core'; +import { + IPublicTypeRootSchema, + IPublicTypeProjectSchema, + IPublicModelEditor, + IPublicApiProject, + IPublicApiSimulatorHost, + IPublicModelDocumentModel, + IPublicTypePropsTransducer, + IPublicEnumTransformStage, + IPublicTypeDisposable, + IPublicTypeAppConfig, +} from '@alilc/lowcode-types'; +import { DocumentModel as ShellDocumentModel } from '../model'; +import { SimulatorHost } from './simulator-host'; +import { editorSymbol, projectSymbol, simulatorHostSymbol, documentSymbol } from '../symbols'; +import { getLogger } from '@alilc/lowcode-utils'; + +const logger = getLogger({ level: 'warn', bizName: 'shell-project' }); + +const innerProjectSymbol = Symbol('innerProject'); +export class Project implements IPublicApiProject { + private readonly [innerProjectSymbol]: InnerProject; + private [simulatorHostSymbol]: BuiltinSimulatorHost; + get [projectSymbol](): InnerProject { + if (this.workspaceMode) { + return this[innerProjectSymbol]; + } + const workspace = globalContext.get('workspace'); + if (workspace.isActive) { + if (!workspace.window?.innerProject) { + logger.error('project api 调用时机出现问题,请检查'); + return this[innerProjectSymbol]; + } + return workspace.window.innerProject; + } + + return this[innerProjectSymbol]; + } + + get [editorSymbol](): IPublicModelEditor { + return this[projectSymbol]?.designer.editor; + } + + constructor(project: InnerProject, public workspaceMode: boolean = false) { + this[innerProjectSymbol] = project; + } + + static create(project: InnerProject, workspaceMode: boolean = false) { + return new Project(project, workspaceMode); + } + + /** + * 获取当前的 document + * @returns + */ + get currentDocument(): IPublicModelDocumentModel | null { + return this.getCurrentDocument(); + } + + /** + * 获取当前 project 下所有 documents + * @returns + */ + get documents(): IPublicModelDocumentModel[] { + return this[projectSymbol].documents.map((doc) => ShellDocumentModel.create(doc)!); + } + + /** + * 获取模拟器的 host + */ + get simulatorHost(): IPublicApiSimulatorHost | null { + return SimulatorHost.create(this[projectSymbol].simulator as any || this[simulatorHostSymbol]); + } + + /** + * @deprecated use .simulatorHost instead. + */ + get simulator() { + return this.simulatorHost; + } + + /** + * 打开一个 document + * @param doc + * @returns + */ + openDocument(doc?: string | IPublicTypeRootSchema | undefined) { + const documentModel = this[projectSymbol].open(doc); + if (!documentModel) { + return null; + } + return ShellDocumentModel.create(documentModel); + } + + /** + * 创建一个 document + * @param data + * @returns + */ + createDocument(data?: IPublicTypeRootSchema): IPublicModelDocumentModel | null { + const doc = this[projectSymbol].createDocument(data); + return ShellDocumentModel.create(doc); + } + + /** + * 删除一个 document + * @param doc + */ + removeDocument(doc: IPublicModelDocumentModel) { + this[projectSymbol].removeDocument((doc as any)[documentSymbol]); + } + + /** + * 根据 fileName 获取 document + * @param fileName + * @returns + */ + getDocumentByFileName(fileName: string): IPublicModelDocumentModel | null { + const innerDocumentModel = this[projectSymbol].getDocumentByFileName(fileName); + return ShellDocumentModel.create(innerDocumentModel); + } + + /** + * 根据 id 获取 document + * @param id + * @returns + */ + getDocumentById(id: string): IPublicModelDocumentModel | null { + return ShellDocumentModel.create(this[projectSymbol].getDocument(id)); + } + + /** + * 导出 project + * @returns + */ + exportSchema(stage: IPublicEnumTransformStage = IPublicEnumTransformStage.Render) { + return this[projectSymbol].getSchema(stage); + } + + /** + * 导入 project + * @param schema 待导入的 project 数据 + */ + importSchema(schema?: IPublicTypeProjectSchema): void { + this[projectSymbol].load(schema, true); + } + + /** + * 获取当前的 document + * @returns + */ + getCurrentDocument(): IPublicModelDocumentModel | null { + return ShellDocumentModel.create(this[projectSymbol].currentDocument); + } + + /** + * 增加一个属性的管道处理函数 + * @param transducer + * @param stage + */ + addPropsTransducer( + transducer: IPublicTypePropsTransducer, + stage: IPublicEnumTransformStage, + ): void { + this[projectSymbol].designer.addPropsReducer(transducer, stage); + } + + /** + * 绑定删除文档事件 + * @param fn + * @returns + */ + onRemoveDocument(fn: (data: { id: string}) => void): IPublicTypeDisposable { + return this[editorSymbol].eventBus.on( + 'designer.document.remove', + (data: { id: string }) => fn(data), + ); + } + + /** + * 当前 project 内的 document 变更事件 + */ + onChangeDocument(fn: (doc: IPublicModelDocumentModel) => void): IPublicTypeDisposable { + const offFn = this[projectSymbol].onCurrentDocumentChange((originalDoc) => { + fn(ShellDocumentModel.create(originalDoc)!); + }); + if (this[projectSymbol].currentDocument) { + fn(ShellDocumentModel.create(this[projectSymbol].currentDocument)!); + } + return offFn; + } + + /** + * 当前 project 的模拟器 ready 事件 + */ + onSimulatorHostReady(fn: (host: IPublicApiSimulatorHost) => void): IPublicTypeDisposable { + const offFn = this[projectSymbol].onSimulatorReady((simulator: BuiltinSimulatorHost) => { + fn(SimulatorHost.create(simulator)!); + }); + return offFn; + } + + /** + * 当前 project 的渲染器 ready 事件 + */ + onSimulatorRendererReady(fn: () => void): IPublicTypeDisposable { + const offFn = this[projectSymbol].onRendererReady(() => { + fn(); + }); + return offFn; + } + + /** + * 设置多语言语料 + * 数据格式参考 https://github.com/alibaba/lowcode-engine/blob/main/specs/lowcode-spec.md#2434%E5%9B%BD%E9%99%85%E5%8C%96%E5%A4%9A%E8%AF%AD%E8%A8%80%E7%B1%BB%E5%9E%8Baa + * @param value object + * @returns + */ + setI18n(value: object): void { + this[projectSymbol].set('i18n', value); + } + + /** + * 设置项目配置 + * @param value object + * @returns + */ + setConfig<T extends keyof IPublicTypeAppConfig>(key: T, value: IPublicTypeAppConfig[T]): void; + setConfig(value: IPublicTypeAppConfig): void; + setConfig(...params: any[]): void { + if (params.length === 2) { + const oldConfig = this[projectSymbol].get('config'); + this[projectSymbol].set('config', { + ...oldConfig, + [params[0]]: params[1], + }); + } else { + this[projectSymbol].set('config', params[0]); + } + } +} diff --git a/packages/shell/src/api/setters.ts b/packages/shell/src/api/setters.ts new file mode 100644 index 0000000000..b7f2d40ecf --- /dev/null +++ b/packages/shell/src/api/setters.ts @@ -0,0 +1,75 @@ +import { IPublicTypeCustomView, IPublicApiSetters, IPublicTypeRegisteredSetter } from '@alilc/lowcode-types'; +import { ISetters, globalContext, untracked } from '@alilc/lowcode-editor-core'; +import { ReactNode } from 'react'; +import { getLogger } from '@alilc/lowcode-utils'; + +const innerSettersSymbol = Symbol('setters'); +const settersSymbol = Symbol('setters'); + +const logger = getLogger({ level: 'warn', bizName: 'shell-setters' }); + +export class Setters implements IPublicApiSetters { + readonly [innerSettersSymbol]: ISetters; + + get [settersSymbol](): ISetters { + if (this.workspaceMode) { + return this[innerSettersSymbol]; + } + + const workspace = globalContext.get('workspace'); + if (workspace.isActive) { + return untracked(() => { + if (!workspace.window?.innerSetters) { + logger.error('setter api 调用时机出现问题,请检查'); + return this[innerSettersSymbol]; + } + return workspace.window.innerSetters; + }); + } + + return this[innerSettersSymbol]; + } + + constructor(innerSetters: ISetters, readonly workspaceMode = false) { + this[innerSettersSymbol] = innerSetters; + } + + /** + * 获取指定 setter + * @param type + * @returns + */ + getSetter = (type: string) => { + return this[settersSymbol].getSetter(type); + }; + + /** + * 获取已注册的所有 settersMap + * @returns + */ + getSettersMap = (): Map<string, IPublicTypeRegisteredSetter & { + type: string; + }> => { + return this[settersSymbol].getSettersMap(); + }; + + /** + * 注册一个 setter + * @param typeOrMaps + * @param setter + * @returns + */ + registerSetter = ( + typeOrMaps: string | { [key: string]: IPublicTypeCustomView | IPublicTypeRegisteredSetter }, + setter?: IPublicTypeCustomView | IPublicTypeRegisteredSetter | undefined, + ) => { + return this[settersSymbol].registerSetter(typeOrMaps, setter); + }; + + /** + * @deprecated + */ + createSetterContent = (setter: any, props: Record<string, any>): ReactNode => { + return this[settersSymbol].createSetterContent(setter, props); + }; +} diff --git a/packages/shell/src/api/simulator-host.ts b/packages/shell/src/api/simulator-host.ts new file mode 100644 index 0000000000..663ba0c668 --- /dev/null +++ b/packages/shell/src/api/simulator-host.ts @@ -0,0 +1,74 @@ +import { + BuiltinSimulatorHost, +} from '@alilc/lowcode-designer'; +import { simulatorHostSymbol, nodeSymbol } from '../symbols'; +import { IPublicApiSimulatorHost, IPublicModelNode, IPublicModelSimulatorRender } from '@alilc/lowcode-types'; +import { SimulatorRender } from '../model/simulator-render'; + +export class SimulatorHost implements IPublicApiSimulatorHost { + private readonly [simulatorHostSymbol]: BuiltinSimulatorHost; + + constructor(simulator: BuiltinSimulatorHost) { + this[simulatorHostSymbol] = simulator; + } + + static create(host: BuiltinSimulatorHost): IPublicApiSimulatorHost | null { + if (!host) return null; + return new SimulatorHost(host); + } + + /** + * 获取 contentWindow + */ + get contentWindow(): Window | undefined { + return this[simulatorHostSymbol].contentWindow; + } + + /** + * 获取 contentDocument + */ + get contentDocument(): Document | undefined { + return this[simulatorHostSymbol].contentDocument; + } + + get renderer(): IPublicModelSimulatorRender | undefined { + if (this[simulatorHostSymbol].renderer) { + return SimulatorRender.create(this[simulatorHostSymbol].renderer); + } + + return undefined; + } + + /** + * 设置 host 配置值 + * @param key + * @param value + */ + set(key: string, value: any): void { + this[simulatorHostSymbol].set(key, value); + } + + /** + * 获取 host 配置值 + * @param key + * @returns + */ + get(key: string): any { + return this[simulatorHostSymbol].get(key); + } + + /** + * scroll to specific node + * @param node + */ + scrollToNode(node: IPublicModelNode): void { + this[simulatorHostSymbol].scrollToNode((node as any)[nodeSymbol]); + } + + /** + * 触发组件构建,并刷新渲染画布 + */ + rerender(): void { + this[simulatorHostSymbol].rerender(); + } +} diff --git a/packages/shell/src/api/skeleton.ts b/packages/shell/src/api/skeleton.ts new file mode 100644 index 0000000000..c61edf95d0 --- /dev/null +++ b/packages/shell/src/api/skeleton.ts @@ -0,0 +1,257 @@ +import { globalContext } from '@alilc/lowcode-editor-core'; +import { + ISkeleton, + SkeletonEvents, +} from '@alilc/lowcode-editor-skeleton'; +import { skeletonSymbol } from '../symbols'; +import { IPublicApiSkeleton, IPublicModelSkeletonItem, IPublicTypeConfigTransducer, IPublicTypeDisposable, IPublicTypeSkeletonConfig, IPublicTypeWidgetConfigArea } from '@alilc/lowcode-types'; +import { getLogger } from '@alilc/lowcode-utils'; +import { SkeletonItem } from '../model/skeleton-item'; + +const innerSkeletonSymbol = Symbol('skeleton'); + +const logger = getLogger({ level: 'warn', bizName: 'shell-skeleton' }); + +export class Skeleton implements IPublicApiSkeleton { + private readonly [innerSkeletonSymbol]: ISkeleton; + private readonly pluginName: string; + + get [skeletonSymbol](): ISkeleton { + if (this.workspaceMode) { + return this[innerSkeletonSymbol]; + } + const workspace = globalContext.get('workspace'); + if (workspace.isActive) { + if (!workspace.window?.innerSkeleton) { + logger.error('skeleton api 调用时机出现问题,请检查'); + return this[innerSkeletonSymbol]; + } + return workspace.window.innerSkeleton; + } + + return this[innerSkeletonSymbol]; + } + + constructor( + skeleton: ISkeleton, + pluginName: string, + readonly workspaceMode: boolean = false, + ) { + this[innerSkeletonSymbol] = skeleton; + this.pluginName = pluginName; + } + + /** + * 增加一个面板实例 + * @param config + * @param extraConfig + * @returns + */ + add(config: IPublicTypeSkeletonConfig, extraConfig?: Record<string, any>): IPublicModelSkeletonItem | undefined { + const configWithName = { + ...config, + pluginName: this.pluginName, + }; + const item = this[skeletonSymbol].add(configWithName, extraConfig); + if (item) { + return new SkeletonItem(item); + } + } + + /** + * 移除一个面板实例 + * @param config + * @returns + */ + remove(config: IPublicTypeSkeletonConfig): number | undefined { + const { area, name } = config; + const skeleton = this[skeletonSymbol]; + if (!normalizeArea(area)) { + return; + } + skeleton[normalizeArea(area)].container?.remove(name); + } + + getAreaItems(areaName: IPublicTypeWidgetConfigArea): IPublicModelSkeletonItem[] { + return this[skeletonSymbol][normalizeArea(areaName)].container.items?.map(d => new SkeletonItem(d)); + } + + getPanel(name: string) { + const item = this[skeletonSymbol].getPanel(name); + if (!item) { + return; + } + + return new SkeletonItem(item); + } + + /** + * 显示面板 + * @param name + */ + showPanel(name: string) { + this[skeletonSymbol].getPanel(name)?.show(); + } + + /** + * 隐藏面板 + * @param name + */ + hidePanel(name: string) { + this[skeletonSymbol].getPanel(name)?.hide(); + } + + /** + * 显示 widget + * @param name + */ + showWidget(name: string) { + this[skeletonSymbol].getWidget(name)?.show(); + } + + /** + * enable widget + * @param name + */ + enableWidget(name: string) { + this[skeletonSymbol].getWidget(name)?.enable?.(); + } + + /** + * 隐藏 widget + * @param name + */ + hideWidget(name: string) { + this[skeletonSymbol].getWidget(name)?.hide(); + } + + /** + * disable widget,不可点击 + * @param name + */ + disableWidget(name: string) { + this[skeletonSymbol].getWidget(name)?.disable?.(); + } + + /** + * show area + * @param areaName name of area + */ + showArea(areaName: string) { + (this[skeletonSymbol] as any)[areaName]?.show(); + } + + /** + * hide area + * @param areaName name of area + */ + hideArea(areaName: string) { + (this[skeletonSymbol] as any)[areaName]?.hide(); + } + + /** + * 监听 panel 显示事件 + * @param listener + * @returns + */ + onShowPanel(listener: (paneName: string, panel: IPublicModelSkeletonItem) => void): IPublicTypeDisposable { + const { editor } = this[skeletonSymbol]; + editor.eventBus.on(SkeletonEvents.PANEL_SHOW, (name: any, panel: any) => { + listener(name, new SkeletonItem(panel)); + }); + return () => editor.eventBus.off(SkeletonEvents.PANEL_SHOW, listener); + } + + onDisableWidget(listener: (...args: any[]) => void): IPublicTypeDisposable { + const { editor } = this[skeletonSymbol]; + editor.eventBus.on(SkeletonEvents.WIDGET_DISABLE, (name: any, panel: any) => { + listener(name, new SkeletonItem(panel)); + }); + return () => editor.eventBus.off(SkeletonEvents.WIDGET_DISABLE, listener); + } + + onEnableWidget(listener: (...args: any[]) => void): IPublicTypeDisposable { + const { editor } = this[skeletonSymbol]; + editor.eventBus.on(SkeletonEvents.WIDGET_ENABLE, (name: any, panel: any) => { + listener(name, new SkeletonItem(panel)); + }); + return () => editor.eventBus.off(SkeletonEvents.WIDGET_ENABLE, listener); + } + + /** + * 监听 panel 隐藏事件 + * @param listener + * @returns + */ + onHidePanel(listener: (...args: any[]) => void): IPublicTypeDisposable { + const { editor } = this[skeletonSymbol]; + editor.eventBus.on(SkeletonEvents.PANEL_HIDE, (name: any, panel: any) => { + listener(name, new SkeletonItem(panel)); + }); + return () => editor.eventBus.off(SkeletonEvents.PANEL_HIDE, listener); + } + + /** + * 监听 widget 显示事件 + * @param listener + * @returns + */ + onShowWidget(listener: (...args: any[]) => void): IPublicTypeDisposable { + const { editor } = this[skeletonSymbol]; + editor.eventBus.on(SkeletonEvents.WIDGET_SHOW, (name: any, panel: any) => { + listener(name, new SkeletonItem(panel)); + }); + return () => editor.eventBus.off(SkeletonEvents.WIDGET_SHOW, listener); + } + + /** + * 监听 widget 隐藏事件 + * @param listener + * @returns + */ + onHideWidget(listener: (...args: any[]) => void): IPublicTypeDisposable { + const { editor } = this[skeletonSymbol]; + editor.eventBus.on(SkeletonEvents.WIDGET_HIDE, (name: any, panel: any) => { + listener(name, new SkeletonItem(panel)); + }); + return () => editor.eventBus.off(SkeletonEvents.WIDGET_HIDE, listener); + } + + registerConfigTransducer(fn: IPublicTypeConfigTransducer, level: number, id?: string) { + this[skeletonSymbol].registerConfigTransducer(fn, level, id); + } +} + +function normalizeArea(area: IPublicTypeWidgetConfigArea | undefined): 'leftArea' | 'rightArea' | 'topArea' | 'toolbar' | 'mainArea' | 'bottomArea' | 'leftFixedArea' | 'leftFloatArea' | 'stages' | 'subTopArea' { + switch (area) { + case 'leftArea': + case 'left': + return 'leftArea'; + case 'rightArea': + case 'right': + return 'rightArea'; + case 'topArea': + case 'top': + return 'topArea'; + case 'toolbar': + return 'toolbar'; + case 'mainArea': + case 'main': + case 'center': + case 'centerArea': + return 'mainArea'; + case 'bottomArea': + case 'bottom': + return 'bottomArea'; + case 'leftFixedArea': + return 'leftFixedArea'; + case 'leftFloatArea': + return 'leftFloatArea'; + case 'stages': + return 'stages'; + case 'subTopArea': + return 'subTopArea'; + default: + throw new Error(`${area} not supported`); + } +} diff --git a/packages/shell/src/api/workspace.ts b/packages/shell/src/api/workspace.ts new file mode 100644 index 0000000000..f5bc79009f --- /dev/null +++ b/packages/shell/src/api/workspace.ts @@ -0,0 +1,115 @@ +import { IPublicApiWorkspace, IPublicResourceList, IPublicTypeDisposable, IPublicTypeResourceType } from '@alilc/lowcode-types'; +import { IWorkspace } from '@alilc/lowcode-workspace'; +import { resourceSymbol, workspaceSymbol } from '../symbols'; +import { Resource as ShellResource, Window as ShellWindow } from '../model'; +import { Plugins } from './plugins'; +import { Skeleton } from './skeleton'; + +export class Workspace implements IPublicApiWorkspace { + readonly [workspaceSymbol]: IWorkspace; + + constructor(innerWorkspace: IWorkspace) { + this[workspaceSymbol] = innerWorkspace; + } + + get resourceList() { + return this[workspaceSymbol].getResourceList().map((d) => new ShellResource(d)); + } + + setResourceList(resourceList: IPublicResourceList) { + this[workspaceSymbol].setResourceList(resourceList); + } + + onResourceListChange(fn: (resourceList: IPublicResourceList) => void): IPublicTypeDisposable { + return this[workspaceSymbol].onResourceListChange(fn); + } + + get isActive() { + return this[workspaceSymbol].isActive; + } + + get window() { + if (!this[workspaceSymbol].window) { + return null; + } + return new ShellWindow(this[workspaceSymbol].window); + } + + get resourceTypeList() { + return Array.from(this[workspaceSymbol].resourceTypeMap.values()).map((d) => { + const { name: resourceName, type: resourceType } = d; + const { + description, + editorViews, + } = d.resourceTypeModel({} as any, {}); + + return { + resourceName, + resourceType, + description, + editorViews: editorViews.map(d => ( + { + viewName: d.viewName, + viewType: d.viewType || 'editor', + } + )), + }; + }); + } + + onWindowRendererReady(fn: () => void): IPublicTypeDisposable { + return this[workspaceSymbol].onWindowRendererReady(fn); + } + + registerResourceType(resourceTypeModel: IPublicTypeResourceType): void { + this[workspaceSymbol].registerResourceType(resourceTypeModel); + } + + async openEditorWindow(): Promise<void> { + if (typeof arguments[0] === 'string') { + await this[workspaceSymbol].openEditorWindow(arguments[0], arguments[1], arguments[2], arguments[3], arguments[4]); + } else { + await this[workspaceSymbol].openEditorWindowByResource(arguments[0]?.[resourceSymbol], arguments[1]); + } + } + + openEditorWindowById(id: string) { + this[workspaceSymbol].openEditorWindowById(id); + } + + removeEditorWindow() { + if (typeof arguments[0] === 'string') { + this[workspaceSymbol].removeEditorWindow(arguments[0], arguments[1]); + } else { + this[workspaceSymbol].removeEditorWindowByResource(arguments[0]?.[resourceSymbol]); + } + } + + removeEditorWindowById(id: string) { + this[workspaceSymbol].removeEditorWindowById(id); + } + + get plugins() { + return new Plugins(this[workspaceSymbol].plugins, true).toProxy(); + } + + get skeleton() { + return new Skeleton(this[workspaceSymbol].skeleton, 'workspace', true); + } + + get windows() { + return this[workspaceSymbol].windows.map((d) => new ShellWindow(d)); + } + + onChangeWindows(fn: () => void): IPublicTypeDisposable { + return this[workspaceSymbol].onChangeWindows(fn); + } + + onChangeActiveWindow(fn: () => void): IPublicTypeDisposable { + return this[workspaceSymbol].onChangeActiveWindow(fn); + } + + onChangeActiveEditorView(fn: () => void): IPublicTypeDisposable { + return this[workspaceSymbol].onChangeActiveEditorView(fn); + } +} diff --git a/packages/shell/src/canvas.ts b/packages/shell/src/canvas.ts deleted file mode 100644 index 268dbc4a8f..0000000000 --- a/packages/shell/src/canvas.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { Designer } from '@alilc/lowcode-designer'; -import { designerSymbol } from './symbols'; -import DropLocation from './drop-location'; - -export default class Canvas { - private readonly [designerSymbol]: Designer; - - constructor(designer: Designer) { - this[designerSymbol] = designer; - } - - static create(designer: Designer) { - if (!designer) return null; - return new Canvas(designer); - } - - get dropLocation() { - return DropLocation.create(this[designerSymbol].dropLocation || null); - } -} \ No newline at end of file diff --git a/packages/shell/src/component-meta.ts b/packages/shell/src/component-meta.ts deleted file mode 100644 index 59635555a8..0000000000 --- a/packages/shell/src/component-meta.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { - ComponentMeta as InnerComponentMeta, -} from '@alilc/lowcode-designer'; -import { componentMetaSymbol } from './symbols'; - - -export default class ComponentMeta { - private readonly [componentMetaSymbol]: InnerComponentMeta; - - constructor(componentMeta: InnerComponentMeta) { - this[componentMetaSymbol] = componentMeta; - } - - static create(componentMeta: InnerComponentMeta | null) { - if (!componentMeta) return null; - return new ComponentMeta(componentMeta); - } - - /** - * 组件名 - */ - get componentName(): string { - return this[componentMetaSymbol].componentName; - } - - /** - * 是否是「容器型」组件 - */ - get isContainer(): boolean { - return this[componentMetaSymbol].isContainer; - } - - /** - * 是否是最小渲染单元。 - * 当组件需要重新渲染时: - * 若为最小渲染单元,则只渲染当前组件, - * 若不为最小渲染单元,则寻找到上层最近的最小渲染单元进行重新渲染,直至根节点。 - */ - get isMinimalRenderUnit(): boolean { - return this[componentMetaSymbol].isMinimalRenderUnit; - } - - /** - * 是否为「模态框」组件 - */ - get isModal(): boolean { - return this[componentMetaSymbol].isModal; - } - - /** - * 元数据配置 - */ - get configure() { - return this[componentMetaSymbol].configure; - } - - /** - * 标题 - */ - get title() { - return this[componentMetaSymbol].title; - } - - /** - * 图标 - */ - get icon() { - return this[componentMetaSymbol].icon; - } - - /** - * 组件 npm 信息 - */ - get npm() { - return this[componentMetaSymbol].npm; - } - - /** - * 设置 npm 信息 - * @param npm - */ - setNpm(npm: any) { - this[componentMetaSymbol].setNpm(npm); - } - - /** - * 获取元数据 - * @returns - */ - getMetadata() { - return this[componentMetaSymbol].getMetadata(); - } -} diff --git a/packages/shell/src/components/context-menu.tsx b/packages/shell/src/components/context-menu.tsx new file mode 100644 index 0000000000..8c7ab446ba --- /dev/null +++ b/packages/shell/src/components/context-menu.tsx @@ -0,0 +1,72 @@ +import { createContextMenu, parseContextMenuAsReactNode, parseContextMenuProperties } from '@alilc/lowcode-utils'; +import { engineConfig } from '@alilc/lowcode-editor-core'; +import { IPublicModelPluginContext, IPublicTypeContextMenuAction } from '@alilc/lowcode-types'; +import React, { useCallback } from 'react'; + +export function ContextMenu({ children, menus, pluginContext }: { + menus: IPublicTypeContextMenuAction[]; + children: React.ReactElement[] | React.ReactElement; + pluginContext: IPublicModelPluginContext; +}): React.ReactElement<any, string | React.JSXElementConstructor<any>> { + const handleContextMenu = useCallback((event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + + let destroyFn: Function | undefined; + const destroy = () => { + destroyFn?.(); + }; + const children: React.ReactNode[] = parseContextMenuAsReactNode(parseContextMenuProperties(menus, { + destroy, + pluginContext, + }), { pluginContext }); + + if (!children?.length) { + return; + } + + destroyFn = createContextMenu(children, { event }); + }, [menus]); + + if (!engineConfig.get('enableContextMenu')) { + return ( + <>{ children }</> + ); + } + + if (!menus) { + return ( + <>{ children }</> + ); + } + + // 克隆 children 并添加 onContextMenu 事件处理器 + const childrenWithContextMenu = React.Children.map(children, (child) => + React.cloneElement( + child, + { onContextMenu: handleContextMenu }, + )); + + return ( + <>{childrenWithContextMenu}</> + ); +} + +ContextMenu.create = (pluginContext: IPublicModelPluginContext, menus: IPublicTypeContextMenuAction[], event: MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + + const children: React.ReactNode[] = parseContextMenuAsReactNode(parseContextMenuProperties(menus, { + pluginContext, + }), { + pluginContext, + }); + + if (!children?.length) { + return; + } + + return createContextMenu(children, { + event, + }); +}; \ No newline at end of file diff --git a/packages/shell/src/detecting.ts b/packages/shell/src/detecting.ts deleted file mode 100644 index 998636542c..0000000000 --- a/packages/shell/src/detecting.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { - Detecting as InnerDetecting, - DocumentModel as InnerDocumentModel, -} from '@alilc/lowcode-designer'; -import { documentSymbol, detectingSymbol } from './symbols'; - -export default class Detecting { - private readonly [documentSymbol]: InnerDocumentModel; - private readonly [detectingSymbol]: InnerDetecting; - - constructor(document: InnerDocumentModel) { - this[documentSymbol] = document; - this[detectingSymbol] = document.designer.detecting; - } - - /** - * hover 指定节点 - * @param id 节点 id - */ - capture(id: string) { - this[detectingSymbol].capture(this[documentSymbol].getNode(id)); - } - - /** - * hover 离开指定节点 - * @param id 节点 id - */ - release(id: string) { - this[detectingSymbol].release(this[documentSymbol].getNode(id)); - } - - /** - * 清空 hover 态 - */ - leave() { - this[detectingSymbol].leave(this[documentSymbol]); - } -} \ No newline at end of file diff --git a/packages/shell/src/document-model.ts b/packages/shell/src/document-model.ts deleted file mode 100644 index 552da0bbee..0000000000 --- a/packages/shell/src/document-model.ts +++ /dev/null @@ -1,241 +0,0 @@ -import { Editor } from '@alilc/lowcode-editor-core'; -import { - DocumentModel as InnerDocumentModel, - Node as InnerNode, - ParentalNode, - IOnChangeOptions as InnerIOnChangeOptions, - PropChangeOptions as InnerPropChangeOptions, -} from '@alilc/lowcode-designer'; -import { TransformStage, RootSchema, NodeSchema, NodeData, GlobalEvent } from '@alilc/lowcode-types'; -import Node from './node'; -import Selection from './selection'; -import Detecting from './detecting'; -import History from './history'; -import Project from './project'; -import Prop from './prop'; -import Canvas from './canvas'; -import ModalNodesManager from './modal-nodes-manager'; -import { documentSymbol, editorSymbol, nodeSymbol } from './symbols'; - -type IOnChangeOptions = { - type: string; - node: Node; -}; - -type PropChangeOptions = { - key?: string | number; - prop?: Prop; - node: Node; - newValue: any; - oldValue: any; -}; - -export default class DocumentModel { - private readonly [documentSymbol]: InnerDocumentModel; - private readonly [editorSymbol]: Editor; - public selection: Selection; - public detecting: Detecting; - public history: History; - public canvas: Canvas; - - constructor(document: InnerDocumentModel) { - this[documentSymbol] = document; - this[editorSymbol] = document.designer.editor as Editor; - this.selection = new Selection(document); - this.detecting = new Detecting(document); - this.history = new History(document.getHistory()); - this.canvas = new Canvas(document.designer); - } - - static create(document: InnerDocumentModel | undefined | null) { - if (document == undefined) return null; - return new DocumentModel(document); - } - - /** - * 获取当前文档所属的 project - * @returns - */ - get project() { - return Project.create(this[documentSymbol].project); - } - - /** - * 获取文档的根节点 - * @returns - */ - get root(): Node | null { - return Node.create(this[documentSymbol].getRoot()); - } - - /** - * 获取文档下所有节点 - * @returns - */ - get nodesMap() { - const map = new Map<string, Node>(); - for (let id of this[documentSymbol].nodesMap.keys()) { - map.set(id, this.getNodeById(id)!); - } - return map; - } - - /** - * 模态节点管理 - */ - get modalNodesManager() { - return ModalNodesManager.create(this[documentSymbol].modalNodesManager); - } - - /** - * 根据 nodeId 返回 Node 实例 - * @param nodeId - * @returns - */ - getNodeById(nodeId: string) { - return Node.create(this[documentSymbol].getNode(nodeId)); - } - - /** - * 导入 schema - * @param schema - */ - importSchema(schema: RootSchema) { - this[documentSymbol].import(schema); - } - - /** - * 导出 schema - * @param stage - * @returns - */ - exportSchema(stage: TransformStage = TransformStage.Render) { - return this[documentSymbol].export(stage); - } - - /** - * 插入节点 - * @param parent - * @param thing - * @param at - * @param copy - * @returns - */ - insertNode( - parent: Node, - thing: Node, - at?: number | null | undefined, - copy?: boolean | undefined, - ) { - const node = this[documentSymbol].insertNode( - parent[nodeSymbol] as any, - thing?.[nodeSymbol], - at, - copy, - ); - return Node.create(node); - } - - /** - * 创建一个节点 - * @param data - * @returns - */ - createNode(data: any) { - return Node.create(this[documentSymbol].createNode(data)); - } - - /** - * 移除指定节点/节点id - * @param idOrNode - */ - removeNode(idOrNode: string | Node) { - this[documentSymbol].removeNode(idOrNode as any); - } - - /** - * 当前 document 新增节点事件 - */ - onAddNode(fn: (node: Node) => void) { - this[documentSymbol].onNodeCreate((node: InnerNode) => { - fn(Node.create(node)!); - }); - } - - /** - * 当前 document 删除节点事件 - */ - onRemoveNode(fn: (node: Node) => void) { - this[documentSymbol].onNodeDestroy((node: InnerNode) => { - fn(Node.create(node)!); - }); - } - - /** - * 当前 document 的 hover 变更事件 - */ - onChangeDetecting(fn: (node: Node) => void) { - this[documentSymbol].designer.detecting.onDetectingChange((node: InnerNode) => { - fn(Node.create(node)!); - }); - } - - /** - * 当前 document 的选中变更事件 - */ - onChangeSelection(fn: (ids: string[]) => void) { - this[documentSymbol].selection.onSelectionChange((ids: string[]) => { - fn(ids); - }); - } - - /** - * 当前 document 的节点显隐状态变更事件 - * @param fn - */ - onChangeNodeVisible(fn: (node: Node, visible: boolean) => void) { - // TODO: history 变化时需要重新绑定 - this[documentSymbol].nodesMap.forEach((node) => { - node.onVisibleChange((flag: boolean) => { - fn(Node.create(node)!, flag); - }); - }); - } - - /** - * 当前 document 的节点 children 变更事件 - * @param fn - */ - onChangeNodeChildren(fn: (info?: IOnChangeOptions) => void) { - // TODO: history 变化时需要重新绑定 - this[documentSymbol].nodesMap.forEach((node) => { - node.onChildrenChange((info?: InnerIOnChangeOptions) => { - return info - ? fn({ - type: info.type, - node: Node.create(node)!, - }) - : fn(); - }); - }); - } - - /** - * 当前 document 节点属性修改事件 - * @param fn - */ - onChangeNodeProp(fn: (info: PropChangeOptions) => void) { - this[editorSymbol].on( - GlobalEvent.Node.Prop.InnerChange, - (info: GlobalEvent.Node.Prop.ChangeOptions) => { - fn({ - key: info.key, - oldValue: info.oldValue, - newValue: info.newValue, - prop: Prop.create(info.prop)!, - node: Node.create(info.node as any)!, - }); - }, - ); - } -} diff --git a/packages/shell/src/dragon.ts b/packages/shell/src/dragon.ts deleted file mode 100644 index b3d770fb46..0000000000 --- a/packages/shell/src/dragon.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { - Dragon as InnerDragon, - DragNodeDataObject, -} from '@alilc/lowcode-designer'; -import { dragonSymbol } from './symbols'; - -export default class Dragon { - private readonly [dragonSymbol]: InnerDragon; - - constructor(dragon: InnerDragon) { - this[dragonSymbol] = dragon; - } - - static create(dragon: InnerDragon | null) { - if (!dragon) return null; - return new Dragon(dragon); - } - - /** - * 绑定 dragstart 事件 - * @param func - * @returns - */ - onDragstart(func: (/* e: LocateEvent */) => any) { - // TODO: 补充必要参数 - return this[dragonSymbol].onDragstart(() => func()); - } - - /** - * 绑定 drag 事件 - * @param func - * @returns - */ - onDrag(func: (/* e: LocateEvent */) => any) { - // TODO: 补充必要参数 - return this[dragonSymbol].onDrag(() => func()); - } - - /** - * 绑定 dragend 事件 - * @param func - * @returns - */ - onDragend(func: (/* e: LocateEvent */) => any) { - // TODO: 补充必要参数 - return this[dragonSymbol].onDragend(() => func()); - } - - /** - * 设置拖拽监听的区域 shell,以及自定义拖拽转换函数 boost - * @param shell 拖拽监听的区域 - * @param boost 拖拽转换函数 - */ - from(shell: Element, boost: (e: MouseEvent) => DragNodeDataObject | null) { - return this[dragonSymbol].from(shell, boost); - } -} \ No newline at end of file diff --git a/packages/shell/src/drop-location.ts b/packages/shell/src/drop-location.ts deleted file mode 100644 index 14eff23edb..0000000000 --- a/packages/shell/src/drop-location.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { - DropLocation as InnerDropLocation, -} from '@alilc/lowcode-designer'; -import { dropLocationSymbol } from './symbols'; -import Node from './node'; - -export default class DropLocation { - private readonly [dropLocationSymbol]: InnerDropLocation; - - constructor(dropLocation: InnerDropLocation) { - this[dropLocationSymbol] = dropLocation; - } - - static create(dropLocation: InnerDropLocation | null) { - if (!dropLocation) return null; - return new DropLocation(dropLocation); - } - - get target() { - return Node.create(this[dropLocationSymbol].target); - } -} diff --git a/packages/shell/src/event.ts b/packages/shell/src/event.ts deleted file mode 100644 index 7dc9881078..0000000000 --- a/packages/shell/src/event.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { Editor as InnerEditor, globalContext } from '@alilc/lowcode-editor-core'; -import { getLogger } from '@alilc/lowcode-utils'; -import { editorSymbol } from './symbols'; - -const logger = getLogger({ level: 'warn', bizName: 'shell:event' }); - -type EventOptions = { - prefix: string; -}; - -export default class Event { - private readonly [editorSymbol]: InnerEditor; - private readonly options: EventOptions; - - // TODO: - /** - * 内核触发的事件名 - */ - readonly names = []; - - constructor(editor: InnerEditor, options: EventOptions) { - this[editorSymbol] = editor; - this.options = options; - if (!this.options.prefix) { - logger.warn('prefix is required while initializing Event'); - } - } - - /** - * 监听事件 - * @param event 事件名称 - * @param listener 事件回调 - */ - on(event: string, listener: (...args: unknown[]) => void) { - if (event.startsWith('designer')) { - logger.warn('designer events are disabled'); - return; - } - this[editorSymbol].on(event, listener); - } - - /** - * 取消监听事件 - * @param event 事件名称 - * @param listener 事件回调 - */ - off(event: string, listener: (...args: unknown[]) => void) { - this[editorSymbol].off(event, listener); - } - - /** - * 触发事件 - * @param event 事件名称 - * @param args 事件参数 - * @returns - */ - emit(event: string, ...args: unknown[]) { - if (!this.options.prefix) { - logger.warn('Event#emit has been forbidden while prefix is not specified'); - return; - } - this[editorSymbol].emit(`${this.options.prefix}:${event}`, ...args); - } -} - -export function getEvent(editor: InnerEditor, options: any = { prefix: 'common' }) { - return new Event(editor, options); -} diff --git a/packages/shell/src/history.ts b/packages/shell/src/history.ts deleted file mode 100644 index debc0ccef9..0000000000 --- a/packages/shell/src/history.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { History as InnerHistory, DocumentModel as InnerDocumentModel } from '@alilc/lowcode-designer'; -import { historySymbol } from './symbols'; - -export default class History { - private readonly [historySymbol]: InnerHistory; - - constructor(history: InnerHistory) { - this[historySymbol] = history; - } - - /** - * 历史记录跳转到指定位置 - * @param cursor - */ - go(cursor: number) { - this[historySymbol].go(cursor); - } - - /** - * 历史记录后退 - */ - back() { - this[historySymbol].back(); - } - - /** - * 历史记录前进 - */ - forward() { - this[historySymbol].forward(); - } - - /** - * 保存当前状态 - */ - savePoint() { - this[historySymbol].savePoint(); - } - - /** - * 当前是否是「保存点」,即是否有状态变更但未保存 - * @returns - */ - isSavePoint() { - return this[historySymbol].isSavePoint(); - } - - /** - * 获取 state,判断当前是否为「可回退」、「可前进」的状态 - * @returns - */ - getState() { - return this[historySymbol].getState(); - } - - /** - * 监听 state 变更事件 - * @param func - * @returns - */ - onChangeState(func: () => any) { - return this[historySymbol].onStateChange(func); - } - - /** - * 监听历史记录游标位置变更事件 - * @param func - * @returns - */ - onChangeCursor(func: () => any) { - return this[historySymbol].onCursor(func); - } -} diff --git a/packages/shell/src/hotkey.ts b/packages/shell/src/hotkey.ts deleted file mode 100644 index 6e98f43591..0000000000 --- a/packages/shell/src/hotkey.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { hotkey, HotkeyCallback } from '@alilc/lowcode-editor-core'; -import { Disposable } from '@alilc/lowcode-types'; - -export default class Hotkey { - /** - * 绑定快捷键 - * @param combos 快捷键,格式如:['command + s'] 、['ctrl + shift + s'] 等 - * @param callback 回调函数 - * @param action - * @returns - */ - bind(combos: string[] | string, callback: HotkeyCallback, action?: string): Disposable { - hotkey.bind(combos, callback, action); - return () => { - hotkey.unbind(combos, callback, action); - }; - } -} \ No newline at end of file diff --git a/packages/shell/src/index.ts b/packages/shell/src/index.ts index 7396b18a9e..fb1e7228f3 100644 --- a/packages/shell/src/index.ts +++ b/packages/shell/src/index.ts @@ -1,18 +1,37 @@ -import Detecting from './detecting'; -// import Dragon from './dragon'; -import DocumentModel from './document-model'; -import Event, { getEvent } from './event'; -import History from './history'; -import Material from './material'; -import Node from './node'; -import Project from './project'; -import Prop from './prop'; -import Selection from './selection'; -import Setters from './setters'; -import Hotkey from './hotkey'; -import Skeleton from './skeleton'; -import Dragon from './dragon'; -import SettingPropEntry from './setting-prop-entry'; +import { + Detecting, + DocumentModel, + History, + Node, + NodeChildren, + Prop, + Selection, + Dragon, + SettingTopEntry, + Clipboard, + SettingField, + Window, + SkeletonItem, +} from './model'; +import { + Project, + Material, + Logger, + Plugins, + Skeleton, + Setters, + Hotkey, + Common, + getEvent, + Event, + Canvas, + Workspace, + SimulatorHost, + Config, + CommonUI, + Command, +} from './api'; + export * from './symbols'; /** @@ -25,18 +44,32 @@ export * from './symbols'; export { DocumentModel, Detecting, - // Dragon, Event, History, Material, Node, + NodeChildren, Project, Prop, Selection, Setters, Hotkey, + Window, Skeleton, - SettingPropEntry, + SettingField as SettingPropEntry, + SettingTopEntry, Dragon, + Common, getEvent, -}; \ No newline at end of file + Plugins, + Logger, + Canvas, + Workspace, + Clipboard, + SimulatorHost, + Config, + SettingField, + SkeletonItem, + CommonUI, + Command, +}; diff --git a/packages/shell/src/material.ts b/packages/shell/src/material.ts deleted file mode 100644 index 1e11ee21a9..0000000000 --- a/packages/shell/src/material.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { Editor } from '@alilc/lowcode-editor-core'; -import { - Designer, - registerMetadataTransducer, - MetadataTransducer, - getRegisteredMetadataTransducers, - addBuiltinComponentAction, - removeBuiltinComponentAction, - modifyBuiltinComponentAction, -} from '@alilc/lowcode-designer'; -import { AssetsJson } from '@alilc/lowcode-utils'; -import { ComponentAction } from '@alilc/lowcode-types'; -import { editorSymbol, designerSymbol } from './symbols'; -import ComponentMeta from './component-meta'; - -export default class Material { - private readonly [editorSymbol]: Editor; - private readonly [designerSymbol]: Designer; - - constructor(editor: Editor) { - this[editorSymbol] = editor; - this[designerSymbol] = editor.get('designer')!; - } - - /** - * 获取组件 map 结构 - */ - get componentsMap() { - return this[designerSymbol].componentsMap; - } - - /** - * 设置「资产包」结构 - * @param assets - * @returns - */ - setAssets(assets: AssetsJson) { - return this[editorSymbol].setAssets(assets); - } - - /** - * 获取「资产包」结构 - * @returns - */ - getAssets() { - return this[editorSymbol].get('assets'); - } - - /** - * 加载增量的「资产包」结构,该增量包会与原有的合并 - * @param incrementalAssets - * @returns - */ - loadIncrementalAssets(incrementalAssets: AssetsJson) { - return this[designerSymbol].loadIncrementalAssets(incrementalAssets); - } - - /** - * 注册物料元数据管道函数 - * @param transducer - * @param level - * @param id - */ - registerMetadataTransducer( - transducer: MetadataTransducer, - level?: number, - id?: string | undefined, - ) { - registerMetadataTransducer(transducer, level, id); - } - - /** - * 获取所有物料元数据管道函数 - * @returns - */ - getRegisteredMetadataTransducers() { - return getRegisteredMetadataTransducers(); - } - - /** - * 获取指定名称的物料元数据 - * @param componentName - * @returns - */ - getComponentMeta(componentName: string) { - return ComponentMeta.create(this[designerSymbol].getComponentMeta(componentName)); - } - - /** - * 获取所有已注册的物料元数据 - * @returns - */ - getComponentMetasMap() { - const map = new Map<string, ComponentMeta>(); - const originalMap = this[designerSymbol].getComponentMetasMap(); - for (let componentName in originalMap.keys()) { - map.set(componentName, this.getComponentMeta(componentName)!); - } - return map; - } - - /** - * 在设计器辅助层增加一个扩展 action - * @param action - */ - addBuiltinComponentAction(action: ComponentAction) { - addBuiltinComponentAction(action); - } - - /** - * 移除设计器辅助层的指定 action - * @param name - */ - removeBuiltinComponentAction(name: string) { - removeBuiltinComponentAction(name); - } - - /** - * 修改已有的设计器辅助层的指定 action - * @param actionName - * @param handle - */ - modifyBuiltinComponentAction(actionName: string, handle: (action: ComponentAction) => void) { - modifyBuiltinComponentAction(actionName, handle); - } - - /** - * 监听 assets 变化的事件 - * @param fn - */ - onChangeAssets(fn: () => void) { - // 设置 assets,经过 setAssets 赋值 - this[editorSymbol].onGot('assets', fn); - // 增量设置 assets,经过 loadIncrementalAssets 赋值 - this[editorSymbol].on('designer.incrementalAssetsReady', fn); - } -} diff --git a/packages/shell/src/modal-nodes-manager.ts b/packages/shell/src/modal-nodes-manager.ts deleted file mode 100644 index 739ca9406d..0000000000 --- a/packages/shell/src/modal-nodes-manager.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { ModalNodesManager as InnerModalNodesManager, Node as InnerNode } from '@alilc/lowcode-designer'; -import { NodeSchema, NodeData, TransformStage } from '@alilc/lowcode-types'; -import Node from './node'; -import { nodeSymbol, modalNodesManagerSymbol } from './symbols'; - -export default class ModalNodesManager { - private readonly [modalNodesManagerSymbol]: InnerModalNodesManager; - - constructor(modalNodesManager: InnerModalNodesManager) { - this[modalNodesManagerSymbol] = modalNodesManager; - } - - static create(modalNodesManager: InnerModalNodesManager | null) { - if (!modalNodesManager) return null; - return new ModalNodesManager(modalNodesManager); - } - - /** - * 设置模态节点,触发内部事件 - */ - setNodes() { - this[modalNodesManagerSymbol].setNodes(); - } - - /** - * 获取模态节点(们) - * @returns - */ - getModalNodes() { - return this[modalNodesManagerSymbol].getModalNodes().map((node) => Node.create(node)); - } - - /** - * 获取当前可见的模态节点 - * @returns - */ - getVisibleModalNode() { - return Node.create(this[modalNodesManagerSymbol].getVisibleModalNode()); - } - - /** - * 隐藏模态节点(们) - */ - hideModalNodes() { - this[modalNodesManagerSymbol].hideModalNodes(); - } - - /** - * 设置指定节点为可见态 - * @param node Node - */ - setVisible(node: Node) { - this[modalNodesManagerSymbol].setVisible(node[nodeSymbol]); - } - - /** - * 设置指定节点为不可见态 - * @param node Node - */ - setInvisible(node: Node) { - this[modalNodesManagerSymbol].setInvisible(node[nodeSymbol]); - } -} \ No newline at end of file diff --git a/packages/shell/src/model/active-tracker.ts b/packages/shell/src/model/active-tracker.ts new file mode 100644 index 0000000000..32d4c04eb9 --- /dev/null +++ b/packages/shell/src/model/active-tracker.ts @@ -0,0 +1,50 @@ +import { IPublicModelActiveTracker, IPublicModelNode, IPublicTypeActiveTarget } from '@alilc/lowcode-types'; +import { IActiveTracker as InnerActiveTracker, ActiveTarget } from '@alilc/lowcode-designer'; +import { Node as ShellNode } from './node'; +import { nodeSymbol } from '../symbols'; + +const activeTrackerSymbol = Symbol('activeTracker'); + +export class ActiveTracker implements IPublicModelActiveTracker { + private readonly [activeTrackerSymbol]: InnerActiveTracker; + + constructor(innerTracker: InnerActiveTracker) { + this[activeTrackerSymbol] = innerTracker; + } + + get target() { + const _target = this[activeTrackerSymbol]._target; + + if (!_target) { + return null; + } + + const { node: innerNode, detail, instance } = _target; + const publicNode = ShellNode.create(innerNode); + return { + node: publicNode!, + detail, + instance, + }; + } + + onChange(fn: (target: IPublicTypeActiveTarget) => void): () => void { + if (!fn) { + return () => {}; + } + return this[activeTrackerSymbol].onChange((t: ActiveTarget) => { + const { node: innerNode, detail, instance } = t; + const publicNode = ShellNode.create(innerNode); + const publicActiveTarget = { + node: publicNode!, + detail, + instance, + }; + fn(publicActiveTarget); + }); + } + + track(node: IPublicModelNode) { + this[activeTrackerSymbol].track((node as any)[nodeSymbol]); + } +} \ No newline at end of file diff --git a/packages/shell/src/model/clipboard.ts b/packages/shell/src/model/clipboard.ts new file mode 100644 index 0000000000..9c4b309450 --- /dev/null +++ b/packages/shell/src/model/clipboard.ts @@ -0,0 +1,22 @@ +import { IPublicModelClipboard } from '@alilc/lowcode-types'; +import { clipboardSymbol } from '../symbols'; +import { IClipboard, clipboard } from '@alilc/lowcode-designer'; + +export class Clipboard implements IPublicModelClipboard { + private readonly [clipboardSymbol]: IClipboard; + + constructor() { + this[clipboardSymbol] = clipboard; + } + + setData(data: any): void { + this[clipboardSymbol].setData(data); + } + + waitPasteData( + keyboardEvent: KeyboardEvent, + cb: (data: any, clipboardEvent: ClipboardEvent) => void, + ): void { + this[clipboardSymbol].waitPasteData(keyboardEvent, cb); + } +} \ No newline at end of file diff --git a/packages/shell/src/model/component-meta.ts b/packages/shell/src/model/component-meta.ts new file mode 100644 index 0000000000..448f0584ee --- /dev/null +++ b/packages/shell/src/model/component-meta.ts @@ -0,0 +1,146 @@ +import { + IComponentMeta as InnerComponentMeta, + INode, +} from '@alilc/lowcode-designer'; +import { IPublicTypeNodeData, IPublicTypeNodeSchema, IPublicModelComponentMeta, IPublicTypeI18nData, IPublicTypeIconType, IPublicTypeNpmInfo, IPublicTypeTransformedComponentMetadata, IPublicModelNode, IPublicTypeAdvanced, IPublicTypeFieldConfig } from '@alilc/lowcode-types'; +import { componentMetaSymbol, nodeSymbol } from '../symbols'; +import { ReactElement } from 'react'; + +export class ComponentMeta implements IPublicModelComponentMeta { + private readonly [componentMetaSymbol]: InnerComponentMeta; + + isComponentMeta = true; + + constructor(componentMeta: InnerComponentMeta) { + this[componentMetaSymbol] = componentMeta; + } + + static create(componentMeta: InnerComponentMeta | null): IPublicModelComponentMeta | null { + if (!componentMeta) { + return null; + } + return new ComponentMeta(componentMeta); + } + + /** + * 组件名 + */ + get componentName(): string { + return this[componentMetaSymbol].componentName; + } + + /** + * 是否是「容器型」组件 + */ + get isContainer(): boolean { + return this[componentMetaSymbol].isContainer; + } + + /** + * 是否是最小渲染单元。 + * 当组件需要重新渲染时: + * 若为最小渲染单元,则只渲染当前组件, + * 若不为最小渲染单元,则寻找到上层最近的最小渲染单元进行重新渲染,直至根节点。 + */ + get isMinimalRenderUnit(): boolean { + return this[componentMetaSymbol].isMinimalRenderUnit; + } + + /** + * 是否为「模态框」组件 + */ + get isModal(): boolean { + return this[componentMetaSymbol].isModal; + } + + /** + * 元数据配置 + */ + get configure(): IPublicTypeFieldConfig[] { + return this[componentMetaSymbol].configure; + } + + /** + * 标题 + */ + get title(): string | IPublicTypeI18nData | ReactElement { + return this[componentMetaSymbol].title; + } + + /** + * 图标 + */ + get icon(): IPublicTypeIconType { + return this[componentMetaSymbol].icon; + } + + /** + * 组件 npm 信息 + */ + get npm(): IPublicTypeNpmInfo { + return this[componentMetaSymbol].npm; + } + + /** + * @deprecated + */ + get prototype() { + return (this[componentMetaSymbol] as any).prototype; + } + + get availableActions(): any { + return this[componentMetaSymbol].availableActions; + } + + get advanced(): IPublicTypeAdvanced { + return this[componentMetaSymbol].advanced; + } + + /** + * 设置 npm 信息 + * @param npm + */ + setNpm(npm: IPublicTypeNpmInfo): void { + this[componentMetaSymbol].setNpm(npm); + } + + /** + * 获取元数据 + * @returns + */ + getMetadata(): IPublicTypeTransformedComponentMetadata { + return this[componentMetaSymbol].getMetadata(); + } + + /** + * check if the current node could be placed in parent node + * @param my + * @param parent + * @returns + */ + checkNestingUp(my: IPublicModelNode | IPublicTypeNodeData, parent: INode): boolean { + const curNode = (my as any).isNode ? (my as any)[nodeSymbol] : my; + return this[componentMetaSymbol].checkNestingUp(curNode as any, parent); + } + + /** + * check if the target node(s) could be placed in current node + * @param my + * @param parent + * @returns + */ + checkNestingDown( + my: IPublicModelNode | IPublicTypeNodeData, + target: IPublicTypeNodeSchema | IPublicModelNode | IPublicTypeNodeSchema[], + ) { + const curNode = (my as any)?.isNode ? (my as any)[nodeSymbol] : my; + return this[componentMetaSymbol].checkNestingDown( + curNode as any, + (target as any)[nodeSymbol] || target, + ); + } + + refreshMetadata(): void { + this[componentMetaSymbol].refreshMetadata(); + } +} diff --git a/packages/shell/src/model/condition-group.ts b/packages/shell/src/model/condition-group.ts new file mode 100644 index 0000000000..e2dd316edc --- /dev/null +++ b/packages/shell/src/model/condition-group.ts @@ -0,0 +1,42 @@ +import type { IExclusiveGroup } from '@alilc/lowcode-designer'; +import { IPublicModelExclusiveGroup, IPublicModelNode } from '@alilc/lowcode-types'; +import { conditionGroupSymbol, nodeSymbol } from '../symbols'; +import { Node } from './node'; + +export class ConditionGroup implements IPublicModelExclusiveGroup { + private [conditionGroupSymbol]: IExclusiveGroup | null; + + constructor(conditionGroup: IExclusiveGroup | null) { + this[conditionGroupSymbol] = conditionGroup; + } + + get id() { + return this[conditionGroupSymbol]?.id; + } + + get title() { + return this[conditionGroupSymbol]?.title; + } + + get firstNode() { + return Node.create(this[conditionGroupSymbol]?.firstNode); + } + + setVisible(node: IPublicModelNode) { + this[conditionGroupSymbol]?.setVisible((node as any)[nodeSymbol] ? (node as any)[nodeSymbol] : node); + } + + static create(conditionGroup: IExclusiveGroup | null) { + if (!conditionGroup) { + return null; + } + // @ts-ignore + if (conditionGroup[conditionGroupSymbol]) { + return (conditionGroup as any)[conditionGroupSymbol]; + } + const shellConditionGroup = new ConditionGroup(conditionGroup); + // @ts-ignore + shellConditionGroup[conditionGroupSymbol] = shellConditionGroup; + return shellConditionGroup; + } +} diff --git a/packages/shell/src/model/detecting.ts b/packages/shell/src/model/detecting.ts new file mode 100644 index 0000000000..7ce0fe1e5c --- /dev/null +++ b/packages/shell/src/model/detecting.ts @@ -0,0 +1,63 @@ +import { Node as ShellNode } from './node'; +import { + Detecting as InnerDetecting, + IDocumentModel as InnerDocumentModel, + INode as InnerNode, +} from '@alilc/lowcode-designer'; +import { documentSymbol, detectingSymbol } from '../symbols'; +import { IPublicModelDetecting, IPublicModelNode, IPublicTypeDisposable } from '@alilc/lowcode-types'; + +export class Detecting implements IPublicModelDetecting { + private readonly [documentSymbol]: InnerDocumentModel; + private readonly [detectingSymbol]: InnerDetecting; + + constructor(document: InnerDocumentModel) { + this[documentSymbol] = document; + this[detectingSymbol] = document.designer?.detecting; + } + + /** + * 控制大纲树 hover 时是否出现悬停效果 + */ + get enable(): boolean { + return this[detectingSymbol].enable; + } + + /** + * 当前 hover 的节点 + */ + get current() { + return ShellNode.create(this[detectingSymbol].current); + } + + /** + * hover 指定节点 + * @param id 节点 id + */ + capture(id: string) { + this[detectingSymbol].capture(this[documentSymbol].getNode(id)); + } + + /** + * hover 离开指定节点 + * @param id 节点 id + */ + release(id: string) { + this[detectingSymbol].release(this[documentSymbol].getNode(id)); + } + + /** + * 清空 hover 态 + */ + leave() { + this[detectingSymbol].leave(this[documentSymbol]); + } + + onDetectingChange(fn: (node: IPublicModelNode | null) => void): IPublicTypeDisposable { + const innerFn = (innerNode: InnerNode) => { + const shellNode = ShellNode.create(innerNode); + fn(shellNode); + }; + return this[detectingSymbol].onDetectingChange(innerFn); + } +} \ No newline at end of file diff --git a/packages/shell/src/model/document-model.ts b/packages/shell/src/model/document-model.ts new file mode 100644 index 0000000000..bd0ccaf75e --- /dev/null +++ b/packages/shell/src/model/document-model.ts @@ -0,0 +1,381 @@ +import { + IDocumentModel as InnerDocumentModel, + INode as InnerNode, +} from '@alilc/lowcode-designer'; +import { + IPublicEnumTransformStage, + IPublicTypeRootSchema, + GlobalEvent, + IPublicModelDocumentModel, + IPublicTypeOnChangeOptions, + IPublicTypeDragNodeObject, + IPublicTypeDragNodeDataObject, + IPublicModelNode, + IPublicModelSelection, + IPublicModelDetecting, + IPublicModelHistory, + IPublicApiProject, + IPublicModelModalNodesManager, + IPublicTypePropChangeOptions, + IPublicModelDropLocation, + IPublicApiCanvas, + IPublicTypeDisposable, + IPublicModelEditor, + IPublicTypeNodeSchema, +} from '@alilc/lowcode-types'; +import { isDragNodeObject } from '@alilc/lowcode-utils'; +import { Node as ShellNode } from './node'; +import { Selection as ShellSelection } from './selection'; +import { Detecting as ShellDetecting } from './detecting'; +import { History as ShellHistory } from './history'; +import { DropLocation as ShellDropLocation } from './drop-location'; +import { Project as ShellProject, Canvas as ShellCanvas } from '../api'; +import { Prop as ShellProp } from './prop'; +import { ModalNodesManager } from './modal-nodes-manager'; +import { documentSymbol, editorSymbol, nodeSymbol } from '../symbols'; + +const shellDocSymbol = Symbol('shellDocSymbol'); + +export class DocumentModel implements IPublicModelDocumentModel { + private readonly [documentSymbol]: InnerDocumentModel; + private readonly [editorSymbol]: IPublicModelEditor; + private _focusNode: IPublicModelNode | null; + selection: IPublicModelSelection; + detecting: IPublicModelDetecting; + history: IPublicModelHistory; + + /** + * @deprecated use canvas API instead + */ + canvas: IPublicApiCanvas; + + constructor(document: InnerDocumentModel) { + this[documentSymbol] = document; + this[editorSymbol] = document.designer?.editor as IPublicModelEditor; + this.selection = new ShellSelection(document); + this.detecting = new ShellDetecting(document); + this.history = new ShellHistory(document); + this.canvas = new ShellCanvas(this[editorSymbol]); + + this._focusNode = ShellNode.create(this[documentSymbol].focusNode); + } + + static create(document: InnerDocumentModel | undefined | null): IPublicModelDocumentModel | null { + if (!document) { + return null; + } + // @ts-ignore 直接返回已挂载的 shell doc 实例 + if (document[shellDocSymbol]) { + return (document as any)[shellDocSymbol]; + } + const shellDoc = new DocumentModel(document); + // @ts-ignore 直接返回已挂载的 shell doc 实例 + document[shellDocSymbol] = shellDoc; + return shellDoc; + } + + /** + * id + */ + get id(): string { + return this[documentSymbol].id; + } + + set id(id) { + this[documentSymbol].id = id; + } + + /** + * 获取当前文档所属的 project + * @returns + */ + get project(): IPublicApiProject { + return ShellProject.create(this[documentSymbol].project, true); + } + + /** + * 获取文档的根节点 + * root node of this documentModel + * @returns + */ + get root(): IPublicModelNode | null { + return ShellNode.create(this[documentSymbol].rootNode); + } + + get focusNode(): IPublicModelNode | null { + return this._focusNode || this.root; + } + + set focusNode(node: IPublicModelNode | null) { + this._focusNode = node; + this[editorSymbol].eventBus.emit( + 'shell.document.focusNodeChanged', + { document: this, focusNode: node }, + ); + } + + /** + * 获取文档下所有节点 Map, key 为 nodeId + * get map of all nodes , using node.id as key + */ + get nodesMap(): Map<string, IPublicModelNode> { + const map = new Map<string, IPublicModelNode>(); + for (let id of this[documentSymbol].nodesMap.keys()) { + map.set(id, this.getNodeById(id)!); + } + return map; + } + + /** + * 模态节点管理 + */ + get modalNodesManager(): IPublicModelModalNodesManager | null { + return ModalNodesManager.create(this[documentSymbol].modalNodesManager); + } + + get dropLocation(): IPublicModelDropLocation | null { + return ShellDropLocation.create(this[documentSymbol].dropLocation); + } + + set dropLocation(loc: IPublicModelDropLocation | null) { + this[documentSymbol].dropLocation = loc; + } + + /** + * 根据 nodeId 返回 Node 实例 + * get node instance by nodeId + * @param {string} nodeId + */ + getNodeById(nodeId: string): IPublicModelNode | null { + return ShellNode.create(this[documentSymbol].getNode(nodeId)); + } + + /** + * 导入 schema + * @param schema + */ + importSchema(schema: IPublicTypeRootSchema): void { + this[documentSymbol].import(schema); + this[editorSymbol].eventBus.emit('shell.document.importSchema', schema); + } + + /** + * 导出 schema + * @param stage + * @returns + */ + exportSchema(stage: IPublicEnumTransformStage = IPublicEnumTransformStage.Render): IPublicTypeRootSchema | undefined { + return this[documentSymbol].export(stage); + } + + /** + * 插入节点 + * @param parent + * @param thing + * @param at + * @param copy + * @returns + */ + insertNode( + parent: IPublicModelNode, + thing: IPublicModelNode, + at?: number | null | undefined, + copy?: boolean | undefined, + ): IPublicModelNode | null { + const node = this[documentSymbol].insertNode( + (parent as any)[nodeSymbol] ? (parent as any)[nodeSymbol] : parent, + (thing as any)?.[nodeSymbol] ? (thing as any)[nodeSymbol] : thing, + at, + copy, + ); + return ShellNode.create(node); + } + + /** + * 创建一个节点 + * @param data + * @returns + */ + createNode<IPublicModelNode>(data: IPublicTypeNodeSchema): IPublicModelNode | null { + return ShellNode.create(this[documentSymbol].createNode(data)); + } + + /** + * 移除指定节点/节点id + * @param idOrNode + */ + removeNode(idOrNode: string | IPublicModelNode): void { + this[documentSymbol].removeNode(idOrNode as any); + } + + /** + * componentsMap of documentModel + * @param extraComps + * @returns + */ + getComponentsMap(extraComps?: string[]): any { + return this[documentSymbol].getComponentsMap(extraComps); + } + + /** + * 检查拖拽放置的目标节点是否可以放置该拖拽对象 + * @param dropTarget 拖拽放置的目标节点 + * @param dragObject 拖拽的对象 + * @returns boolean 是否可以放置 + */ + checkNesting( + dropTarget: IPublicModelNode, + dragObject: IPublicTypeDragNodeObject | IPublicTypeDragNodeDataObject, + ): boolean { + let innerDragObject = dragObject; + if (isDragNodeObject(dragObject)) { + innerDragObject.nodes = innerDragObject.nodes?.map( + (node: IPublicModelNode) => ((node as any)[nodeSymbol] || node), + ); + } + return this[documentSymbol].checkNesting( + ((dropTarget as any)[nodeSymbol] || dropTarget) as any, + innerDragObject as any, + ); + } + + /** + * 当前 document 新增节点事件 + */ + onAddNode(fn: (node: IPublicModelNode) => void): IPublicTypeDisposable { + return this[documentSymbol].onNodeCreate((node: InnerNode) => { + fn(ShellNode.create(node)!); + }); + } + + /** + * 当前 document 新增节点事件,此时节点已经挂载到 document 上 + */ + onMountNode(fn: (payload: { node: IPublicModelNode }) => void): IPublicTypeDisposable { + return this[documentSymbol].onMountNode(({ + node, + }) => { + fn({ node: ShellNode.create(node)! }); + }); + } + + /** + * 当前 document 删除节点事件 + */ + onRemoveNode(fn: (node: IPublicModelNode) => void): IPublicTypeDisposable { + return this[documentSymbol].onNodeDestroy((node: InnerNode) => { + fn(ShellNode.create(node)!); + }); + } + + /** + * 当前 document 的 hover 变更事件 + */ + onChangeDetecting(fn: (node: IPublicModelNode) => void): IPublicTypeDisposable { + return this[documentSymbol].designer.detecting.onDetectingChange((node: InnerNode) => { + fn(ShellNode.create(node)!); + }); + } + + /** + * 当前 document 的选中变更事件 + */ + onChangeSelection(fn: (ids: string[]) => void): IPublicTypeDisposable { + return this[documentSymbol].selection.onSelectionChange((ids: string[]) => { + fn(ids); + }); + } + + /** + * 当前 document 的节点显隐状态变更事件 + * @param fn + */ + onChangeNodeVisible(fn: (node: IPublicModelNode, visible: boolean) => void): IPublicTypeDisposable { + return this[documentSymbol].onChangeNodeVisible((node: InnerNode, visible: boolean) => { + fn(ShellNode.create(node)!, visible); + }); + } + + /** + * 当前 document 的节点 children 变更事件 + * @param fn + */ + onChangeNodeChildren(fn: (info: IPublicTypeOnChangeOptions) => void): IPublicTypeDisposable { + return this[documentSymbol].onChangeNodeChildren((info?: IPublicTypeOnChangeOptions<InnerNode>) => { + if (!info) { + return; + } + fn({ + type: info.type, + node: ShellNode.create(info.node)!, + }); + }); + } + + /** + * 当前 document 节点属性修改事件 + * @param fn + */ + onChangeNodeProp(fn: (info: IPublicTypePropChangeOptions) => void): IPublicTypeDisposable { + const callback = (info: GlobalEvent.Node.Prop.ChangeOptions) => { + fn({ + key: info.key, + oldValue: info.oldValue, + newValue: info.newValue, + prop: ShellProp.create(info.prop)!, + node: ShellNode.create(info.node as any)!, + }); + }; + this[editorSymbol].on( + GlobalEvent.Node.Prop.InnerChange, + callback, + ); + + return () => { + this[editorSymbol].off( + GlobalEvent.Node.Prop.InnerChange, + callback, + ); + }; + } + + /** + * import schema event + * @param fn + */ + onImportSchema(fn: (schema: IPublicTypeRootSchema) => void): IPublicTypeDisposable { + return this[editorSymbol].eventBus.on('shell.document.importSchema', fn as any); + } + + isDetectingNode(node: IPublicModelNode): boolean { + return this.detecting.current === node; + } + + onFocusNodeChanged( + fn: (doc: IPublicModelDocumentModel, focusNode: IPublicModelNode) => void, + ): IPublicTypeDisposable { + if (!fn) { + return () => {}; + } + return this[editorSymbol].eventBus.on( + 'shell.document.focusNodeChanged', + (payload) => { + const { document, focusNode } = payload; + fn(document, focusNode); + }, + ); + } + + onDropLocationChanged(fn: (doc: IPublicModelDocumentModel) => void): IPublicTypeDisposable { + if (!fn) { + return () => {}; + } + return this[editorSymbol].eventBus.on( + 'document.dropLocation.changed', + (payload) => { + const { document } = payload; + fn(document); + }, + ); + } +} diff --git a/packages/shell/src/model/drag-object.ts b/packages/shell/src/model/drag-object.ts new file mode 100644 index 0000000000..064680fdee --- /dev/null +++ b/packages/shell/src/model/drag-object.ts @@ -0,0 +1,34 @@ +import { dragObjectSymbol } from '../symbols'; +import { IPublicModelDragObject, IPublicModelDragObject as InnerDragObject, IPublicTypeDragNodeDataObject, IPublicTypeNodeSchema } from '@alilc/lowcode-types'; +import { Node } from './node'; + +export class DragObject implements IPublicModelDragObject { + private readonly [dragObjectSymbol]: InnerDragObject; + + constructor(dragObject: InnerDragObject) { + this[dragObjectSymbol] = dragObject; + } + + static create(dragObject: InnerDragObject | null): IPublicModelDragObject | null { + if (!dragObject) { + return null; + } + return new DragObject(dragObject); + } + + get type() { + return this[dragObjectSymbol].type; + } + + get nodes() { + const { nodes } = this[dragObjectSymbol]; + if (!nodes) { + return null; + } + return nodes.map(Node.create); + } + + get data(): IPublicTypeNodeSchema | IPublicTypeNodeSchema[] { + return (this[dragObjectSymbol] as IPublicTypeDragNodeDataObject).data; + } +} \ No newline at end of file diff --git a/packages/shell/src/model/dragon.ts b/packages/shell/src/model/dragon.ts new file mode 100644 index 0000000000..7f2492e7ea --- /dev/null +++ b/packages/shell/src/model/dragon.ts @@ -0,0 +1,130 @@ +import { + IDragon, + ILocateEvent as InnerLocateEvent, + INode, +} from '@alilc/lowcode-designer'; +import { dragonSymbol, nodeSymbol } from '../symbols'; +import LocateEvent from './locate-event'; +import { DragObject } from './drag-object'; +import { globalContext } from '@alilc/lowcode-editor-core'; +import { + IPublicModelDragon, + IPublicModelLocateEvent, + IPublicModelDragObject, + IPublicTypeDragNodeDataObject, + IPublicModelNode, + IPublicTypeDragObject, +} from '@alilc/lowcode-types'; + +export const innerDragonSymbol = Symbol('innerDragonSymbol'); + +export class Dragon implements IPublicModelDragon { + private readonly [innerDragonSymbol]: IDragon; + + constructor(innerDragon: IDragon, readonly workspaceMode: boolean) { + this[innerDragonSymbol] = innerDragon; + } + + get [dragonSymbol](): IDragon { + if (this.workspaceMode) { + return this[innerDragonSymbol]; + } + const workspace = globalContext.get('workspace'); + let editor = globalContext.get('editor'); + + if (workspace.isActive) { + editor = workspace.window.editor; + } + + const designer = editor.get('designer'); + return designer.dragon; + } + + static create( + dragon: IDragon | null, + workspaceMode: boolean, + ): IPublicModelDragon | null { + if (!dragon) { + return null; + } + return new Dragon(dragon, workspaceMode); + } + + /** + * is dragging or not + */ + get dragging(): boolean { + return this[dragonSymbol].dragging; + } + + /** + * 绑定 dragstart 事件 + * @param func + * @returns + */ + onDragstart(func: (e: IPublicModelLocateEvent) => any): () => void { + return this[dragonSymbol].onDragstart((e: InnerLocateEvent) => func(LocateEvent.create(e)!)); + } + + /** + * 绑定 drag 事件 + * @param func + * @returns + */ + onDrag(func: (e: IPublicModelLocateEvent) => any): () => void { + return this[dragonSymbol].onDrag((e: InnerLocateEvent) => func(LocateEvent.create(e)!)); + } + + /** + * 绑定 dragend 事件 + * @param func + * @returns + */ + onDragend(func: (o: { dragObject: IPublicModelDragObject; copy?: boolean }) => any): () => void { + return this[dragonSymbol].onDragend( + (o: { dragObject: IPublicModelDragObject; copy?: boolean }) => { + const dragObject = DragObject.create(o.dragObject); + const { copy } = o; + return func({ dragObject: dragObject!, copy }); + }, + ); + } + + /** + * 设置拖拽监听的区域 shell,以及自定义拖拽转换函数 boost + * @param shell 拖拽监听的区域 + * @param boost 拖拽转换函数 + */ + from(shell: Element, boost: (e: MouseEvent) => IPublicTypeDragNodeDataObject | null): any { + return this[dragonSymbol].from(shell, boost); + } + + /** + * boost your dragObject for dragging(flying) 发射拖拽对象 + * + * @param dragObject 拖拽对象 + * @param boostEvent 拖拽初始时事件 + */ + boost(dragObject: IPublicTypeDragObject, boostEvent: MouseEvent | DragEvent, fromRglNode?: IPublicModelNode & { + [nodeSymbol]: INode; + }): void { + return this[dragonSymbol].boost({ + ...dragObject, + nodes: dragObject.nodes.map((node: any) => node[nodeSymbol]), + }, boostEvent, fromRglNode?.[nodeSymbol]); + } + + /** + * 添加投放感应区 + */ + addSensor(sensor: any): void { + return this[dragonSymbol].addSensor(sensor); + } + + /** + * 移除投放感应 + */ + removeSensor(sensor: any): void { + return this[dragonSymbol].removeSensor(sensor); + } +} diff --git a/packages/shell/src/model/drop-location.ts b/packages/shell/src/model/drop-location.ts new file mode 100644 index 0000000000..f38e74a07a --- /dev/null +++ b/packages/shell/src/model/drop-location.ts @@ -0,0 +1,37 @@ +import { + IDropLocation as InnerDropLocation, +} from '@alilc/lowcode-designer'; +import { dropLocationSymbol } from '../symbols'; +import { Node as ShellNode } from './node'; +import { IPublicModelDropLocation, IPublicTypeLocationDetail, IPublicModelLocateEvent } from '@alilc/lowcode-types'; + +export class DropLocation implements IPublicModelDropLocation { + private readonly [dropLocationSymbol]: InnerDropLocation; + + constructor(dropLocation: InnerDropLocation) { + this[dropLocationSymbol] = dropLocation; + } + + static create(dropLocation: InnerDropLocation | null): IPublicModelDropLocation | null { + if (!dropLocation) { + return null; + } + return new DropLocation(dropLocation); + } + + get target() { + return ShellNode.create(this[dropLocationSymbol].target); + } + + get detail(): IPublicTypeLocationDetail { + return this[dropLocationSymbol].detail; + } + + get event(): IPublicModelLocateEvent { + return this[dropLocationSymbol].event; + } + + clone(event: IPublicModelLocateEvent): IPublicModelDropLocation { + return new DropLocation(this[dropLocationSymbol].clone(event)); + } +} diff --git a/packages/shell/src/model/editor-view.ts b/packages/shell/src/model/editor-view.ts new file mode 100644 index 0000000000..92d1a57726 --- /dev/null +++ b/packages/shell/src/model/editor-view.ts @@ -0,0 +1,35 @@ +import { editorViewSymbol, pluginContextSymbol } from '../symbols'; +import { IPublicModelPluginContext } from '@alilc/lowcode-types'; +import { IViewContext } from '@alilc/lowcode-workspace'; + +export class EditorView { + [editorViewSymbol]: IViewContext; + + [pluginContextSymbol]: IPublicModelPluginContext; + + constructor(editorView: IViewContext) { + this[editorViewSymbol] = editorView; + this[pluginContextSymbol] = this[editorViewSymbol].innerPlugins._getLowCodePluginContext({ + pluginName: editorView.editorWindow + editorView.viewName, + }); + } + + toProxy() { + return new Proxy(this, { + get(target, prop, receiver) { + if ((target[pluginContextSymbol] as any)[prop as string]) { + return Reflect.get(target[pluginContextSymbol], prop, receiver); + } + return Reflect.get(target, prop, receiver); + }, + }); + } + + get viewName() { + return this[editorViewSymbol].viewName; + } + + get viewType() { + return this[editorViewSymbol].viewType; + } +} diff --git a/packages/shell/src/model/history.ts b/packages/shell/src/model/history.ts new file mode 100644 index 0000000000..ddc567aeef --- /dev/null +++ b/packages/shell/src/model/history.ts @@ -0,0 +1,78 @@ +import type { IDocumentModel as InnerDocumentModel, IHistory as InnerHistory } from '@alilc/lowcode-designer'; +import { historySymbol, documentSymbol } from '../symbols'; +import { IPublicModelHistory, IPublicTypeDisposable } from '@alilc/lowcode-types'; + +export class History implements IPublicModelHistory { + private readonly [documentSymbol]: InnerDocumentModel; + + private get [historySymbol](): InnerHistory { + return this[documentSymbol].getHistory(); + } + + constructor(document: InnerDocumentModel) { + this[documentSymbol] = document; + } + + /** + * 历史记录跳转到指定位置 + * @param cursor + */ + go(cursor: number): void { + this[historySymbol].go(cursor); + } + + /** + * 历史记录后退 + */ + back(): void { + this[historySymbol].back(); + } + + /** + * 历史记录前进 + */ + forward(): void { + this[historySymbol].forward(); + } + + /** + * 保存当前状态 + */ + savePoint(): void { + this[historySymbol].savePoint(); + } + + /** + * 当前是否是「保存点」,即是否有状态变更但未保存 + * @returns + */ + isSavePoint(): boolean { + return this[historySymbol].isSavePoint(); + } + + /** + * 获取 state,判断当前是否为「可回退」、「可前进」的状态 + * @returns + */ + getState(): number { + return this[historySymbol].getState(); + } + + /** + * 监听 state 变更事件 + * @param func + * @returns + */ + onChangeState(func: () => any): IPublicTypeDisposable { + return this[historySymbol].onChangeState(func); + } + + /** + * 监听历史记录游标位置变更事件 + * @param func + * @returns + */ + onChangeCursor(func: () => any): IPublicTypeDisposable { + return this[historySymbol].onChangeCursor(func); + } +} diff --git a/packages/shell/src/model/index.ts b/packages/shell/src/model/index.ts new file mode 100644 index 0000000000..a15d50b549 --- /dev/null +++ b/packages/shell/src/model/index.ts @@ -0,0 +1,23 @@ +export * from './component-meta'; +export * from './detecting'; +export * from './document-model'; +export * from './drag-object'; +export * from './dragon'; +export * from './drop-location'; +export * from './history'; +export * from './locate-event'; +export * from './modal-nodes-manager'; +export * from './node-children'; +export * from './node'; +export * from './prop'; +export * from './props'; +export * from './selection'; +export * from './setting-top-entry'; +export * from './setting-field'; +export * from './resource'; +export * from './active-tracker'; +export * from './plugin-instance'; +export * from './window'; +export * from './clipboard'; +export * from './editor-view'; +export * from './skeleton-item'; diff --git a/packages/shell/src/model/locate-event.ts b/packages/shell/src/model/locate-event.ts new file mode 100644 index 0000000000..20451f9462 --- /dev/null +++ b/packages/shell/src/model/locate-event.ts @@ -0,0 +1,51 @@ +import { ILocateEvent } from '@alilc/lowcode-designer'; +import { locateEventSymbol } from '../symbols'; +import { DragObject } from './drag-object'; +import { IPublicModelLocateEvent, IPublicModelDragObject } from '@alilc/lowcode-types'; + +export default class LocateEvent implements IPublicModelLocateEvent { + private readonly [locateEventSymbol]: ILocateEvent; + + constructor(locateEvent: ILocateEvent) { + this[locateEventSymbol] = locateEvent; + } + + static create(locateEvent: ILocateEvent): IPublicModelLocateEvent | null { + if (!locateEvent) { + return null; + } + return new LocateEvent(locateEvent); + } + + get type(): string { + return this[locateEventSymbol].type; + } + + get globalX(): number { + return this[locateEventSymbol].globalX; + } + + get globalY(): number { + return this[locateEventSymbol].globalY; + } + + get originalEvent(): MouseEvent | DragEvent { + return this[locateEventSymbol].originalEvent; + } + + get target(): Element | null | undefined { + return this[locateEventSymbol].target; + } + + get canvasX(): number | undefined { + return this[locateEventSymbol].canvasX; + } + + get canvasY(): number | undefined { + return this[locateEventSymbol].canvasY; + } + + get dragObject(): IPublicModelDragObject | null { + return DragObject.create(this[locateEventSymbol].dragObject); + } +} \ No newline at end of file diff --git a/packages/shell/src/model/modal-nodes-manager.ts b/packages/shell/src/model/modal-nodes-manager.ts new file mode 100644 index 0000000000..b1e27596f2 --- /dev/null +++ b/packages/shell/src/model/modal-nodes-manager.ts @@ -0,0 +1,76 @@ +import { + IModalNodesManager as InnerModalNodesManager, + INode as InnerNode, +} from '@alilc/lowcode-designer'; +import { IPublicModelModalNodesManager, IPublicModelNode } from '@alilc/lowcode-types'; +import { Node as ShellNode } from './node'; +import { nodeSymbol, modalNodesManagerSymbol } from '../symbols'; + +export class ModalNodesManager implements IPublicModelModalNodesManager { + private readonly [modalNodesManagerSymbol]: InnerModalNodesManager; + + constructor(modalNodesManager: InnerModalNodesManager) { + this[modalNodesManagerSymbol] = modalNodesManager; + } + + static create( + modalNodesManager: InnerModalNodesManager | null, + ): IPublicModelModalNodesManager | null { + if (!modalNodesManager) { + return null; + } + return new ModalNodesManager(modalNodesManager); + } + + /** + * 设置模态节点,触发内部事件 + */ + setNodes(): void { + this[modalNodesManagerSymbol].setNodes(); + } + + /** + * 获取模态节点(们) + */ + getModalNodes(): IPublicModelNode[] { + const innerNodes = this[modalNodesManagerSymbol].getModalNodes(); + const shellNodes: IPublicModelNode[] = []; + innerNodes?.forEach((node: InnerNode) => { + const shellNode = ShellNode.create(node); + if (shellNode) { + shellNodes.push(shellNode); + } + }); + return shellNodes; + } + + /** + * 获取当前可见的模态节点 + */ + getVisibleModalNode(): IPublicModelNode | null { + return ShellNode.create(this[modalNodesManagerSymbol].getVisibleModalNode()); + } + + /** + * 隐藏模态节点(们) + */ + hideModalNodes(): void { + this[modalNodesManagerSymbol].hideModalNodes(); + } + + /** + * 设置指定节点为可见态 + * @param node Node + */ + setVisible(node: IPublicModelNode): void { + this[modalNodesManagerSymbol].setVisible((node as any)[nodeSymbol]); + } + + /** + * 设置指定节点为不可见态 + * @param node Node + */ + setInvisible(node: IPublicModelNode): void { + this[modalNodesManagerSymbol].setInvisible((node as any)[nodeSymbol]); + } +} \ No newline at end of file diff --git a/packages/shell/src/model/node-children.ts b/packages/shell/src/model/node-children.ts new file mode 100644 index 0000000000..b6d52e86fe --- /dev/null +++ b/packages/shell/src/model/node-children.ts @@ -0,0 +1,245 @@ +import { INode as InnerNode, INodeChildren } from '@alilc/lowcode-designer'; +import { IPublicTypeNodeData, IPublicEnumTransformStage, IPublicModelNodeChildren, IPublicModelNode } from '@alilc/lowcode-types'; +import { Node as ShellNode } from './node'; +import { nodeSymbol, nodeChildrenSymbol } from '../symbols'; + +export class NodeChildren implements IPublicModelNodeChildren { + private readonly [nodeChildrenSymbol]: INodeChildren; + + constructor(nodeChildren: INodeChildren) { + this[nodeChildrenSymbol] = nodeChildren; + } + + static create(nodeChildren: INodeChildren | null): IPublicModelNodeChildren | null { + if (!nodeChildren) { + return null; + } + return new NodeChildren(nodeChildren); + } + + /** + * 返回当前 children 实例所属的节点实例 + */ + get owner(): IPublicModelNode | null { + return ShellNode.create(this[nodeChildrenSymbol].owner); + } + + /** + * children 内的节点实例数 + */ + get size(): number { + return this[nodeChildrenSymbol].size; + } + + /** + * @deprecated + * 是否为空 + * @returns + */ + get isEmpty(): boolean { + return this[nodeChildrenSymbol].isEmptyNode; + } + + /** + * 是否为空 + * @returns + */ + get isEmptyNode(): boolean { + return this[nodeChildrenSymbol].isEmptyNode; + } + + /** + * @deprecated + * judge if it is not empty + */ + get notEmpty(): boolean { + return this[nodeChildrenSymbol].notEmptyNode; + } + + /** + * judge if it is not empty + */ + get notEmptyNode(): boolean { + return this[nodeChildrenSymbol].notEmptyNode; + } + + /** + * 删除指定节点 + * delete the node + * @param node + */ + delete(node: IPublicModelNode): boolean { + return this[nodeChildrenSymbol].delete((node as any)?.[nodeSymbol]); + } + + /** + * 插入一个节点 + * @param node 待插入节点 + * @param at 插入下标 + * @returns + */ + insert(node: IPublicModelNode, at?: number | null): void { + return this[nodeChildrenSymbol].insert((node as any)?.[nodeSymbol], at); + } + + /** + * 返回指定节点的下标 + * @param node + * @returns + */ + indexOf(node: IPublicModelNode): number { + return this[nodeChildrenSymbol].indexOf((node as any)?.[nodeSymbol]); + } + + /** + * 类似数组 splice 操作 + * @param start + * @param deleteCount + * @param node + */ + splice(start: number, deleteCount: number, node?: IPublicModelNode): any { + this[nodeChildrenSymbol].splice(start, deleteCount, (node as any)?.[nodeSymbol]); + } + + /** + * 返回指定下标的节点 + * @param index + * @returns + */ + get(index: number): IPublicModelNode | null { + return ShellNode.create(this[nodeChildrenSymbol].get(index)); + } + + /** + * 是否包含指定节点 + * @param node + * @returns + */ + has(node: IPublicModelNode): boolean { + return this[nodeChildrenSymbol].has((node as any)?.[nodeSymbol]); + } + + /** + * 类似数组的 forEach + * @param fn + */ + forEach(fn: (node: IPublicModelNode, index: number) => void): void { + this[nodeChildrenSymbol].forEach((item: InnerNode, index: number) => { + fn(ShellNode.create(item)!, index); + }); + } + + /** + * 类似数组的 reverse + */ + reverse(): IPublicModelNode[] { + return this[nodeChildrenSymbol].reverse().map(d => { + return ShellNode.create(d)!; + }); + } + + /** + * 类似数组的 map + * @param fn + */ + map<T = any>(fn: (node: IPublicModelNode, index: number) => T): T[] | null { + return this[nodeChildrenSymbol].map<T>((item: InnerNode, index: number): T => { + return fn(ShellNode.create(item)!, index); + }); + } + + /** + * 类似数组的 every + * @param fn + */ + every(fn: (node: IPublicModelNode, index: number) => boolean): boolean { + return this[nodeChildrenSymbol].every((item: InnerNode, index: number) => { + return fn(ShellNode.create(item)!, index); + }); + } + + /** + * 类似数组的 some + * @param fn + */ + some(fn: (node: IPublicModelNode, index: number) => boolean): boolean { + return this[nodeChildrenSymbol].some((item: InnerNode, index: number) => { + return fn(ShellNode.create(item)!, index); + }); + } + + /** + * 类似数组的 filter + * @param fn + */ + filter(fn: (node: IPublicModelNode, index: number) => boolean): any { + return this[nodeChildrenSymbol] + .filter((item: InnerNode, index: number) => { + return fn(ShellNode.create(item)!, index); + }) + .map((item: InnerNode) => ShellNode.create(item)!); + } + + /** + * 类似数组的 find + * @param fn + */ + find(fn: (node: IPublicModelNode, index: number) => boolean): IPublicModelNode | null { + return ShellNode.create( + this[nodeChildrenSymbol].find((item: InnerNode, index: number) => { + return fn(ShellNode.create(item)!, index); + }), + ); + } + + /** + * 类似数组的 reduce + * @param fn + */ + reduce(fn: (acc: any, cur: IPublicModelNode) => any, initialValue: any): void { + return this[nodeChildrenSymbol].reduce((acc: any, cur: InnerNode) => { + return fn(acc, ShellNode.create(cur)!); + }, initialValue); + } + + /** + * 导入 schema + * @param data + */ + importSchema(data?: IPublicTypeNodeData | IPublicTypeNodeData[]): void { + this[nodeChildrenSymbol].import(data); + } + + /** + * 导出 schema + * @param stage + * @returns + */ + exportSchema(stage: IPublicEnumTransformStage = IPublicEnumTransformStage.Render): any { + return this[nodeChildrenSymbol].export(stage); + } + + /** + * 执行新增、删除、排序等操作 + * @param remover + * @param adder + * @param sorter + */ + mergeChildren( + remover: (node: IPublicModelNode, idx: number) => boolean, + adder: (children: IPublicModelNode[]) => any, + originalSorter: (firstNode: IPublicModelNode, secondNode: IPublicModelNode) => number, + ) { + let sorter = originalSorter; + if (!sorter) { + sorter = () => 0; + } + this[nodeChildrenSymbol].mergeChildren( + (node: InnerNode, idx: number) => remover(ShellNode.create(node)!, idx), + (children: InnerNode[]) => adder(children.map((node) => ShellNode.create(node)!)), + (firstNode: InnerNode, secondNode: InnerNode) => { + return sorter(ShellNode.create(firstNode)!, ShellNode.create(secondNode)!); + }, + ); + } +} diff --git a/packages/shell/src/model/node.ts b/packages/shell/src/model/node.ts new file mode 100644 index 0000000000..29d24232eb --- /dev/null +++ b/packages/shell/src/model/node.ts @@ -0,0 +1,678 @@ +import { + IDocumentModel as InnerDocumentModel, + INode as InnerNode, +} from '@alilc/lowcode-designer'; +import { + IPublicTypeCompositeValue, + IPublicTypeNodeSchema, + IPublicEnumTransformStage, + IPublicModelNode, + IPublicTypeIconType, + IPublicTypeI18nData, + IPublicModelComponentMeta, + IPublicModelDocumentModel, + IPublicModelNodeChildren, + IPublicModelProp, + IPublicModelProps, + IPublicTypePropsMap, + IPublicTypePropsList, + IPublicModelSettingTopEntry, + IPublicModelExclusiveGroup, +} from '@alilc/lowcode-types'; +import { Prop as ShellProp } from './prop'; +import { Props as ShellProps } from './props'; +import { DocumentModel as ShellDocumentModel } from './document-model'; +import { NodeChildren as ShellNodeChildren } from './node-children'; +import { ComponentMeta as ShellComponentMeta } from './component-meta'; +import { SettingTopEntry as ShellSettingTopEntry } from './setting-top-entry'; +import { documentSymbol, nodeSymbol } from '../symbols'; +import { ReactElement } from 'react'; +import { ConditionGroup } from './condition-group'; + +const shellNodeSymbol = Symbol('shellNodeSymbol'); + +function isShellNode(node: any): node is IPublicModelNode { + return node[shellNodeSymbol]; +} + +export class Node implements IPublicModelNode { + private readonly [documentSymbol]: InnerDocumentModel | null; + private readonly [nodeSymbol]: InnerNode; + + private _id: string; + + /** + * 节点 id + */ + get id() { + return this._id; + } + + /** + * set id + */ + set id(id: string) { + this._id = id; + } + + /** + * 节点标题 + */ + get title(): string | IPublicTypeI18nData | ReactElement { + return this[nodeSymbol].title; + } + + /** + * @deprecated + * 是否为「容器型」节点 + */ + get isContainer(): boolean { + return this[nodeSymbol].isContainerNode; + } + + /** + * 是否为「容器型」节点 + */ + get isContainerNode(): boolean { + return this[nodeSymbol].isContainerNode; + } + + /** + * @deprecated + * 是否为根节点 + */ + get isRoot(): boolean { + return this[nodeSymbol].isRootNode; + } + + /** + * 是否为根节点 + */ + get isRootNode(): boolean { + return this[nodeSymbol].isRootNode; + } + + /** + * @deprecated + * 是否为空节点(无 children 或者 children 为空) + */ + get isEmpty(): boolean { + return this[nodeSymbol].isEmptyNode; + } + + /** + * 是否为空节点(无 children 或者 children 为空) + */ + get isEmptyNode(): boolean { + return this[nodeSymbol].isEmptyNode; + } + + /** + * @deprecated + * 是否为 Page 节点 + */ + get isPage(): boolean { + return this[nodeSymbol].isPageNode; + } + + /** + * 是否为 Page 节点 + */ + get isPageNode(): boolean { + return this[nodeSymbol].isPageNode; + } + + /** + * @deprecated + * 是否为 Component 节点 + */ + get isComponent(): boolean { + return this[nodeSymbol].isComponentNode; + } + + /** + * 是否为 Component 节点 + */ + get isComponentNode(): boolean { + return this[nodeSymbol].isComponentNode; + } + + /** + * @deprecated + * 是否为「模态框」节点 + */ + get isModal(): boolean { + return this[nodeSymbol].isModalNode; + } + + /** + * 是否为「模态框」节点 + */ + get isModalNode(): boolean { + return this[nodeSymbol].isModalNode; + } + + /** + * @deprecated + * 是否为插槽节点 + */ + get isSlot(): boolean { + return this[nodeSymbol].isSlotNode; + } + + /** + * 是否为插槽节点 + */ + get isSlotNode(): boolean { + return this[nodeSymbol].isSlotNode; + } + + /** + * @deprecated + * 是否为父类/分支节点 + */ + get isParental(): boolean { + return this[nodeSymbol].isParentalNode; + } + + /** + * 是否为父类/分支节点 + */ + get isParentalNode(): boolean { + return this[nodeSymbol].isParentalNode; + } + + /** + * @deprecated + * 是否为叶子节点 + */ + get isLeaf(): boolean { + return this[nodeSymbol].isLeafNode; + } + + /** + * 是否为叶子节点 + */ + get isLeafNode(): boolean { + return this[nodeSymbol].isLeafNode; + } + + /** + * judge if it is a node or not + */ + readonly isNode = true; + + /** + * 获取当前节点的锁定状态 + */ + get isLocked(): boolean { + return this[nodeSymbol].isLocked; + } + + /** + * 下标 + */ + get index() { + return this[nodeSymbol].index; + } + + /** + * 图标 + */ + get icon(): IPublicTypeIconType { + return this[nodeSymbol].icon; + } + + /** + * 节点所在树的层级深度,根节点深度为 0 + */ + get zLevel(): number { + return this[nodeSymbol].zLevel; + } + + /** + * 节点 componentName + */ + get componentName(): string { + return this[nodeSymbol].componentName; + } + + /** + * 节点的物料元数据 + */ + get componentMeta(): IPublicModelComponentMeta | null { + return ShellComponentMeta.create(this[nodeSymbol].componentMeta); + } + + /** + * 获取节点所属的文档模型对象 + * @returns + */ + get document(): IPublicModelDocumentModel | null { + return ShellDocumentModel.create(this[documentSymbol]); + } + + /** + * 获取当前节点的前一个兄弟节点 + * @returns + */ + get prevSibling(): IPublicModelNode | null { + return Node.create(this[nodeSymbol].prevSibling); + } + + /** + * 获取当前节点的后一个兄弟节点 + * @returns + */ + get nextSibling(): IPublicModelNode | null { + return Node.create(this[nodeSymbol].nextSibling); + } + + /** + * 获取当前节点的父亲节点 + * @returns + */ + get parent(): IPublicModelNode | null { + return Node.create(this[nodeSymbol].parent); + } + + /** + * 获取当前节点的孩子节点模型 + * @returns + */ + get children(): IPublicModelNodeChildren | null { + return ShellNodeChildren.create(this[nodeSymbol].children); + } + + /** + * 节点上挂载的插槽节点们 + */ + get slots(): IPublicModelNode[] { + return this[nodeSymbol].slots.map((node: InnerNode) => Node.create(node)!); + } + + /** + * 当前节点为插槽节点时,返回节点对应的属性实例 + */ + get slotFor(): IPublicModelProp | null | undefined { + return ShellProp.create(this[nodeSymbol].slotFor); + } + + /** + * 返回节点的属性集 + */ + get props(): IPublicModelProps | null { + return ShellProps.create(this[nodeSymbol].props); + } + + /** + * 返回节点的属性集 + */ + get propsData(): IPublicTypePropsMap | IPublicTypePropsList | null { + return this[nodeSymbol].propsData; + } + + /** + * 获取符合搭建协议 - 节点 schema 结构 + */ + get schema(): IPublicTypeNodeSchema { + return this[nodeSymbol].schema; + } + + get settingEntry(): IPublicModelSettingTopEntry { + return ShellSettingTopEntry.create(this[nodeSymbol].settingEntry as any); + } + + constructor(node: InnerNode) { + this[nodeSymbol] = node; + this[documentSymbol] = node.document; + + this._id = this[nodeSymbol].id; + } + + static create(node: InnerNode | IPublicModelNode | null | undefined): IPublicModelNode | null { + if (!node) { + return null; + } + // @ts-ignore 直接返回已挂载的 shell node 实例 + if (isShellNode(node)) { + return (node as any)[shellNodeSymbol]; + } + const shellNode = new Node(node); + // @ts-ignore 挂载 shell node 实例 + // eslint-disable-next-line no-param-reassign + node[shellNodeSymbol] = shellNode; + return shellNode; + } + + /** + * @deprecated use .children instead + */ + getChildren() { + return this.children; + } + + /** + * 获取节点实例对应的 dom 节点 + */ + getDOMNode() { + return (this[nodeSymbol] as any).getDOMNode(); + } + + /** + * 执行新增、删除、排序等操作 + * @param remover + * @param adder + * @param sorter + */ + mergeChildren( + remover: (node: IPublicModelNode, idx: number) => boolean, + adder: (children: IPublicModelNode[]) => any, + sorter: (firstNode: IPublicModelNode, secondNode: IPublicModelNode) => number, + ): any { + return this.children?.mergeChildren(remover, adder, sorter); + } + + /** + * 返回节点的尺寸、位置信息 + * @returns + */ + getRect(): DOMRect | null { + return this[nodeSymbol].getRect(); + } + + /** + * 是否有挂载插槽节点 + * @returns + */ + hasSlots(): boolean { + return this[nodeSymbol].hasSlots(); + } + + /** + * 是否设定了渲染条件 + * @returns + */ + hasCondition(): boolean { + return this[nodeSymbol].hasCondition(); + } + + /** + * 是否设定了循环数据 + * @returns + */ + hasLoop(): boolean { + return this[nodeSymbol].hasLoop(); + } + + get visible(): boolean { + return this[nodeSymbol].getVisible(); + } + + set visible(value: boolean) { + this[nodeSymbol].setVisible(value); + } + + getVisible(): boolean { + return this[nodeSymbol].getVisible(); + } + + setVisible(flag: boolean): void { + this[nodeSymbol].setVisible(flag); + } + + isConditionalVisible(): boolean | undefined { + return this[nodeSymbol].isConditionalVisible(); + } + + /** + * 设置节点锁定状态 + * @param flag + */ + lock(flag?: boolean): void { + this[nodeSymbol].lock(flag); + } + + /** + * @deprecated use .props instead + */ + getProps() { + return this.props; + } + + contains(node: IPublicModelNode): boolean { + return this[nodeSymbol].contains((node as any)[nodeSymbol]); + } + + /** + * 获取指定 path 的属性模型实例 + * @param path 属性路径,支持 a / a.b / a.0 等格式 + * @returns + */ + getProp(path: string, createIfNone = true): IPublicModelProp | null { + return ShellProp.create(this[nodeSymbol].getProp(path, createIfNone)); + } + + /** + * 获取指定 path 的属性模型实例值 + * @param path 属性路径,支持 a / a.b / a.0 等格式 + * @returns + */ + getPropValue(path: string) { + return this.getProp(path, false)?.getValue(); + } + + /** + * 获取指定 path 的属性模型实例, + * 注:导出时,不同于普通属性,该属性并不挂载在 props 之下,而是与 props 同级 + * @param path 属性路径,支持 a / a.b / a.0 等格式 + * @param createIfNone 当没有属性的时候,是否创建一个属性 + * @returns + */ + getExtraProp(path: string, createIfNone?: boolean): IPublicModelProp | null { + return ShellProp.create(this[nodeSymbol].getExtraProp(path, createIfNone)); + } + + /** + * 获取指定 path 的属性模型实例, + * 注:导出时,不同于普通属性,该属性并不挂载在 props 之下,而是与 props 同级 + * @param path 属性路径,支持 a / a.b / a.0 等格式 + * @returns + */ + getExtraPropValue(path: string): any { + return this.getExtraProp(path)?.getValue(); + } + + /** + * 设置指定 path 的属性模型实例值 + * @param path 属性路径,支持 a / a.b / a.0 等格式 + * @param value 值 + * @returns + */ + setPropValue(path: string, value: IPublicTypeCompositeValue): void { + return this.getProp(path)?.setValue(value); + } + + /** + * 设置指定 path 的属性模型实例值 + * @param path 属性路径,支持 a / a.b / a.0 等格式 + * @param value 值 + * @returns + */ + setExtraPropValue(path: string, value: IPublicTypeCompositeValue): void { + return this.getExtraProp(path)?.setValue(value); + } + + /** + * 导入节点数据 + * @param data + */ + importSchema(data: IPublicTypeNodeSchema): void { + this[nodeSymbol].import(data); + } + + /** + * 导出节点数据 + * @param stage + * @param options + * @returns + */ + exportSchema( + stage: IPublicEnumTransformStage = IPublicEnumTransformStage.Render, + options?: any, + ): IPublicTypeNodeSchema { + return this[nodeSymbol].export(stage, options); + } + + /** + * 在指定位置之前插入一个节点 + * @param node + * @param ref + * @param useMutator + */ + insertBefore( + node: IPublicModelNode, + ref?: IPublicModelNode | undefined, + useMutator?: boolean, + ): void { + this[nodeSymbol].insertBefore( + (node as any)[nodeSymbol] || node, + (ref as any)?.[nodeSymbol], + useMutator, + ); + } + + /** + * 在指定位置之后插入一个节点 + * @param node + * @param ref + * @param useMutator + */ + insertAfter( + node: IPublicModelNode, + ref?: IPublicModelNode | undefined, + useMutator?: boolean, + ): void { + this[nodeSymbol].insertAfter( + (node as any)[nodeSymbol] || node, + (ref as any)?.[nodeSymbol], + useMutator, + ); + } + + /** + * 替换指定节点 + * @param node 待替换的子节点 + * @param data 用作替换的节点对象或者节点描述 + * @returns + */ + replaceChild(node: IPublicModelNode, data: any): IPublicModelNode | null { + return Node.create(this[nodeSymbol].replaceChild((node as any)[nodeSymbol], data)); + } + + /** + * 将当前节点替换成指定节点描述 + * @param schema + */ + replaceWith(schema: IPublicTypeNodeSchema): any { + this[nodeSymbol].replaceWith(schema); + } + + /** + * 选中当前节点实例 + */ + select(): void { + this[nodeSymbol].select(); + } + + /** + * 设置悬停态 + * @param flag + */ + hover(flag = true): void { + this[nodeSymbol].hover(flag); + } + + /** + * 删除当前节点实例 + */ + remove(): void { + this[nodeSymbol].remove(); + } + + /** + * @deprecated + * 设置为磁贴布局节点 + */ + set isRGLContainer(flag: boolean) { + this[nodeSymbol].isRGLContainerNode = flag; + } + + /** + * @deprecated + * 获取磁贴布局节点设置状态 + * @returns Boolean + */ + get isRGLContainer() { + return this[nodeSymbol].isRGLContainerNode; + } + + /** + * 设置为磁贴布局节点 + */ + set isRGLContainerNode(flag: boolean) { + this[nodeSymbol].isRGLContainerNode = flag; + } + + /** + * 获取磁贴布局节点设置状态 + * @returns Boolean + */ + get isRGLContainerNode() { + return this[nodeSymbol].isRGLContainerNode; + } + + internalToShellNode() { + return this; + } + + canPerformAction(actionName: string): boolean { + return this[nodeSymbol].canPerformAction(actionName); + } + + /** + * get conditionGroup + * @since v1.1.0 + */ + get conditionGroup(): IPublicModelExclusiveGroup | null { + return ConditionGroup.create(this[nodeSymbol].conditionGroup); + } + + /** + * set value for conditionalVisible + * @since v1.1.0 + */ + setConditionalVisible(): void { + this[nodeSymbol].setConditionalVisible(); + } + + getRGL() { + const { + isContainerNode, + isEmptyNode, + isRGLContainerNode, + isRGLNode, + isRGL, + rglNode, + } = this[nodeSymbol].getRGL(); + + return { + isContainerNode, + isEmptyNode, + isRGLContainerNode, + isRGLNode, + isRGL, + rglNode: Node.create(rglNode), + }; + } +} diff --git a/packages/shell/src/model/plugin-instance.ts b/packages/shell/src/model/plugin-instance.ts new file mode 100644 index 0000000000..156ec7579c --- /dev/null +++ b/packages/shell/src/model/plugin-instance.ts @@ -0,0 +1,31 @@ +import { ILowCodePluginRuntime } from '@alilc/lowcode-designer'; +import { IPublicModelPluginInstance } from '@alilc/lowcode-types'; +import { pluginInstanceSymbol } from '../symbols'; + +export class PluginInstance implements IPublicModelPluginInstance { + private readonly [pluginInstanceSymbol]: ILowCodePluginRuntime; + + constructor(pluginInstance: ILowCodePluginRuntime) { + this[pluginInstanceSymbol] = pluginInstance; + } + + get pluginName(): string { + return this[pluginInstanceSymbol].name; + } + + get dep(): string[] { + return this[pluginInstanceSymbol].dep; + } + + get disabled(): boolean { + return this[pluginInstanceSymbol].disabled; + } + + set disabled(disabled: boolean) { + this[pluginInstanceSymbol].setDisabled(disabled); + } + + get meta() { + return this[pluginInstanceSymbol].meta; + } +} diff --git a/packages/shell/src/model/prop.ts b/packages/shell/src/model/prop.ts new file mode 100644 index 0000000000..8d4ca7842e --- /dev/null +++ b/packages/shell/src/model/prop.ts @@ -0,0 +1,94 @@ +import { IProp as InnerProp } from '@alilc/lowcode-designer'; +import { IPublicTypeCompositeValue, IPublicEnumTransformStage, IPublicModelProp, IPublicModelNode } from '@alilc/lowcode-types'; +import { propSymbol } from '../symbols'; +import { Node as ShellNode } from './node'; + +export class Prop implements IPublicModelProp { + private readonly [propSymbol]: InnerProp; + + constructor(prop: InnerProp) { + this[propSymbol] = prop; + } + + static create(prop: InnerProp | undefined | null): IPublicModelProp | null { + if (!prop) { + return null; + } + return new Prop(prop); + } + + /** + * id + */ + get id(): string { + return this[propSymbol].id; + } + + /** + * key 值 + * get key of prop + */ + get key(): string | number | undefined { + return this[propSymbol].key; + } + + /** + * 返回当前 prop 的路径 + */ + get path(): string[] { + return this[propSymbol].path; + } + + /** + * 返回所属的节点实例 + */ + get node(): IPublicModelNode | null { + return ShellNode.create(this[propSymbol].getNode()); + } + + /** + * return the slot node (only if the current prop represents a slot) + */ + get slotNode(): IPublicModelNode | null { + return ShellNode.create(this[propSymbol].slotNode); + } + + /** + * judge if it is a prop or not + */ + get isProp(): boolean { + return true; + } + + /** + * 设置值 + * @param val + */ + setValue(val: IPublicTypeCompositeValue): void { + this[propSymbol].setValue(val); + } + + /** + * 获取值 + * @returns + */ + getValue(): any { + return this[propSymbol].getValue(); + } + + /** + * 移除值 + */ + remove(): void { + this[propSymbol].remove(); + } + + /** + * 导出值 + * @param stage + * @returns + */ + exportSchema(stage: IPublicEnumTransformStage = IPublicEnumTransformStage.Render) { + return this[propSymbol].export(stage); + } +} \ No newline at end of file diff --git a/packages/shell/src/model/props.ts b/packages/shell/src/model/props.ts new file mode 100644 index 0000000000..86a9a2142b --- /dev/null +++ b/packages/shell/src/model/props.ts @@ -0,0 +1,118 @@ +import { IProps as InnerProps, getConvertedExtraKey } from '@alilc/lowcode-designer'; +import { IPublicTypeCompositeValue, IPublicModelProps, IPublicModelNode, IPublicModelProp } from '@alilc/lowcode-types'; +import { propsSymbol } from '../symbols'; +import { Node as ShellNode } from './node'; +import { Prop as ShellProp } from './prop'; + +export class Props implements IPublicModelProps { + private readonly [propsSymbol]: InnerProps; + + constructor(props: InnerProps) { + this[propsSymbol] = props; + } + + static create(props: InnerProps | undefined | null): IPublicModelProps | null { + if (!props) { + return null; + } + return new Props(props); + } + + /** + * id + */ + get id(): string { + return this[propsSymbol].id; + } + + /** + * 返回当前 props 的路径 + */ + get path(): string[] { + return this[propsSymbol].path; + } + + /** + * 返回所属的 node 实例 + */ + get node(): IPublicModelNode | null { + return ShellNode.create(this[propsSymbol].getNode()); + } + + /** + * 获取指定 path 的属性模型实例 + * @param path 属性路径,支持 a / a.b / a.0 等格式 + * @returns + */ + getProp(path: string): IPublicModelProp | null { + return ShellProp.create(this[propsSymbol].getProp(path)); + } + + /** + * 获取指定 path 的属性模型实例值 + * @param path 属性路径,支持 a / a.b / a.0 等格式 + * @returns + */ + getPropValue(path: string): any { + return this.getProp(path)?.getValue(); + } + + /** + * 获取指定 path 的属性模型实例, + * 注:导出时,不同于普通属性,该属性并不挂载在 props 之下,而是与 props 同级 + * @param path 属性路径,支持 a / a.b / a.0 等格式 + * @returns + */ + getExtraProp(path: string): IPublicModelProp | null { + return ShellProp.create(this[propsSymbol].getProp(getConvertedExtraKey(path))); + } + + /** + * 获取指定 path 的属性模型实例值 + * 注:导出时,不同于普通属性,该属性并不挂载在 props 之下,而是与 props 同级 + * @param path 属性路径,支持 a / a.b / a.0 等格式 + * @returns + */ + getExtraPropValue(path: string): any { + return this.getExtraProp(path)?.getValue(); + } + + /** + * 设置指定 path 的属性模型实例值 + * @param path 属性路径,支持 a / a.b / a.0 等格式 + * @param value 值 + * @returns + */ + setPropValue(path: string, value: IPublicTypeCompositeValue): void { + return this.getProp(path)?.setValue(value); + } + + /** + * 设置指定 path 的属性模型实例值 + * @param path 属性路径,支持 a / a.b / a.0 等格式 + * @param value 值 + * @returns + */ + setExtraPropValue(path: string, value: IPublicTypeCompositeValue): void { + return this.getExtraProp(path)?.setValue(value); + } + + /** + * test if the specified key is existing or not. + * @param key + * @returns + */ + has(key: string): boolean { + return this[propsSymbol].has(key); + } + + /** + * add a key with given value + * @param value + * @param key + * @returns + */ + add(value: IPublicTypeCompositeValue, key?: string | number | undefined): any { + return this[propsSymbol].add(value, key); + } +} \ No newline at end of file diff --git a/packages/shell/src/model/resource.ts b/packages/shell/src/model/resource.ts new file mode 100644 index 0000000000..29a385b993 --- /dev/null +++ b/packages/shell/src/model/resource.ts @@ -0,0 +1,55 @@ +import { IPublicModelResource } from '@alilc/lowcode-types'; +import { IResource } from '@alilc/lowcode-workspace'; +import { resourceSymbol } from '../symbols'; + +export class Resource implements IPublicModelResource { + readonly [resourceSymbol]: IResource; + + constructor(resource: IResource) { + this[resourceSymbol] = resource; + } + + get title() { + return this[resourceSymbol].title; + } + + get id() { + return this[resourceSymbol].id; + } + + get icon() { + return this[resourceSymbol].icon; + } + + get options() { + return this[resourceSymbol].options; + } + + get name() { + return this[resourceSymbol].resourceType.name; + } + + get config() { + return this[resourceSymbol].config; + } + + get type() { + return this[resourceSymbol].resourceType.type; + } + + get category() { + return this[resourceSymbol].category; + } + + get description() { + return this[resourceSymbol].description; + } + + get children() { + return this[resourceSymbol].children.map((child) => new Resource(child)); + } + + get viewName() { + return this[resourceSymbol].viewName; + } +} \ No newline at end of file diff --git a/packages/shell/src/model/selection.ts b/packages/shell/src/model/selection.ts new file mode 100644 index 0000000000..073083a650 --- /dev/null +++ b/packages/shell/src/model/selection.ts @@ -0,0 +1,118 @@ +import { + IDocumentModel as InnerDocumentModel, + INode as InnerNode, + ISelection, +} from '@alilc/lowcode-designer'; +import { Node as ShellNode } from './node'; +import { selectionSymbol } from '../symbols'; +import { IPublicModelSelection, IPublicModelNode, IPublicTypeDisposable } from '@alilc/lowcode-types'; + +export class Selection implements IPublicModelSelection { + private readonly [selectionSymbol]: ISelection; + + constructor(document: InnerDocumentModel) { + this[selectionSymbol] = document.selection; + } + + /** + * 返回选中的节点 id + */ + get selected(): string[] { + return this[selectionSymbol].selected; + } + + /** + * return selected Node instance + */ + get node(): IPublicModelNode | null { + const nodes = this.getNodes(); + return nodes && nodes.length > 0 ? nodes[0] : null; + } + + /** + * 选中指定节点(覆盖方式) + * @param id + */ + select(id: string): void { + this[selectionSymbol].select(id); + } + + /** + * 批量选中指定节点们 + * @param ids + */ + selectAll(ids: string[]): void { + this[selectionSymbol].selectAll(ids); + } + + /** + * 移除选中的指定节点 + * @param id + */ + remove(id: string): void { + this[selectionSymbol].remove(id); + } + + /** + * 清除所有选中节点 + */ + clear(): void { + this[selectionSymbol].clear(); + } + + /** + * 判断是否选中了指定节点 + * @param id + * @returns + */ + has(id: string): boolean { + return this[selectionSymbol].has(id); + } + + /** + * 选中指定节点(增量方式) + * @param id + */ + add(id: string): void { + this[selectionSymbol].add(id); + } + + /** + * 获取选中的节点实例 + * @returns + */ + getNodes(): IPublicModelNode[] { + const innerNodes = this[selectionSymbol].getNodes(); + const nodes: IPublicModelNode[] = []; + innerNodes.forEach((node: InnerNode) => { + const shellNode = ShellNode.create(node); + if (shellNode) { + nodes.push(shellNode); + } + }); + return nodes; + } + + /** + * 获取选区的顶层节点 + * for example: + * getNodes() returns [A, subA, B], then + * getTopNodes() will return [A, B], subA will be removed + * @returns + */ + getTopNodes(includeRoot: boolean = false): IPublicModelNode[] { + const innerNodes = this[selectionSymbol].getTopNodes(includeRoot); + const nodes: IPublicModelNode[] = []; + innerNodes.forEach((node: InnerNode) => { + const shellNode = ShellNode.create(node); + if (shellNode) { + nodes.push(shellNode); + } + }); + return nodes; + } + + onSelectionChange(fn: (ids: string[]) => void): IPublicTypeDisposable { + return this[selectionSymbol].onSelectionChange(fn); + } +} diff --git a/packages/shell/src/model/setting-field.ts b/packages/shell/src/model/setting-field.ts new file mode 100644 index 0000000000..ffc97ccc8f --- /dev/null +++ b/packages/shell/src/model/setting-field.ts @@ -0,0 +1,307 @@ +import { ISettingField, isSettingField } from '@alilc/lowcode-designer'; +import { + IPublicTypeCompositeValue, + IPublicTypeFieldConfig, + IPublicTypeCustomView, + IPublicTypeSetterType, + IPublicTypeFieldExtraProps, + IPublicModelSettingTopEntry, + IPublicModelNode, + IPublicModelComponentMeta, + IPublicTypeSetValueOptions, + IPublicModelSettingField, + IPublicTypeDisposable, +} from '@alilc/lowcode-types'; +import { settingFieldSymbol } from '../symbols'; +import { Node as ShellNode } from './node'; +import { SettingTopEntry, SettingTopEntry as ShellSettingTopEntry } from './setting-top-entry'; +import { ComponentMeta as ShellComponentMeta } from './component-meta'; +import { isCustomView } from '@alilc/lowcode-utils'; + +export class SettingField implements IPublicModelSettingField { + private readonly [settingFieldSymbol]: ISettingField; + + constructor(prop: ISettingField) { + this[settingFieldSymbol] = prop; + } + + static create(prop: ISettingField): IPublicModelSettingField { + return new SettingField(prop); + } + + /** + * 获取设置属性的 isGroup + */ + get isGroup(): boolean { + return this[settingFieldSymbol].isGroup; + } + + /** + * 获取设置属性的 id + */ + get id(): string { + return this[settingFieldSymbol].id; + } + + /** + * 获取设置属性的 name + */ + get name(): string | number | undefined { + return this[settingFieldSymbol].name; + } + + /** + * 获取设置属性的 key + */ + get key(): string | number | undefined { + return this[settingFieldSymbol].getKey(); + } + + /** + * 获取设置属性的 path + */ + get path(): any[] { + return this[settingFieldSymbol].path; + } + + /** + * 获取设置属性的 title + */ + get title(): any { + return this[settingFieldSymbol].title; + } + + /** + * 获取设置属性的 setter + */ + get setter(): IPublicTypeSetterType | null { + return this[settingFieldSymbol].setter; + } + + /** + * 获取设置属性的 expanded + */ + get expanded(): boolean { + return this[settingFieldSymbol].expanded; + } + + /** + * 获取设置属性的 extraProps + */ + get extraProps(): IPublicTypeFieldExtraProps { + return this[settingFieldSymbol].extraProps; + } + + get props(): IPublicModelSettingTopEntry { + return ShellSettingTopEntry.create(this[settingFieldSymbol].props); + } + + /** + * 获取设置属性对应的节点实例 + */ + get node(): IPublicModelNode | null { + return ShellNode.create(this[settingFieldSymbol].getNode()); + } + + /** + * 获取设置属性的父设置属性 + */ + get parent(): IPublicModelSettingField | IPublicModelSettingTopEntry { + if (isSettingField(this[settingFieldSymbol].parent)) { + return SettingField.create(this[settingFieldSymbol].parent); + } + + return SettingTopEntry.create(this[settingFieldSymbol].parent); + } + + /** + * 获取顶级设置属性 + */ + get top(): IPublicModelSettingTopEntry { + return ShellSettingTopEntry.create(this[settingFieldSymbol].top); + } + + /** + * 是否是 SettingField 实例 + */ + get isSettingField(): boolean { + return this[settingFieldSymbol].isSettingField; + } + + /** + * componentMeta + */ + get componentMeta(): IPublicModelComponentMeta | null { + return ShellComponentMeta.create(this[settingFieldSymbol].componentMeta); + } + + /** + * 获取设置属性的 items + */ + get items(): Array<IPublicModelSettingField | IPublicTypeCustomView> { + return this[settingFieldSymbol].items?.map((item) => { + if (isCustomView(item)) { + return item; + } + return item.internalToShellField(); + }); + } + + /** + * 设置 key 值 + * @param key + */ + setKey(key: string | number): void { + this[settingFieldSymbol].setKey(key); + } + + /** + * @deprecated use .node instead + */ + getNode() { + return this.node; + } + + /** + * @deprecated use .parent instead + */ + getParent() { + return this.parent; + } + + /** + * 设置值 + * @param val 值 + */ + setValue(val: IPublicTypeCompositeValue, extraOptions?: IPublicTypeSetValueOptions): void { + this[settingFieldSymbol].setValue(val, false, false, extraOptions); + } + + /** + * 设置子级属性值 + * @param propName 子属性名 + * @param value 值 + */ + setPropValue(propName: string | number, value: any): void { + this[settingFieldSymbol].setPropValue(propName, value); + } + + /** + * 清空指定属性值 + * @param propName + */ + clearPropValue(propName: string | number): void { + this[settingFieldSymbol].clearPropValue(propName); + } + + /** + * 获取配置的默认值 + * @returns + */ + getDefaultValue(): any { + return this[settingFieldSymbol].getDefaultValue(); + } + + /** + * 获取值 + * @returns + */ + getValue(): any { + return this[settingFieldSymbol].getValue(); + } + + /** + * 获取子级属性值 + * @param propName 子属性名 + * @returns + */ + getPropValue(propName: string | number): any { + return this[settingFieldSymbol].getPropValue(propName); + } + + /** + * 获取顶层附属属性值 + */ + getExtraPropValue(propName: string): any { + return this[settingFieldSymbol].getExtraPropValue(propName); + } + + /** + * 设置顶层附属属性值 + */ + setExtraPropValue(propName: string, value: any): void { + this[settingFieldSymbol].setExtraPropValue(propName, value); + } + + /** + * 获取设置属性集 + * @returns + */ + getProps(): IPublicModelSettingTopEntry { + return ShellSettingTopEntry.create(this[settingFieldSymbol].getProps()); + } + + /** + * 是否绑定了变量 + * @returns + */ + isUseVariable(): boolean { + return this[settingFieldSymbol].isUseVariable(); + } + + /** + * 设置绑定变量 + * @param flag + */ + setUseVariable(flag: boolean): void { + this[settingFieldSymbol].setUseVariable(flag); + } + + /** + * 创建一个设置 field 实例 + * @param config + * @returns + */ + createField(config: IPublicTypeFieldConfig): IPublicModelSettingField { + return SettingField.create(this[settingFieldSymbol].createField(config)); + } + + /** + * 获取值,当为变量时,返回 mock + * @returns + */ + getMockOrValue(): any { + return this[settingFieldSymbol].getMockOrValue(); + } + + /** + * 销毁当前 field 实例 + */ + purge(): void { + this[settingFieldSymbol].purge(); + } + + /** + * 移除当前 field 实例 + */ + remove(): void { + this[settingFieldSymbol].remove(); + } + + /** + * 设置 autorun + * @param action + * @returns + */ + onEffect(action: () => void): IPublicTypeDisposable { + return this[settingFieldSymbol].onEffect(action); + } + + /** + * 返回 shell 模型,兼容某些场景下 field 已经是 shell field 了 + * @returns + */ + internalToShellField() { + return this; + } +} diff --git a/packages/shell/src/model/setting-top-entry.ts b/packages/shell/src/model/setting-top-entry.ts new file mode 100644 index 0000000000..8afed43a50 --- /dev/null +++ b/packages/shell/src/model/setting-top-entry.ts @@ -0,0 +1,62 @@ +import { ISettingTopEntry } from '@alilc/lowcode-designer'; +import { settingTopEntrySymbol } from '../symbols'; +import { Node as ShellNode } from './node'; +import { IPublicModelSettingTopEntry, IPublicModelNode, IPublicModelSettingField } from '@alilc/lowcode-types'; +import { SettingField } from './setting-field'; + +export class SettingTopEntry implements IPublicModelSettingTopEntry { + private readonly [settingTopEntrySymbol]: ISettingTopEntry; + + constructor(prop: ISettingTopEntry) { + this[settingTopEntrySymbol] = prop; + } + + static create(prop: ISettingTopEntry): IPublicModelSettingTopEntry { + return new SettingTopEntry(prop); + } + + /** + * 返回所属的节点实例 + */ + get node(): IPublicModelNode | null { + return ShellNode.create(this[settingTopEntrySymbol].getNode()); + } + + /** + * 获取子级属性对象 + * @param propName + * @returns + */ + get(propName: string | number): IPublicModelSettingField { + return SettingField.create(this[settingTopEntrySymbol].get(propName)!); + } + + /** + * @deprecated use .node instead + */ + getNode() { + return this.node; + } + + /** + * 获取指定 propName 的值 + * @param propName + * @returns + */ + getPropValue(propName: string | number): any { + return this[settingTopEntrySymbol].getPropValue(propName); + } + + /** + * 设置指定 propName 的值 + * @param propName + * @param value + */ + setPropValue(propName: string | number, value: any): void { + this[settingTopEntrySymbol].setPropValue(propName, value); + } + + clearPropValue(propName: string | number) { + this[settingTopEntrySymbol].clearPropValue(propName); + } +} \ No newline at end of file diff --git a/packages/shell/src/model/simulator-render.ts b/packages/shell/src/model/simulator-render.ts new file mode 100644 index 0000000000..f6ae47996c --- /dev/null +++ b/packages/shell/src/model/simulator-render.ts @@ -0,0 +1,23 @@ +import { IPublicModelSimulatorRender } from '@alilc/lowcode-types'; +import { simulatorRenderSymbol } from '../symbols'; +import { BuiltinSimulatorRenderer } from '@alilc/lowcode-designer'; + +export class SimulatorRender implements IPublicModelSimulatorRender { + private readonly [simulatorRenderSymbol]: BuiltinSimulatorRenderer; + + constructor(simulatorRender: BuiltinSimulatorRenderer) { + this[simulatorRenderSymbol] = simulatorRender; + } + + static create(simulatorRender: BuiltinSimulatorRenderer): IPublicModelSimulatorRender { + return new SimulatorRender(simulatorRender); + } + + get components() { + return this[simulatorRenderSymbol].components; + } + + rerender() { + return this[simulatorRenderSymbol].rerender(); + } +} \ No newline at end of file diff --git a/packages/shell/src/model/skeleton-item.ts b/packages/shell/src/model/skeleton-item.ts new file mode 100644 index 0000000000..7f1224c0d9 --- /dev/null +++ b/packages/shell/src/model/skeleton-item.ts @@ -0,0 +1,39 @@ +import { skeletonItemSymbol } from '../symbols'; +import { IPublicModelSkeletonItem } from '@alilc/lowcode-types'; +import { Dock, IWidget, Panel, PanelDock, Stage, Widget } from '@alilc/lowcode-editor-skeleton'; + +export class SkeletonItem implements IPublicModelSkeletonItem { + private [skeletonItemSymbol]: IWidget | Widget | Panel | Stage | Dock | PanelDock; + + constructor(skeletonItem: IWidget | Widget | Panel | Stage | Dock | PanelDock) { + this[skeletonItemSymbol] = skeletonItem; + } + + get name() { + return this[skeletonItemSymbol].name; + } + + get visible() { + return this[skeletonItemSymbol].visible; + } + + disable() { + this[skeletonItemSymbol].disable?.(); + } + + enable() { + this[skeletonItemSymbol].enable?.(); + } + + hide() { + this[skeletonItemSymbol].hide(); + } + + show() { + this[skeletonItemSymbol].show(); + } + + toggle() { + this[skeletonItemSymbol].toggle(); + } +} \ No newline at end of file diff --git a/packages/shell/src/model/window.ts b/packages/shell/src/model/window.ts new file mode 100644 index 0000000000..1bc84e661c --- /dev/null +++ b/packages/shell/src/model/window.ts @@ -0,0 +1,60 @@ +import { windowSymbol } from '../symbols'; +import { IPublicModelResource, IPublicModelWindow, IPublicTypeDisposable } from '@alilc/lowcode-types'; +import { IEditorWindow } from '@alilc/lowcode-workspace'; +import { Resource as ShellResource } from './resource'; +import { EditorView } from './editor-view'; + +export class Window implements IPublicModelWindow { + private readonly [windowSymbol]: IEditorWindow; + + get id() { + return this[windowSymbol]?.id; + } + + get title() { + return this[windowSymbol].title; + } + + get icon() { + return this[windowSymbol].icon; + } + + get resource(): IPublicModelResource { + return new ShellResource(this[windowSymbol].resource); + } + + constructor(editorWindow: IEditorWindow) { + this[windowSymbol] = editorWindow; + } + + importSchema(schema: any): any { + this[windowSymbol].importSchema(schema); + } + + changeViewType(viewName: string) { + this[windowSymbol].changeViewName(viewName, false); + } + + onChangeViewType(fun: (viewName: string) => void): IPublicTypeDisposable { + return this[windowSymbol].onChangeViewType(fun); + } + + async save() { + return await this[windowSymbol].save(); + } + + onSave(fn: () => void) { + return this[windowSymbol].onSave(fn); + } + + get currentEditorView() { + if (this[windowSymbol]._editorView) { + return new EditorView(this[windowSymbol]._editorView).toProxy() as any; + } + return null; + } + + get editorViews() { + return Array.from(this[windowSymbol].editorViews.values()).map(d => new EditorView(d).toProxy() as any); + } +} diff --git a/packages/shell/src/node-children.ts b/packages/shell/src/node-children.ts deleted file mode 100644 index 024e5b2967..0000000000 --- a/packages/shell/src/node-children.ts +++ /dev/null @@ -1,208 +0,0 @@ -import { NodeChildren as InnerNodeChildren, Node as InnerNode } from '@alilc/lowcode-designer'; -import { NodeSchema, NodeData, TransformStage } from '@alilc/lowcode-types'; -import Node from './node'; -import { nodeSymbol, nodeChildrenSymbol } from './symbols'; - -export default class NodeChildren { - private readonly [nodeChildrenSymbol]: InnerNodeChildren; - - constructor(nodeChildren: InnerNodeChildren) { - this[nodeChildrenSymbol] = nodeChildren; - } - - static create(nodeChldren: InnerNodeChildren | null) { - if (!nodeChldren) return null; - return new NodeChildren(nodeChldren); - } - - /** - * 返回当前 children 实例所属的节点实例 - */ - get owner(): Node | null { - return Node.create(this[nodeChildrenSymbol].owner); - } - - /** - * children 内的节点实例数 - */ - get size() { - return this[nodeChildrenSymbol].size; - } - - /** - * 是否为空 - * @returns - */ - get isEmpty() { - return this[nodeChildrenSymbol].isEmpty(); - } - - /** - * 删除指定节点 - * @param node - * @returns - */ - delete(node: Node) { - return this[nodeChildrenSymbol].delete(node[nodeSymbol]); - } - - /** - * 插入一个节点 - * @param node 待插入节点 - * @param at 插入下标 - * @returns - */ - insert(node: Node, at?: number | null) { - return this[nodeChildrenSymbol].insert(node[nodeSymbol], at, true); - } - - /** - * 返回指定节点的下标 - * @param node - * @returns - */ - indexOf(node: Node) { - return this[nodeChildrenSymbol].indexOf(node[nodeSymbol]); - } - - /** - * 类似数组 splice 操作 - * @param start - * @param deleteCount - * @param node - */ - splice(start: number, deleteCount: number, node?: Node) { - this[nodeChildrenSymbol].splice(start, deleteCount, node?.[nodeSymbol]); - } - - /** - * 返回指定下标的节点 - * @param index - * @returns - */ - get(index: number) { - return this[nodeChildrenSymbol].get(index); - } - - /** - * 是否包含指定节点 - * @param node - * @returns - */ - has(node: Node) { - return this[nodeChildrenSymbol].has(node[nodeSymbol]); - } - - /** - * 类似数组的 forEach - * @param fn - */ - forEach(fn: (node: Node, index: number) => void) { - this[nodeChildrenSymbol].forEach((item: InnerNode<NodeSchema>, index: number) => { - fn(Node.create(item)!, index); - }); - } - - /** - * 类似数组的 map - * @param fn - */ - map<T>(fn: (node: Node, index: number) => T[]) { - return this[nodeChildrenSymbol].map((item: InnerNode<NodeSchema>, index: number) => { - return fn(Node.create(item)!, index); - }); - } - - /** - * 类似数组的 every - * @param fn - */ - every(fn: (node: Node, index: number) => boolean) { - return this[nodeChildrenSymbol].every((item: InnerNode<NodeSchema>, index: number) => { - return fn(Node.create(item)!, index); - }); - } - - /** - * 类似数组的 some - * @param fn - */ - some(fn: (node: Node, index: number) => boolean) { - return this[nodeChildrenSymbol].some((item: InnerNode<NodeSchema>, index: number) => { - return fn(Node.create(item)!, index); - }); - } - - /** - * 类似数组的 filter - * @param fn - */ - filter(fn: (node: Node, index: number) => boolean) { - return this[nodeChildrenSymbol] - .filter((item: InnerNode<NodeSchema>, index: number) => { - return fn(Node.create(item)!, index); - }) - .map((item: InnerNode<NodeSchema>) => Node.create(item)!); - } - - /** - * 类似数组的 find - * @param fn - */ - find(fn: (node: Node, index: number) => boolean) { - return Node.create( - this[nodeChildrenSymbol].find((item: InnerNode<NodeSchema>, index: number) => { - return fn(Node.create(item)!, index); - }), - ); - } - - /** - * 类似数组的 reduce - * @param fn - */ - reduce(fn: (acc: any, cur: Node) => any, initialValue: any) { - return this[nodeChildrenSymbol].reduce((acc: any, cur: InnerNode) => { - return fn(acc, Node.create(cur)!); - }, initialValue); - } - - /** - * 导入 schema - * @param data - */ - importSchema(data?: NodeData | NodeData[]) { - this[nodeChildrenSymbol].import(data); - } - - /** - * 导出 schema - * @param stage - * @returns - */ - exportSchema(stage: TransformStage = TransformStage.Render) { - return this[nodeChildrenSymbol].export(stage); - } - - /** - * 执行新增、删除、排序等操作 - * @param remover - * @param adder - * @param sorter - */ - mergeChildren( - remover: (node: Node, idx: number) => boolean, - adder: (children: Node[]) => any, - sorter: (firstNode: Node, secondNode: Node) => number, - ) { - if (!sorter) { - sorter = () => 0; - } - this[nodeChildrenSymbol].mergeChildren( - (node: InnerNode, idx: number) => remover(Node.create(node)!, idx), - (children: InnerNode[]) => adder(children.map((node) => Node.create(node)!)), - (firstNode: InnerNode, secondNode: InnerNode) => - sorter(Node.create(firstNode)!, Node.create(secondNode)!), - ); - } -} diff --git a/packages/shell/src/node.ts b/packages/shell/src/node.ts deleted file mode 100644 index 27b8a62fa3..0000000000 --- a/packages/shell/src/node.ts +++ /dev/null @@ -1,403 +0,0 @@ -import { - DocumentModel as InnerDocumentModel, - Node as InnerNode, - getConvertedExtraKey, -} from '@alilc/lowcode-designer'; -import { CompositeValue, NodeSchema, TransformStage } from '@alilc/lowcode-types'; -import Prop from './prop'; -import Props from './props'; -import DocumentModel from './document-model'; -import NodeChildren from './node-children'; -import ComponentMeta from './component-meta'; -import { documentSymbol, nodeSymbol } from './symbols'; - -const shellNodeSymbol = Symbol('shellNodeSymbol'); - -export default class Node { - private readonly [documentSymbol]: InnerDocumentModel; - private readonly [nodeSymbol]: InnerNode; - - constructor(node: InnerNode) { - this[nodeSymbol] = node; - this[documentSymbol] = node.document; - } - - static create(node: InnerNode | null | undefined) { - if (!node) return null; - // @ts-ignore 直接返回已挂载的 shell node 实例 - if (node[shellNodeSymbol]) return node[shellNodeSymbol]; - const shellNode = new Node(node); - // @ts-ignore 挂载 shell node 实例 - node[shellNodeSymbol] = shellNode; - return shellNode; - } - - /** - * 节点 id - */ - get id() { - return this[nodeSymbol].id; - } - - /** - * 节点标题 - */ - get title() { - return this[nodeSymbol].title; - } - - /** - * 是否为「容器型」节点 - */ - get isContainer() { - return this[nodeSymbol].isContainer(); - } - - /** - * 是否为根节点 - */ - get isRoot() { - return this[nodeSymbol].isRoot(); - } - - /** - * 是否为空节点(无 children 或者 children 为空) - */ - get isEmpty() { - return this[nodeSymbol].isEmpty(); - } - - /** - * 是否为 Page 节点 - */ - get isPage() { - return this[nodeSymbol].isPage(); - } - - /** - * 是否为 Component 节点 - */ - get isComponent() { - return this[nodeSymbol].isComponent(); - } - - /** - * 是否为「模态框」节点 - */ - get isModal() { - return this[nodeSymbol].isModal(); - } - - /** - * 是否为插槽节点 - */ - get isSlot() { - return this[nodeSymbol].isSlot(); - } - - /** - * 是否为父类/分支节点 - */ - get isParental() { - return this[nodeSymbol].isParental(); - } - - /** - * 是否为叶子节点 - */ - get isLeaf() { - return this[nodeSymbol].isLeaf(); - } - - /** - * 下标 - */ - get index() { - return this[nodeSymbol].index; - } - - /** - * 图标 - */ - get icon() { - return this[nodeSymbol].icon; - } - - /** - * 节点所在树的层级深度,根节点深度为 0 - */ - get zLevel() { - return this[nodeSymbol].zLevel; - } - - /** - * 节点 componentName - */ - get componentName() { - return this[nodeSymbol].componentName; - } - - /** - * 节点的物料元数据 - */ - get componentMeta() { - return ComponentMeta.create(this[nodeSymbol].componentMeta); - } - - /** - * 获取节点所属的文档模型对象 - * @returns - */ - get document() { - return DocumentModel.create(this[documentSymbol]); - } - - /** - * 获取当前节点的前一个兄弟节点 - * @returns - */ - get prevSibling(): Node | null { - return Node.create(this[nodeSymbol].prevSibling); - } - - /** - * 获取当前节点的后一个兄弟节点 - * @returns - */ - get nextSibling(): Node | null { - return Node.create(this[nodeSymbol].nextSibling); - } - - /** - * 获取当前节点的父亲节点 - * @returns - */ - get parent(): Node | null { - return Node.create(this[nodeSymbol].parent); - } - - /** - * 获取当前节点的孩子节点模型 - * @returns - */ - get children() { - return NodeChildren.create(this[nodeSymbol].children); - } - - /** - * 节点上挂载的插槽节点们 - */ - get slots(): Node[] { - return this[nodeSymbol].slots.map((node: InnerNode) => Node.create(node)!); - } - - /** - * 当前节点为插槽节点时,返回节点对应的属性实例 - */ - get slotFor() { - return Prop.create(this[nodeSymbol].slotFor); - } - - /** - * 返回节点的属性集 - */ - get props() { - return Props.create(this[nodeSymbol].props); - } - - /** - * 返回节点的属性集 - */ - get propsData() { - return this[nodeSymbol].propsData; - } - - /** - * @deprecated use .children instead - */ - getChildren() { - return this.children; - } - - /** - * 获取节点实例对应的 dom 节点 - */ - getDOMNode() { - return this[nodeSymbol].getDOMNode(); - } - - /** - * 返回节点的尺寸、位置信息 - * @returns - */ - getRect() { - return this[nodeSymbol].getRect(); - } - - /** - * 是否有挂载插槽节点 - * @returns - */ - hasSlots() { - return this[nodeSymbol].hasSlots(); - } - - /** - * 是否设定了渲染条件 - * @returns - */ - hasCondition() { - return this[nodeSymbol].hasCondition(); - } - - /** - * 是否设定了循环数据 - * @returns - */ - hasLoop() { - return this[nodeSymbol].hasLoop(); - } - - /** - * @deprecated use .props instead - */ - getProps() { - return this.props; - } - - /** - * 获取指定 path 的属性模型实例 - * @param path 属性路径,支持 a / a.b / a.0 等格式 - * @returns - */ - getProp(path: string): Prop | null { - return Prop.create(this[nodeSymbol].getProp(path)); - } - - /** - * 获取指定 path 的属性模型实例值 - * @param path 属性路径,支持 a / a.b / a.0 等格式 - * @returns - */ - getPropValue(path: string) { - return this.getProp(path)?.getValue(); - } - - /** - * 获取指定 path 的属性模型实例, - * 注:导出时,不同于普通属性,该属性并不挂载在 props 之下,而是与 props 同级 - * @param path 属性路径,支持 a / a.b / a.0 等格式 - * @returns - */ - getExtraProp(path: string): Prop | null { - return Prop.create(this[nodeSymbol].getProp(getConvertedExtraKey(path))); - } - - /** - * 获取指定 path 的属性模型实例, - * 注:导出时,不同于普通属性,该属性并不挂载在 props 之下,而是与 props 同级 - * @param path 属性路径,支持 a / a.b / a.0 等格式 - * @returns - */ - getExtraPropValue(path: string) { - return this.getExtraProp(path)?.getValue(); - } - - /** - * 设置指定 path 的属性模型实例值 - * @param path 属性路径,支持 a / a.b / a.0 等格式 - * @param value 值 - * @returns - */ - setPropValue(path: string, value: CompositeValue) { - return this.getProp(path)?.setValue(value); - } - - /** - * 设置指定 path 的属性模型实例值 - * @param path 属性路径,支持 a / a.b / a.0 等格式 - * @param value 值 - * @returns - */ - setExtraPropValue(path: string, value: CompositeValue) { - return this.getExtraProp(path)?.setValue(value); - } - - /** - * 导入节点数据 - * @param data - */ - importSchema(data: NodeSchema) { - this[nodeSymbol].import(data); - } - - /** - * 导出节点数据 - * @param stage - * @param options - * @returns - */ - exportSchema(stage: TransformStage = TransformStage.Render, options?: any) { - return this[nodeSymbol].export(stage, options); - } - - /** - * 在指定位置之前插入一个节点 - * @param node - * @param ref - * @param useMutator - */ - insertBefore(node: Node, ref?: Node | undefined, useMutator?: boolean) { - this[nodeSymbol].insertBefore(node[nodeSymbol] || node, ref?.[nodeSymbol], useMutator); - } - - /** - * 在指定位置之后插入一个节点 - * @param node - * @param ref - * @param useMutator - */ - insertAfter(node: Node, ref?: Node | undefined, useMutator?: boolean) { - this[nodeSymbol].insertAfter(node[nodeSymbol] || node, ref?.[nodeSymbol], useMutator); - } - - /** - * 替换指定节点 - * @param node 待替换的子节点 - * @param data 用作替换的节点对象或者节点描述 - * @returns - */ - replaceChild(node: Node, data: any) { - return Node.create(this[nodeSymbol].replaceChild(node[nodeSymbol], data)); - } - - /** - * 将当前节点替换成指定节点描述 - * @param schema - */ - replaceWith(schema: NodeSchema) { - this[nodeSymbol].replaceWith(schema); - } - - /** - * 选中当前节点实例 - */ - select() { - this[nodeSymbol].select(); - } - - /** - * 设置悬停态 - * @param flag - */ - hover(flag = true) { - this[nodeSymbol].hover(flag); - } - - /** - * 删除当前节点实例 - */ - remove() { - this[nodeSymbol].remove(); - } -} diff --git a/packages/shell/src/project.ts b/packages/shell/src/project.ts deleted file mode 100644 index 7bcc900954..0000000000 --- a/packages/shell/src/project.ts +++ /dev/null @@ -1,176 +0,0 @@ -import { - BuiltinSimulatorHost, - Project as InnerProject, - PropsReducer as PropsTransducer, - TransformStage, -} from '@alilc/lowcode-designer'; -import { RootSchema, ProjectSchema } from '@alilc/lowcode-types'; -import DocumentModel from './document-model'; -import SimulatorHost from './simulator-host'; -import { projectSymbol, simulatorHostSymbol, simulatorRendererSymbol, documentSymbol } from './symbols'; - -export default class Project { - private readonly [projectSymbol]: InnerProject; - private [simulatorHostSymbol]: BuiltinSimulatorHost; - private [simulatorRendererSymbol]: any; - - constructor(project: InnerProject) { - this[projectSymbol] = project; - } - - static create(project: InnerProject) { - return new Project(project); - } - - /** - * 获取当前的 document - * @returns - */ - get currentDocument(): DocumentModel | null { - return this.getCurrentDocument(); - } - - /** - * 获取当前 project 下所有 documents - * @returns - */ - get documents(): DocumentModel[] { - return this[projectSymbol].documents.map((doc) => DocumentModel.create(doc)!); - } - - /** - * 获取模拟器的 host - */ - get simulatorHost() { - return SimulatorHost.create(this[projectSymbol].simulator as any || this[simulatorHostSymbol]); - } - - /** - * @deprecated use .simulatorHost instead. - */ - get simulator() { - return this.simulatorHost; - } - - /** - * 打开一个 document - * @param doc - * @returns - */ - openDocument(doc?: string | RootSchema | undefined) { - const documentModel = this[projectSymbol].open(doc); - if (!documentModel) return null; - return DocumentModel.create(documentModel); - } - - /** - * 创建一个 document - * @param data - * @returns - */ - createDocument(data?: RootSchema): DocumentModel | null { - const doc = this[projectSymbol].createDocument(data); - return DocumentModel.create(doc); - } - - /** - * 删除一个 document - * @param doc - */ - removeDocument(doc: DocumentModel) { - this[projectSymbol].removeDocument(doc[documentSymbol]); - } - - /** - * 根据 fileName 获取 document - * @param fileName - * @returns - */ - getDocumentByFileName(fileName: string): DocumentModel | null { - return DocumentModel.create(this[projectSymbol].getDocumentByFileName(fileName)); - } - - /** - * 根据 id 获取 document - * @param id - * @returns - */ - getDocumentById(id: string): DocumentModel | null { - return DocumentModel.create(this[projectSymbol].getDocument(id)); - } - - /** - * 导出 project - * @returns - */ - exportSchema() { - return this[projectSymbol].getSchema(); - } - - /** - * 导入 project - * @param schema 待导入的 project 数据 - */ - importSchema(schema?: ProjectSchema) { - this[projectSymbol].load(schema, true); - } - - /** - * 获取当前的 document - * @returns - */ - getCurrentDocument(): DocumentModel | null { - return DocumentModel.create(this[projectSymbol].currentDocument); - } - - /** - * 增加一个属性的管道处理函数 - * @param transducer - * @param stage - */ - addPropsTransducer(transducer: PropsTransducer, stage: TransformStage) { - this[projectSymbol].designer.addPropsReducer(transducer, stage); - } - - /** - * 当前 project 内的 document 变更事件 - */ - onChangeDocument(fn: (doc: DocumentModel) => void) { - if (this[projectSymbol].currentDocument) { - fn(DocumentModel.create(this[projectSymbol].currentDocument)!); - return () => {}; - } - return this[projectSymbol].onCurrentDocumentChange((originalDoc) => { - fn(DocumentModel.create(originalDoc)!); - }); - } - - /** - * 当前 project 的模拟器 ready 事件 - */ - onSimulatorHostReady(fn: (host: SimulatorHost) => void) { - if (this[simulatorHostSymbol]) { - fn(SimulatorHost.create(this[simulatorHostSymbol])!); - return () => {}; - } - return this[projectSymbol].onSimulatorReady((simulator: BuiltinSimulatorHost) => { - this[simulatorHostSymbol] = simulator; - fn(SimulatorHost.create(simulator)!); - }); - } - - /** - * 当前 project 的渲染器 ready 事件 - */ - onSimulatorRendererReady(fn: () => void) { - if (this[simulatorRendererSymbol]) { - fn(); - return () => {}; - } - // TODO: 补充 renderer 实例 - return this[projectSymbol].onRendererReady((renderer: any) => { - this[simulatorRendererSymbol] = renderer; - fn(); - }); - } -} diff --git a/packages/shell/src/prop.ts b/packages/shell/src/prop.ts deleted file mode 100644 index f7967f7dba..0000000000 --- a/packages/shell/src/prop.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { Prop as InnerProp } from '@alilc/lowcode-designer'; -import { CompositeValue, TransformStage } from '@alilc/lowcode-types'; -import { propSymbol } from './symbols'; -import Node from './node'; - -export default class Prop { - private readonly [propSymbol]: InnerProp; - - constructor(prop: InnerProp) { - this[propSymbol] = prop; - } - - static create(prop: InnerProp | undefined | null) { - if (!prop) return null; - return new Prop(prop); - } - - /** - * id - */ - get id() { - return this[propSymbol].id; - } - - /** - * key 值 - */ - get key() { - return this[propSymbol].key; - } - - /** - * 返回当前 prop 的路径 - */ - get path() { - return this[propSymbol].path; - } - - /** - * 返回所属的节点实例 - */ - get node(): Node | null { - return Node.create(this[propSymbol].getNode()); - } - - /** - * 设置值 - * @param val - */ - setValue(val: CompositeValue) { - this[propSymbol].setValue(val); - } - - /** - * 获取值 - * @returns - */ - getValue() { - return this[propSymbol].getValue(); - } - - /** - * 导出值 - * @param stage - * @returns - */ - exportSchema(stage: TransformStage = TransformStage.Render) { - return this[propSymbol].export(stage); - } -} \ No newline at end of file diff --git a/packages/shell/src/props.ts b/packages/shell/src/props.ts deleted file mode 100644 index d4d40cdf8c..0000000000 --- a/packages/shell/src/props.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { Props as InnerProps, getConvertedExtraKey } from '@alilc/lowcode-designer'; -import { CompositeValue, TransformStage } from '@alilc/lowcode-types'; -import { propsSymbol } from './symbols'; -import Node from './node'; -import Prop from './prop'; - -export default class Props { - private readonly [propsSymbol]: InnerProps; - - constructor(props: InnerProps) { - this[propsSymbol] = props; - } - - static create(props: InnerProps | undefined | null) { - if (!props) return null; - return new Props(props); - } - - /** - * id - */ - get id() { - return this[propsSymbol].id; - } - - /** - * 返回当前 props 的路径 - */ - get path() { - return this[propsSymbol].path; - } - - /** - * 返回所属的 node 实例 - */ - get node(): Node | null { - return Node.create(this[propsSymbol].getNode()); - } - - /** - * 获取指定 path 的属性模型实例 - * @param path 属性路径,支持 a / a.b / a.0 等格式 - * @returns - */ - getProp(path: string): Prop | null { - return Prop.create(this[propsSymbol].getProp(path)); - } - - /** - * 获取指定 path 的属性模型实例值 - * @param path 属性路径,支持 a / a.b / a.0 等格式 - * @returns - */ - getPropValue(path: string) { - return this.getProp(path)?.getValue(); - } - - /** - * 获取指定 path 的属性模型实例, - * 注:导出时,不同于普通属性,该属性并不挂载在 props 之下,而是与 props 同级 - * @param path 属性路径,支持 a / a.b / a.0 等格式 - * @returns - */ - getExtraProp(path: string): Prop | null { - return Prop.create(this[propsSymbol].getProp(getConvertedExtraKey(path))); - } - - /** - * 获取指定 path 的属性模型实例值 - * 注:导出时,不同于普通属性,该属性并不挂载在 props 之下,而是与 props 同级 - * @param path 属性路径,支持 a / a.b / a.0 等格式 - * @returns - */ - getExtraPropValue(path: string) { - return this.getExtraProp(path)?.getValue(); - } - - /** - * 设置指定 path 的属性模型实例值 - * @param path 属性路径,支持 a / a.b / a.0 等格式 - * @param value 值 - * @returns - */ - setPropValue(path: string, value: CompositeValue) { - return this.getProp(path)?.setValue(value); - } - - /** - * 设置指定 path 的属性模型实例值 - * @param path 属性路径,支持 a / a.b / a.0 等格式 - * @param value 值 - * @returns - */ - setExtraPropValue(path: string, value: CompositeValue) { - return this.getExtraProp(path)?.setValue(value); - } -} \ No newline at end of file diff --git a/packages/shell/src/selection.ts b/packages/shell/src/selection.ts deleted file mode 100644 index df90447c08..0000000000 --- a/packages/shell/src/selection.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { - DocumentModel as InnerDocumentModel, - Node as InnerNode, - Selection as InnerSelection, -} from '@alilc/lowcode-designer'; -import Node from './node'; -import { documentSymbol, selectionSymbol } from './symbols'; - -export default class Selection { - private readonly [documentSymbol]: InnerDocumentModel; - private readonly [selectionSymbol]: InnerSelection; - - constructor(document: InnerDocumentModel) { - this[documentSymbol] = document; - this[selectionSymbol] = document.selection; - } - - /** - * 返回选中的节点 id - */ - get selected() { - return this[selectionSymbol].selected; - } - - /** - * 选中指定节点(覆盖方式) - * @param id - */ - select(id: string) { - this[selectionSymbol].select(id); - } - - /** - * 批量选中指定节点们 - * @param ids - */ - selectAll(ids: string[]) { - this[selectionSymbol].selectAll(ids); - } - - /** - * 移除选中的指定节点 - * @param id - */ - remove(id: string) { - this[selectionSymbol].remove(id); - } - - /** - * 清除所有选中节点 - */ - clear() { - this[selectionSymbol].clear(); - } - - /** - * 判断是否选中了指定节点 - * @param id - * @returns - */ - has(id: string) { - return this[selectionSymbol].has(id); - } - - /** - * 选中指定节点(增量方式) - * @param id - */ - add(id: string) { - this[selectionSymbol].add(id); - } - - /** - * 获取选中的节点实例 - * @returns - */ - getNodes() { - return this[selectionSymbol].getNodes().map((node: InnerNode) => Node.create(node)); - } -} diff --git a/packages/shell/src/setters.ts b/packages/shell/src/setters.ts deleted file mode 100644 index 000213883a..0000000000 --- a/packages/shell/src/setters.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { getSetter, registerSetter, getSettersMap, RegisteredSetter } from '@alilc/lowcode-editor-core'; -import { CustomView } from '@alilc/lowcode-types'; - -export default class Setters { - /** - * 获取指定 setter - * @param type - * @returns - */ - getSetter(type: string) { - return getSetter(type); - } - - /** - * 获取已注册的所有 settersMap - * @returns - */ - getSettersMap() { - return getSettersMap(); - } - - /** - * 注册一个 setter - * @param typeOrMaps - * @param setter - * @returns - */ - registerSetter( - typeOrMaps: string | { [key: string]: CustomView | RegisteredSetter }, - setter?: CustomView | RegisteredSetter | undefined, - ) { - return registerSetter(typeOrMaps, setter); - } -} diff --git a/packages/shell/src/setting-prop-entry.ts b/packages/shell/src/setting-prop-entry.ts deleted file mode 100644 index 57b4c8a369..0000000000 --- a/packages/shell/src/setting-prop-entry.ts +++ /dev/null @@ -1,231 +0,0 @@ -import { SettingField, ISetValueOptions } from '@alilc/lowcode-designer'; -import { CompositeValue, FieldConfig } from '@alilc/lowcode-types'; -import { settingPropEntrySymbol } from './symbols'; -import Node from './node'; -import SettingTopEntry from './setting-top-entry'; - -export default class SettingPropEntry { - private readonly [settingPropEntrySymbol]: SettingField; - - constructor(prop: SettingField) { - this[settingPropEntrySymbol] = prop; - } - - static create(prop: SettingField) { - return new SettingPropEntry(prop); - } - - /** - * 获取设置属性的 id - */ - get id() { - return this[settingPropEntrySymbol].id; - } - - /** - * 获取设置属性的 name - */ - get name() { - return this[settingPropEntrySymbol].name; - } - - /** - * 获取设置属性的 key - */ - get key() { - return this[settingPropEntrySymbol].getKey(); - } - - /** - * 获取设置属性的 path - */ - get path() { - return this[settingPropEntrySymbol].path; - } - - /** - * 获取设置属性的 title - */ - get title() { - return this[settingPropEntrySymbol].title; - } - - /** - * 获取设置属性的 setter - */ - get setter() { - return this[settingPropEntrySymbol].setter; - } - - /** - * 获取设置属性的 extraProps - */ - get extraProps() { - return this[settingPropEntrySymbol].extraProps; - } - - /** - * 获取设置属性对应的节点实例 - */ - get node(): Node | null { - return Node.create(this[settingPropEntrySymbol].getNode()); - } - - /** - * 获取设置属性的父设置属性 - */ - get parent(): SettingPropEntry { - return SettingPropEntry.create(this[settingPropEntrySymbol].parent as any); - } - - /** - * 是否是 SettingField 实例 - */ - get isSettingField(): boolean { - return this[settingPropEntrySymbol].isSettingField; - } - - /** - * 设置 key 值 - * @param key - */ - setKey(key: string | number) { - this[settingPropEntrySymbol].setKey(key); - } - - /** - * @deprecated use .node instead - */ - getNode() { - return this.node; - } - - /** - * @deprecated use .parent instead - */ - getParent() { - return this.parent; - } - - /** - * 设置值 - * @param val 值 - */ - setValue(val: CompositeValue, extraOptions?: ISetValueOptions) { - this[settingPropEntrySymbol].setValue(val, false, false, extraOptions); - } - - /** - * 设置子级属性值 - * @param propName 子属性名 - * @param value 值 - */ - setPropValue(propName: string | number, value: any) { - this[settingPropEntrySymbol].setPropValue(propName, value); - } - - /** - * 清空指定属性值 - * @param propName - */ - clearPropValue(propName: string | number) { - this[settingPropEntrySymbol].clearPropValue(propName); - } - - /** - * 获取配置的默认值 - * @returns - */ - getDefaultValue() { - return this[settingPropEntrySymbol].getDefaultValue(); - } - - /** - * 获取值 - * @returns - */ - getValue() { - return this[settingPropEntrySymbol].getValue(); - } - - /** - * 获取子级属性值 - * @param propName 子属性名 - * @returns - */ - getPropValue(propName: string | number) { - return this[settingPropEntrySymbol].getPropValue(propName); - } - - /** - * 获取设置属性集 - * @returns - */ - getProps() { - return SettingTopEntry.create(this[settingPropEntrySymbol].getProps() as SettingEntry) as any; - } - - /** - * 是否绑定了变量 - * @returns - */ - isUseVariable() { - return this[settingPropEntrySymbol].isUseVariable(); - } - - /** - * 设置绑定变量 - * @param flag - */ - setUseVariable(flag: boolean) { - this[settingPropEntrySymbol].setUseVariable(flag); - } - - /** - * 创建一个设置 field 实例 - * @param config - * @returns - */ - createField(config: FieldConfig) { - return SettingPropEntry.create(this[settingPropEntrySymbol].createField(config)); - } - - /** - * 获取值,当为变量时,返回 mock - * @returns - */ - getMockOrValue() { - return this[settingPropEntrySymbol].getMockOrValue(); - } - - /** - * 销毁当前 field 实例 - */ - purge() { - this[settingPropEntrySymbol].purge(); - } - - /** - * 移除当前 field 实例 - */ - remove() { - this[settingPropEntrySymbol].remove(); - } - - /** - * 设置 autorun - * @param action - * @returns - */ - onEffect(action: () => void) { - return this[settingPropEntrySymbol].onEffect(action); - } - - /** - * 返回 shell 模型,兼容某些场景下 field 已经是 shell field 了 - * @returns - */ - internalToShellPropEntry() { - return this; - } -} diff --git a/packages/shell/src/setting-top-entry.ts b/packages/shell/src/setting-top-entry.ts deleted file mode 100644 index 6f5d888d42..0000000000 --- a/packages/shell/src/setting-top-entry.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { SettingEntry } from '@alilc/lowcode-designer'; -import { settingTopEntrySymbol } from './symbols'; -import Node from './node'; -import SettingPropEntry from './setting-prop-entry'; - -export default class SettingTopEntry { - private readonly [settingTopEntrySymbol]: SettingEntry; - - constructor(prop: SettingEntry) { - this[settingTopEntrySymbol] = prop; - } - - static create(prop: SettingEntry) { - return new SettingTopEntry(prop); - } - - /** - * 返回所属的节点实例 - */ - get node(): Node | null { - return Node.create(this[settingTopEntrySymbol].getNode()); - } - - /** - * 获取子级属性对象 - * @param propName - * @returns - */ - get(propName: string | number) { - return SettingPropEntry.create(this[settingTopEntrySymbol].get(propName) as any); - } - - /** - * @deprecated use .node instead - */ - getNode() { - return this.node; - } - - /** - * 获取指定 propName 的值 - * @param propName - * @returns - */ - getPropValue(propName: string | number) { - return this[settingTopEntrySymbol].getPropValue(propName); - } - - /** - * 设置指定 propName 的值 - * @param propName - * @param value - */ - setPropValue(propName: string | number, value: any) { - this[settingTopEntrySymbol].setPropValue(propName, value); - } -} \ No newline at end of file diff --git a/packages/shell/src/simulator-host.ts b/packages/shell/src/simulator-host.ts deleted file mode 100644 index 7785fed929..0000000000 --- a/packages/shell/src/simulator-host.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { - BuiltinSimulatorHost, -} from '@alilc/lowcode-designer'; -import { simulatorHostSymbol } from './symbols'; - -export default class SimulatorHost { - private readonly [simulatorHostSymbol]: BuiltinSimulatorHost; - - constructor(simulator: BuiltinSimulatorHost) { - this[simulatorHostSymbol] = simulator; - } - - static create(host: BuiltinSimulatorHost) { - if (!host) return null; - return new SimulatorHost(host); - } - - /** - * 获取 contentWindow - */ - get contentWindow() { - return this[simulatorHostSymbol].contentWindow; - } - - /** - * 获取 contentDocument - */ - get contentDocument() { - return this[simulatorHostSymbol].contentDocument; - } - - /** - * 设置 host 配置值 - * @param key - * @param value - */ - set(key: string, value: any) { - this[simulatorHostSymbol].set(key, value); - } - - /** - * 获取 host 配置值 - * @param key - * @returns - */ - get(key: string) { - return this[simulatorHostSymbol].get(key); - } - - /** - * 刷新渲染画布 - */ - rerender() { - this[simulatorHostSymbol].rerender(); - } -} diff --git a/packages/shell/src/skeleton.ts b/packages/shell/src/skeleton.ts deleted file mode 100644 index 15872635bf..0000000000 --- a/packages/shell/src/skeleton.ts +++ /dev/null @@ -1,177 +0,0 @@ -import { - Skeleton as InnerSkeleton, - IWidgetBaseConfig, - IWidgetConfigArea, - SkeletonEvents, -} from '@alilc/lowcode-editor-skeleton'; -import { skeletonSymbol } from './symbols'; - -export default class Skeleton { - private readonly [skeletonSymbol]: InnerSkeleton; - - constructor(skeleton: InnerSkeleton) { - this[skeletonSymbol] = skeleton; - } - - /** - * 增加一个面板实例 - * @param config - * @param extraConfig - * @returns - */ - add(config: IWidgetBaseConfig, extraConfig?: Record<string, any>) { - return this[skeletonSymbol].add(config, extraConfig); - } - - /** - * 移除一个面板实例 - * @param config - * @returns - */ - remove(config: IWidgetBaseConfig) { - const { area, name } = config; - const skeleton = this[skeletonSymbol]; - if (!normalizeArea(area)) return; - skeleton[normalizeArea(area)!].container.remove(name); - } - - /** - * 显示面板 - * @param name - */ - showPanel(name: string) { - this[skeletonSymbol].getPanel(name)?.show(); - } - - /** - * 隐藏面板 - * @param name - */ - hidePanel(name: string) { - this[skeletonSymbol].getPanel(name)?.hide(); - } - - /** - * 显示 widget - * @param name - */ - showWidget(name: string) { - this[skeletonSymbol].getWidget(name)?.show(); - } - - /** - * enable widget - * @param name - */ - enableWidget(name: string) { - this[skeletonSymbol].getWidget(name)?.enable?.(); - } - - /** - * 隐藏 widget - * @param name - */ - hideWidget(name: string) { - this[skeletonSymbol].getWidget(name)?.hide(); - } - - /** - * disable widget,不可点击 - * @param name - */ - disableWidget(name: string) { - this[skeletonSymbol].getWidget(name)?.disable?.(); - } - - /** - * 监听 panel 显示事件 - * @param listener - * @returns - */ - onShowPanel(listener: (...args: unknown[]) => void) { - const { editor } = this[skeletonSymbol]; - editor.on(SkeletonEvents.PANEL_SHOW, (name: any, panel: any) => { - // 不泄漏 skeleton - const { skeleton, ...restPanel } = panel; - listener(name, restPanel); - }); - return () => editor.off(SkeletonEvents.PANEL_SHOW, listener); - } - - /** - * 监听 panel 隐藏事件 - * @param listener - * @returns - */ - onHidePanel(listener: (...args: unknown[]) => void) { - const { editor } = this[skeletonSymbol]; - editor.on(SkeletonEvents.PANEL_HIDE, (name: any, panel: any) => { - // 不泄漏 skeleton - const { skeleton, ...restPanel } = panel; - listener(name, restPanel); - }); - return () => editor.off(SkeletonEvents.PANEL_HIDE, listener); - } - - /** - * 监听 widget 显示事件 - * @param listener - * @returns - */ - onShowWidget(listener: (...args: unknown[]) => void) { - const { editor } = this[skeletonSymbol]; - editor.on(SkeletonEvents.WIDGET_SHOW, (name: any, panel: any) => { - // 不泄漏 skeleton - const { skeleton, ...rest } = panel; - listener(name, rest); - }); - return () => editor.off(SkeletonEvents.WIDGET_SHOW, listener); - } - - /** - * 监听 widget 隐藏事件 - * @param listener - * @returns - */ - onHideWidget(listener: (...args: unknown[]) => void) { - const { editor } = this[skeletonSymbol]; - editor.on(SkeletonEvents.WIDGET_HIDE, (name: any, panel: any) => { - // 不泄漏 skeleton - const { skeleton, ...rest } = panel; - listener(name, rest); - }); - return () => editor.off(SkeletonEvents.WIDGET_HIDE, listener); - } -} - -function normalizeArea(area: IWidgetConfigArea | undefined) { - switch (area) { - case 'leftArea': - case 'left': - return 'leftArea'; - case 'rightArea': - case 'right': - return 'rightArea'; - case 'topArea': - case 'top': - return 'topArea'; - case 'toolbar': - return 'toolbar'; - case 'mainArea': - case 'main': - case 'center': - case 'centerArea': - return 'mainArea'; - case 'bottomArea': - case 'bottom': - return 'bottomArea'; - case 'leftFixedArea': - return 'leftFixedArea'; - case 'leftFloatArea': - return 'leftFloatArea'; - case 'stages': - return 'stages'; - default: - throw new Error(`${area} not supported`); - } -} diff --git a/packages/shell/src/symbols.ts b/packages/shell/src/symbols.ts index 27bc59f33f..e0f846ad36 100644 --- a/packages/shell/src/symbols.ts +++ b/packages/shell/src/symbols.ts @@ -10,7 +10,7 @@ export const nodeSymbol = Symbol('node'); export const modalNodesManagerSymbol = Symbol('modalNodesManager'); export const nodeChildrenSymbol = Symbol('nodeChildren'); export const propSymbol = Symbol('prop'); -export const settingPropEntrySymbol = Symbol('settingPropEntry'); +export const settingFieldSymbol = Symbol('settingField'); export const settingTopEntrySymbol = Symbol('settingTopEntry'); export const propsSymbol = Symbol('props'); export const detectingSymbol = Symbol('detecting'); @@ -21,4 +21,23 @@ export const dragonSymbol = Symbol('dragon'); export const componentMetaSymbol = Symbol('componentMeta'); export const dropLocationSymbol = Symbol('dropLocation'); export const simulatorHostSymbol = Symbol('simulatorHost'); -export const simulatorRendererSymbol = Symbol('simulatorRenderer'); \ No newline at end of file +export const simulatorRenderSymbol = Symbol('simulatorRender'); +export const dragObjectSymbol = Symbol('dragObject'); +export const locateEventSymbol = Symbol('locateEvent'); +export const designerCabinSymbol = Symbol('designerCabin'); +export const editorCabinSymbol = Symbol('editorCabin'); +export const skeletonCabinSymbol = Symbol('skeletonCabin'); +export const hotkeySymbol = Symbol('hotkey'); +export const pluginsSymbol = Symbol('plugins'); +export const workspaceSymbol = Symbol('workspace'); +export const windowSymbol = Symbol('window'); +export const pluginInstanceSymbol = Symbol('plugin-instance'); +export const resourceTypeSymbol = Symbol('resourceType'); +export const resourceSymbol = Symbol('resource'); +export const clipboardSymbol = Symbol('clipboard'); +export const configSymbol = Symbol('configSymbol'); +export const conditionGroupSymbol = Symbol('conditionGroup'); +export const editorViewSymbol = Symbol('editorView'); +export const pluginContextSymbol = Symbol('pluginContext'); +export const skeletonItemSymbol = Symbol('skeletonItem'); +export const commandSymbol = Symbol('command'); \ No newline at end of file diff --git a/packages/types/build.json b/packages/types/build.json index bd5cf18dde..3e92600554 100644 --- a/packages/types/build.json +++ b/packages/types/build.json @@ -1,5 +1,5 @@ { "plugins": [ - "build-plugin-component" + "@alilc/build-plugin-lce" ] } diff --git a/packages/types/package.json b/packages/types/package.json index a71326e203..5651d427d4 100644 --- a/packages/types/package.json +++ b/packages/types/package.json @@ -1,6 +1,6 @@ { "name": "@alilc/lowcode-types", - "version": "1.0.3", + "version": "1.3.2", "description": "Types for Ali lowCode engine", "files": [ "es", @@ -9,18 +9,17 @@ "main": "lib/index.js", "module": "es/index.js", "scripts": { - "build": "build-scripts build --skip-demo" + "build": "build-scripts build" }, "dependencies": { "@alilc/lowcode-datasource-types": "^1.0.0", - "react": "^16.9 || ^17", + "react": "^16.9", "strict-event-emitter-types": "^2.0.0" }, "devDependencies": { "@alib/build-scripts": "^0.1.18", "@types/node": "^13.7.1", - "@types/react": "^16", - "build-plugin-component": "^0.2.10" + "@types/react": "^16" }, "publishConfig": { "access": "public", @@ -30,5 +29,7 @@ "type": "http", "url": "https://github.com/alibaba/lowcode-engine/tree/main/packages/types" }, - "gitHead": "2669f179e6f899d395ce1942d0fe04f9c5ed48a6" + "gitHead": "2669f179e6f899d395ce1942d0fe04f9c5ed48a6", + "bugs": "https://github.com/alibaba/lowcode-engine/issues", + "homepage": "https://github.com/alibaba/lowcode-engine/#readme" } diff --git a/packages/types/src/activity.ts b/packages/types/src/activity.ts index 7491f9698f..8549df7b3d 100644 --- a/packages/types/src/activity.ts +++ b/packages/types/src/activity.ts @@ -1,4 +1,4 @@ -import { NodeSchema } from './schema'; +import { IPublicTypeNodeSchema } from './shell'; export enum ActivityType { 'ADDED' = 'added', @@ -7,8 +7,8 @@ export enum ActivityType { 'COMPOSITE' = 'composite', } -export interface IActivityPayload { - schema: NodeSchema; +interface IActivityPayload { + schema: IPublicTypeNodeSchema; location?: { parent: { nodeId: string; @@ -20,6 +20,10 @@ export interface IActivityPayload { newValue: any; } +/** + * TODO: not sure if this is used anywhere + * @deprecated + */ export type ActivityData = { type: ActivityType; payload: IActivityPayload; diff --git a/packages/types/src/app-config.ts b/packages/types/src/app-config.ts deleted file mode 100644 index 6343645c96..0000000000 --- a/packages/types/src/app-config.ts +++ /dev/null @@ -1,19 +0,0 @@ -export interface AppConfig { - sdkVersion?: string; - historyMode?: string; - targetRootID?: string; - layout?: Layout; - theme?: Theme; - [key: string]: any; -} - -interface Theme { - package: string; - version: string; - primary: string; -} - -interface Layout { - componentName?: string; - props?: Record<string, any>; -} diff --git a/packages/types/src/assets.ts b/packages/types/src/assets.ts index 37f5634299..f0e6d35396 100644 --- a/packages/types/src/assets.ts +++ b/packages/types/src/assets.ts @@ -1,14 +1,3 @@ -import { Snippet, ComponentMetadata } from './metadata'; -import { I18nData } from './i18n'; - -export interface AssetItem { - type: AssetType; - content?: string | null; - device?: string; - level?: AssetLevel; - id?: string; -} - export enum AssetLevel { // 环境依赖库 比如 react, react-dom Environment = 1, @@ -43,181 +32,21 @@ export enum AssetType { Bundle = 'bundle', } -export interface AssetBundle { - type: AssetType.Bundle; +export interface AssetItem { + type: AssetType; + content?: string | null; + device?: string; level?: AssetLevel; - assets?: Asset | AssetList | null; + id?: string; + scriptType?: string; } -export type Asset = AssetList | AssetBundle | AssetItem | URL; - export type AssetList = Array<Asset | undefined | null>; -/** - * 资产包协议 - */ -export interface AssetsJson { - /** - * 资产包协议版本号 - */ - version: string; - /** - * 大包列表,external与package的概念相似,融合在一起 - */ - packages?: Package[]; - /** - * 所有组件的描述协议列表所有组件的列表 - */ - components: Array<ComponentDescription | RemoteComponentDescription>; - /** - * 组件分类列表,用来描述物料面板 - * @deprecated 最新版物料面板已不需要此描述 - */ - componentList?: ComponentCategory[]; - /** - * 业务组件分类列表,用来描述物料面板 - * @deprecated 最新版物料面板已不需要此描述 - */ - bizComponentList?: ComponentCategory[]; - /** - * 用于描述组件面板中的 tab 和 category - */ - sort?: ComponentSort; -} - -/** - * 用于描述组件面板中的 tab 和 category - */ -export interface ComponentSort { - /** - * 用于描述组件面板的 tab 项及其排序,例如:["精选组件", "原子组件"] - */ - groupList?: string[]; - /** - * 组件面板中同一个 tab 下的不同区间用 category 区分,category 的排序依照 categoryList 顺序排列; - */ - categoryList?: string[]; -} - -/** - * 定义组件大包及 external 资源的信息 - * 应该被编辑器默认加载 - */ -export interface Package { - /** - * 包名 - */ - package: string; - /** - * 包版本号 - */ - version: string; - /** - * 组件渲染态视图打包后的 CDN url 列表,包含 js 和 css - */ - urls?: string[] | any; - /** - * 组件编辑态视图打包后的 CDN url 列表,包含 js 和 css - */ - editUrls?: string[] | any; - /** - * 作为全局变量引用时的名称,和webpack output.library字段含义一样,用来定义全局变量名 - */ - library: string; - /** - * @experimental - * - * @todo 需推进提案 @度城 - */ - async?: boolean; - /** - * 组件描述导出名字,可以通过 window[exportName] 获取到组件描述的 Object 内容; - */ - exportName?: string; -} - -/** - * 组件分类 - * @deprecated 已被 ComponentMetadata 替代 - */ -export interface ComponentCategory { - /** - * 组件分类title - */ - title: string; - /** - * 组件分类icon - */ - icon?: string; - /** - * 可能有子分类 - */ - children?: ComponentItem[] | ComponentCategory[]; -} - -/** - * 组件 - * @deprecated 已被 ComponentMetadata 替代 - */ -export interface ComponentItem { - /** - * 组件title - */ - title: string; - /** - * 组件名 - */ - componentName?: string; - /** - * 组件icon - */ - icon?: string; - /** - * 可用片段 - */ - snippets?: Snippet[]; - /** - * 一级分组 - */ - group?: string | I18nData; - - /** - * 二级分组 - */ - category?: string | I18nData; - - /** - * 组件优先级排序 - */ - priority?: number; -} - -/** - * 本地物料描述 - */ -export interface ComponentDescription extends ComponentMetadata { - /** - * @todo 待补充文档 @jinchan - */ - keywords: string[]; -} +export type Asset = AssetList | AssetBundle | AssetItem | URL; -/** - * 远程物料描述 - */ -export interface RemoteComponentDescription { - /** - * 组件描述导出名字,可以通过 window[exportName] 获取到组件描述的 Object 内容; - */ - exportName?: string; - /** - * 组件描述的资源链接; - */ - url?: string; - /** - * 组件(库)的 npm 信息; - */ - package?: { - npm?: string; - }; +export interface AssetBundle { + type: AssetType.Bundle; + level?: AssetLevel; + assets?: Asset | AssetList | null; } diff --git a/packages/types/src/deprecated/index.ts b/packages/types/src/deprecated/index.ts new file mode 100644 index 0000000000..7e65173c0d --- /dev/null +++ b/packages/types/src/deprecated/index.ts @@ -0,0 +1,18 @@ +export * from './isActionContentObject'; +export * from './isCustomView'; +export * from './isDOMText'; +export * from './isDynamicSetter'; +export * from './isI18nData'; +export * from './isJSBlock'; +export * from './isJSExpression'; +export * from './isJSFunction'; +export * from './isJSSlot'; +export * from './isLowCodeComponentType'; +export * from './isNodeSchema'; +export * from './isPlainObject'; +export * from './isProCodeComponentType'; +export * from './isProjectSchema'; +export * from './isReactClass'; +export * from './isReactComponent'; +export * from './isSetterConfig'; +export * from './isTitleConfig'; \ No newline at end of file diff --git a/packages/types/src/deprecated/isActionContentObject.ts b/packages/types/src/deprecated/isActionContentObject.ts new file mode 100644 index 0000000000..88a8e57d2e --- /dev/null +++ b/packages/types/src/deprecated/isActionContentObject.ts @@ -0,0 +1,8 @@ +import { IPublicTypeActionContentObject } from '../shell'; + +/** + * @deprecated use same function from '@alilc/lowcode-utils' instead + */ +export function isActionContentObject(obj: any): obj is IPublicTypeActionContentObject { + return obj && typeof obj === 'object'; +} diff --git a/packages/types/src/deprecated/isCustomView.ts b/packages/types/src/deprecated/isCustomView.ts new file mode 100644 index 0000000000..159490e550 --- /dev/null +++ b/packages/types/src/deprecated/isCustomView.ts @@ -0,0 +1,10 @@ +import { isValidElement } from 'react'; +import { isReactComponent } from './isReactComponent'; +import { IPublicTypeCustomView } from '../shell/type/custom-view'; + +/** + * @deprecated use same function from '@alilc/lowcode-utils' instead + */ +export function isCustomView(obj: any): obj is IPublicTypeCustomView { + return obj && (isValidElement(obj) || isReactComponent(obj)); +} diff --git a/packages/types/src/deprecated/isDOMText.ts b/packages/types/src/deprecated/isDOMText.ts new file mode 100644 index 0000000000..4ddc91320f --- /dev/null +++ b/packages/types/src/deprecated/isDOMText.ts @@ -0,0 +1,8 @@ +import { IPublicTypeDOMText } from '../shell/type/dom-text'; + +/** + * @deprecated use same function from '@alilc/lowcode-utils' instead + */ +export function isDOMText(data: any): data is IPublicTypeDOMText { + return typeof data === 'string'; +} diff --git a/packages/types/src/deprecated/isDynamicSetter.ts b/packages/types/src/deprecated/isDynamicSetter.ts new file mode 100644 index 0000000000..55532d258d --- /dev/null +++ b/packages/types/src/deprecated/isDynamicSetter.ts @@ -0,0 +1,9 @@ +import { isReactClass } from './isReactClass'; +import { IPublicTypeDynamicSetter } from '../shell/type/dynamic-setter'; + +/** + * @deprecated use same function from '@alilc/lowcode-utils' instead + */ +export function isDynamicSetter(obj: any): obj is IPublicTypeDynamicSetter { + return obj && typeof obj === 'function' && !isReactClass(obj); +} diff --git a/packages/types/src/deprecated/isI18nData.ts b/packages/types/src/deprecated/isI18nData.ts new file mode 100644 index 0000000000..4767ccd373 --- /dev/null +++ b/packages/types/src/deprecated/isI18nData.ts @@ -0,0 +1,7 @@ + +/** + * @deprecated use same function from '@alilc/lowcode-utils' instead + */ +export function isI18nData(obj: any): boolean { + return obj && obj.type === 'i18n'; +} diff --git a/packages/types/src/deprecated/isJSBlock.ts b/packages/types/src/deprecated/isJSBlock.ts new file mode 100644 index 0000000000..6f92e2fcf1 --- /dev/null +++ b/packages/types/src/deprecated/isJSBlock.ts @@ -0,0 +1,8 @@ +import { IPublicTypeJSBlock } from '../shell/type/value-type'; + +/** + * @deprecated use same function from '@alilc/lowcode-utils' instead + */ +export function isJSBlock(data: any): data is IPublicTypeJSBlock { + return data && data.type === 'JSBlock'; +} diff --git a/packages/types/src/deprecated/isJSExpression.ts b/packages/types/src/deprecated/isJSExpression.ts new file mode 100644 index 0000000000..f722d55293 --- /dev/null +++ b/packages/types/src/deprecated/isJSExpression.ts @@ -0,0 +1,8 @@ +import { IPublicTypeJSExpression } from '../shell/type/value-type'; + +/** + * @deprecated use same function from '@alilc/lowcode-utils' instead + */ +export function isJSExpression(data: any): data is IPublicTypeJSExpression { + return data && data.type === 'JSExpression' && data.extType !== 'function'; +} diff --git a/packages/types/src/deprecated/isJSFunction.ts b/packages/types/src/deprecated/isJSFunction.ts new file mode 100644 index 0000000000..40ab4f52dc --- /dev/null +++ b/packages/types/src/deprecated/isJSFunction.ts @@ -0,0 +1,8 @@ +import { IPublicTypeJSFunction } from '../shell/type/value-type'; + +/** + * @deprecated use same function from '@alilc/lowcode-utils' instead + */ +export function isJSFunction(x: any): x is IPublicTypeJSFunction { + return typeof x === 'object' && x && x.type === 'JSFunction'; +} diff --git a/packages/types/src/deprecated/isJSSlot.ts b/packages/types/src/deprecated/isJSSlot.ts new file mode 100644 index 0000000000..7cba651958 --- /dev/null +++ b/packages/types/src/deprecated/isJSSlot.ts @@ -0,0 +1,8 @@ +import { IPublicTypeJSSlot } from '../shell/type/value-type'; + +/** + * @deprecated use same function from '@alilc/lowcode-utils' instead + */ +export function isJSSlot(data: any): data is IPublicTypeJSSlot { + return data && data.type === 'JSSlot'; +} diff --git a/packages/types/src/deprecated/isLowCodeComponentType.ts b/packages/types/src/deprecated/isLowCodeComponentType.ts new file mode 100644 index 0000000000..c14c85f1eb --- /dev/null +++ b/packages/types/src/deprecated/isLowCodeComponentType.ts @@ -0,0 +1,9 @@ +import { isProCodeComponentType } from './isProCodeComponentType'; +import { IPublicTypeComponentMap, IPublicTypeLowCodeComponent } from '../shell/type/npm'; + +/** + * @deprecated use same function from '@alilc/lowcode-utils' instead + */ +export function isLowCodeComponentType(desc: IPublicTypeComponentMap): desc is IPublicTypeLowCodeComponent { + return !isProCodeComponentType(desc); +} diff --git a/packages/types/src/deprecated/isNodeSchema.ts b/packages/types/src/deprecated/isNodeSchema.ts new file mode 100644 index 0000000000..cab4dc46e1 --- /dev/null +++ b/packages/types/src/deprecated/isNodeSchema.ts @@ -0,0 +1,8 @@ +import { IPublicTypeNodeSchema } from '../shell'; + +/** + * @deprecated use same function from '@alilc/lowcode-utils' instead + */ +export function isNodeSchema(data: any): data is IPublicTypeNodeSchema { + return data && data.componentName; +} diff --git a/packages/types/src/deprecated/isPlainObject.ts b/packages/types/src/deprecated/isPlainObject.ts new file mode 100644 index 0000000000..549f497360 --- /dev/null +++ b/packages/types/src/deprecated/isPlainObject.ts @@ -0,0 +1,10 @@ +/** + * @deprecated use same function from '@alilc/lowcode-utils' instead + */ +export function isPlainObject(value: any): value is Record<string, unknown> { + if (typeof value !== 'object') { + return false; + } + const proto = Object.getPrototypeOf(value); + return proto === Object.prototype || proto === null || Object.getPrototypeOf(proto) === null; +} diff --git a/packages/types/src/deprecated/isProCodeComponentType.ts b/packages/types/src/deprecated/isProCodeComponentType.ts new file mode 100644 index 0000000000..40e8e977f9 --- /dev/null +++ b/packages/types/src/deprecated/isProCodeComponentType.ts @@ -0,0 +1,8 @@ +import { IPublicTypeComponentMap, IPublicTypeProCodeComponent } from '../shell/type/npm'; + +/** + * @deprecated use same function from '@alilc/lowcode-utils' instead + */ +export function isProCodeComponentType(desc: IPublicTypeComponentMap): desc is IPublicTypeProCodeComponent { + return 'package' in desc; +} diff --git a/packages/types/src/deprecated/isProjectSchema.ts b/packages/types/src/deprecated/isProjectSchema.ts new file mode 100644 index 0000000000..1622fa8466 --- /dev/null +++ b/packages/types/src/deprecated/isProjectSchema.ts @@ -0,0 +1,6 @@ +/** + * @deprecated use same function from '@alilc/lowcode-utils' instead + */ +export function isProjectSchema(data: any): boolean { + return data && data.componentsTree; +} diff --git a/packages/types/src/deprecated/isReactClass.ts b/packages/types/src/deprecated/isReactClass.ts new file mode 100644 index 0000000000..846c522d7b --- /dev/null +++ b/packages/types/src/deprecated/isReactClass.ts @@ -0,0 +1,8 @@ +import { ComponentClass, Component } from 'react'; + +/** + * @deprecated use same function from '@alilc/lowcode-utils' instead + */ +export function isReactClass(obj: any): obj is ComponentClass<any> { + return obj && obj.prototype && (obj.prototype.isReactComponent || obj.prototype instanceof Component); +} diff --git a/packages/types/src/deprecated/isReactComponent.ts b/packages/types/src/deprecated/isReactComponent.ts new file mode 100644 index 0000000000..1ed04427f3 --- /dev/null +++ b/packages/types/src/deprecated/isReactComponent.ts @@ -0,0 +1,9 @@ +import { ComponentType } from 'react'; +import { isReactClass } from './isReactClass'; + +/** + * @deprecated use same function from '@alilc/lowcode-utils' instead + */ +export function isReactComponent(obj: any): obj is ComponentType<any> { + return obj && (isReactClass(obj) || typeof obj === 'function'); +} diff --git a/packages/types/src/deprecated/isSetterConfig.ts b/packages/types/src/deprecated/isSetterConfig.ts new file mode 100644 index 0000000000..bf0d77e115 --- /dev/null +++ b/packages/types/src/deprecated/isSetterConfig.ts @@ -0,0 +1,9 @@ +import { IPublicTypeSetterConfig } from '../shell/type/setter-config'; +import { isCustomView } from './isCustomView'; + +/** + * @deprecated use same function from '@alilc/lowcode-utils' instead + */ +export function isSetterConfig(obj: any): obj is IPublicTypeSetterConfig { + return obj && typeof obj === 'object' && 'componentName' in obj && !isCustomView(obj); +} diff --git a/packages/types/src/deprecated/isTitleConfig.ts b/packages/types/src/deprecated/isTitleConfig.ts new file mode 100644 index 0000000000..9ee38c9c25 --- /dev/null +++ b/packages/types/src/deprecated/isTitleConfig.ts @@ -0,0 +1,10 @@ +import { isI18nData } from './isI18nData'; +import { isPlainObject } from './isPlainObject'; +import { IPublicTypeTitleConfig } from '../shell/type/title-config'; + +/** + * @deprecated use same function from '@alilc/lowcode-utils' instead + */ +export function isTitleConfig(obj: any): obj is IPublicTypeTitleConfig { + return isPlainObject(obj) && !isI18nData(obj); +} diff --git a/packages/types/src/disposable.ts b/packages/types/src/disposable.ts deleted file mode 100644 index 1743677f18..0000000000 --- a/packages/types/src/disposable.ts +++ /dev/null @@ -1,3 +0,0 @@ -export interface Disposable { - (): void; -} \ No newline at end of file diff --git a/packages/types/src/editor.ts b/packages/types/src/editor.ts index 6d917ec477..3691a7f948 100644 --- a/packages/types/src/editor.ts +++ b/packages/types/src/editor.ts @@ -1,57 +1,5 @@ -import { EventEmitter } from 'events'; -import StrictEventEmitter from 'strict-event-emitter-types'; import { ReactNode, ComponentType } from 'react'; -import { NpmInfo } from './npm'; -import * as GlobalEvent from './event'; - -export type KeyType = (new (...args: any[]) => any) | symbol | string; -export type ClassType = new (...args: any[]) => any; -export interface GetOptions { - forceNew?: boolean; - sourceCls?: ClassType; -} -export type GetReturnType<T, ClsType> = T extends undefined - ? ClsType extends { - prototype: infer R; - } - ? R - : any - : T; - -/** - * duck-typed power-di - * - * @see https://www.npmjs.com/package/power-di - */ -interface PowerDIRegisterOptions { - /** default: true */ - singleton?: boolean; - /** if data a class, auto new a instance. - * if data a function, auto run(lazy). - * default: true */ - autoNew?: boolean; -} - -export interface IEditor extends StrictEventEmitter<EventEmitter, GlobalEvent.EventConfig> { - get: <T = undefined, KeyOrType = any>( - keyOrType: KeyOrType, - opt?: GetOptions - ) => GetReturnType<T, KeyOrType> | undefined; - - has: (keyOrType: KeyType) => boolean; - - set: (key: KeyType, data: any) => void; - - onceGot: <T = undefined, KeyOrType extends KeyType = any> - (keyOrType: KeyOrType) => Promise<GetReturnType<T, KeyOrType>>; - - onGot: <T = undefined, KeyOrType extends KeyType = any>( - keyOrType: KeyOrType, - fn: (data: GetReturnType<T, KeyOrType>) => void, - ) => () => void; - - register: (data: any, key?: KeyType, options?: PowerDIRegisterOptions) => void; -} +import { IPublicTypeNpmInfo, IPublicModelEditor } from './shell'; export interface EditorConfig { skeleton?: SkeletonConfig; @@ -66,7 +14,7 @@ export interface EditorConfig { } export interface SkeletonConfig { - config: NpmInfo; + config: IPublicTypeNpmInfo; props?: Record<string, unknown>; handler?: (config: EditorConfig) => EditorConfig; } @@ -102,7 +50,7 @@ export interface PluginConfig { panelProps?: Record<string, unknown>; linkProps?: Record<string, unknown>; }; - config?: NpmInfo; + config?: IPublicTypeNpmInfo; pluginProps?: Record<string, unknown>; } @@ -111,14 +59,14 @@ export type HooksConfig = HookConfig[]; export interface HookConfig { message: string; type: 'on' | 'once'; - handler: (this: IEditor, editor: IEditor, ...args: any[]) => void; + handler: (this: IPublicModelEditor, editor: IPublicModelEditor, ...args: any[]) => void; } export type ShortCutsConfig = ShortCutConfig[]; export interface ShortCutConfig { keyboard: string; - handler: (editor: IEditor, ev: Event, keymaster: any) => void; + handler: (editor: IPublicModelEditor, ev: Event, keymaster: any) => void; } export type UtilsConfig = UtilConfig[]; @@ -126,14 +74,14 @@ export type UtilsConfig = UtilConfig[]; export interface UtilConfig { name: string; type: 'npm' | 'function'; - content: NpmInfo | ((...args: []) => any); + content: IPublicTypeNpmInfo | ((...args: []) => any); } export type ConstantsConfig = Record<string, unknown>; export interface LifeCyclesConfig { - init?: (editor: IEditor) => any; - destroy?: (editor: IEditor) => any; + init?: (editor: IPublicModelEditor) => any; + destroy?: (editor: IPublicModelEditor) => any; } export type LocaleType = 'zh-CN' | 'zh-TW' | 'en-US' | 'ja-JP'; @@ -156,7 +104,7 @@ export interface Utils { } export interface PluginProps { - editor: IEditor; + editor?: IPublicModelEditor; config: PluginConfig; [key: string]: any; } @@ -176,7 +124,7 @@ export interface PluginSet { } export type PluginClass = ComponentType<PluginProps> & { - init?: (editor: IEditor) => void; + init?: (editor: IPublicModelEditor) => void; defaultProps?: { locale?: LocaleType; messages?: I18nMessages; @@ -196,4 +144,4 @@ export interface PluginStatus { export interface PluginStatusSet { [key: string]: PluginStatus; -} +} \ No newline at end of file diff --git a/packages/types/src/field-config.ts b/packages/types/src/field-config.ts deleted file mode 100644 index 70a881fde7..0000000000 --- a/packages/types/src/field-config.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { TitleContent } from './title'; -import { SetterType, DynamicSetter } from './setter-config'; -import { SettingTarget } from './setting-target'; -import { LiveTextEditingConfig } from './metadata'; - -/** - * extra props for field - */ -export interface FieldExtraProps { - /** - * 是否必填参数 - */ - isRequired?: boolean; - /** - * default value of target prop for setter use - */ - defaultValue?: any; - /** - * get value for field - */ - getValue?: (target: SettingTarget, fieldValue: any) => any; - /** - * set value for field - */ - setValue?: (target: SettingTarget, value: any) => void; - /** - * the field conditional show, is not set always true - * @default undefined - */ - condition?: (target: SettingTarget) => boolean; - /** - * autorun when something change - */ - autorun?: (target: SettingTarget) => void; - /** - * is this field is a virtual field that not save to schema - */ - virtual?: (target: SettingTarget) => boolean; - /** - * default collapsed when display accordion - */ - defaultCollapsed?: boolean; - /** - * important field - */ - important?: boolean; - /** - * internal use - */ - forceInline?: number; - /** - * 是否支持变量配置 - */ - supportVariable?: boolean; - /** - * compatiable vision display - */ - display?: 'accordion' | 'inline' | 'block' | 'plain' | 'popup' | 'entry'; - // @todo 这个 omit 是否合理? - /** - * @todo 待补充文档 - */ - liveTextEditing?: Omit<LiveTextEditingConfig, 'propTarget'>; -} - -/** - * 属性面板配置 - */ -export interface FieldConfig extends FieldExtraProps { - /** - * 面板配置隶属于单个 field 还是分组 - */ - type?: 'field' | 'group'; - /** - * the name of this setting field, which used in quickEditor - */ - name: string | number; - /** - * the field title - * @default sameas .name - */ - title?: TitleContent; - /** - * 单个属性的 setter 配置 - * - * the field body contains when .type = 'field' - */ - setter?: SetterType | DynamicSetter; - /** - * the setting items which group body contains when .type = 'group' - */ - items?: FieldConfig[]; - /** - * extra props for field - * 其他配置属性(不做流通要求) - */ - extraProps?: FieldExtraProps; - /** - * @deprecated - */ - description?: TitleContent; - /** - * @deprecated - */ - isExtends?: boolean; -} diff --git a/packages/types/src/i18n.ts b/packages/types/src/i18n.ts deleted file mode 100644 index 44f8ef8550..0000000000 --- a/packages/types/src/i18n.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { ReactNode } from 'react'; - -export interface I18nData { - type: 'i18n'; - intl?: ReactNode; - [key: string]: any; -} - -// type checks -export function isI18nData(obj: any): obj is I18nData { - return obj && obj.type === 'i18n'; -} - -export interface I18nMap { - [lang: string]: { [key: string]: string }; -} diff --git a/packages/types/src/icon.ts b/packages/types/src/icon.ts deleted file mode 100644 index 2f2ba23360..0000000000 --- a/packages/types/src/icon.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { ReactElement, ComponentType } from 'react'; - -export interface IconConfig { - type: string; - size?: number | 'small' | 'xxs' | 'xs' | 'medium' | 'large' | 'xl' | 'xxl' | 'xxxl' | 'inherit'; - className?: string; -} - -export type IconType = string | ReactElement | ComponentType<any> | IconConfig; diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 19d9bfa709..d14dc9b995 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -1,23 +1,11 @@ export * from '@alilc/lowcode-datasource-types'; export * from './editor'; -export * from './field-config'; -export * from './i18n'; -export * from './icon'; -export * from './metadata'; -export * from './npm'; -export * from './prop-config'; -export * from './schema'; export * from './activity'; -export * from './tip'; -export * from './title'; -export * from './utils'; -export * from './value-type'; -export * from './setter-config'; -export * from './setting-target'; -export * from './node'; -export * from './transform-stage'; export * from './code-intermediate'; export * from './code-result'; export * from './assets'; export * as GlobalEvent from './event'; -export * from './disposable'; +export * from './shell'; +export * from './shell-model-factory'; +// TODO: remove this in future versions +export * from './deprecated'; diff --git a/packages/types/src/metadata.ts b/packages/types/src/metadata.ts deleted file mode 100644 index ec67569921..0000000000 --- a/packages/types/src/metadata.ts +++ /dev/null @@ -1,475 +0,0 @@ -import { ReactNode, ComponentType, ReactElement } from 'react'; -import { IconType } from './icon'; -import { TipContent } from './tip'; -import { TitleContent } from './title'; -import { PropConfig, PropType } from './prop-config'; -import { NpmInfo } from './npm'; -import { FieldConfig } from './field-config'; -import { NodeSchema, NodeData, ComponentSchema } from './schema'; -import { SettingTarget } from './setting-target'; -import { I18nData } from './i18n'; - -/** - * 嵌套控制函数 - */ -export type NestingFilter = (testNode: any, currentNode: any) => boolean; -/** - * 嵌套控制 - * 防止错误的节点嵌套,比如 a 嵌套 a, FormField 只能在 Form 容器下,Column 只能在 Table 下等 - */ -export interface NestingRule { - /** - * 子级白名单 - */ - childWhitelist?: string[] | string | RegExp | NestingFilter; - /** - * 父级白名单 - */ - parentWhitelist?: string[] | string | RegExp | NestingFilter; - /** - * 后裔白名单 - */ - descendantWhitelist?: string[] | string | RegExp | NestingFilter; - /** - * 后裔黑名单 - */ - descendantBlacklist?: string[] | string | RegExp | NestingFilter; - /** - * 祖先白名单 可用来做区域高亮 - */ - ancestorWhitelist?: string[] | string | RegExp | NestingFilter; -} - -/** - * 组件能力配置 - */ -export interface ComponentConfigure { - /** - * 是否容器组件 - */ - isContainer?: boolean; - /** - * 组件是否带浮层,浮层组件拖入设计器时会遮挡画布区域,此时应当辅助一些交互以防止阻挡 - */ - isModal?: boolean; - /** - * 是否存在渲染的根节点 - */ - isNullNode?: boolean; - /** - * 组件树描述信息 - */ - descriptor?: string; - /** - * 嵌套控制:防止错误的节点嵌套 - * 比如 a 嵌套 a, FormField 只能在 Form 容器下,Column 只能在 Table 下等 - */ - nestingRule?: NestingRule; - - /** - * 是否是最小渲染单元 - * 最小渲染单元下的组件渲染和更新都从单元的根节点开始渲染和更新。如果嵌套了多层最小渲染单元,渲染会从最外层的最小渲染单元开始渲染。 - */ - isMinimalRenderUnit?: boolean; - - /** - * 组件选中框的 cssSelector - */ - rootSelector?: string; - /** - * 禁用的行为,可以为 `'copy'`, `'move'`, `'remove'` 或它们组成的数组 - */ - disableBehaviors?: string[] | string; - /** - * 用于详细配置上述操作项的内容 - */ - actions?: ComponentAction[]; -} - -/** - * 可用片段 - * - * 内容为组件不同状态下的低代码 schema (可以有多个),用户从组件面板拖入组件到设计器时会向页面 schema 中插入 snippets 中定义的组件低代码 schema - */ -export interface Snippet { - /** - * 组件分类title - */ - title?: string; - /** - * snippet 截图 - */ - screenshot?: string; - /** - * snippet 打标 - * - * @deprecated 暂未使用 - */ - label?: string; - /** - * 待插入的 schema - */ - schema?: NodeSchema; -} - -export interface InitialItem { - name: string; - initial: (target: SettingTarget, currentValue: any) => any; -} -export interface FilterItem { - name: string; - filter: (target: SettingTarget | null, currentValue: any) => any; -} -export interface AutorunItem { - name: string; - autorun: (target: SettingTarget) => any; -} - -/** - * 高级特性配置 - */ -export interface Advanced { - /** - * @todo 待补充文档 - */ - context?: { [contextInfoName: string]: any }; - /** - * @deprecated 使用组件 metadata 上的 snippets 字段即可 - */ - snippets?: Snippet[]; - /** - * @todo 待补充文档 - */ - view?: ComponentType<any>; - /** - * @todo 待补充文档 - */ - transducers?: any; - /** - * @deprecated 用于动态初始化拖拽到设计器里的组件的 prop 的值 - */ - initials?: InitialItem[]; - /** - * @todo 待补充文档 - */ - filters?: FilterItem[]; - /** - * @todo 待补充文档 - */ - autoruns?: AutorunItem[]; - /** - * 配置 callbacks 可捕获引擎抛出的一些事件,例如 onNodeAdd、onResize 等 - */ - callbacks?: Callbacks; - /** - * 拖入容器时,自动带入 children 列表 - */ - initialChildren?: NodeData[] | ((target: SettingTarget) => NodeData[]); - /** - * @todo 待补充文档 - */ - isAbsoluteLayoutContainer?: boolean; - /** - * @todo 待补充文档 - */ - hideSelectTools?: boolean; - - /** - * 样式 及 位置,handle上必须有明确的标识以便事件路由判断,或者主动设置事件独占模式 - * NWSE 是交给引擎计算放置位置,ReactElement 必须自己控制初始位置 - */ - /** - * 用于配置设计器中组件 resize 操作工具的样式和内容 - * - hover 时控制柄高亮 - * - mousedown 时请求独占 - * - dragstart 请求通用 resizing 控制 请求 hud 显示 - * - drag 时 计算并设置效果,更新控制柄位置 - */ - getResizingHandlers?: ( - currentNode: any, - ) => ( - | Array<{ - type: 'N' | 'W' | 'S' | 'E' | 'NW' | 'NE' | 'SE' | 'SW'; - content?: ReactElement; - propTarget?: string; - appearOn?: 'mouse-enter' | 'mouse-hover' | 'selected' | 'always'; - }> - | ReactElement[] - ); - - /** - * Live Text Editing:如果 children 内容是纯文本,支持双击直接编辑 - */ - liveTextEditing?: LiveTextEditingConfig[]; - - /** - * @deprecated 暂未使用 - */ - isTopFixed?: boolean; -} - -// thinkof Array -/** - * Live Text Editing(如果 children 内容是纯文本,支持双击直接编辑)的可配置项目 - */ -export interface LiveTextEditingConfig { - /** - * @todo 待补充文档 - */ - propTarget: string; - /** - * @todo 待补充文档 - */ - selector?: string; - /** - * 编辑模式 纯文本|段落编辑|文章编辑(默认纯文本,无跟随工具条) - * @default 'plaintext' - */ - mode?: 'plaintext' | 'paragraph' | 'article'; - /** - * 从 contentEditable 获取内容并设置到属性 - */ - onSaveContent?: (content: string, prop: any) => any; -} - -export type ConfigureSupportEvent = string | { - name: string; - propType?: PropType; - description?: string; -}; - -/** - * 通用扩展面板支持性配置 - */ -export interface ConfigureSupport { - /** - * 支持事件列表 - */ - events?: ConfigureSupportEvent[]; - /** - * 支持 className 设置 - */ - className?: boolean; - /** - * 支持样式设置 - */ - style?: boolean; - /** - * 支持生命周期设置 - */ - lifecycles?: any[]; - // general?: boolean; - /** - * 支持循环设置 - */ - loop?: boolean; - /** - * 支持条件式渲染设置 - */ - condition?: boolean; -} - -/** - * 编辑体验配置 - */ -export interface Configure { - /** - * 属性面板配置 - */ - props?: FieldConfig[]; - /** - * 组件能力配置 - */ - component?: ComponentConfigure; - /** - * 通用扩展面板支持性配置 - */ - supports?: ConfigureSupport; - /** - * 高级特性配置 - */ - advanced?: Advanced; -} - -/** - * 动作描述 - */ -export interface ActionContentObject { - /** - * 图标 - */ - icon?: IconType; - /** - * 描述 - */ - title?: TipContent; - /** - * 执行动作 - */ - action?: (currentNode: any) => void; -} - -/** - * @todo 工具条动作 - */ -export interface ComponentAction { - /** - * behaviorName - */ - name: string; - /** - * 菜单名称 - */ - content: string | ReactNode | ActionContentObject; - /** - * 子集 - */ - items?: ComponentAction[]; - /** - * 显示与否 - * always: 无法禁用 - */ - condition?: boolean | ((currentNode: any) => boolean) | 'always'; - /** - * 显示在工具条上 - */ - important?: boolean; -} - -export function isActionContentObject(obj: any): obj is ActionContentObject { - return obj && typeof obj === 'object'; -} - -/** - * 组件 meta 配置 - */ -export interface ComponentMetadata { - /** - * 组件名 - */ - componentName: string; - /** - * unique id - */ - uri?: string; - /** - * title or description - */ - title?: TitleContent; - /** - * svg icon for component - */ - icon?: IconType; - /** - * 组件标签 - */ - tags?: string[]; - /** - * 组件描述 - */ - description?: string; - /** - * 组件文档链接 - */ - docUrl?: string; - /** - * 组件快照 - */ - screenshot?: string; - /** - * 组件研发模式 - */ - devMode?: 'procode' | 'lowcode'; - /** - * npm 源引入完整描述对象 - */ - npm?: NpmInfo; - /** - * 组件属性信息 - */ - props?: PropConfig[]; - /** - * 编辑体验增强 - */ - configure?: FieldConfig[] | Configure; - /** - * @deprecated, use advanced instead - */ - experimental?: Advanced; - /** - * @todo 待补充文档 - */ - schema?: ComponentSchema; - /** - * 可用片段 - */ - snippets?: Snippet[]; - /** - * 一级分组 - */ - group?: string | I18nData; - /** - * 二级分组 - */ - category?: string | I18nData; - /** - * 组件优先级排序 - */ - priority?: number; -} - -/** - * @todo 待补充文档 - */ -export interface TransformedComponentMetadata extends ComponentMetadata { - configure: Configure & { combined?: FieldConfig[] }; -} - -/** - * handleResizing - */ - -/** - * 配置 callbacks 可捕获引擎抛出的一些事件,例如 onNodeAdd、onResize 等 - */ -export interface Callbacks { - // hooks - onMouseDownHook?: (e: MouseEvent, currentNode: any) => any; - onDblClickHook?: (e: MouseEvent, currentNode: any) => any; - onClickHook?: (e: MouseEvent, currentNode: any) => any; - // onLocateHook?: (e: any, currentNode: any) => any; - // onAcceptHook?: (currentNode: any, locationData: any) => any; - onMoveHook?: (currentNode: any) => boolean; - // thinkof 限制性拖拽 - onHoverHook?: (currentNode: any) => boolean; - onChildMoveHook?: (childNode: any, currentNode: any) => boolean; - - // events - onNodeRemove?: (removedNode: any, currentNode: any) => void; - onNodeAdd?: (addedNode: any, currentNode: any) => void; - onSubtreeModified?: (currentNode: any, options: any) => void; - onResize?: ( - e: MouseEvent & { - trigger: string; - deltaX?: number; - deltaY?: number; - }, - currentNode: any, - ) => void; - onResizeStart?: ( - e: MouseEvent & { - trigger: string; - deltaX?: number; - deltaY?: number; - }, - currentNode: any, - ) => void; - onResizeEnd?: ( - e: MouseEvent & { - trigger: string; - deltaX?: number; - deltaY?: number; - }, - currentNode: any, - ) => void; -} diff --git a/packages/types/src/node.ts b/packages/types/src/node.ts deleted file mode 100644 index 400f6d9873..0000000000 --- a/packages/types/src/node.ts +++ /dev/null @@ -1,5 +0,0 @@ -export interface NodeStatus { - locking: boolean; - pseudo: boolean; - inPlaceEditing: boolean; -} diff --git a/packages/types/src/npm.ts b/packages/types/src/npm.ts deleted file mode 100644 index 837dc18d08..0000000000 --- a/packages/types/src/npm.ts +++ /dev/null @@ -1,35 +0,0 @@ -/** - * npm 源引入完整描述对象 - */ -export interface NpmInfo { - /** - * 源码组件名称 - */ - componentName?: string; - /** - * 源码组件库名 - */ - package: string; - /** - * 源码组件版本号 - */ - version?: string; - /** - * 是否解构 - */ - destructuring?: boolean; - /** - * 源码组件名称 - */ - exportName?: string; - /** - * 子组件名 - */ - subName?: string; - /** - * 组件路径 - */ - main?: string; -} - -export type ComponentsMap = NpmInfo[]; diff --git a/packages/types/src/prop-config.ts b/packages/types/src/prop-config.ts deleted file mode 100644 index 502a23ce7c..0000000000 --- a/packages/types/src/prop-config.ts +++ /dev/null @@ -1,65 +0,0 @@ -export type PropType = BasicType | RequiredType | ComplexType; -export type BasicType = 'array' | 'bool' | 'func' | 'number' | 'object' | 'string' | 'node' | 'element' | 'any'; -export type ComplexType = OneOf | OneOfType | ArrayOf | ObjectOf | Shape | Exact; - -export interface RequiredType { - type: BasicType; - isRequired?: boolean; -} - -export interface OneOf { - type: 'oneOf'; - value: string[]; - isRequired?: boolean; -} -export interface OneOfType { - type: 'oneOfType'; - value: PropType[]; - isRequired?: boolean; -} -export interface ArrayOf { - type: 'arrayOf'; - value: PropType; - isRequired?: boolean; -} -export interface ObjectOf { - type: 'objectOf'; - value: PropType; - isRequired?: boolean; -} -export interface Shape { - type: 'shape'; - value: PropConfig[]; - isRequired?: boolean; -} -export interface Exact { - type: 'exact'; - value: PropConfig[]; - isRequired?: boolean; -} - -/** - * 组件属性信息 - */ -export interface PropConfig { - /** - * 属性名称 - */ - name: string; - /** - * 属性类型 - */ - propType: PropType; - /** - * 属性描述 - */ - description?: string; - /** - * 属性默认值 - */ - defaultValue?: any; - /** - * @deprecated 已被弃用 - */ - setter?: any; -} diff --git a/packages/types/src/schema.ts b/packages/types/src/schema.ts deleted file mode 100644 index 75cf6bfbb1..0000000000 --- a/packages/types/src/schema.ts +++ /dev/null @@ -1,230 +0,0 @@ -import { InterpretDataSource as DataSource } from '@alilc/lowcode-datasource-types'; -import { ComponentsMap } from './npm'; -import { - CompositeValue, - JSExpression, - JSFunction, - CompositeObject, - JSONObject, -} from './value-type'; -import { I18nMap } from './i18n'; -import { UtilsMap } from './utils'; -import { AppConfig } from './app-config'; - -// 转换成一个 .jsx 文件内 React Class 类 render 函数返回的 jsx 代码 - -/** - * 搭建基础协议 - 单个组件树节点描述 - */ -export interface NodeSchema { - id?: string; - /** - * 组件名称 必填、首字母大写 - */ - componentName: string; - /** - * 组件属性对象 - */ - props?: { - children?: NodeData[]; - } & PropsMap;// | PropsList; - /** - * 组件属性对象 - */ - leadingComponents?: string; - /** - * 渲染条件 - */ - condition?: CompositeValue; - /** - * 循环数据 - */ - loop?: CompositeValue; - /** - * 循环迭代对象、索引名称 ["item", "index"] - */ - loopArgs?: [string, string]; - /** - * 子节点 - */ - children?: NodeData | NodeData[]; - /** - * 是否锁定 - */ - isLocked?: boolean; - - // @todo - // ------- future support ----- - conditionGroup?: string; - title?: string; - ignore?: boolean; - locked?: boolean; - hidden?: boolean; - isTopFixed?: boolean; - - /** @experimental 编辑态内部使用 */ - __ctx?: any; - /** @experimental 编辑态内部使用 */ - __ignoreParse?: any[]; -} - -export type PropsMap = CompositeObject; -export type PropsList = Array<{ - spread?: boolean; - name?: string; - value: CompositeValue; -}>; - -export type NodeData = NodeSchema | JSExpression | DOMText; -export type NodeDataType = NodeData | NodeData[]; - -export function isDOMText(data: any): data is DOMText { - return typeof data === 'string'; -} - -export type DOMText = string; - -/** - * 容器结构描述 - */ -export interface ContainerSchema extends NodeSchema { - /** - * 'Block' | 'Page' | 'Component'; - */ - componentName: string; - /** - * 文件名称 - */ - fileName: string; - /** - * @todo 待文档定义 - */ - meta?: Record<string, unknown>; - /** - * 容器初始数据 - */ - state?: { - [key: string]: CompositeValue; - }; - /** - * 自定义方法设置 - */ - methods?: { - [key: string]: JSExpression | JSFunction; - }; - /** - * 生命周期对象 - */ - lifeCycles?: { - // @todo 生命周期对象建议改为闭合集合 - [key: string]: JSExpression | JSFunction; - }; - /** - * 样式文件 - */ - css?: string; - /** - * 异步数据源配置 - */ - dataSource?: DataSource; - /** - * 低代码业务组件默认属性 - */ - defaultProps?: CompositeObject; - // @todo propDefinitions -} - -/** - * 页面容器 - * @see https://yuque.antfin-inc.com/mo/spec/spec-low-code-building-schema#XMeF5 - */ -export interface PageSchema extends ContainerSchema { - componentName: 'Page'; -} - -/** - * 低代码业务组件容器 - * @see https://yuque.antfin-inc.com/mo/spec/spec-low-code-building-schema#XMeF5 - */ -export interface ComponentSchema extends ContainerSchema { - componentName: 'Component'; -} - -/** - * 区块容器 - * @see https://yuque.antfin-inc.com/mo/spec/spec-low-code-building-schema#XMeF5 - */ -export interface BlockSchema extends ContainerSchema { - componentName: 'Block'; -} - -/** - * @todo - */ -export type RootSchema = PageSchema | ComponentSchema | BlockSchema; - -/** - * Slot schema 描述 - */ -export interface SlotSchema extends NodeSchema { - componentName: 'Slot'; - name?: string; - params?: string[]; -} - -/** - * 应用描述 - */ -export interface ProjectSchema { - id?: string; - /** - * 当前应用协议版本号 - */ - version: string; - /** - * 当前应用所有组件映射关系 - */ - componentsMap: ComponentsMap; - /** - * 描述应用所有页面、低代码组件的组件树 - * 低代码业务组件树描述 - * 是长度固定为1的数组, 即数组内仅包含根容器的描述(低代码业务组件容器类型) - */ - componentsTree: RootSchema[]; - /** - * 国际化语料 - */ - i18n?: I18nMap; - /** - * 应用范围内的全局自定义函数或第三方工具类扩展 - */ - utils?: UtilsMap; - /** - * 应用范围内的全局常量 - */ - constants?: JSONObject; - /** - * 应用范围内的全局样式 - */ - css?: string; - /** - * 当前应用的公共数据源 - */ - dataSource?: DataSource; - /** - * 当前应用配置信息 - */ - config?: AppConfig | Record<string, any>; - /** - * 当前应用元数据信息 - */ - meta?: Record<string, any>; -} - -export function isNodeSchema(data: any): data is NodeSchema { - return data && data.componentName; -} - -export function isProjectSchema(data: any): data is ProjectSchema { - return data && data.componentsTree; -} diff --git a/packages/types/src/setter-config.ts b/packages/types/src/setter-config.ts deleted file mode 100644 index 060bff9912..0000000000 --- a/packages/types/src/setter-config.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { ComponentClass, Component, ComponentType, ReactElement, isValidElement } from 'react'; -import { TitleContent } from './title'; -import { SettingTarget } from './setting-target'; -import { CompositeValue } from './value-type'; - -function isReactClass(obj: any): obj is ComponentClass<any> { - return obj && obj.prototype && (obj.prototype.isReactComponent || obj.prototype instanceof Component); -} - -function isReactComponent(obj: any): obj is ComponentType<any> { - return obj && (isReactClass(obj) || typeof obj === 'function'); -} - -export type CustomView = ReactElement | ComponentType<any>; - -export type DynamicProps = (target: SettingTarget) => Record<string, unknown>; -export type DynamicSetter = (target: SettingTarget) => string | SetterConfig | CustomView; - -/** - * 设置器配置 - */ -export interface SetterConfig { - // if *string* passed must be a registered Setter Name - /** - * 配置设置器用哪一个 setter - */ - componentName: string | CustomView; - /** - * 传递给 setter 的属性 - * - * the props pass to Setter Component - */ - props?: Record<string, unknown> | DynamicProps; - /** - * @deprecated - */ - children?: any; - /** - * 是否必填? - * - * ArraySetter 里有个快捷预览,可以在不打开面板的情况下直接编辑 - */ - isRequired?: boolean; - /** - * Setter 的初始值 - * - * @todo initialValue 可能要和 defaultValue 二选一 - */ - initialValue?: any | ((target: SettingTarget) => any); - // for MixedSetter - /** - * 给 MixedSetter 时切换 Setter 展示用的 - */ - title?: TitleContent; - // for MixedSetter check this is available - /** - * 给 MixedSetter 用于判断优先选中哪个 - */ - condition?: (target: SettingTarget) => boolean; - /** - * 给 MixedSetter,切换值时声明类型 - * - * @todo 物料协议推进 - */ - valueType?: CompositeValue[]; -} - -// if *string* passed must be a registered Setter Name, future support blockSchema -export type SetterType = SetterConfig | SetterConfig[] | string | CustomView; - -export function isSetterConfig(obj: any): obj is SetterConfig { - return obj && typeof obj === 'object' && 'componentName' in obj && !isCustomView(obj); -} - -export function isCustomView(obj: any): obj is CustomView { - return obj && (isValidElement(obj) || isReactComponent(obj)); -} - -export function isDynamicSetter(obj: any): obj is DynamicSetter { - return obj && typeof obj === 'function' && !isReactClass(obj); -} diff --git a/packages/types/src/setting-target.ts b/packages/types/src/setting-target.ts deleted file mode 100644 index 9d2a3b1fae..0000000000 --- a/packages/types/src/setting-target.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { IEditor } from './editor'; - -export interface SettingTarget { - /** - * 同样类型的节点 - */ - readonly isSameComponent: boolean; - - /** - * 一个 - */ - readonly isSingle: boolean; - - /** - * 多个 - */ - readonly isMultiple: boolean; - - /** - * 编辑器引用 - */ - readonly editor: IEditor; - - /** - * 访问路径 - */ - readonly path: Array<string| number>; - - /** - * 顶端 - */ - readonly top: SettingTarget; - - /** - * 父级 - */ - readonly parent: SettingTarget; - - - /** - * 获取当前值 - */ - getValue: () => any; - - /** - * 设置当前值 - */ - setValue: (value: any) => void; - - /** - * 取得子项 - */ - get: (propName: string | number) => SettingTarget | null; - - /** - * 取得子项 - */ - getProps?: () => SettingTarget; - - /** - * 获取子项属性值 - */ - getPropValue: (propName: string | number) => any; - - /** - * 设置子项属性值 - */ - setPropValue: (propName: string | number, value: any) => void; - - /** - * 清除已设置值 - */ - clearPropValue: (propName: string | number) => void; - - /** - * 获取顶层附属属性值 - */ - getExtraPropValue: (propName: string) => any; - - /** - * 设置顶层附属属性值 - */ - setExtraPropValue: (propName: string, value: any) => void; - - // @todo 补充 node 定义 - /** - * 获取 node 中的第一项 - */ - getNode: () => any; -} diff --git a/packages/types/src/shell-model-factory.ts b/packages/types/src/shell-model-factory.ts new file mode 100644 index 0000000000..b6044ced9e --- /dev/null +++ b/packages/types/src/shell-model-factory.ts @@ -0,0 +1,9 @@ +import { IPublicModelNode, IPublicModelSettingField } from './shell'; + +export interface IShellModelFactory { + // TODO: 需要给 innerNode 提供一个 interface 并用在这里 + createNode(node: any | null | undefined): IPublicModelNode | null; + // TODO: 需要给 InnerSettingField 提供一个 interface 并用在这里 + + createSettingField(prop: any): IPublicModelSettingField; +} diff --git a/packages/types/src/shell/api/canvas.ts b/packages/types/src/shell/api/canvas.ts new file mode 100644 index 0000000000..6cb3df9fc5 --- /dev/null +++ b/packages/types/src/shell/api/canvas.ts @@ -0,0 +1,73 @@ +import { IPublicModelDragon, IPublicModelDropLocation, IPublicModelScrollTarget, IPublicModelScroller, IPublicModelActiveTracker, IPublicModelClipboard } from '../model'; +import { IPublicTypeLocationData, IPublicTypeScrollable } from '../type'; + +/** + * canvas - 画布 API + * @since v1.1.0 + */ +export interface IPublicApiCanvas { + + /** + * 创一个滚动控制器 Scroller,赋予一个视图滚动的基本能力, + * + * a Scroller is a controller that gives a view (IPublicTypeScrollable) the ability scrolling + * to some cordination by api scrollTo. + * + * when a scroller is inited, will need to pass is a scrollable, which has a scrollTarget. + * and when scrollTo(options: { left?: number; top?: number }) is called, scroller will + * move scrollTarget`s top-left corner to (options.left, options.top) that passed in. + * @since v1.1.0 + */ + createScroller(scrollable: IPublicTypeScrollable): IPublicModelScroller; + + /** + * 创建一个 ScrollTarget,与 Scroller 一起发挥作用,详见 createScroller 中的描述 + * + * this works with Scroller, refer to createScroller`s description + * @since v1.1.0 + */ + createScrollTarget(shell: HTMLDivElement): IPublicModelScrollTarget; + + /** + * 创建一个文档插入位置对象,该对象用来描述一个即将插入的节点在文档中的位置 + * + * create a drop location for document, drop location describes a location in document + * @since v1.1.0 + */ + createLocation(locationData: IPublicTypeLocationData): IPublicModelDropLocation; + + /** + * 获取拖拽操作对象的实例 + * + * get dragon instance, you can use this to obtain draging related abilities and lifecycle hooks + * @since v1.1.0 + */ + get dragon(): IPublicModelDragon | null; + + /** + * 获取活动追踪器实例 + * + * get activeTracker instance, which is a singleton running in engine. + * it tracks document`s current focusing node/node[], and notify it`s subscribers that when + * focusing node/node[] changed. + * @since v1.1.0 + */ + get activeTracker(): IPublicModelActiveTracker | null; + + /** + * 是否处于 LiveEditing 状态 + * + * check if canvas is in liveEditing state + * @since v1.1.0 + */ + get isInLiveEditing(): boolean; + + /** + * 获取全局剪贴板实例 + * + * get clipboard instance + * + * @since v1.1.0 + */ + get clipboard(): IPublicModelClipboard; +} diff --git a/packages/types/src/shell/api/command.ts b/packages/types/src/shell/api/command.ts new file mode 100644 index 0000000000..1f8425dcef --- /dev/null +++ b/packages/types/src/shell/api/command.ts @@ -0,0 +1,34 @@ +import { IPublicTypeCommand, IPublicTypeCommandHandlerArgs, IPublicTypeListCommand } from '../type'; + +export interface IPublicApiCommand { + + /** + * 注册一个新命令及其处理函数 + */ + registerCommand(command: IPublicTypeCommand): void; + + /** + * 注销一个已存在的命令 + */ + unregisterCommand(name: string): void; + + /** + * 通过名称和给定参数执行一个命令,会校验参数是否符合命令定义 + */ + executeCommand(name: string, args?: IPublicTypeCommandHandlerArgs): void; + + /** + * 批量执行命令,执行完所有命令后再进行一次重绘,历史记录中只会记录一次 + */ + batchExecuteCommand(commands: { name: string; args?: IPublicTypeCommandHandlerArgs }[]): void; + + /** + * 列出所有已注册的命令 + */ + listCommands(): IPublicTypeListCommand[]; + + /** + * 注册错误处理回调函数 + */ + onCommandError(callback: (name: string, error: Error) => void): void; +} \ No newline at end of file diff --git a/packages/types/src/shell/api/common.ts b/packages/types/src/shell/api/common.ts new file mode 100644 index 0000000000..05ef0da17f --- /dev/null +++ b/packages/types/src/shell/api/common.ts @@ -0,0 +1,124 @@ + +import { Component, ReactNode } from 'react'; +import { IPublicTypeI18nData, IPublicTypeNodeSchema, IPublicTypeTitleContent } from '../type'; +import { IPublicEnumTransitionType } from '../enum'; + +export interface IPublicApiCommonUtils { + + /** + * 是否为合法的 schema 结构 + * check if data is valid NodeSchema + * + * @param {*} data + * @returns {boolean} + */ + isNodeSchema(data: any): boolean; + + /** + * 是否为表单事件类型 + * check if e is a form event + * @param {(KeyboardEvent | MouseEvent)} e + * @returns {boolean} + */ + isFormEvent(e: KeyboardEvent | MouseEvent): boolean; + + /** + * 从 schema 结构中查找指定 id 节点 + * get node schema from a larger schema with node id + * @param {IPublicTypeNodeSchema} schema + * @param {string} nodeId + * @returns {(IPublicTypeNodeSchema | undefined)} + */ + getNodeSchemaById( + schema: IPublicTypeNodeSchema, + nodeId: string, + ): IPublicTypeNodeSchema | undefined; + + // TODO: add comments + getConvertedExtraKey(key: string): string; + + // TODO: add comments + getOriginalExtraKey(key: string): string; + + /** + * 批处理事务,用于优化特定场景的性能 + * excute something in a transaction for performence + * + * @param {() => void} fn + * @param {IPublicEnumTransitionType} type + * @since v1.0.16 + */ + executeTransaction(fn: () => void, type: IPublicEnumTransitionType): void; + + /** + * i18n 相关工具 + * i18n tools + * + * @param {(string | object)} instance + * @returns {{ + * intlNode(id: string, params?: object): ReactNode; + * intl(id: string, params?: object): string; + * getLocale(): string; + * setLocale(locale: string): void; + * }} + * @since v1.0.17 + */ + createIntl(instance: string | object): { + intlNode(id: string, params?: object): ReactNode; + intl(id: string, params?: object): string; + getLocale(): string; + setLocale(locale: string): void; + }; + + /** + * i18n 转换方法 + */ + intl(data: IPublicTypeI18nData | string, params?: object): string; +} +export interface IPublicApiCommonSkeletonCabin { + + /** + * 编辑器框架 View + * get Workbench Component + */ + get Workbench(): Component; +} + +export interface IPublicApiCommonEditorCabin { + + /** + * Title 组件 + * @experimental unstable API, pay extra caution when trying to use this + */ + get Tip(): React.ComponentClass<{}>; + + /** + * Tip 组件 + * @experimental unstable API, pay extra caution when trying to use this + */ + get Title(): React.ComponentClass<{ + title: IPublicTypeTitleContent | undefined; + match?: boolean; + keywords?: string | null; + }>; +} + +export interface IPublicApiCommonDesignerCabin { +} + +export interface IPublicApiCommon { + + get utils(): IPublicApiCommonUtils; + + /** + * @deprecated + */ + get designerCabin(): IPublicApiCommonDesignerCabin; + + /** + * @experimental unstable API, pay extra caution when trying to use this + */ + get editorCabin(): IPublicApiCommonEditorCabin; + + get skeletonCabin(): IPublicApiCommonSkeletonCabin; +} diff --git a/packages/types/src/shell/api/commonUI.ts b/packages/types/src/shell/api/commonUI.ts new file mode 100644 index 0000000000..5ac025fcde --- /dev/null +++ b/packages/types/src/shell/api/commonUI.ts @@ -0,0 +1,74 @@ +import React, { ReactElement } from 'react'; +import { IPublicTypeContextMenuAction, IPublicTypeHelpTipConfig, IPublicTypeTipConfig, IPublicTypeTitleContent } from '../type'; +import { Balloon, Breadcrumb, Button, Card, Checkbox, DatePicker, Dialog, Dropdown, Form, Icon, Input, Loading, Message, Overlay, Pagination, Radio, Search, Select, SplitButton, Step, Switch, Tab, Table, Tree, TreeSelect, Upload, Divider } from '@alifd/next'; +import { IconProps } from '@alifd/next/types/icon'; + +export interface IPublicApiCommonUI { + Balloon: typeof Balloon; + Breadcrumb: typeof Breadcrumb; + Button: typeof Button; + Card: typeof Card; + Checkbox: typeof Checkbox; + DatePicker: typeof DatePicker; + Dialog: typeof Dialog; + Dropdown: typeof Dropdown; + Form: typeof Form; + Icon: typeof Icon; + Input: typeof Input; + Loading: typeof Loading; + Message: typeof Message; + Overlay: typeof Overlay; + Pagination: typeof Pagination; + Radio: typeof Radio; + Search: typeof Search; + Select: typeof Select; + SplitButton: typeof SplitButton; + Step: typeof Step; + Switch: typeof Switch; + Tab: typeof Tab; + Table: typeof Table; + Tree: typeof Tree; + TreeSelect: typeof TreeSelect; + Upload: typeof Upload; + Divider: typeof Divider; + + /** + * Title 组件 + */ + get Tip(): React.ComponentClass<IPublicTypeTipConfig>; + + /** + * HelpTip 组件 + */ + get HelpTip(): React.VFC<{ + help: IPublicTypeHelpTipConfig; + + /** + * 方向 + * @default 'top' + */ + direction: IPublicTypeTipConfig['direction']; + + /** + * 大小 + * @default 'small' + */ + size: IconProps['size']; + }>; + + /** + * Tip 组件 + */ + get Title(): React.ComponentClass<{ + title: IPublicTypeTitleContent | undefined; + match?: boolean; + keywords?: string | null; + }>; + + get ContextMenu(): ((props: { + menus: IPublicTypeContextMenuAction[]; + children: React.ReactElement[] | React.ReactElement; + }) => ReactElement) & { + create(menus: IPublicTypeContextMenuAction[], event: MouseEvent | React.MouseEvent): void; + }; +} diff --git a/packages/types/src/shell/api/event.ts b/packages/types/src/shell/api/event.ts new file mode 100644 index 0000000000..5b8c59e139 --- /dev/null +++ b/packages/types/src/shell/api/event.ts @@ -0,0 +1,37 @@ +import { IPublicTypeDisposable } from '../type'; + +export interface IPublicApiEvent { + + /** + * 监听事件 + * add monitor to a event + * @param event 事件名称 + * @param listener 事件回调 + */ + on(event: string, listener: (...args: any[]) => void): IPublicTypeDisposable; + + /** + * 监听事件,会在其他回调函数之前执行 + * add monitor to a event + * @param event 事件名称 + * @param listener 事件回调 + */ + prependListener(event: string, listener: (...args: any[]) => void): IPublicTypeDisposable; + + /** + * 取消监听事件 + * cancel a monitor from a event + * @param event 事件名称 + * @param listener 事件回调 + */ + off(event: string, listener: (...args: any[]) => void): void; + + /** + * 触发事件 + * emit a message for a event + * @param event 事件名称 + * @param args 事件参数 + * @returns + */ + emit(event: string, ...args: any[]): void; +} diff --git a/packages/types/src/shell/api/hotkey.ts b/packages/types/src/shell/api/hotkey.ts new file mode 100644 index 0000000000..894eb0e2f9 --- /dev/null +++ b/packages/types/src/shell/api/hotkey.ts @@ -0,0 +1,25 @@ +import { IPublicTypeDisposable, IPublicTypeHotkeyCallback, IPublicTypeHotkeyCallbacks } from '../type'; + +export interface IPublicApiHotkey { + + /** + * 获取当前快捷键配置 + * + * @experimental + * @since v1.1.0 + */ + get callbacks(): IPublicTypeHotkeyCallbacks; + + /** + * 绑定快捷键 + * bind hotkey/hotkeys, + * @param combos 快捷键,格式如:['command + s'] 、['ctrl + shift + s'] 等 + * @param callback 回调函数 + * @param action + */ + bind( + combos: string[] | string, + callback: IPublicTypeHotkeyCallback, + action?: string, + ): IPublicTypeDisposable; +} diff --git a/packages/types/src/shell/api/index.ts b/packages/types/src/shell/api/index.ts new file mode 100644 index 0000000000..8f14d8dadd --- /dev/null +++ b/packages/types/src/shell/api/index.ts @@ -0,0 +1,14 @@ +export * from './common'; +export * from './event'; +export * from './hotkey'; +export * from './material'; +export * from './project'; +export * from './setters'; +export * from './simulator-host'; +export * from './skeleton'; +export * from './plugins'; +export * from './logger'; +export * from './canvas'; +export * from './workspace'; +export * from './commonUI'; +export * from './command'; \ No newline at end of file diff --git a/packages/types/src/shell/api/logger.ts b/packages/types/src/shell/api/logger.ts new file mode 100644 index 0000000000..db81aeaaed --- /dev/null +++ b/packages/types/src/shell/api/logger.ts @@ -0,0 +1,33 @@ +export type LoggerLevel = 'debug' | 'log' | 'info' | 'warn' | 'error'; +export interface ILoggerOptions { + level?: LoggerLevel; + bizName?: string; +} + +export interface IPublicApiLogger { + + /** + * debug info + */ + debug(...args: any | any[]): void; + + /** + * normal info output + */ + info(...args: any | any[]): void; + + /** + * warning info output + */ + warn(...args: any | any[]): void; + + /** + * error info output + */ + error(...args: any | any[]): void; + + /** + * log info output + */ + log(...args: any | any[]): void; +} diff --git a/packages/types/src/shell/api/material.ts b/packages/types/src/shell/api/material.ts new file mode 100644 index 0000000000..89b2b39ad1 --- /dev/null +++ b/packages/types/src/shell/api/material.ts @@ -0,0 +1,149 @@ +import { IPublicTypeAssetsJson, IPublicTypeMetadataTransducer, IPublicTypeComponentAction, IPublicTypeNpmInfo, IPublicTypeDisposable, IPublicTypeContextMenuAction, IPublicTypeContextMenuItem } from '../type'; +import { IPublicModelComponentMeta } from '../model'; +import { ComponentType } from 'react'; + +export interface IPublicApiMaterial { + + /** + * 获取组件 map 结构 + * get map of components + */ + get componentsMap(): { [key: string]: IPublicTypeNpmInfo | ComponentType<any> | object } ; + + /** + * 设置「资产包」结构 + * set data for Assets + * @returns void + */ + setAssets(assets: IPublicTypeAssetsJson): Promise<void>; + + /** + * 获取「资产包」结构 + * get AssetsJson data + * @returns IPublicTypeAssetsJson + */ + getAssets(): IPublicTypeAssetsJson | undefined; + + /** + * 加载增量的「资产包」结构,该增量包会与原有的合并 + * load Assets incrementally, and will merge this with exiting assets + * @param incrementalAssets + * @returns + */ + loadIncrementalAssets(incrementalAssets: IPublicTypeAssetsJson): void; + + /** + * 注册物料元数据管道函数,在物料信息初始化时执行。 + * register transducer to process component meta, which will be + * excuted during component meta`s initialization + * @param transducer + * @param level + * @param id + */ + registerMetadataTransducer( + transducer: IPublicTypeMetadataTransducer, + level?: number, + id?: string | undefined + ): void; + + /** + * 获取所有物料元数据管道函数 + * get all registered metadata transducers + * @returns {IPublicTypeMetadataTransducer[]} + */ + getRegisteredMetadataTransducers(): IPublicTypeMetadataTransducer[]; + + /** + * 获取指定名称的物料元数据 + * get component meta by component name + * @param componentName + * @returns + */ + getComponentMeta(componentName: string): IPublicModelComponentMeta | null; + + /** + * test if the given object is a ComponentMeta instance or not + * @param obj + * @experiemental unstable API, pay extra caution when trying to use it + */ + isComponentMeta(obj: any): boolean; + + /** + * 获取所有已注册的物料元数据 + * get map of all component metas + */ + getComponentMetasMap(): Map<string, IPublicModelComponentMeta>; + + /** + * 在设计器辅助层增加一个扩展 action + * + * add an action button in canvas context menu area + * @param action + * @example + * ```ts + * import { plugins } from '@alilc/lowcode-engine'; + * import { IPublicModelPluginContext } from '@alilc/lowcode-types'; + * + * const removeCopyAction = (ctx: IPublicModelPluginContext) => { + * return { + * async init() { + * const { removeBuiltinComponentAction } = ctx.material; + * removeBuiltinComponentAction('copy'); + * } + * } + * }; + * removeCopyAction.pluginName = 'removeCopyAction'; + * await plugins.register(removeCopyAction); + * ``` + */ + addBuiltinComponentAction(action: IPublicTypeComponentAction): void; + + /** + * 移除设计器辅助层的指定 action + * remove a builtin action button from canvas context menu area + * @param name + */ + removeBuiltinComponentAction(name: string): void; + + /** + * 修改已有的设计器辅助层的指定 action + * modify a builtin action button in canvas context menu area + * @param actionName + * @param handle + */ + modifyBuiltinComponentAction( + actionName: string, + handle: (action: IPublicTypeComponentAction) => void, + ): void; + + /** + * 监听 assets 变化的事件 + * add callback for assets changed event + * @param fn + */ + onChangeAssets(fn: () => void): IPublicTypeDisposable; + + /** + * 刷新 componentMetasMap,可触发模拟器里的 components 重新构建 + * @since v1.1.7 + */ + refreshComponentMetasMap(): void; + + /** + * 添加右键菜单项 + * @param action + */ + addContextMenuOption(action: IPublicTypeContextMenuAction): void; + + /** + * 删除特定右键菜单项 + * @param name + */ + removeContextMenuOption(name: string): void; + + /** + * 调整右键菜单项布局 + * @param actions + */ + adjustContextMenuLayout(fn: (actions: IPublicTypeContextMenuItem[]) => IPublicTypeContextMenuItem[]): void; +} diff --git a/packages/types/src/shell/api/plugins.ts b/packages/types/src/shell/api/plugins.ts new file mode 100644 index 0000000000..a930162909 --- /dev/null +++ b/packages/types/src/shell/api/plugins.ts @@ -0,0 +1,63 @@ +import { IPublicModelPluginInstance, IPublicTypePlugin } from '../model'; +import { IPublicTypePreferenceValueType } from '../type'; +import { IPublicTypePluginRegisterOptions } from '../type/plugin-register-options'; + +export interface IPluginPreferenceMananger { + // eslint-disable-next-line max-len + getPreferenceValue: ( + key: string, + defaultValue?: IPublicTypePreferenceValueType, + ) => IPublicTypePreferenceValueType | undefined; +} + +export type PluginOptionsType = string | number | boolean | object; + +export interface IPublicApiPlugins { + /** + * 可以通过 plugin api 获取其他插件 export 导出的内容 + */ + [key: string]: any; + + register( + pluginModel: IPublicTypePlugin, + options?: Record<string, PluginOptionsType>, + registerOptions?: IPublicTypePluginRegisterOptions, + ): Promise<void>; + + /** + * 引擎初始化时可以提供全局配置给到各插件,通过这个方法可以获得本插件对应的配置 + * + * use this to get preference config for this plugin when engine.init() called + */ + getPluginPreference( + pluginName: string, + ): Record<string, IPublicTypePreferenceValueType> | null | undefined; + + /** + * 获取指定插件 + * + * get plugin instance by name + */ + get(pluginName: string): IPublicModelPluginInstance | null; + + /** + * 获取所有的插件实例 + * + * get all plugin instances + */ + getAll(): IPublicModelPluginInstance[]; + + /** + * 判断是否有指定插件 + * + * check if plugin with certain name exists + */ + has(pluginName: string): boolean; + + /** + * 删除指定插件 + * + * delete plugin instance by name + */ + delete(pluginName: string): void; +} diff --git a/packages/types/src/shell/api/project.ts b/packages/types/src/shell/api/project.ts new file mode 100644 index 0000000000..662f302ccc --- /dev/null +++ b/packages/types/src/shell/api/project.ts @@ -0,0 +1,147 @@ +import { IPublicTypeProjectSchema, IPublicTypeDisposable, IPublicTypeRootSchema, IPublicTypePropsTransducer, IPublicTypeAppConfig } from '../type'; +import { IPublicEnumTransformStage } from '../enum'; +import { IPublicApiSimulatorHost } from './'; +import { IPublicModelDocumentModel } from '../model'; + +export interface IBaseApiProject< + DocumentModel +> { + + /** + * 获取当前的 document + * get current document + */ + get currentDocument(): DocumentModel | null; + + /** + * 获取当前 project 下所有 documents + * get all documents of this project + * @returns + */ + get documents(): DocumentModel[]; + + /** + * 获取模拟器的 host + * get simulator host + */ + get simulatorHost(): IPublicApiSimulatorHost | null; + + /** + * 打开一个 document + * open a document + * @param doc + * @returns + */ + openDocument(doc?: string | IPublicTypeRootSchema | undefined): DocumentModel | null; + + /** + * 创建一个 document + * create a document + * @param data + * @returns + */ + createDocument(data?: IPublicTypeRootSchema): DocumentModel | null; + + /** + * 删除一个 document + * remove a document + * @param doc + */ + removeDocument(doc: DocumentModel): void; + + /** + * 根据 fileName 获取 document + * get a document by filename + * @param fileName + * @returns + */ + getDocumentByFileName(fileName: string): DocumentModel | null; + + /** + * 根据 id 获取 document + * get a document by id + * @param id + * @returns + */ + getDocumentById(id: string): DocumentModel | null; + + /** + * 导出 project + * export project to schema + * @returns + */ + exportSchema(stage: IPublicEnumTransformStage): IPublicTypeProjectSchema; + + /** + * 导入 project schema + * import schema to project + * @param schema 待导入的 project 数据 + */ + importSchema(schema?: IPublicTypeProjectSchema): void; + + /** + * 获取当前的 document + * get current document + * @returns + */ + getCurrentDocument(): DocumentModel | null; + + /** + * 增加一个属性的管道处理函数 + * add a transducer to process prop + * @param transducer + * @param stage + */ + addPropsTransducer( + transducer: IPublicTypePropsTransducer, + stage: IPublicEnumTransformStage, + ): void; + + /** + * 绑定删除文档事件 + * set callback for event onDocumentRemoved + * @param fn + * @since v1.0.16 + */ + onRemoveDocument(fn: (data: { id: string }) => void): IPublicTypeDisposable; + + /** + * 当前 project 内的 document 变更事件 + * set callback for event onDocumentChanged + */ + onChangeDocument(fn: (doc: DocumentModel) => void): IPublicTypeDisposable; + + /** + * 当前 project 的模拟器 ready 事件 + * set callback for event onSimulatorHostReady + */ + onSimulatorHostReady(fn: (host: IPublicApiSimulatorHost) => void): IPublicTypeDisposable; + + /** + * 当前 project 的渲染器 ready 事件 + * set callback for event onSimulatorRendererReady + */ + onSimulatorRendererReady(fn: () => void): IPublicTypeDisposable; + + /** + * 设置多语言语料 + * 数据格式参考 https://github.com/alibaba/lowcode-engine/blob/main/specs/lowcode-spec.md#2434%E5%9B%BD%E9%99%85%E5%8C%96%E5%A4%9A%E8%AF%AD%E8%A8%80%E7%B1%BB%E5%9E%8Baa + * + * set I18n data for this project + * @param value object + * @since v1.0.17 + */ + setI18n(value: object): void; + + /** + * 设置当前项目配置 + * + * set config data for this project + * @param value object + * @since v1.1.4 + */ + setConfig<T extends keyof IPublicTypeAppConfig>(key: T, value: IPublicTypeAppConfig[T]): void; + setConfig(value: IPublicTypeAppConfig): void; +} + +export interface IPublicApiProject extends IBaseApiProject<IPublicModelDocumentModel> {} diff --git a/packages/types/src/shell/api/setters.ts b/packages/types/src/shell/api/setters.ts new file mode 100644 index 0000000000..011a9dcacd --- /dev/null +++ b/packages/types/src/shell/api/setters.ts @@ -0,0 +1,40 @@ +import { ReactNode } from 'react'; + +import { IPublicTypeRegisteredSetter, IPublicTypeCustomView } from '../type'; + +export interface IPublicApiSetters { + + /** + * 获取指定 setter + * get setter by type + * @param type + * @returns + */ + getSetter(type: string): IPublicTypeRegisteredSetter | null; + + /** + * 获取已注册的所有 settersMap + * get map of all registered setters + * @returns + */ + getSettersMap(): Map<string, IPublicTypeRegisteredSetter & { + type: string; + }>; + + /** + * 注册一个 setter + * register a setter + * @param typeOrMaps + * @param setter + * @returns + */ + registerSetter( + typeOrMaps: string | { [key: string]: IPublicTypeCustomView | IPublicTypeRegisteredSetter }, + setter?: IPublicTypeCustomView | IPublicTypeRegisteredSetter | undefined + ): void; + + /** + * @deprecated + */ + createSetterContent (setter: any, props: Record<string, any>): ReactNode; +} diff --git a/packages/types/src/shell/api/simulator-host.ts b/packages/types/src/shell/api/simulator-host.ts new file mode 100644 index 0000000000..9137067951 --- /dev/null +++ b/packages/types/src/shell/api/simulator-host.ts @@ -0,0 +1,51 @@ +import { IPublicModelNode, IPublicModelSimulatorRender } from '../model'; + +export interface IPublicApiSimulatorHost { + + /** + * 获取 contentWindow + * @experimental unstable api, pay extra caution when trying to use it + */ + get contentWindow(): Window | undefined; + + /** + * 获取 contentDocument + * @experimental unstable api, pay extra caution when trying to use it + */ + get contentDocument(): Document | undefined; + + /** + * @experimental unstable api, pay extra caution when trying to use it + */ + get renderer(): IPublicModelSimulatorRender | undefined; + + /** + * 设置若干用于画布渲染的变量,比如画布大小、locale 等。 + * set config for simulator host, eg. device locale and so on. + * @param key + * @param value + */ + set(key: string, value: any): void; + + /** + * 获取模拟器中设置的变量,比如画布大小、locale 等。 + * set config value by key + * @param key + * @returns + */ + get(key: string): any; + + /** + * 滚动到指定节点 + * scroll to specific node + * @param node + * @since v1.1.0 + */ + scrollToNode(node: IPublicModelNode): void; + + /** + * 刷新渲染画布 + * make simulator render again + */ + rerender(): void; +} diff --git a/packages/types/src/shell/api/skeleton.ts b/packages/types/src/shell/api/skeleton.ts new file mode 100644 index 0000000000..1bf788e121 --- /dev/null +++ b/packages/types/src/shell/api/skeleton.ts @@ -0,0 +1,152 @@ +import { IPublicModelSkeletonItem } from '../model'; +import { IPublicTypeConfigTransducer, IPublicTypeDisposable, IPublicTypeSkeletonConfig, IPublicTypeWidgetConfigArea } from '../type'; + +export interface IPublicApiSkeleton { + + /** + * 增加一个面板实例 + * add a new panel + * @param config + * @param extraConfig + * @returns + */ + add(config: IPublicTypeSkeletonConfig, extraConfig?: Record<string, any>): IPublicModelSkeletonItem | undefined; + + /** + * 移除一个面板实例 + * remove a panel + * @param config + * @returns + */ + remove(config: IPublicTypeSkeletonConfig): number | undefined; + + /** + * 获取某个区域下的所有面板实例 + * @param areaName IPublicTypeWidgetConfigArea + */ + getAreaItems(areaName: IPublicTypeWidgetConfigArea): IPublicModelSkeletonItem[] | undefined; + + /** + * 获取面板实例 + * @param name 面板名称 + * @since v1.1.10 + */ + getPanel(name: string): IPublicModelSkeletonItem | undefined; + + /** + * 展示指定 Panel 实例 + * show panel by name + * @param name + */ + showPanel(name: string): void; + + /** + * 隐藏面板 + * hide panel by name + * @param name + */ + hidePanel(name: string): void; + + /** + * 展示指定 Widget 实例 + * show widget by name + * @param name + */ + showWidget(name: string): void; + + /** + * 将 widget 启用 + * enable widget by name + * @param name + */ + enableWidget(name: string): void; + + /** + * 隐藏指定 widget 实例 + * hide widget by name + * @param name + */ + hideWidget(name: string): void; + + /** + * 将 widget 禁用掉,禁用后,所有鼠标事件都会被禁止掉。 + * disable widget,and make it not responding any click event. + * @param name + */ + disableWidget(name: string): void; + + /** + * 显示某个 Area + * show area + * @param areaName name of area + */ + showArea(areaName: string): void; + + /** + * 隐藏某个 Area + * hide area + * @param areaName name of area + */ + hideArea(areaName: string): void; + + /** + * 监听 Panel 实例显示事件 + * set callback for panel shown event + * @param listener + * @returns + */ + onShowPanel(listener: (paneName?: string, panel?: IPublicModelSkeletonItem) => void): IPublicTypeDisposable; + + /** + * 监听 Panel 实例隐藏事件 + * set callback for panel hidden event + * @param listener + * @returns + */ + onHidePanel(listener: (paneName?: string, panel?: IPublicModelSkeletonItem) => void): IPublicTypeDisposable; + + /** + * 监听 Widget 实例 Disable 事件 + * @param listener + */ + onDisableWidget(listener: (paneName?: string, panel?: IPublicModelSkeletonItem) => void): IPublicTypeDisposable; + + /** + * 监听 Widget 实例 Enable 事件 + * @param listener + */ + onEnableWidget(listener: (paneName?: string, panel?: IPublicModelSkeletonItem) => void): IPublicTypeDisposable; + + /** + * 监听 Widget 显示事件 + * set callback for widget shown event + * @param listener + * @returns + */ + onShowWidget(listener: (paneName?: string, panel?: IPublicModelSkeletonItem) => void): IPublicTypeDisposable; + + /** + * 监听 Widget 隐藏事件 + * set callback for widget hidden event + * @param listener + * @returns + */ + onHideWidget(listener: (paneName?: string, panel?: IPublicModelSkeletonItem) => void): IPublicTypeDisposable; + + /** + * 注册一个面板的配置转换器(transducer)。 + * Registers a configuration transducer for a panel. + * @param {IPublicTypeConfigTransducer} transducer + * - 要注册的转换器函数。该函数接受一个配置对象(类型为 IPublicTypeSkeletonConfig)作为输入,并返回修改后的配置对象。 + * - The transducer function to be registered. This function takes a configuration object (of type IPublicTypeSkeletonConfig) as input and returns a modified configuration object. + * + * @param {number} level + * - 转换器的优先级。优先级较高的转换器会先执行。 + * - The priority level of the transducer. Transducers with higher priority levels are executed first. + * + * @param {string} [id] + * - (可选)转换器的唯一标识符。用于在需要时引用或操作特定的转换器。 + * - (Optional) A unique identifier for the transducer. Used for referencing or manipulating a specific transducer when needed. + */ + registerConfigTransducer(transducer: IPublicTypeConfigTransducer, level: number, id?: string): void; +} diff --git a/packages/types/src/shell/api/workspace.ts b/packages/types/src/shell/api/workspace.ts new file mode 100644 index 0000000000..b6e7d84cb7 --- /dev/null +++ b/packages/types/src/shell/api/workspace.ts @@ -0,0 +1,79 @@ +import { IPublicModelWindow } from '../model'; +import { IPublicApiPlugins, IPublicApiSkeleton, IPublicModelResource, IPublicResourceList, IPublicTypeDisposable, IPublicTypeResourceType } from '@alilc/lowcode-types'; + +export interface IPublicApiWorkspace< + Plugins = IPublicApiPlugins, + Skeleton = IPublicApiSkeleton, + ModelWindow = IPublicModelWindow, + Resource = IPublicModelResource, +> { + + /** 是否启用 workspace 模式 */ + isActive: boolean; + + /** 当前设计器窗口 */ + window: ModelWindow | null; + + plugins: Plugins; + + skeleton: Skeleton; + + /** 当前设计器的编辑窗口 */ + windows: ModelWindow[]; + + /** 获取资源树列表 */ + get resourceList(): IPublicModelResource[]; + + /** 设置资源树列表 */ + setResourceList(resourceList: IPublicResourceList): void; + + /** 资源树列表更新事件 */ + onResourceListChange(fn: (resourceList: IPublicResourceList) => void): IPublicTypeDisposable; + + /** 注册资源 */ + registerResourceType(resourceTypeModel: IPublicTypeResourceType): void; + + /** + * 打开视图窗口 + * @deprecated + */ + openEditorWindow(resourceName: string, id: string, extra: Object, viewName?: string, sleep?: boolean): Promise<void>; + + /** 打开视图窗口 */ + openEditorWindow(resource: Resource, sleep?: boolean): Promise<void>; + + /** 通过视图 id 打开窗口 */ + openEditorWindowById(id: string): void; + + /** + * 移除视图窗口 + * @deprecated + */ + removeEditorWindow(resourceName: string, id: string): void; + + /** + * 移除视图窗口 + */ + removeEditorWindow(resource: Resource): void; + + /** 通过视图 id 移除窗口 */ + removeEditorWindowById(id: string): void; + + /** 窗口新增/删除的事件 */ + onChangeWindows(fn: () => void): IPublicTypeDisposable; + + /** active 窗口变更事件 */ + onChangeActiveWindow(fn: () => void): IPublicTypeDisposable; + + /** + * active 视图变更事件 + * @since v1.1.7 + */ + onChangeActiveEditorView(fn: () => void): IPublicTypeDisposable; + + /** + * window 下的所有视图 renderer ready 事件 + * @since v1.1.7 + */ + onWindowRendererReady(fn: () => void): IPublicTypeDisposable; +} \ No newline at end of file diff --git a/packages/types/src/shell/enum/context-menu.ts b/packages/types/src/shell/enum/context-menu.ts new file mode 100644 index 0000000000..fd209b1974 --- /dev/null +++ b/packages/types/src/shell/enum/context-menu.ts @@ -0,0 +1,7 @@ +export enum IPublicEnumContextMenuType { + SEPARATOR = 'separator', + // 'menuItem' + MENU_ITEM = 'menuItem', + // 'nodeTree' + NODE_TREE = 'nodeTree', +} \ No newline at end of file diff --git a/packages/types/src/shell/enum/drag-object-type.ts b/packages/types/src/shell/enum/drag-object-type.ts new file mode 100644 index 0000000000..c6bacda23d --- /dev/null +++ b/packages/types/src/shell/enum/drag-object-type.ts @@ -0,0 +1,14 @@ +// eslint-disable-next-line no-shadow +export enum IPublicEnumDragObjectType { + // eslint-disable-next-line no-shadow + Node = 'node', + NodeData = 'nodedata', +} + +/** + * @deprecated use IPublicEnumDragObjectType instead + */ +export enum DragObjectType { + Node = IPublicEnumDragObjectType.Node, + NodeData = IPublicEnumDragObjectType.NodeData, +} diff --git a/packages/types/src/shell/enum/event-names.ts b/packages/types/src/shell/enum/event-names.ts new file mode 100644 index 0000000000..1bb8682d44 --- /dev/null +++ b/packages/types/src/shell/enum/event-names.ts @@ -0,0 +1,9 @@ +/** + * 所有公开可用的事件名定义 + * All public event names + * names should be like 'namespace.modelName.whatHappened' + * + */ +// eslint-disable-next-line no-shadow +export enum IPublicEnumEventNames { +} \ No newline at end of file diff --git a/packages/types/src/shell/enum/index.ts b/packages/types/src/shell/enum/index.ts new file mode 100644 index 0000000000..13282d0f2b --- /dev/null +++ b/packages/types/src/shell/enum/index.ts @@ -0,0 +1,7 @@ +export * from './event-names'; +export * from './transition-type'; +export * from './transform-stage'; +export * from './drag-object-type'; +export * from './prop-value-changed-type'; +export * from './plugin-register-level'; +export * from './context-menu'; \ No newline at end of file diff --git a/packages/types/src/shell/enum/plugin-register-level.ts b/packages/types/src/shell/enum/plugin-register-level.ts new file mode 100644 index 0000000000..a0d9b746bb --- /dev/null +++ b/packages/types/src/shell/enum/plugin-register-level.ts @@ -0,0 +1,6 @@ +export enum IPublicEnumPluginRegisterLevel { + Default = 'default', + Workspace = 'workspace', + Resource = 'resource', + EditorView = 'editorView', +} \ No newline at end of file diff --git a/packages/types/src/shell/enum/prop-value-changed-type.ts b/packages/types/src/shell/enum/prop-value-changed-type.ts new file mode 100644 index 0000000000..b261aa3343 --- /dev/null +++ b/packages/types/src/shell/enum/prop-value-changed-type.ts @@ -0,0 +1,25 @@ +// eslint-disable-next-line no-shadow +export enum IPublicEnumPropValueChangedType { + /** + * normal set value + */ + SET_VALUE = 'SET_VALUE', + /** + * value changed caused by sub-prop value change + */ + SUB_VALUE_CHANGE = 'SUB_VALUE_CHANGE' +} + +/** + * @deprecated please use IPublicEnumPropValueChangedType + */ +export enum PROP_VALUE_CHANGED_TYPE { + /** + * normal set value + */ + SET_VALUE = 'SET_VALUE', + /** + * value changed caused by sub-prop value change + */ + SUB_VALUE_CHANGE = 'SUB_VALUE_CHANGE' +} diff --git a/packages/types/src/shell/enum/transform-stage.ts b/packages/types/src/shell/enum/transform-stage.ts new file mode 100644 index 0000000000..18c08f3e47 --- /dev/null +++ b/packages/types/src/shell/enum/transform-stage.ts @@ -0,0 +1,19 @@ +export enum IPublicEnumTransformStage { + Render = 'render', + Serilize = 'serilize', + Save = 'save', + Clone = 'clone', + Init = 'init', + Upgrade = 'upgrade', +} +/** + * @deprecated use IPublicEnumTransformStage instead + */ +export enum TransformStage { + Render = 'render', + Serilize = 'serilize', + Save = 'save', + Clone = 'clone', + Init = 'init', + Upgrade = 'upgrade', +} diff --git a/packages/types/src/shell/enum/transition-type.ts b/packages/types/src/shell/enum/transition-type.ts new file mode 100644 index 0000000000..98dbdba8bd --- /dev/null +++ b/packages/types/src/shell/enum/transition-type.ts @@ -0,0 +1,13 @@ +// eslint-disable-next-line no-shadow +export enum IPublicEnumTransitionType { + /** 节点更新后重绘处理 */ + REPAINT +} + +/** + * @deprecated use IPublicEnumTransitionType instead + */ +export enum TransitionType { + /** 节点更新后重绘处理 */ + REPAINT +} \ No newline at end of file diff --git a/packages/types/src/shell/index.ts b/packages/types/src/shell/index.ts new file mode 100644 index 0000000000..c392c1e120 --- /dev/null +++ b/packages/types/src/shell/index.ts @@ -0,0 +1,5 @@ + +export * from './type'; +export * from './api'; +export * from './model'; +export * from './enum'; \ No newline at end of file diff --git a/packages/types/src/shell/model/active-tracker.ts b/packages/types/src/shell/model/active-tracker.ts new file mode 100644 index 0000000000..ac116a9473 --- /dev/null +++ b/packages/types/src/shell/model/active-tracker.ts @@ -0,0 +1,14 @@ +import { IPublicTypeActiveTarget } from '../type'; +import { IPublicModelNode } from './node'; + +export interface IPublicModelActiveTracker { + + /** + * @since 1.1.7 + */ + target: IPublicTypeActiveTarget | null; + + onChange(fn: (target: IPublicTypeActiveTarget) => void): () => void; + + track(node: IPublicModelNode): void; +} diff --git a/packages/types/src/shell/model/clipboard.ts b/packages/types/src/shell/model/clipboard.ts new file mode 100644 index 0000000000..7fdcc4b1c7 --- /dev/null +++ b/packages/types/src/shell/model/clipboard.ts @@ -0,0 +1,25 @@ + +export interface IPublicModelClipboard { + + /** + * 给剪贴板赋值 + * set data to clipboard + * + * @param {*} data + * @since v1.1.0 + */ + setData(data: any): void; + + /** + * 设置剪贴板数据设置的回调 + * set callback for clipboard provide paste data + * + * @param {KeyboardEvent} keyboardEvent + * @param {(data: any, clipboardEvent: ClipboardEvent) => void} cb + * @since v1.1.0 + */ + waitPasteData( + keyboardEvent: KeyboardEvent, + cb: (data: any, clipboardEvent: ClipboardEvent) => void, + ): void; +} diff --git a/packages/types/src/shell/model/component-meta.ts b/packages/types/src/shell/model/component-meta.ts new file mode 100644 index 0000000000..f2b0032a75 --- /dev/null +++ b/packages/types/src/shell/model/component-meta.ts @@ -0,0 +1,115 @@ +import { IPublicTypeNodeSchema, IPublicTypeNodeData, IPublicTypeIconType, IPublicTypeTransformedComponentMetadata, IPublicTypeI18nData, IPublicTypeNpmInfo, IPublicTypeAdvanced, IPublicTypeFieldConfig, IPublicTypeComponentAction } from '../type'; +import { ReactElement } from 'react'; +import { IPublicModelNode } from './node'; + +export interface IPublicModelComponentMeta< + Node = IPublicModelNode +> { + + /** + * 组件名 + * component name + */ + get componentName(): string; + + /** + * 是否是「容器型」组件 + * is container node or not + */ + get isContainer(): boolean; + + /** + * 是否是最小渲染单元。 + * 当组件需要重新渲染时: + * 若为最小渲染单元,则只渲染当前组件, + * 若不为最小渲染单元,则寻找到上层最近的最小渲染单元进行重新渲染,直至根节点。 + * + * check if this is a mininal render unit. + * when a rerender is needed for a component: + * case 'it`s a mininal render unit': only render itself. + * case 'it`s not a mininal render unit': find a mininal render unit to render in + * its ancesters until root node is reached. + */ + get isMinimalRenderUnit(): boolean; + + /** + * 是否为「模态框」组件 + * check if this is a modal component or not. + */ + get isModal(): boolean; + + /** + * 获取用于设置面板显示用的配置 + * get configs for Settings Panel + */ + get configure(): IPublicTypeFieldConfig[]; + + /** + * 标题 + * title for this component + */ + get title(): string | IPublicTypeI18nData | ReactElement; + + /** + * 图标 + * icon config for this component + */ + get icon(): IPublicTypeIconType; + + /** + * 组件 npm 信息 + * npm informations + */ + get npm(): IPublicTypeNpmInfo; + + /** + * 当前组件的可用 Action + * available actions + */ + get availableActions(): IPublicTypeComponentAction[]; + + /** + * 组件元数据中高级配置部分 + * configure.advanced + * @since v1.1.0 + */ + get advanced(): IPublicTypeAdvanced; + + /** + * 设置 npm 信息 + * set method for npm inforamtion + * @param npm + */ + setNpm(npm: IPublicTypeNpmInfo): void; + + /** + * 获取元数据 + * get component metadata + */ + getMetadata(): IPublicTypeTransformedComponentMetadata; + + /** + * 检测当前对应节点是否可被放置在父节点中 + * check if the current node could be placed in parent node + * @param my 当前节点 + * @param parent 父节点 + */ + checkNestingUp(my: Node | IPublicTypeNodeData, parent: any): boolean; + + /** + * 检测目标节点是否可被放置在父节点中 + * check if the target node(s) could be placed in current node + * @param my 当前节点 + * @param parent 父节点 + */ + checkNestingDown( + my: Node | IPublicTypeNodeData, + target: IPublicTypeNodeSchema | Node | IPublicTypeNodeSchema[], + ): boolean; + + /** + * 刷新元数据,会触发元数据的重新解析和刷新 + * refresh metadata + */ + refreshMetadata(): void; +} diff --git a/packages/types/src/shell/model/detecting.ts b/packages/types/src/shell/model/detecting.ts new file mode 100644 index 0000000000..ec6320ad2f --- /dev/null +++ b/packages/types/src/shell/model/detecting.ts @@ -0,0 +1,46 @@ +import { IPublicModelNode } from './'; +import { IPublicTypeDisposable } from '../type'; + +export interface IPublicModelDetecting<Node = IPublicModelNode> { + + /** + * 是否启用 + * check if current detecting is enabled + * @since v1.1.0 + */ + get enable(): boolean; + + /** + * 当前 hover 的节点 + * get current hovering node + * @since v1.0.16 + */ + get current(): Node | null; + + /** + * hover 指定节点 + * capture node with nodeId + * @param id 节点 id + */ + capture(id: string): void; + + /** + * hover 离开指定节点 + * release node with nodeId + * @param id 节点 id + */ + release(id: string): void; + + /** + * 清空 hover 态 + * clear all hover state + */ + leave(): void; + + /** + * hover 节点变化事件 + * set callback which will be called when hovering object changed. + * @since v1.1.0 + */ + onDetectingChange(fn: (node: Node | null) => void): IPublicTypeDisposable; +} diff --git a/packages/types/src/shell/model/document-model.ts b/packages/types/src/shell/model/document-model.ts new file mode 100644 index 0000000000..4c9344eb48 --- /dev/null +++ b/packages/types/src/shell/model/document-model.ts @@ -0,0 +1,236 @@ +import { IPublicTypeRootSchema, IPublicTypeDragNodeDataObject, IPublicTypeDragNodeObject, IPublicTypePropChangeOptions, IPublicTypeDisposable } from '../type'; +import { IPublicEnumTransformStage } from '../enum'; +import { IPublicApiProject } from '../api'; +import { IPublicModelDropLocation, IPublicModelDetecting, IPublicModelNode, IPublicModelSelection, IPublicModelHistory, IPublicModelModalNodesManager } from './'; +import { IPublicTypeNodeData, IPublicTypeNodeSchema, IPublicTypeOnChangeOptions } from '@alilc/lowcode-types'; + +export interface IPublicModelDocumentModel< + Selection = IPublicModelSelection, + History = IPublicModelHistory, + Node = IPublicModelNode, + DropLocation = IPublicModelDropLocation, + ModalNodesManager = IPublicModelModalNodesManager, + Project = IPublicApiProject +> { + + /** + * 节点选中区模型实例 + * instance of selection + */ + selection: Selection; + + /** + * 画布节点 hover 区模型实例 + * instance of detecting + */ + detecting: IPublicModelDetecting; + + /** + * 操作历史模型实例 + * instance of history + */ + history: History; + + /** + * id + */ + get id(): string; + + set id(id); + + /** + * 获取当前文档所属的 project + * get project which this documentModel belongs to + * @returns + */ + get project(): Project; + + /** + * 获取文档的根节点 + * root node of this documentModel + * @returns + */ + get root(): Node | null; + + get focusNode(): Node | null; + + set focusNode(node: Node | null); + + /** + * 获取文档下所有节点 + * @returns + */ + get nodesMap(): Map<string, Node>; + + /** + * 模态节点管理 + * get instance of modalNodesManager + */ + get modalNodesManager(): ModalNodesManager | null; + + /** + * 根据 nodeId 返回 Node 实例 + * get node by nodeId + * @param nodeId + * @returns + */ + getNodeById(nodeId: string): Node | null; + + /** + * 导入 schema + * import schema data + * @param schema + */ + importSchema(schema: IPublicTypeRootSchema): void; + + /** + * 导出 schema + * export schema + * @param stage + * @returns + */ + exportSchema(stage: IPublicEnumTransformStage): IPublicTypeRootSchema | undefined; + + /** + * 插入节点 + * insert a node + */ + insertNode( + parent: Node, + thing: Node | IPublicTypeNodeData, + at?: number | null | undefined, + copy?: boolean | undefined + ): Node | null; + + /** + * 创建一个节点 + * create a node + * @param data + * @returns + */ + createNode<T = Node>(data: IPublicTypeNodeSchema): T | null; + + /** + * 移除指定节点/节点id + * remove a node by node instance or nodeId + * @param idOrNode + */ + removeNode(idOrNode: string | Node): void; + + /** + * componentsMap of documentModel + * @param extraComps + * @returns + */ + getComponentsMap(extraComps?: string[]): any; + + /** + * 检查拖拽放置的目标节点是否可以放置该拖拽对象 + * check if dragOjbect can be put in this dragTarget + * @param dropTarget 拖拽放置的目标节点 + * @param dragObject 拖拽的对象 + * @returns boolean 是否可以放置 + * @since v1.0.16 + */ + checkNesting( + dropTarget: Node, + dragObject: IPublicTypeDragNodeObject | IPublicTypeDragNodeDataObject + ): boolean; + + /** + * 当前 document 新增节点事件 + * set callback for event on node is created for a document + */ + onAddNode(fn: (node: Node) => void): IPublicTypeDisposable; + + /** + * 当前 document 新增节点事件,此时节点已经挂载到 document 上 + * set callback for event on node is mounted to canvas + */ + onMountNode(fn: (payload: { node: Node }) => void): IPublicTypeDisposable; + + /** + * 当前 document 删除节点事件 + * set callback for event on node is removed + */ + onRemoveNode(fn: (node: Node) => void): IPublicTypeDisposable; + + /** + * 当前 document 的 hover 变更事件 + * + * set callback for event on detecting changed + */ + onChangeDetecting(fn: (node: Node) => void): IPublicTypeDisposable; + + /** + * 当前 document 的选中变更事件 + * set callback for event on selection changed + */ + onChangeSelection(fn: (ids: string[]) => void): IPublicTypeDisposable; + + /** + * 当前 document 的节点显隐状态变更事件 + * set callback for event on visibility changed for certain node + * @param fn + */ + onChangeNodeVisible(fn: (node: Node, visible: boolean) => void): IPublicTypeDisposable; + + /** + * 当前 document 的节点 children 变更事件 + * @param fn + */ + onChangeNodeChildren(fn: (info: IPublicTypeOnChangeOptions<Node>) => void): IPublicTypeDisposable; + + /** + * 当前 document 节点属性修改事件 + * @param fn + */ + onChangeNodeProp(fn: (info: IPublicTypePropChangeOptions<Node>) => void): IPublicTypeDisposable; + + /** + * import schema event + * @param fn + * @since v1.0.15 + */ + onImportSchema(fn: (schema: IPublicTypeRootSchema) => void): IPublicTypeDisposable; + + /** + * 判断是否当前节点处于被探测状态 + * check is node being detected + * @param node + * @since v1.1.0 + */ + isDetectingNode(node: Node): boolean; + + /** + * 获取当前的 DropLocation 信息 + * get current drop location + * @since v1.1.0 + */ + get dropLocation(): DropLocation | null; + + /** + * 设置当前的 DropLocation 信息 + * set current drop location + * @since v1.1.0 + */ + set dropLocation(loc: DropLocation | null); + + /** + * 设置聚焦节点变化的回调 + * triggered focused node is set mannually from plugin + * @param fn + * @since v1.1.0 + */ + onFocusNodeChanged( + fn: (doc: IPublicModelDocumentModel, focusNode: Node) => void, + ): IPublicTypeDisposable; + + /** + * 设置 DropLocation 变化的回调 + * triggered when drop location changed + * @param fn + * @since v1.1.0 + */ + onDropLocationChanged(fn: (doc: IPublicModelDocumentModel) => void): IPublicTypeDisposable; +} diff --git a/packages/types/src/shell/model/drag-object.ts b/packages/types/src/shell/model/drag-object.ts new file mode 100644 index 0000000000..92d92eca35 --- /dev/null +++ b/packages/types/src/shell/model/drag-object.ts @@ -0,0 +1,11 @@ +import { IPublicEnumDragObjectType } from '../enum'; +import { IPublicTypeNodeSchema } from '../type'; +import { IPublicModelNode } from './node'; + +export class IPublicModelDragObject { + type: IPublicEnumDragObjectType.Node | IPublicEnumDragObjectType.NodeData; + + data: IPublicTypeNodeSchema | IPublicTypeNodeSchema[] | null; + + nodes: (IPublicModelNode | null)[] | null; +} diff --git a/packages/types/src/shell/model/dragon.ts b/packages/types/src/shell/model/dragon.ts new file mode 100644 index 0000000000..917149faf3 --- /dev/null +++ b/packages/types/src/shell/model/dragon.ts @@ -0,0 +1,70 @@ +/* eslint-disable max-len */ +import { IPublicTypeDisposable, IPublicTypeDragNodeDataObject, IPublicTypeDragObject } from '../type'; +import { IPublicModelDragObject, IPublicModelLocateEvent, IPublicModelNode } from './'; + +export interface IPublicModelDragon< + Node = IPublicModelNode, + LocateEvent = IPublicModelLocateEvent +> { + + /** + * 是否正在拖动 + * is dragging or not + */ + get dragging(): boolean; + + /** + * 绑定 dragstart 事件 + * bind a callback function which will be called on dragging start + * @param func + * @returns + */ + onDragstart(func: (e: LocateEvent) => any): IPublicTypeDisposable; + + /** + * 绑定 drag 事件 + * bind a callback function which will be called on dragging + * @param func + * @returns + */ + onDrag(func: (e: LocateEvent) => any): IPublicTypeDisposable; + + /** + * 绑定 dragend 事件 + * bind a callback function which will be called on dragging end + * @param func + * @returns + */ + onDragend(func: (o: { dragObject: IPublicModelDragObject; copy?: boolean }) => any): IPublicTypeDisposable; + + /** + * 设置拖拽监听的区域 shell,以及自定义拖拽转换函数 boost + * set a html element as shell to dragon as monitoring target, and + * set boost function which is used to transform a MouseEvent to type + * IPublicTypeDragNodeDataObject. + * @param shell 拖拽监听的区域 + * @param boost 拖拽转换函数 + */ + from(shell: Element, boost: (e: MouseEvent) => IPublicTypeDragNodeDataObject | null): any; + + /** + * 发射拖拽对象 + * boost your dragObject for dragging(flying) + * + * @param dragObject 拖拽对象 + * @param boostEvent 拖拽初始时事件 + */ + boost(dragObject: IPublicTypeDragObject, boostEvent: MouseEvent | DragEvent, fromRglNode?: Node): void; + + /** + * 添加投放感应区 + * add sensor area + */ + addSensor(sensor: any): void; + + /** + * 移除投放感应 + * remove sensor area + */ + removeSensor(sensor: any): void; +} diff --git a/packages/types/src/shell/model/drop-location.ts b/packages/types/src/shell/model/drop-location.ts new file mode 100644 index 0000000000..e25522bce9 --- /dev/null +++ b/packages/types/src/shell/model/drop-location.ts @@ -0,0 +1,29 @@ +import { IPublicTypeLocationDetail } from '../type'; +import { IPublicModelLocateEvent, IPublicModelNode } from './'; + +export interface IPublicModelDropLocation { + + /** + * 拖拽位置目标 + * get target of dropLocation + */ + get target(): IPublicModelNode | null; + + /** + * 拖拽放置位置详情 + * get detail of dropLocation + */ + get detail(): IPublicTypeLocationDetail; + + /** + * 拖拽放置位置对应的事件 + * get event of dropLocation + */ + get event(): IPublicModelLocateEvent; + + /** + * 获取一份当前对象的克隆 + * get a clone object of current dropLocation + */ + clone(event: IPublicModelLocateEvent): IPublicModelDropLocation; +} diff --git a/packages/types/src/shell/model/editor-view.ts b/packages/types/src/shell/model/editor-view.ts new file mode 100644 index 0000000000..d51e4f9ff2 --- /dev/null +++ b/packages/types/src/shell/model/editor-view.ts @@ -0,0 +1,7 @@ +import { IPublicModelPluginContext } from './plugin-context'; + +export interface IPublicModelEditorView extends IPublicModelPluginContext { + viewName: string; + + viewType: 'editor' | 'webview'; +} \ No newline at end of file diff --git a/packages/types/src/shell/model/editor.ts b/packages/types/src/shell/model/editor.ts new file mode 100644 index 0000000000..e6171f0312 --- /dev/null +++ b/packages/types/src/shell/model/editor.ts @@ -0,0 +1,44 @@ +/* eslint-disable max-len */ +import { EventEmitter } from 'events'; +import StrictEventEmitter from 'strict-event-emitter-types'; +import * as GlobalEvent from '../../event'; +import { IPublicApiEvent } from '../api'; +import { IPublicTypeEditorValueKey, IPublicTypeEditorGetOptions, IPublicTypeEditorGetResult, IPublicTypeEditorRegisterOptions, IPublicTypeAssetsJson } from '../type'; + +export interface IPublicModelEditor extends StrictEventEmitter<EventEmitter, GlobalEvent.EventConfig> { + get: <T = undefined, KeyOrType = any>( + keyOrType: KeyOrType, + opt?: IPublicTypeEditorGetOptions + ) => IPublicTypeEditorGetResult<T, KeyOrType> | undefined; + + has: (keyOrType: IPublicTypeEditorValueKey) => boolean; + + set: (key: IPublicTypeEditorValueKey, data: any) => void | Promise<void>; + + /** + * 获取 keyOrType 一次 + */ + onceGot: <T = undefined, KeyOrType extends IPublicTypeEditorValueKey = any>(keyOrType: KeyOrType) => Promise<IPublicTypeEditorGetResult<T, KeyOrType>>; + + /** + * 获取 keyOrType 多次 + */ + onGot: <T = undefined, KeyOrType extends IPublicTypeEditorValueKey = any>( + keyOrType: KeyOrType, + fn: (data: IPublicTypeEditorGetResult<T, KeyOrType>) => void + ) => () => void; + + /** + * 监听 keyOrType 变化 + */ + onChange: <T = undefined, KeyOrType extends IPublicTypeEditorValueKey = any>( + keyOrType: KeyOrType, + fn: (data: IPublicTypeEditorGetResult<T, KeyOrType>) => void + ) => () => void; + + register: (data: any, key?: IPublicTypeEditorValueKey, options?: IPublicTypeEditorRegisterOptions) => void; + + get eventBus(): IPublicApiEvent; + + setAssets(assets: IPublicTypeAssetsJson): void; +} diff --git a/packages/types/src/shell/model/engine-config.ts b/packages/types/src/shell/model/engine-config.ts new file mode 100644 index 0000000000..c9473cd120 --- /dev/null +++ b/packages/types/src/shell/model/engine-config.ts @@ -0,0 +1,66 @@ +import { IPublicTypeDisposable } from '../type'; +import { IPublicModelPreference } from './'; + +export interface IPublicModelEngineConfig { + + /** + * 判断指定 key 是否有值 + * check if config has certain key configed + * @param key + * @returns + */ + has(key: string): boolean; + + /** + * 获取指定 key 的值 + * get value by key + * @param key + * @param defaultValue + * @returns + */ + get(key: string, defaultValue?: any): any; + + /** + * 设置指定 key 的值 + * set value for certain key + * @param key + * @param value + */ + set(key: string, value: any): void; + + /** + * 批量设值,set 的对象版本 + * set multiple config key-values + * @param config + */ + setConfig(config: { [key: string]: any }): void; + + /** + * 获取指定 key 的值,若此时还未赋值,则等待,若已有值,则直接返回值 + * 注:此函数返回 Promise 实例,只会执行(fullfill)一次 + * wait until value of certain key is set, will only be + * triggered once. + * @param key + * @returns + */ + onceGot(key: string): Promise<any>; + + /** + * 获取指定 key 的值,函数回调模式,若多次被赋值,回调会被多次调用 + * set callback for event of value set for some key + * this will be called each time the value is set + * @param key + * @param fn + * @returns + */ + onGot(key: string, fn: (data: any) => void): IPublicTypeDisposable; + + /** + * 获取全局 Preference, 用于管理全局浏览器侧用户 Preference,如 Panel 是否钉住 + * get global user preference manager, which can be use to store + * user`s preference in user localstorage, such as a panel is pinned or not. + * @returns {IPublicModelPreference} + * @since v1.1.0 + */ + getPreference(): IPublicModelPreference; +} diff --git a/packages/types/src/shell/model/exclusive-group.ts b/packages/types/src/shell/model/exclusive-group.ts new file mode 100644 index 0000000000..b930a13444 --- /dev/null +++ b/packages/types/src/shell/model/exclusive-group.ts @@ -0,0 +1,10 @@ +import { IPublicModelNode, IPublicTypeTitleContent } from '..'; + +export interface IPublicModelExclusiveGroup< + Node = IPublicModelNode, +> { + readonly id: string | undefined; + readonly title: IPublicTypeTitleContent | undefined; + get firstNode(): Node | null; + setVisible(node: Node): void; +} diff --git a/packages/types/src/shell/model/history.ts b/packages/types/src/shell/model/history.ts new file mode 100644 index 0000000000..9d75295ab4 --- /dev/null +++ b/packages/types/src/shell/model/history.ts @@ -0,0 +1,62 @@ +import { IPublicTypeDisposable } from '../type'; + +export interface IPublicModelHistory { + + /** + * 历史记录跳转到指定位置 + * go to a specific history + * @param cursor + */ + go(cursor: number): void; + + /** + * 历史记录后退 + * go backward in history + */ + back(): void; + + /** + * 历史记录前进 + * go forward in history + */ + forward(): void; + + /** + * 保存当前状态 + * do save current change as a record in history + */ + savePoint(): void; + + /** + * 当前是否是「保存点」,即是否有状态变更但未保存 + * check if there is unsaved change for history + */ + isSavePoint(): boolean; + + /** + * 获取 state,判断当前是否为「可回退」、「可前进」的状态 + * get flags in number which indicat current change state + * + * | 1 | 1 | 1 | + * | -------- | -------- | -------- | + * | modified | redoable | undoable | + * eg. + * 7 means : modified && redoable && undoable + * 5 means : modified && undoable + */ + getState(): number; + + /** + * 监听 state 变更事件 + * monitor on stateChange event + * @param func + */ + onChangeState(func: () => any): IPublicTypeDisposable; + + /** + * 监听历史记录游标位置变更事件 + * monitor on cursorChange event + * @param func + */ + onChangeCursor(func: () => any): IPublicTypeDisposable; +} diff --git a/packages/types/src/shell/model/index.ts b/packages/types/src/shell/model/index.ts new file mode 100644 index 0000000000..ffe6347ac2 --- /dev/null +++ b/packages/types/src/shell/model/index.ts @@ -0,0 +1,35 @@ +export * from './component-meta'; +export * from './detecting'; +export * from './document-model'; +export * from './drag-object'; +export * from './dragon'; +export * from './drop-location'; +export * from './history'; +export * from './locate-event'; +export * from './modal-nodes-manager'; +export * from './node-children'; +export * from './node'; +export * from './prop'; +export * from './props'; +export * from './selection'; +export * from './setting-prop-entry'; +export * from './setting-top-entry'; +export * from '../type/plugin'; +export * from './window'; +export * from './scroll-target'; +export * from './scroller'; +export * from './active-tracker'; +export * from './exclusive-group'; +export * from './plugin-context'; +export * from './setting-target'; +export * from './engine-config'; +export * from './editor'; +export * from './preference'; +export * from './plugin-instance'; +export * from './sensor'; +export * from './resource'; +export * from './clipboard'; +export * from './setting-field'; +export * from './editor-view'; +export * from './skeleton-item'; +export * from './simulator-render'; diff --git a/packages/types/src/shell/model/locate-event.ts b/packages/types/src/shell/model/locate-event.ts new file mode 100644 index 0000000000..bb64ab15eb --- /dev/null +++ b/packages/types/src/shell/model/locate-event.ts @@ -0,0 +1,38 @@ +import { IPublicModelDocumentModel, IPublicModelDragObject } from './'; + +export interface IPublicModelLocateEvent { + + get type(): string; + + /** + * 浏览器窗口坐标系 + */ + readonly globalX: number; + readonly globalY: number; + + /** + * 原始事件 + */ + readonly originalEvent: MouseEvent | DragEvent; + + /** + * 浏览器事件响应目标 + */ + target?: Element | null; + + canvasX?: number; + + canvasY?: number; + + /** + * 事件订正标识,初始构造时,从发起端构造,缺少 canvasX,canvasY, 需要经过订正才有 + */ + fixed?: true; + + /** + * 激活或目标文档 + */ + documentModel?: IPublicModelDocumentModel | null; + + get dragObject(): IPublicModelDragObject | null; +} diff --git a/packages/types/src/shell/model/modal-nodes-manager.ts b/packages/types/src/shell/model/modal-nodes-manager.ts new file mode 100644 index 0000000000..07656c0701 --- /dev/null +++ b/packages/types/src/shell/model/modal-nodes-manager.ts @@ -0,0 +1,42 @@ +import { IPublicModelNode } from './'; + +export interface IPublicModelModalNodesManager<Node = IPublicModelNode> { + + /** + * 设置模态节点,触发内部事件 + * set modal nodes, trigger internal events + */ + setNodes(): void; + + /** + * 获取模态节点(们) + * get modal nodes + */ + getModalNodes(): Node[]; + + /** + * 获取当前可见的模态节点 + * get current visible modal node + */ + getVisibleModalNode(): Node | null; + + /** + * 隐藏模态节点(们) + * hide modal nodes + */ + hideModalNodes(): void; + + /** + * 设置指定节点为可见态 + * set specfic model node as visible + * @param node Node + */ + setVisible(node: Node): void; + + /** + * 设置指定节点为不可见态 + * set specfic model node as invisible + * @param node Node + */ + setInvisible(node: Node): void; +} diff --git a/packages/types/src/shell/model/node-children.ts b/packages/types/src/shell/model/node-children.ts new file mode 100644 index 0000000000..f2be13250b --- /dev/null +++ b/packages/types/src/shell/model/node-children.ts @@ -0,0 +1,190 @@ +import { IPublicTypeNodeSchema, IPublicTypeNodeData } from '../type'; +import { IPublicEnumTransformStage } from '../enum'; +import { IPublicModelNode } from './'; + +export interface IPublicModelNodeChildren< + Node = IPublicModelNode +> { + + /** + * 返回当前 children 实例所属的节点实例 + * get owner node of this nodeChildren + */ + get owner(): Node | null; + + /** + * children 内的节点实例数 + * get count of child nodes + */ + get size(): number; + + /** + * @deprecated please use isEmptyNode + * 是否为空 + * @returns + */ + get isEmpty(): boolean; + + /** + * 是否为空 + * + * @returns + */ + get isEmptyNode(): boolean; + + /** + * @deprecated please use notEmptyNode + * judge if it is not empty + */ + get notEmpty(): boolean; + + /** + * judge if it is not empty + */ + get notEmptyNode(): boolean; + + /** + * 删除指定节点 + * + * delete the node + * @param node + */ + delete(node: Node): boolean; + + /** + * 插入一个节点 + * + * insert a node at specific position + * @param node 待插入节点 + * @param at 插入下标 + * @returns + */ + insert(node: Node, at?: number | null): void; + + /** + * 返回指定节点的下标 + * + * get index of node in current children + * @param node + * @returns + */ + indexOf(node: Node): number; + + /** + * 类似数组 splice 操作 + * + * provide the same function with {Array.prototype.splice} + * @param start + * @param deleteCount + * @param node + */ + splice(start: number, deleteCount: number, node?: Node): any; + + /** + * 返回指定下标的节点 + * + * get node with index + * @param index + * @returns + */ + get(index: number): Node | null; + + /** + * 是否包含指定节点 + * + * check if node exists in current children + * @param node + * @returns + */ + has(node: Node): boolean; + + /** + * 类似数组的 forEach + * + * provide the same function with {Array.prototype.forEach} + * @param fn + */ + forEach(fn: (node: Node, index: number) => void): void; + + /** + * 类似数组的 reverse + * + * provide the same function with {Array.prototype.reverse} + */ + reverse(): Node[]; + + /** + * 类似数组的 map + * + * provide the same function with {Array.prototype.map} + * @param fn + */ + map<T = any>(fn: (node: Node, index: number) => T): T[] | null; + + /** + * 类似数组的 every + * provide the same function with {Array.prototype.every} + * @param fn + */ + every(fn: (node: Node, index: number) => boolean): boolean; + + /** + * 类似数组的 some + * provide the same function with {Array.prototype.some} + * @param fn + */ + some(fn: (node: Node, index: number) => boolean): boolean; + + /** + * 类似数组的 filter + * provide the same function with {Array.prototype.filter} + * @param fn + */ + filter(fn: (node: Node, index: number) => boolean): any; + + /** + * 类似数组的 find + * provide the same function with {Array.prototype.find} + * @param fn + */ + find(fn: (node: Node, index: number) => boolean): Node | null | undefined; + + /** + * 类似数组的 reduce + * + * provide the same function with {Array.prototype.reduce} + * @param fn + */ + reduce(fn: (acc: any, cur: Node) => any, initialValue: any): void; + + /** + * 导入 schema + * + * import schema + * @param data + */ + importSchema(data?: IPublicTypeNodeData | IPublicTypeNodeData[]): void; + + /** + * 导出 schema + * + * export schema + * @param stage + */ + exportSchema(stage: IPublicEnumTransformStage): IPublicTypeNodeSchema; + + /** + * 执行新增、删除、排序等操作 + * + * excute remove/add/sort operations + * @param remover + * @param adder + * @param sorter + */ + mergeChildren( + remover: (node: Node, idx: number) => boolean, + adder: (children: Node[]) => IPublicTypeNodeData[] | null, + sorter: (firstNode: Node, secondNode: Node) => number + ): any; + +} diff --git a/packages/types/src/shell/model/node.ts b/packages/types/src/shell/model/node.ts new file mode 100644 index 0000000000..9d8cec3647 --- /dev/null +++ b/packages/types/src/shell/model/node.ts @@ -0,0 +1,507 @@ +import { ReactElement } from 'react'; +import { IPublicTypeNodeSchema, IPublicTypeIconType, IPublicTypeI18nData, IPublicTypeCompositeValue, IPublicTypePropsMap, IPublicTypePropsList } from '../type'; +import { IPublicEnumTransformStage } from '../enum'; +import { IPublicModelNodeChildren, IPublicModelComponentMeta, IPublicModelProp, IPublicModelProps, IPublicModelSettingTopEntry, IPublicModelDocumentModel, IPublicModelExclusiveGroup } from './'; + +export interface IBaseModelNode< + Document = IPublicModelDocumentModel, + Node = IPublicModelNode, + NodeChildren = IPublicModelNodeChildren, + ComponentMeta = IPublicModelComponentMeta, + SettingTopEntry = IPublicModelSettingTopEntry, + Props = IPublicModelProps, + Prop = IPublicModelProp, + ExclusiveGroup = IPublicModelExclusiveGroup +> { + + /** + * 节点 id + * node id + */ + id: string; + + /** + * 节点标题 + * title of node + */ + get title(): string | IPublicTypeI18nData | ReactElement; + + /** + * @deprecated please use isContainerNode + */ + get isContainer(): boolean; + + /** + * 是否为「容器型」节点 + * check if node is a container type node + * @since v1.1.0 + */ + get isContainerNode(): boolean; + + /** + * @deprecated please use isRootNode + */ + get isRoot(): boolean; + + /** + * 是否为根节点 + * check if node is root in the tree + * @since v1.1.0 + */ + get isRootNode(): boolean; + + /** + * @deprecated please use isEmptyNode + */ + get isEmpty(): boolean; + + /** + * 是否为空节点(无 children 或者 children 为空) + * check if current node is empty, which means no children or children is empty + * @since v1.1.0 + */ + get isEmptyNode(): boolean; + + /** + * @deprecated please use isPageNode + * 是否为 Page 节点 + */ + get isPage(): boolean; + + /** + * 是否为 Page 节点 + * check if node is Page + * @since v1.1.0 + */ + get isPageNode(): boolean; + + /** + * @deprecated please use isComponentNode + */ + get isComponent(): boolean; + + /** + * 是否为 Component 节点 + * check if node is Component + * @since v1.1.0 + */ + get isComponentNode(): boolean; + + /** + * @deprecated please use isModalNode + */ + get isModal(): boolean; + + /** + * 是否为「模态框」节点 + * check if node is Modal + * @since v1.1.0 + */ + get isModalNode(): boolean; + + /** + * @deprecated please use isSlotNode + */ + get isSlot(): boolean; + + /** + * 是否为插槽节点 + * check if node is a Slot + * @since v1.1.0 + */ + get isSlotNode(): boolean; + + /** + * @deprecated please use isParentalNode + */ + get isParental(): boolean; + + /** + * 是否为父类/分支节点 + * check if node a parental node + * @since v1.1.0 + */ + get isParentalNode(): boolean; + + /** + * @deprecated please use isLeafNode + */ + get isLeaf(): boolean; + + /** + * 是否为叶子节点 + * check if node is a leaf node in tree + * @since v1.1.0 + */ + get isLeafNode(): boolean; + + /** + * 获取当前节点的锁定状态 + * check if current node is locked + * @since v1.0.16 + */ + get isLocked(): boolean; + + /** + * @deprecated please use isRGLContainerNode + */ + set isRGLContainer(flag: boolean); + + /** + * @deprecated please use isRGLContainerNode + * @returns Boolean + */ + get isRGLContainer(); + + /** + * 设置为磁贴布局节点 + * @since v1.1.0 + */ + set isRGLContainerNode(flag: boolean); + + /** + * 获取磁贴布局节点设置状态 + * @returns Boolean + * @since v1.1.0 + */ + get isRGLContainerNode(); + + /** + * 下标 + * index + */ + get index(): number | undefined; + + /** + * 图标 + * get icon of this node + */ + get icon(): IPublicTypeIconType; + + /** + * 节点所在树的层级深度,根节点深度为 0 + * depth level of this node, value of root node is 0 + */ + get zLevel(): number; + + /** + * 节点 componentName + * componentName + */ + get componentName(): string; + + /** + * 节点的物料元数据 + * get component meta of this node + */ + get componentMeta(): ComponentMeta | null; + + /** + * 获取节点所属的文档模型对象 + * get documentModel of this node + */ + get document(): Document | null; + + /** + * 获取当前节点的前一个兄弟节点 + * get previous sibling of this node + */ + get prevSibling(): Node | null | undefined; + + /** + * 获取当前节点的后一个兄弟节点 + * get next sibling of this node + */ + get nextSibling(): Node | null | undefined; + + /** + * 获取当前节点的父亲节点 + * get parent of this node + */ + get parent(): Node | null; + + /** + * 获取当前节点的孩子节点模型 + * get children of this node + */ + get children(): NodeChildren | null; + + /** + * 节点上挂载的插槽节点们 + * get slots of this node + */ + get slots(): Node[]; + + /** + * 当前节点为插槽节点时,返回节点对应的属性实例 + * return coresponding prop when this node is a slot node + */ + get slotFor(): Prop | null | undefined; + + /** + * 返回节点的属性集 + * get props + */ + get props(): Props | null; + + /** + * 返回节点的属性集 + * get props data + */ + get propsData(): IPublicTypePropsMap | IPublicTypePropsList | null; + + /** + * get conditionGroup + */ + get conditionGroup(): ExclusiveGroup | null; + + /** + * 获取符合搭建协议 - 节点 schema 结构 + * get schema of this node + * @since v1.1.0 + */ + get schema(): IPublicTypeNodeSchema; + + /** + * 获取对应的 setting entry + * get setting entry of this node + * @since v1.1.0 + */ + get settingEntry(): SettingTopEntry; + + /** + * 返回节点的尺寸、位置信息 + * get rect information for this node + */ + getRect(): DOMRect | null; + + /** + * 是否有挂载插槽节点 + * check if current node has slots + */ + hasSlots(): boolean; + + /** + * 是否设定了渲染条件 + * check if current node has condition value set + */ + hasCondition(): boolean; + + /** + * 是否设定了循环数据 + * check if loop is set for this node + */ + hasLoop(): boolean; + + /** + * 获取指定 path 的属性模型实例 + * get prop by path + * @param path 属性路径,支持 a / a.b / a.0 等格式 + * @param createIfNone 如果不存在,是否新建,默认为 true + */ + getProp(path: string | number, createIfNone?: boolean): Prop | null; + + /** + * 获取指定 path 的属性模型实例值 + * get prop value by path + * @param path 属性路径,支持 a / a.b / a.0 等格式 + */ + getPropValue(path: string): any; + + /** + * 获取指定 path 的属性模型实例, + * 注:导出时,不同于普通属性,该属性并不挂载在 props 之下,而是与 props 同级 + * + * get extra prop by path, an extra prop means a prop not exists in the `props` + * but as siblint of the `props` + * @param path 属性路径,支持 a / a.b / a.0 等格式 + * @param createIfNone 当没有属性的时候,是否创建一个属性 + */ + getExtraProp(path: string, createIfNone?: boolean): Prop | null; + + /** + * 获取指定 path 的属性模型实例, + * 注:导出时,不同于普通属性,该属性并不挂载在 props 之下,而是与 props 同级 + * + * get extra prop value by path, an extra prop means a prop not exists in the `props` + * but as siblint of the `props` + * @param path 属性路径,支持 a / a.b / a.0 等格式 + * @returns + */ + getExtraPropValue(path: string): any; + + /** + * 设置指定 path 的属性模型实例值 + * set value for prop with path + * @param path 属性路径,支持 a / a.b / a.0 等格式 + * @param value 值 + */ + setPropValue(path: string | number, value: IPublicTypeCompositeValue): void; + + /** + * 设置指定 path 的属性模型实例值 + * set value for extra prop with path + * @param path 属性路径,支持 a / a.b / a.0 等格式 + * @param value 值 + */ + setExtraPropValue(path: string, value: IPublicTypeCompositeValue): void; + + /** + * 导入节点数据 + * import node schema + * @param data + */ + importSchema(data: IPublicTypeNodeSchema): void; + + /** + * 导出节点数据 + * export schema from this node + * @param stage + * @param options + */ + exportSchema(stage: IPublicEnumTransformStage, options?: any): IPublicTypeNodeSchema; + + /** + * 在指定位置之前插入一个节点 + * insert a node befor current node + * @param node + * @param ref + * @param useMutator + */ + insertBefore( + node: Node, + ref?: Node | undefined, + useMutator?: boolean, + ): void; + + /** + * 在指定位置之后插入一个节点 + * insert a node after this node + * @param node + * @param ref + * @param useMutator + */ + insertAfter( + node: Node, + ref?: Node | undefined, + useMutator?: boolean, + ): void; + + /** + * 替换指定节点 + * replace a child node with data provided + * @param node 待替换的子节点 + * @param data 用作替换的节点对象或者节点描述 + * @returns + */ + replaceChild(node: Node, data: any): Node | null; + + /** + * 将当前节点替换成指定节点描述 + * replace current node with a new node schema + * @param schema + */ + replaceWith(schema: IPublicTypeNodeSchema): any; + + /** + * 选中当前节点实例 + * select current node + */ + select(): void; + + /** + * 设置悬停态 + * set hover value for current node + * @param flag + */ + hover(flag: boolean): void; + + /** + * 设置节点锁定状态 + * set lock value for current node + * @param flag + * @since v1.0.16 + */ + lock(flag?: boolean): void; + + /** + * 删除当前节点实例 + * remove current node + */ + remove(): void; + + /** + * 执行新增、删除、排序等操作 + * excute remove/add/sort operations on node`s children + * + * @since v1.1.0 + */ + mergeChildren( + remover: (node: Node, idx: number) => boolean, + adder: (children: Node[]) => any, + sorter: (firstNode: Node, secondNode: Node) => number + ): any; + + /** + * 当前节点是否包含某子节点 + * check if current node contains another node as a child + * @param node + * @since v1.1.0 + */ + contains(node: Node): boolean; + + /** + * 是否可执行某 action + * check if current node can perform certain aciton with actionName + * @param actionName action 名字 + * @since v1.1.0 + */ + canPerformAction(actionName: string): boolean; + + /** + * 当前节点是否可见 + * check if current node is visible + * @since v1.1.0 + */ + get visible(): boolean; + + /** + * 设置当前节点是否可见 + * set visible value for current node + * @since v1.1.0 + */ + set visible(value: boolean); + + /** + * 获取该节点的 ConditionalVisible 值 + * check if current node ConditionalVisible + * @since v1.1.0 + */ + isConditionalVisible(): boolean | undefined; + + /** + * 设置该节点的 ConditionalVisible 为 true + * make this node as conditionalVisible === true + * @since v1.1.0 + */ + setConditionalVisible(): void; + + /** + * 获取节点实例对应的 dom 节点 + */ + getDOMNode(): HTMLElement; + + /** + * 获取磁贴相关信息 + */ + getRGL(): { + isContainerNode: boolean; + isEmptyNode: boolean; + isRGLContainerNode: boolean; + isRGLNode: boolean; + isRGL: boolean; + rglNode: Node | null; + }; +} + +export interface IPublicModelNode extends IBaseModelNode<IPublicModelDocumentModel, IPublicModelNode> {} \ No newline at end of file diff --git a/packages/types/src/shell/model/plugin-context.ts b/packages/types/src/shell/model/plugin-context.ts new file mode 100644 index 0000000000..d4d715e96b --- /dev/null +++ b/packages/types/src/shell/model/plugin-context.ts @@ -0,0 +1,131 @@ +import { + IPublicApiSkeleton, + IPublicApiHotkey, + IPublicApiSetters, + IPublicApiMaterial, + IPublicApiEvent, + IPublicApiProject, + IPublicApiCommon, + IPublicApiLogger, + IPublicApiCanvas, + IPluginPreferenceMananger, + IPublicApiPlugins, + IPublicApiWorkspace, + IPublicApiCommonUI, + IPublicApiCommand, +} from '../api'; +import { IPublicEnumPluginRegisterLevel } from '../enum'; +import { IPublicModelEngineConfig, IPublicModelWindow } from './'; + +export interface IPublicModelPluginContext { + + /** + * 可通过该对象读取插件初始化配置 + * by using this, init options can be accessed from inside plugin + */ + preference: IPluginPreferenceMananger; + + /** + * skeleton API + * @tutorial https://lowcode-engine.cn/site/docs/api/skeleton + */ + get skeleton(): IPublicApiSkeleton; + + /** + * hotkey API + * @tutorial https://lowcode-engine.cn/site/docs/api/hotkey + */ + get hotkey(): IPublicApiHotkey; + + /** + * setter API + * @tutorial https://lowcode-engine.cn/site/docs/api/setters + */ + get setters(): IPublicApiSetters; + + /** + * config API + * @tutorial https://lowcode-engine.cn/site/docs/api/config + */ + get config(): IPublicModelEngineConfig; + + /** + * material API + * @tutorial https://lowcode-engine.cn/site/docs/api/material + */ + get material(): IPublicApiMaterial; + + /** + * event API + * this event works globally, can be used between plugins and engine. + * @tutorial https://lowcode-engine.cn/site/docs/api/event + */ + get event(): IPublicApiEvent; + + /** + * project API + * @tutorial https://lowcode-engine.cn/site/docs/api/project + */ + get project(): IPublicApiProject; + + /** + * common API + * @tutorial https://lowcode-engine.cn/site/docs/api/common + */ + get common(): IPublicApiCommon; + + /** + * plugins API + * @tutorial https://lowcode-engine.cn/site/docs/api/plugins + */ + get plugins(): IPublicApiPlugins; + + /** + * logger API + * @tutorial https://lowcode-engine.cn/site/docs/api/logger + */ + get logger(): IPublicApiLogger; + + /** + * this event works within current plugin, on an emit locally. + * @tutorial https://lowcode-engine.cn/site/docs/api/event + */ + get pluginEvent(): IPublicApiEvent; + + /** + * canvas API + * @tutorial https://lowcode-engine.cn/site/docs/api/canvas + */ + get canvas(): IPublicApiCanvas; + + /** + * workspace API + * @tutorial https://lowcode-engine.cn/site/docs/api/workspace + */ + get workspace(): IPublicApiWorkspace; + + /** + * commonUI API + * @tutorial https://lowcode-engine.cn/site/docs/api/commonUI + */ + get commonUI(): IPublicApiCommonUI; + + get command(): IPublicApiCommand; + + /** + * 插件注册层级 + * @since v1.1.7 + */ + get registerLevel(): IPublicEnumPluginRegisterLevel; + + get isPluginRegisteredInWorkspace(): boolean; + + get editorWindow(): IPublicModelWindow; +} + +/** + * @deprecated please use IPublicModelPluginContext instead + */ +export interface ILowCodePluginContext extends IPublicModelPluginContext { + +} diff --git a/packages/types/src/shell/model/plugin-instance.ts b/packages/types/src/shell/model/plugin-instance.ts new file mode 100644 index 0000000000..88904205d0 --- /dev/null +++ b/packages/types/src/shell/model/plugin-instance.ts @@ -0,0 +1,28 @@ +import { IPublicTypePluginMeta } from '../type/plugin-meta'; + +export interface IPublicModelPluginInstance { + + /** + * 是否 disable + * current plugin instance is disabled or not + */ + disabled: boolean; + + /** + * 插件名称 + * plugin name + */ + get pluginName(): string; + + /** + * 依赖信息,依赖的其他插件 + * depenency info + */ + get dep(): string[]; + + /** + * 插件配置元数据 + * meta info of this plugin + */ + get meta(): IPublicTypePluginMeta; +} diff --git a/packages/types/src/shell/model/preference.ts b/packages/types/src/shell/model/preference.ts new file mode 100644 index 0000000000..e200dae9db --- /dev/null +++ b/packages/types/src/shell/model/preference.ts @@ -0,0 +1,18 @@ + +export interface IPublicModelPreference { + + /** + * set value from local storage by module and key + */ + set(key: string, value: any, module?: string): void; + + /** + * get value from local storage by module and key + */ + get(key: string, module: string): any; + + /** + * check if local storage contain certain key + */ + contains(key: string, module: string): boolean; +} diff --git a/packages/types/src/shell/model/prop.ts b/packages/types/src/shell/model/prop.ts new file mode 100644 index 0000000000..71442e64ab --- /dev/null +++ b/packages/types/src/shell/model/prop.ts @@ -0,0 +1,72 @@ +import { IPublicEnumTransformStage } from '../enum'; +import { IPublicTypeCompositeValue } from '../type'; +import { IPublicModelNode } from './'; + +export interface IPublicModelProp< + Node = IPublicModelNode +> { + + /** + * id + */ + get id(): string; + + /** + * key 值 + * get key of prop + */ + get key(): string | number | undefined; + + /** + * 返回当前 prop 的路径 + * get path of current prop + */ + get path(): string[]; + + /** + * 返回所属的节点实例 + * get node instance, which this prop belongs to + */ + get node(): Node | null; + + /** + * 当本 prop 代表一个 Slot 时,返回对应的 slotNode + * return the slot node (only if the current prop represents a slot) + * @since v1.1.0 + */ + get slotNode(): Node | undefined | null; + + /** + * 是否是 Prop , 固定返回 true + * check if it is a prop or not, and of course always return true + * @experimental + */ + get isProp(): boolean; + + /** + * 设置值 + * set value for this prop + * @param val + */ + setValue(val: IPublicTypeCompositeValue): void; + + /** + * 获取值 + * get value of this prop + */ + getValue(): any; + + /** + * 移除值 + * remove value of this prop + * @since v1.0.16 + */ + remove(): void; + + /** + * 导出值 + * export schema + * @param stage + */ + exportSchema(stage: IPublicEnumTransformStage): IPublicTypeCompositeValue; +} diff --git a/packages/types/src/shell/model/props.ts b/packages/types/src/shell/model/props.ts new file mode 100644 index 0000000000..f3ef2e4519 --- /dev/null +++ b/packages/types/src/shell/model/props.ts @@ -0,0 +1,89 @@ +import { IPublicTypeCompositeValue } from '../type'; +import { IPublicModelNode, IPublicModelProp } from './'; + +export interface IBaseModelProps< + Prop +> { + + /** + * id + */ + get id(): string; + + /** + * 返回当前 props 的路径 + * return path of current props + */ + get path(): string[]; + + /** + * 返回所属的 node 实例 + */ + get node(): IPublicModelNode | null; + + /** + * 获取指定 path 的属性模型实例 + * get prop by path + * @param path 属性路径,支持 a / a.b / a.0 等格式 + */ + getProp(path: string): Prop | null; + + /** + * 获取指定 path 的属性模型实例值 + * get value of prop by path + * @param path 属性路径,支持 a / a.b / a.0 等格式 + */ + getPropValue(path: string): any; + + /** + * 获取指定 path 的属性模型实例, + * 注:导出时,不同于普通属性,该属性并不挂载在 props 之下,而是与 props 同级 + * get extra prop by path + * @param path 属性路径,支持 a / a.b / a.0 等格式 + */ + getExtraProp(path: string): Prop | null; + + /** + * 获取指定 path 的属性模型实例值 + * 注:导出时,不同于普通属性,该属性并不挂载在 props 之下,而是与 props 同级 + * get value of extra prop by path + * @param path 属性路径,支持 a / a.b / a.0 等格式 + */ + getExtraPropValue(path: string): any; + + /** + * 设置指定 path 的属性模型实例值 + * set value of prop by path + * @param path 属性路径,支持 a / a.b / a.0 等格式 + * @param value 值 + */ + setPropValue(path: string, value: IPublicTypeCompositeValue): void; + + /** + * 设置指定 path 的属性模型实例值 + * set value of extra prop by path + * @param path 属性路径,支持 a / a.b / a.0 等格式 + * @param value 值 + */ + setExtraPropValue(path: string, value: IPublicTypeCompositeValue): void; + + /** + * 当前 props 是否包含某 prop + * check if the specified key is existing or not. + * @param key + * @since v1.1.0 + */ + has(key: string): boolean; + + /** + * 添加一个 prop + * add a key with given value + * @param value + * @param key + * @since v1.1.0 + */ + add(value: IPublicTypeCompositeValue, key?: string | number | undefined): any; + +} + +export interface IPublicModelProps extends IBaseModelProps<IPublicModelProp> {}; \ No newline at end of file diff --git a/packages/types/src/shell/model/resource.ts b/packages/types/src/shell/model/resource.ts new file mode 100644 index 0000000000..acd7d056f5 --- /dev/null +++ b/packages/types/src/shell/model/resource.ts @@ -0,0 +1,31 @@ +import { ReactElement } from 'react'; + +export interface IBaseModelResource< + Resource +> { + get title(): string | undefined; + + get id(): string | undefined; + + get icon(): ReactElement | undefined; + + get options(): Record<string, any>; + + get name(): string | undefined; + + get type(): string | undefined; + + get category(): string | undefined; + + get children(): Resource[]; + + get viewName(): string | undefined; + + get description(): string | undefined; + + get config(): { + [key: string]: any; + } | undefined; +} + +export type IPublicModelResource = IBaseModelResource<IPublicModelResource>; diff --git a/packages/types/src/shell/model/scroll-target.ts b/packages/types/src/shell/model/scroll-target.ts new file mode 100644 index 0000000000..1dbbaeeda7 --- /dev/null +++ b/packages/types/src/shell/model/scroll-target.ts @@ -0,0 +1,9 @@ + +export interface IPublicModelScrollTarget { + get left(): number; + get top(): number; + scrollTo(options: { left?: number; top?: number }): void; + scrollToXY(x: number, y: number): void; + get scrollHeight(): number; + get scrollWidth(): number; +} diff --git a/packages/types/src/shell/model/scroller.ts b/packages/types/src/shell/model/scroller.ts new file mode 100644 index 0000000000..7c1a438400 --- /dev/null +++ b/packages/types/src/shell/model/scroller.ts @@ -0,0 +1,8 @@ +export interface IPublicModelScroller { + + scrollTo(options: { left?: number; top?: number }): void; + + cancel(): void; + + scrolling(point: { globalX: number; globalY: number }): void; +} \ No newline at end of file diff --git a/packages/types/src/shell/model/selection.ts b/packages/types/src/shell/model/selection.ts new file mode 100644 index 0000000000..317a49837d --- /dev/null +++ b/packages/types/src/shell/model/selection.ts @@ -0,0 +1,85 @@ +import { IPublicModelNode } from './'; +import { IPublicTypeDisposable } from '../type'; + +export interface IPublicModelSelection< + Node = IPublicModelNode +> { + + /** + * 返回选中的节点 id + * get ids of selected nodes + */ + get selected(): string[]; + + /** + * 返回选中的节点(如多个节点只返回第一个) + * return selected Node instance,return the first one if multiple nodes are selected + * @since v1.1.0 + */ + get node(): Node | null; + + /** + * 选中指定节点(覆盖方式) + * select node with id, this will override current selection + * @param id + */ + select(id: string): void; + + /** + * 批量选中指定节点们 + * select node with ids, this will override current selection + * + * @param ids + */ + selectAll(ids: string[]): void; + + /** + * 移除选中的指定节点 + * remove node from selection with node id + * @param id + */ + remove(id: string): void; + + /** + * 清除所有选中节点 + * clear current selection + */ + clear(): void; + + /** + * 判断是否选中了指定节点 + * check if node with specific id is selected + * @param id + */ + has(id: string): boolean; + + /** + * 选中指定节点(增量方式) + * add node with specific id to selection + * @param id + */ + add(id: string): void; + + /** + * 获取选中的节点实例 + * get selected nodes + */ + getNodes(): Node[]; + + /** + * 获取选区的顶层节点 + * get seleted top nodes + * for example: + * getNodes() returns [A, subA, B], then + * getTopNodes() will return [A, B], subA will be removed + * @since v1.0.16 + */ + getTopNodes(includeRoot?: boolean): Node[]; + + /** + * 注册 selection 变化事件回调 + * set callback which will be called when selection is changed + * @since v1.1.0 + */ + onSelectionChange(fn: (ids: string[]) => void): IPublicTypeDisposable; +} diff --git a/packages/types/src/shell/model/sensor.ts b/packages/types/src/shell/model/sensor.ts new file mode 100644 index 0000000000..b563cddb14 --- /dev/null +++ b/packages/types/src/shell/model/sensor.ts @@ -0,0 +1,45 @@ +import { IPublicTypeNodeInstance } from '../type/node-instance'; +import { + IPublicModelLocateEvent, + IPublicModelDropLocation, + IPublicTypeComponentInstance, + IPublicModelNode, +} from '..'; + +/** + * 拖拽敏感板 + */ +export interface IPublicModelSensor< + Node = IPublicModelNode +> { + + /** + * 是否可响应,比如面板被隐藏,可设置该值 false + */ + readonly sensorAvailable: boolean; + + /** + * 给事件打补丁 + */ + fixEvent(e: IPublicModelLocateEvent): IPublicModelLocateEvent; + + /** + * 定位并激活 + */ + locate(e: IPublicModelLocateEvent): IPublicModelDropLocation | undefined | null; + + /** + * 是否进入敏感板区域 + */ + isEnter(e: IPublicModelLocateEvent): boolean; + + /** + * 取消激活 + */ + deactiveSensor(): void; + + /** + * 获取节点实例 + */ + getNodeInstanceFromElement?: (e: Element | null) => IPublicTypeNodeInstance<IPublicTypeComponentInstance, Node> | null; +} diff --git a/packages/types/src/shell/model/setting-field.ts b/packages/types/src/shell/model/setting-field.ts new file mode 100644 index 0000000000..a011fc21f0 --- /dev/null +++ b/packages/types/src/shell/model/setting-field.ts @@ -0,0 +1,198 @@ +import { IPublicTypeCustomView, IPublicTypeCompositeValue, IPublicTypeSetterType, IPublicTypeSetValueOptions, IPublicTypeFieldConfig, IPublicTypeFieldExtraProps, IPublicTypeDisposable } from '../type'; +import { IPublicModelNode, IPublicModelComponentMeta, IPublicModelSettingTopEntry } from './'; + +export interface IBaseModelSettingField< + SettingTopEntry, + SettingField, + ComponentMeta, + Node +> { + + /** + * 获取设置属性的父设置属性 + */ + readonly parent: SettingTopEntry | SettingField; + + /** + * 获取设置属性的 isGroup + */ + get isGroup(): boolean; + + /** + * 获取设置属性的 id + */ + get id(): string; + + /** + * 获取设置属性的 name + */ + get name(): string | number | undefined; + + /** + * 获取设置属性的 key + */ + get key(): string | number | undefined; + + /** + * 获取设置属性的 path + */ + get path(): (string | number)[]; + + /** + * 获取设置属性的 title + */ + get title(): string; + + /** + * 获取设置属性的 setter + */ + get setter(): IPublicTypeSetterType | null; + + /** + * 获取设置属性的 expanded + */ + get expanded(): boolean; + + /** + * 获取设置属性的 extraProps + */ + get extraProps(): IPublicTypeFieldExtraProps; + + get props(): SettingTopEntry; + + /** + * 获取设置属性对应的节点实例 + */ + get node(): Node | null; + + /** + * 获取顶级设置属性 + */ + get top(): SettingTopEntry; + + /** + * 是否是 SettingField 实例 + */ + get isSettingField(): boolean; + + /** + * componentMeta + */ + get componentMeta(): ComponentMeta | null; + + /** + * 获取设置属性的 items + */ + get items(): Array<SettingField | IPublicTypeCustomView>; + + /** + * 设置 key 值 + * @param key + */ + setKey(key: string | number): void; + + /** + * 设置值 + * @param val 值 + */ + setValue(val: IPublicTypeCompositeValue, extraOptions?: IPublicTypeSetValueOptions): void; + + /** + * 设置子级属性值 + * @param propName 子属性名 + * @param value 值 + */ + setPropValue(propName: string | number, value: any): void; + + /** + * 清空指定属性值 + * @param propName + */ + clearPropValue(propName: string | number): void; + + /** + * 获取配置的默认值 + * @returns + */ + getDefaultValue(): any; + + /** + * 获取值 + * @returns + */ + getValue(): any; + + /** + * 获取子级属性值 + * @param propName 子属性名 + * @returns + */ + getPropValue(propName: string | number): any; + + /** + * 获取顶层附属属性值 + */ + getExtraPropValue(propName: string): any; + + /** + * 设置顶层附属属性值 + */ + setExtraPropValue(propName: string, value: any): void; + + /** + * 获取设置属性集 + * @returns + */ + getProps(): SettingTopEntry; + + /** + * 是否绑定了变量 + * @returns + */ + isUseVariable(): boolean; + + /** + * 设置绑定变量 + * @param flag + */ + setUseVariable(flag: boolean): void; + + /** + * 创建一个设置 field 实例 + * @param config + * @returns + */ + createField(config: IPublicTypeFieldConfig): SettingField; + + /** + * 获取值,当为变量时,返回 mock + * @returns + */ + getMockOrValue(): any; + + /** + * 销毁当前 field 实例 + */ + purge(): void; + + /** + * 移除当前 field 实例 + */ + remove(): void; + + /** + * 设置 autorun + * @param action + * @returns + */ + onEffect(action: () => void): IPublicTypeDisposable; +} + +export interface IPublicModelSettingField extends IBaseModelSettingField< + IPublicModelSettingTopEntry, + IPublicModelSettingField, + IPublicModelComponentMeta, + IPublicModelNode +> { + +} \ No newline at end of file diff --git a/packages/types/src/shell/model/setting-prop-entry.ts b/packages/types/src/shell/model/setting-prop-entry.ts new file mode 100644 index 0000000000..b40eab8bff --- /dev/null +++ b/packages/types/src/shell/model/setting-prop-entry.ts @@ -0,0 +1,6 @@ +import { IPublicModelSettingField } from './'; + +/** + * @deprecated please use IPublicModelSettingField + */ +export type IPublicModelSettingPropEntry = IPublicModelSettingField diff --git a/packages/types/src/shell/model/setting-target.ts b/packages/types/src/shell/model/setting-target.ts new file mode 100644 index 0000000000..6df1e36e99 --- /dev/null +++ b/packages/types/src/shell/model/setting-target.ts @@ -0,0 +1,6 @@ +import { IPublicModelSettingField } from './'; + +/** + * @deprecated please use IPublicModelSettingField + */ +export type IPublicModelSettingTarget = IPublicModelSettingField; diff --git a/packages/types/src/shell/model/setting-top-entry.ts b/packages/types/src/shell/model/setting-top-entry.ts new file mode 100644 index 0000000000..1c3d6c2f15 --- /dev/null +++ b/packages/types/src/shell/model/setting-top-entry.ts @@ -0,0 +1,39 @@ +import { IPublicModelNode, IPublicModelSettingField } from './'; + +export interface IPublicModelSettingTopEntry< + Node = IPublicModelNode, + SettingField = IPublicModelSettingField +> { + + /** + * 返回所属的节点实例 + */ + get node(): Node | null; + + /** + * 获取子级属性对象 + * @param propName + * @returns + */ + get(propName: string | number): SettingField | null; + + /** + * 获取指定 propName 的值 + * @param propName + * @returns + */ + getPropValue(propName: string | number): any; + + /** + * 设置指定 propName 的值 + * @param propName + * @param value + */ + setPropValue(propName: string | number, value: any): void; + + /** + * 清除指定 propName 的值 + * @param propName + */ + clearPropValue(propName: string | number): void; +} diff --git a/packages/types/src/shell/model/simulator-render.ts b/packages/types/src/shell/model/simulator-render.ts new file mode 100644 index 0000000000..8cf3a03c55 --- /dev/null +++ b/packages/types/src/shell/model/simulator-render.ts @@ -0,0 +1,14 @@ +export interface IPublicModelSimulatorRender { + + /** + * 画布组件列表 + */ + components: { + [key: string]: any; + }; + + /** + * 触发画布重新渲染 + */ + rerender: () => void; +} diff --git a/packages/types/src/shell/model/skeleton-item.ts b/packages/types/src/shell/model/skeleton-item.ts new file mode 100644 index 0000000000..beb18f2228 --- /dev/null +++ b/packages/types/src/shell/model/skeleton-item.ts @@ -0,0 +1,21 @@ +/** + * @since 1.1.7 + */ +export interface IPublicModelSkeletonItem { + name: string; + + visible: boolean; + + disable(): void; + + enable(): void; + + hide(): void; + + show(): void; + + /** + * @since v1.1.10 + */ + toggle(): void; +} \ No newline at end of file diff --git a/packages/types/src/shell/model/window.ts b/packages/types/src/shell/model/window.ts new file mode 100644 index 0000000000..95ab738bc1 --- /dev/null +++ b/packages/types/src/shell/model/window.ts @@ -0,0 +1,51 @@ +import { ReactElement } from 'react'; +import { IPublicTypeDisposable, IPublicTypeNodeSchema } from '../type'; +import { IPublicModelResource } from './resource'; +import { IPublicModelEditorView } from './editor-view'; + +export interface IPublicModelWindow< + Resource = IPublicModelResource +> { + + /** 窗口 id */ + id: string; + + /** 窗口标题 */ + title?: string; + + /** 窗口 icon */ + icon?: ReactElement; + + /** 窗口资源类型 */ + resource?: Resource; + + /** + * 窗口当前视图 + * @since v1.1.7 + */ + currentEditorView: IPublicModelEditorView | null; + + /** + * 窗口全部视图实例 + * @since v1.1.7 + */ + editorViews: IPublicModelEditorView[]; + + /** 当前窗口导入 schema */ + importSchema(schema: IPublicTypeNodeSchema): void; + + /** 修改当前窗口视图类型 */ + changeViewType(viewName: string): void; + + /** 调用当前窗口视图保存钩子 */ + save(): Promise<any>; + + /** 窗口视图变更事件 */ + onChangeViewType(fn: (viewName: string) => void): IPublicTypeDisposable; + + /** + * 窗口视图保存事件 + * @since 1.1.7 + */ + onSave(fn: () => void): IPublicTypeDisposable; +} \ No newline at end of file diff --git a/packages/types/src/shell/type/action-content-object.ts b/packages/types/src/shell/type/action-content-object.ts new file mode 100644 index 0000000000..cf3e0c0481 --- /dev/null +++ b/packages/types/src/shell/type/action-content-object.ts @@ -0,0 +1,23 @@ +import { IPublicModelNode } from '../model'; +import { IPublicTypeIconType, TipContent } from './'; + +/** + * 动作描述 + */ +export interface IPublicTypeActionContentObject { + + /** + * 图标 + */ + icon?: IPublicTypeIconType; + + /** + * 描述 + */ + title?: TipContent; + + /** + * 执行动作 + */ + action?: (currentNode: IPublicModelNode) => void; +} diff --git a/packages/types/src/shell/type/active-target.ts b/packages/types/src/shell/type/active-target.ts new file mode 100644 index 0000000000..97845160be --- /dev/null +++ b/packages/types/src/shell/type/active-target.ts @@ -0,0 +1,8 @@ +import { IPublicModelNode } from '../model'; +import { IPublicTypeLocationDetail, IPublicTypeComponentInstance } from './'; + +export interface IPublicTypeActiveTarget { + node: IPublicModelNode; + detail?: IPublicTypeLocationDetail; + instance?: IPublicTypeComponentInstance; +} diff --git a/packages/types/src/shell/type/advanced.ts b/packages/types/src/shell/type/advanced.ts new file mode 100644 index 0000000000..8a6db85b67 --- /dev/null +++ b/packages/types/src/shell/type/advanced.ts @@ -0,0 +1,105 @@ +import { ComponentType, ReactElement } from 'react'; +import { IPublicTypeNodeData, IPublicTypeSnippet, IPublicTypeInitialItem, IPublicTypeFilterItem, IPublicTypeAutorunItem, IPublicTypeCallbacks, IPublicTypeLiveTextEditingConfig } from './'; +import { IPublicModelNode, IPublicModelSettingField } from '../model'; + +/** + * 高级特性配置 + */ +export interface IPublicTypeAdvanced { + + /** + * 配置 callbacks 可捕获引擎抛出的一些事件,例如 onNodeAdd、onResize 等 + * callbacks/hooks which can be used to do + * things on some special ocations like onNodeAdd or onResize + */ + callbacks?: IPublicTypeCallbacks; + + /** + * 拖入容器时,自动带入 children 列表 + */ + initialChildren?: IPublicTypeNodeData[] | ((target: IPublicModelNode) => IPublicTypeNodeData[]); + + /** + * 样式 及 位置,handle 上必须有明确的标识以便事件路由判断,或者主动设置事件独占模式 + * NWSE 是交给引擎计算放置位置,ReactElement 必须自己控制初始位置 + * + * 用于配置设计器中组件 resize 操作工具的样式和内容 + * - hover 时控制柄高亮 + * - mousedown 时请求独占 + * - dragstart 请求通用 resizing 控制 请求 hud 显示 + * - drag 时 计算并设置效果,更新控制柄位置 + */ + getResizingHandlers?: ( + currentNode: any + ) => (Array<{ + type: 'N' | 'W' | 'S' | 'E' | 'NW' | 'NE' | 'SE' | 'SW'; + content?: ReactElement; + propTarget?: string; + appearOn?: 'mouse-enter' | 'mouse-hover' | 'selected' | 'always'; + }> | + ReactElement[]); + + /** + * @deprecated 用于动态初始化拖拽到设计器里的组件的 prop 的值 + */ + initials?: IPublicTypeInitialItem[]; + + /** + * @deprecated 使用组件 metadata 上的 snippets 字段即可 + */ + snippets?: IPublicTypeSnippet[]; + + /** + * 是否绝对布局容器,还未进入协议 + * @experimental not in spec yet + */ + isAbsoluteLayoutContainer?: boolean; + + /** + * hide bem tools when selected + * @experimental not in spec yet + */ + hideSelectTools?: boolean; + + /** + * Live Text Editing:如果 children 内容是纯文本,支持双击直接编辑 + * @experimental not in spec yet + */ + liveTextEditing?: IPublicTypeLiveTextEditingConfig[]; + + /** + * TODO: 补充文档 + * @experimental not in spec yet + */ + view?: ComponentType<any>; + + /** + * @legacy capability for vision + * @deprecated + */ + isTopFixed?: boolean; + + /** + * TODO: 补充文档 或 删除 + * @deprecated not used anywhere, dont know what is it for + */ + context?: { [contextInfoName: string]: any }; + + /** + * @legacy capability for vision + * @deprecated + */ + filters?: IPublicTypeFilterItem[]; + + /** + * @legacy capability for vision + * @deprecated + */ + autoruns?: IPublicTypeAutorunItem[]; + + /** + * @legacy capability for vision + * @deprecated + */ + transducers?: any; +} diff --git a/packages/types/src/shell/type/app-config.ts b/packages/types/src/shell/type/app-config.ts new file mode 100644 index 0000000000..2bcb5f47a9 --- /dev/null +++ b/packages/types/src/shell/type/app-config.ts @@ -0,0 +1,18 @@ +export interface IPublicTypeAppConfig { + sdkVersion?: string; + historyMode?: string; + targetRootID?: string; + layout?: IPublicTypeLayout; + theme?: IPublicTypeTheme; +} + +interface IPublicTypeTheme { + package: string; + version: string; + primary: string; +} + +interface IPublicTypeLayout { + componentName?: string; + props?: Record<string, any>; +} diff --git a/packages/types/src/shell/type/assets-json.ts b/packages/types/src/shell/type/assets-json.ts new file mode 100644 index 0000000000..e00349779a --- /dev/null +++ b/packages/types/src/shell/type/assets-json.ts @@ -0,0 +1,34 @@ +import { IPublicTypeComponentSort, IPublicTypePackage, IPublicTypeRemoteComponentDescription, IPublicTypeComponentDescription } from './'; + +/** + * 资产包协议 + */ + +export interface IPublicTypeAssetsJson { + /** + * 资产包协议版本号 + */ + version: string; + /** + * 大包列表,external 与 package 的概念相似,融合在一起 + */ + packages?: IPublicTypePackage[]; + /** + * 所有组件的描述协议列表所有组件的列表 + */ + components: Array<IPublicTypeComponentDescription | IPublicTypeRemoteComponentDescription>; + /** + * 组件分类列表,用来描述物料面板 + * @deprecated 最新版物料面板已不需要此描述 + */ + componentList?: any[]; + /** + * 业务组件分类列表,用来描述物料面板 + * @deprecated 最新版物料面板已不需要此描述 + */ + bizComponentList?: any[]; + /** + * 用于描述组件面板中的 tab 和 category + */ + sort?: IPublicTypeComponentSort; +} diff --git a/packages/types/src/shell/type/block-schema.ts b/packages/types/src/shell/type/block-schema.ts new file mode 100644 index 0000000000..118a4f8c70 --- /dev/null +++ b/packages/types/src/shell/type/block-schema.ts @@ -0,0 +1,10 @@ +import { IPublicTypeContainerSchema } from './'; + +/** + * 区块容器 + * @see https://lowcode-engine.cn/lowcode + */ + +export interface IPublicTypeBlockSchema extends IPublicTypeContainerSchema { + componentName: 'Block'; +} diff --git a/packages/types/src/shell/type/command.ts b/packages/types/src/shell/type/command.ts new file mode 100644 index 0000000000..0f301bd658 --- /dev/null +++ b/packages/types/src/shell/type/command.ts @@ -0,0 +1,59 @@ +import { IPublicTypePropType } from './prop-types'; + +// 定义命令处理函数的参数类型 +export interface IPublicTypeCommandHandlerArgs { + [key: string]: any; +} + +// 定义命令参数的接口 +export interface IPublicTypeCommandParameter { + + /** + * 参数名称 + */ + name: string; + + /** + * 参数类型或详细类型描述 + */ + propType: string | IPublicTypePropType; + + /** + * 参数描述 + */ + description: string; + + /** + * 参数默认值(可选) + */ + defaultValue?: any; +} + +// 定义单个命令的接口 +export interface IPublicTypeCommand { + + /** + * 命令名称 + * 命名规则:commandName + * 使用规则:commandScope:commandName (commandScope 在插件 meta 中定义,用于区分不同插件的命令) + */ + name: string; + + /** + * 命令参数 + */ + parameters?: IPublicTypeCommandParameter[]; + + /** + * 命令描述 + */ + description?: string; + + /** + * 命令处理函数 + */ + handler: (args: any) => void; +} + +export interface IPublicTypeListCommand extends Pick<IPublicTypeCommand, 'name' | 'description' | 'parameters'> { +} \ No newline at end of file diff --git a/packages/types/src/shell/type/component-action.ts b/packages/types/src/shell/type/component-action.ts new file mode 100644 index 0000000000..f86324e7ac --- /dev/null +++ b/packages/types/src/shell/type/component-action.ts @@ -0,0 +1,35 @@ +import { ReactNode } from 'react'; +import { IPublicTypeActionContentObject } from './'; + +/** + * @todo 工具条动作 + */ + +export interface IPublicTypeComponentAction { + + /** + * behaviorName + */ + name: string; + + /** + * 菜单名称 + */ + content: string | ReactNode | IPublicTypeActionContentObject; + + /** + * 子集 + */ + items?: IPublicTypeComponentAction[]; + + /** + * 显示与否 + * always: 无法禁用 + */ + condition?: boolean | ((currentNode: any) => boolean) | 'always'; + + /** + * 显示在工具条上 + */ + important?: boolean; +} diff --git a/packages/types/src/shell/type/component-description.ts b/packages/types/src/shell/type/component-description.ts new file mode 100644 index 0000000000..7ee7f5f004 --- /dev/null +++ b/packages/types/src/shell/type/component-description.ts @@ -0,0 +1,16 @@ +import { IPublicTypeComponentMetadata, IPublicTypeReference } from './'; + +/** + * 本地物料描述 + */ + +export interface IPublicTypeComponentDescription extends IPublicTypeComponentMetadata { + /** + * @todo 待补充文档 @jinchan + */ + keywords: string[]; + /** + * 替代 npm 字段的升级版本 + */ + reference?: IPublicTypeReference; +} diff --git a/packages/types/src/shell/type/component-instance.ts b/packages/types/src/shell/type/component-instance.ts new file mode 100644 index 0000000000..4c3716f5d0 --- /dev/null +++ b/packages/types/src/shell/type/component-instance.ts @@ -0,0 +1,6 @@ + +import { Component as ReactComponent } from 'react'; +/** + * 组件实例定义 + */ +export type IPublicTypeComponentInstance = Element | ReactComponent<any> | object; \ No newline at end of file diff --git a/packages/types/src/shell/type/component-metadata.ts b/packages/types/src/shell/type/component-metadata.ts new file mode 100644 index 0000000000..69dc36c309 --- /dev/null +++ b/packages/types/src/shell/type/component-metadata.ts @@ -0,0 +1,101 @@ +import { IPublicTypeIconType, IPublicTypeNpmInfo, IPublicTypeFieldConfig, IPublicTypeI18nData, IPublicTypeComponentSchema, IPublicTypeTitleContent, IPublicTypePropConfig, IPublicTypeConfigure, IPublicTypeAdvanced, IPublicTypeSnippet } from './'; + +/** + * 组件 meta 配置 + */ + +export interface IPublicTypeComponentMetadata { + + /** 其他扩展协议 */ + [key: string]: any; + + /** + * 组件名 + */ + componentName: string; + + /** + * unique id + */ + uri?: string; + + /** + * title or description + */ + title?: IPublicTypeTitleContent; + + /** + * svg icon for component + */ + icon?: IPublicTypeIconType; + + /** + * 组件标签 + */ + tags?: string[]; + + /** + * 组件描述 + */ + description?: string; + + /** + * 组件文档链接 + */ + docUrl?: string; + + /** + * 组件快照 + */ + screenshot?: string; + + /** + * 组件研发模式 + */ + devMode?: 'proCode' | 'lowCode'; + + /** + * npm 源引入完整描述对象 + */ + npm?: IPublicTypeNpmInfo; + + /** + * 组件属性信息 + */ + props?: IPublicTypePropConfig[]; + + /** + * 编辑体验增强 + */ + configure?: IPublicTypeFieldConfig[] | IPublicTypeConfigure; + + /** + * @deprecated, use advanced instead + */ + experimental?: IPublicTypeAdvanced; + + /** + * @todo 待补充文档 + */ + schema?: IPublicTypeComponentSchema; + + /** + * 可用片段 + */ + snippets?: IPublicTypeSnippet[]; + + /** + * 一级分组 + */ + group?: string | IPublicTypeI18nData; + + /** + * 二级分组 + */ + category?: string | IPublicTypeI18nData; + + /** + * 组件优先级排序 + */ + priority?: number; +} diff --git a/packages/types/src/shell/type/component-schema.ts b/packages/types/src/shell/type/component-schema.ts new file mode 100644 index 0000000000..41daae5c34 --- /dev/null +++ b/packages/types/src/shell/type/component-schema.ts @@ -0,0 +1,10 @@ +import { IPublicTypeContainerSchema } from './'; + +/** + * 低代码业务组件容器 + * @see https://lowcode-engine.cn/lowcode + */ + +export interface IPublicTypeComponentSchema extends IPublicTypeContainerSchema { + componentName: 'Component'; +} diff --git a/packages/types/src/shell/type/component-sort.ts b/packages/types/src/shell/type/component-sort.ts new file mode 100644 index 0000000000..add821ea98 --- /dev/null +++ b/packages/types/src/shell/type/component-sort.ts @@ -0,0 +1,14 @@ +/** + * 用于描述组件面板中的 tab 和 category + */ + +export interface IPublicTypeComponentSort { + /** + * 用于描述组件面板的 tab 项及其排序,例如:["精选组件", "原子组件"] + */ + groupList?: string[]; + /** + * 组件面板中同一个 tab 下的不同区间用 category 区分,category 的排序依照 categoryList 顺序排列; + */ + categoryList?: string[]; +} diff --git a/packages/types/src/shell/type/composite-value.ts b/packages/types/src/shell/type/composite-value.ts new file mode 100644 index 0000000000..e7aea645ec --- /dev/null +++ b/packages/types/src/shell/type/composite-value.ts @@ -0,0 +1,11 @@ +import { IPublicTypeJSONValue, IPublicTypeJSExpression, IPublicTypeJSFunction, IPublicTypeJSSlot, IPublicTypeCompositeArray, IPublicTypeCompositeObject } from './'; + +/** + * 复合类型 + */ +export type IPublicTypeCompositeValue = IPublicTypeJSONValue | + IPublicTypeJSExpression | + IPublicTypeJSFunction | + IPublicTypeJSSlot | + IPublicTypeCompositeArray | + IPublicTypeCompositeObject; diff --git a/packages/types/src/shell/type/config-transducer.ts b/packages/types/src/shell/type/config-transducer.ts new file mode 100644 index 0000000000..64c33a5c4e --- /dev/null +++ b/packages/types/src/shell/type/config-transducer.ts @@ -0,0 +1,9 @@ +import { IPublicTypeSkeletonConfig } from '.'; + +export interface IPublicTypeConfigTransducer { + (prev: IPublicTypeSkeletonConfig): IPublicTypeSkeletonConfig; + + level?: number; + + id?: string; +} diff --git a/packages/types/src/shell/type/configure.ts b/packages/types/src/shell/type/configure.ts new file mode 100644 index 0000000000..44fd1ffe68 --- /dev/null +++ b/packages/types/src/shell/type/configure.ts @@ -0,0 +1,27 @@ +import { IPublicTypeComponentConfigure, ConfigureSupport, IPublicTypeFieldConfig, IPublicTypeAdvanced } from './'; + +/** + * 编辑体验配置 + */ +export interface IPublicTypeConfigure { + + /** + * 属性面板配置 + */ + props?: IPublicTypeFieldConfig[]; + + /** + * 组件能力配置 + */ + component?: IPublicTypeComponentConfigure; + + /** + * 通用扩展面板支持性配置 + */ + supports?: ConfigureSupport; + + /** + * 高级特性配置 + */ + advanced?: IPublicTypeAdvanced; +} diff --git a/packages/types/src/shell/type/container-schema.ts b/packages/types/src/shell/type/container-schema.ts new file mode 100644 index 0000000000..416788f867 --- /dev/null +++ b/packages/types/src/shell/type/container-schema.ts @@ -0,0 +1,57 @@ +import { InterpretDataSource as DataSource } from '@alilc/lowcode-datasource-types'; +import { + IPublicTypeJSExpression, + IPublicTypeJSFunction, + IPublicTypeCompositeObject, + IPublicTypeCompositeValue, + IPublicTypeNodeSchema, +} from './'; + +/** + * 容器结构描述 + */ +export interface IPublicTypeContainerSchema extends IPublicTypeNodeSchema { + /** + * 'Block' | 'Page' | 'Component'; + */ + componentName: string; + /** + * 文件名称 + */ + fileName: string; + /** + * @todo 待文档定义 + */ + meta?: Record<string, unknown>; + /** + * 容器初始数据 + */ + state?: { + [key: string]: IPublicTypeCompositeValue; + }; + /** + * 自定义方法设置 + */ + methods?: { + [key: string]: IPublicTypeJSExpression | IPublicTypeJSFunction; + }; + /** + * 生命周期对象 + */ + lifeCycles?: { + // @todo 生命周期对象建议改为闭合集合 + [key: string]: IPublicTypeJSExpression | IPublicTypeJSFunction; + }; + /** + * 样式文件 + */ + css?: string; + /** + * 异步数据源配置 + */ + dataSource?: DataSource; + /** + * 低代码业务组件默认属性 + */ + defaultProps?: IPublicTypeCompositeObject; +} diff --git a/packages/types/src/shell/type/context-menu.ts b/packages/types/src/shell/type/context-menu.ts new file mode 100644 index 0000000000..dd6d583c25 --- /dev/null +++ b/packages/types/src/shell/type/context-menu.ts @@ -0,0 +1,63 @@ +import { IPublicEnumContextMenuType } from '../enum'; +import { IPublicModelNode } from '../model'; +import { IPublicTypeI18nData } from './i8n-data'; +import { IPublicTypeHelpTipConfig } from './widget-base-config'; + +export interface IPublicTypeContextMenuItem extends Omit<IPublicTypeContextMenuAction, 'condition' | 'disabled' | 'items'> { + disabled?: boolean; + + items?: Omit<IPublicTypeContextMenuItem, 'items'>[]; +} + +export interface IPublicTypeContextMenuAction { + + /** + * 动作的唯一标识符 + * Unique identifier for the action + */ + name: string; + + /** + * 显示的标题,可以是字符串或国际化数据 + * Display title, can be a string or internationalized data + */ + title?: string | IPublicTypeI18nData; + + /** + * 菜单项类型 + * Menu item type + * @see IPublicEnumContextMenuType + * @default IPublicEnumContextMenuType.MENU_ITEM + */ + type?: IPublicEnumContextMenuType; + + /** + * 点击时执行的动作,可选 + * Action to execute on click, optional + */ + action?: (nodes?: IPublicModelNode[], event?: MouseEvent) => void; + + /** + * 子菜单项或生成子节点的函数,可选,仅支持两级 + * Sub-menu items or function to generate child node, optional + */ + items?: Omit<IPublicTypeContextMenuAction, 'items'>[] | ((nodes?: IPublicModelNode[]) => Omit<IPublicTypeContextMenuAction, 'items'>[]); + + /** + * 显示条件函数 + * Function to determine display condition + */ + condition?: (nodes?: IPublicModelNode[]) => boolean; + + /** + * 禁用条件函数,可选 + * Function to determine disabled condition, optional + */ + disabled?: (nodes?: IPublicModelNode[]) => boolean; + + /** + * 帮助提示,可选 + */ + help?: IPublicTypeHelpTipConfig; +} + diff --git a/packages/types/src/shell/type/custom-view.ts b/packages/types/src/shell/type/custom-view.ts new file mode 100644 index 0000000000..076fd83a3d --- /dev/null +++ b/packages/types/src/shell/type/custom-view.ts @@ -0,0 +1,3 @@ +import { ComponentType, ReactElement } from 'react'; + +export type IPublicTypeCustomView = ReactElement | ComponentType<any>; diff --git a/packages/types/src/shell/type/disposable.ts b/packages/types/src/shell/type/disposable.ts new file mode 100644 index 0000000000..aa0c2ac4dc --- /dev/null +++ b/packages/types/src/shell/type/disposable.ts @@ -0,0 +1,3 @@ +export interface IPublicTypeDisposable { + (): void; +} \ No newline at end of file diff --git a/packages/types/src/shell/type/dom-text.ts b/packages/types/src/shell/type/dom-text.ts new file mode 100644 index 0000000000..3bb227f2d0 --- /dev/null +++ b/packages/types/src/shell/type/dom-text.ts @@ -0,0 +1 @@ +export type IPublicTypeDOMText = string; diff --git a/packages/types/src/shell/type/drag-any-object.ts b/packages/types/src/shell/type/drag-any-object.ts new file mode 100644 index 0000000000..93269945ff --- /dev/null +++ b/packages/types/src/shell/type/drag-any-object.ts @@ -0,0 +1,5 @@ + +export interface IPublicTypeDragAnyObject { + type: string; + [key: string]: any; +} diff --git a/packages/types/src/shell/type/drag-node-data-object.ts b/packages/types/src/shell/type/drag-node-data-object.ts new file mode 100644 index 0000000000..8c6980c514 --- /dev/null +++ b/packages/types/src/shell/type/drag-node-data-object.ts @@ -0,0 +1,10 @@ +import { IPublicTypeNodeSchema } from './'; +import { IPublicEnumDragObjectType } from '../enum'; + +export interface IPublicTypeDragNodeDataObject { + type: IPublicEnumDragObjectType.NodeData; + data: IPublicTypeNodeSchema | IPublicTypeNodeSchema[]; + thumbnail?: string; + description?: string; + [extra: string]: any; +} diff --git a/packages/types/src/shell/type/drag-node-object.ts b/packages/types/src/shell/type/drag-node-object.ts new file mode 100644 index 0000000000..21f14b2bcb --- /dev/null +++ b/packages/types/src/shell/type/drag-node-object.ts @@ -0,0 +1,7 @@ +import { IPublicModelNode } from '..'; +import { IPublicEnumDragObjectType } from '../enum'; + +export interface IPublicTypeDragNodeObject<Node = IPublicModelNode> { + type: IPublicEnumDragObjectType.Node; + nodes: Node[]; +} diff --git a/packages/types/src/shell/type/drag-object.ts b/packages/types/src/shell/type/drag-object.ts new file mode 100644 index 0000000000..1df6ce107c --- /dev/null +++ b/packages/types/src/shell/type/drag-object.ts @@ -0,0 +1,4 @@ +import { IPublicTypeDragNodeDataObject, IPublicTypeDragNodeObject, IPublicTypeDragAnyObject } from './'; + +// eslint-disable-next-line max-len +export type IPublicTypeDragObject = IPublicTypeDragNodeObject | IPublicTypeDragNodeDataObject | IPublicTypeDragAnyObject; diff --git a/packages/types/src/shell/type/dynamic-props.ts b/packages/types/src/shell/type/dynamic-props.ts new file mode 100644 index 0000000000..1d87a65671 --- /dev/null +++ b/packages/types/src/shell/type/dynamic-props.ts @@ -0,0 +1,3 @@ +import { IPublicModelSettingField } from '../model'; + +export type IPublicTypeDynamicProps = (target: IPublicModelSettingField) => Record<string, unknown>; diff --git a/packages/types/src/shell/type/dynamic-setter.ts b/packages/types/src/shell/type/dynamic-setter.ts new file mode 100644 index 0000000000..5883bb2bb9 --- /dev/null +++ b/packages/types/src/shell/type/dynamic-setter.ts @@ -0,0 +1,4 @@ +import { IPublicModelSettingPropEntry, IPublicTypeCustomView } from '..'; +import { IPublicTypeSetterConfig } from './setter-config'; + +export type IPublicTypeDynamicSetter = (target: IPublicModelSettingPropEntry) => (string | IPublicTypeSetterConfig | IPublicTypeCustomView); diff --git a/packages/types/src/shell/type/editor-get-options.ts b/packages/types/src/shell/type/editor-get-options.ts new file mode 100644 index 0000000000..ed5477057d --- /dev/null +++ b/packages/types/src/shell/type/editor-get-options.ts @@ -0,0 +1,5 @@ + +export interface IPublicTypeEditorGetOptions { + forceNew?: boolean; + sourceCls?: new (...args: any[]) => any; +} diff --git a/packages/types/src/shell/type/editor-get-result.ts b/packages/types/src/shell/type/editor-get-result.ts new file mode 100644 index 0000000000..af3639ac04 --- /dev/null +++ b/packages/types/src/shell/type/editor-get-result.ts @@ -0,0 +1,4 @@ + +export type IPublicTypeEditorGetResult<T, ClsType> = T extends undefined ? ClsType extends { + prototype: infer R; +} ? R : any : T; diff --git a/packages/types/src/shell/type/editor-register-options.ts b/packages/types/src/shell/type/editor-register-options.ts new file mode 100644 index 0000000000..3853465813 --- /dev/null +++ b/packages/types/src/shell/type/editor-register-options.ts @@ -0,0 +1,19 @@ +/** + * duck-typed power-di + * + * @see https://www.npmjs.com/package/power-di + */ +export interface IPublicTypeEditorRegisterOptions { + + /** + * default: true + */ + singleton?: boolean; + + /** + * if data a class, auto new a instance. + * if data a function, auto run(lazy). + * default: true + */ + autoNew?: boolean; +} diff --git a/packages/types/src/shell/type/editor-value-key.ts b/packages/types/src/shell/type/editor-value-key.ts new file mode 100644 index 0000000000..8c0d3c6c96 --- /dev/null +++ b/packages/types/src/shell/type/editor-value-key.ts @@ -0,0 +1,2 @@ + +export type IPublicTypeEditorValueKey = (new (...args: any[]) => any) | symbol | string; diff --git a/packages/types/src/shell/type/editor-view-config.ts b/packages/types/src/shell/type/editor-view-config.ts new file mode 100644 index 0000000000..9bb3b75559 --- /dev/null +++ b/packages/types/src/shell/type/editor-view-config.ts @@ -0,0 +1,11 @@ +export interface IPublicEditorViewConfig { + + /** 视图初始化钩子 */ + init?: () => Promise<void>; + + /** 资源保存时,会调用视图的钩子 */ + save?: () => Promise<void>; + + /** viewType 类型为 'webview' 时渲染的地址 */ + url?: () => Promise<string>; +} \ No newline at end of file diff --git a/packages/types/src/shell/type/editor-view.ts b/packages/types/src/shell/type/editor-view.ts new file mode 100644 index 0000000000..2357a48f53 --- /dev/null +++ b/packages/types/src/shell/type/editor-view.ts @@ -0,0 +1,12 @@ +import { IPublicEditorViewConfig } from './editor-view-config'; + +export interface IPublicTypeEditorView { + + /** 资源名字 */ + viewName: string; + + /** 资源类型 */ + viewType?: 'editor' | 'webview'; + + (ctx: any, options: any): IPublicEditorViewConfig; +} \ No newline at end of file diff --git a/packages/types/src/shell/type/engine-options.ts b/packages/types/src/shell/type/engine-options.ts new file mode 100644 index 0000000000..8221c4089c --- /dev/null +++ b/packages/types/src/shell/type/engine-options.ts @@ -0,0 +1,199 @@ +import { RequestHandlersMap } from '@alilc/lowcode-datasource-types'; +import { ComponentType } from 'react'; + +export interface IPublicTypeEngineOptions { + + /** + * 是否开启 condition 的能力,默认在设计器中不管 condition 是啥都正常展示 + * when this is true, node that configured as conditional not renderring + * will not display in canvas. + * @default false + */ + enableCondition?: boolean; + + /** + * TODO: designMode 无法映射到文档渲染模块 + * + * 设计模式,live 模式将会实时展示变量值,默认值:'design' + * + * @default 'design' + * @experimental + */ + designMode?: 'design' | 'live'; + + /** + * 设备类型,默认值:'default' + * @default 'default' + */ + device?: 'default' | 'mobile' | string; + + /** + * 指定初始化的 deviceClassName,挂载到画布的顶层节点上 + */ + deviceClassName?: string; + + /** + * 语言,默认值:'zh-CN' + * @default 'zh-CN' + */ + locale?: string; + + /** + * 渲染器类型,默认值:'react' + */ + renderEnv?: 'react' | string; + + /** + * 设备类型映射器,处理设计器与渲染器中 device 的映射 + */ + deviceMapper?: { + transform: (originalDevice: string) => string; + }; + + /** + * 开启严格插件模式,默认值:STRICT_PLUGIN_MODE_DEFAULT , 严格模式下,插件将无法通过 engineOptions 传递自定义配置项 + * enable strict plugin mode, default value: false + * under strict mode, customed engineOption is not accepted. + */ + enableStrictPluginMode?: boolean; + + /** + * 开启拖拽组件时,即将被放入的容器是否有视觉反馈,默认值:false + */ + enableReactiveContainer?: boolean; + + /** + * 关闭画布自动渲染,在资产包多重异步加载的场景有效,默认值:false + */ + disableAutoRender?: boolean; + + /** + * 关闭拖拽组件时的虚线响应,性能考虑,默认值:false + */ + disableDetecting?: boolean; + + /** + * 定制画布中点击被忽略的 selectors,默认值:undefined + */ + customizeIgnoreSelectors?: (defaultIgnoreSelectors: string[], e: MouseEvent) => string[]; + + /** + * 禁止默认的设置面板,默认值:false + */ + disableDefaultSettingPanel?: boolean; + + /** + * 禁止默认的设置器,默认值:false + */ + disableDefaultSetters?: boolean; + + /** + * 打开画布的锁定操作,默认值:false + */ + enableCanvasLock?: boolean; + + /** + * 容器锁定后,容器本身是否可以设置属性,仅当画布锁定特性开启时生效,默认值为:false + */ + enableLockedNodeSetting?: boolean; + + /** + * 当选中节点切换时,是否停留在相同的设置 tab 上,默认值:false + */ + stayOnTheSameSettingTab?: boolean; + + /** + * 是否在只有一个 item 的时候隐藏设置 tabs,默认值:false + */ + hideSettingsTabsWhenOnlyOneItem?: boolean; + + /** + * 自定义 loading 组件 + */ + loadingComponent?: ComponentType; + + /** + * 设置所有属性支持变量配置,默认值:false + */ + supportVariableGlobally?: boolean; + + /** + * 设置 simulator 相关的 url,默认值:undefined + */ + simulatorUrl?: string[]; + + /** + * Vision-polyfill settings + * @deprecated this exists for some legacy reasons + */ + visionSettings?: { + // 是否禁用降级 reducer,默认值:false + disableCompatibleReducer?: boolean; + // 是否开启在 render 阶段开启 filter reducer,默认值:false + enableFilterReducerInRenderStage?: boolean; + }; + + /** + * 与 react-renderer 的 appHelper 一致,https://lowcode-engine.cn/site/docs/guide/expand/runtime/renderer#apphelper + */ + appHelper?: { + + /** 全局公共函数 */ + utils?: Record<string, any>; + + /** 全局常量 */ + constants?: Record<string, any>; + }; + + /** + * 数据源引擎的请求处理器映射 + */ + requestHandlersMap?: RequestHandlersMap; + + /** + * @default true + * JSExpression 是否只支持使用 this 来访问上下文变量,假如需要兼容原来的 'state.xxx',则设置为 false + */ + thisRequiredInJSE?: boolean; + + /** + * @default false + * 当开启组件未找到严格模式时,渲染模块不会默认给一个容器组件 + */ + enableStrictNotFoundMode?: boolean; + + /** + * 配置指定节点为根组件 + */ + focusNodeSelector?: (rootNode: Node) => Node; + + /** + * 开启应用级设计模式 + */ + enableWorkspaceMode?: boolean; + + /** + * @default true + * 应用级设计模式下,自动打开第一个窗口 + */ + enableAutoOpenFirstWindow?: boolean; + + /** + * @default false + * 开启右键菜单能力 + */ + enableContextMenu?: boolean; + + /** + * @default false + * 隐藏设计器辅助层 + */ + hideComponentAction?: boolean; +} + +/** + * @deprecated use IPublicTypeEngineOptions instead + */ +export interface EngineOptions { + +} \ No newline at end of file diff --git a/packages/types/src/shell/type/field-config.ts b/packages/types/src/shell/type/field-config.ts new file mode 100644 index 0000000000..bd09e7b906 --- /dev/null +++ b/packages/types/src/shell/type/field-config.ts @@ -0,0 +1,51 @@ +import { IPublicTypeTitleContent, IPublicTypeSetterType, IPublicTypeFieldExtraProps, IPublicTypeDynamicSetter } from './'; + +/** + * 属性面板配置 + */ +export interface IPublicTypeFieldConfig extends IPublicTypeFieldExtraProps { + + /** + * 面板配置隶属于单个 field 还是分组 + */ + type?: 'field' | 'group'; + + /** + * the name of this setting field, which used in quickEditor + */ + name?: string | number; + + /** + * the field title + * @default sameas .name + */ + title?: IPublicTypeTitleContent; + + /** + * 单个属性的 setter 配置 + * + * the field body contains when .type = 'field' + */ + setter?: IPublicTypeSetterType | IPublicTypeDynamicSetter; + + /** + * the setting items which group body contains when .type = 'group' + */ + items?: IPublicTypeFieldConfig[]; + + /** + * extra props for field + * 其他配置属性(不做流通要求) + */ + extraProps?: IPublicTypeFieldExtraProps; + + /** + * @deprecated + */ + description?: IPublicTypeTitleContent; + + /** + * @deprecated + */ + isExtends?: boolean; +} diff --git a/packages/types/src/shell/type/field-extra-props.ts b/packages/types/src/shell/type/field-extra-props.ts new file mode 100644 index 0000000000..7aae7e0fe8 --- /dev/null +++ b/packages/types/src/shell/type/field-extra-props.ts @@ -0,0 +1,81 @@ +import { IPublicModelSettingField } from '../model'; +import { IPublicTypeLiveTextEditingConfig } from './'; + +/** + * extra props for field + */ +export interface IPublicTypeFieldExtraProps { + + /** + * 是否必填参数 + */ + isRequired?: boolean; + + /** + * default value of target prop for setter use + */ + defaultValue?: any; + + /** + * get value for field + */ + getValue?: (target: IPublicModelSettingField, fieldValue: any) => any; + + /** + * set value for field + */ + setValue?: (target: IPublicModelSettingField, value: any) => void; + + /** + * the field conditional show, is not set always true + * @default undefined + */ + condition?: (target: IPublicModelSettingField) => boolean; + + /** + * 配置当前 prop 是否忽略默认值处理逻辑,如果返回值是 true 引擎不会处理默认值 + * @returns boolean + */ + ignoreDefaultValue?: (target: IPublicModelSettingField) => boolean; + + /** + * autorun when something change + */ + autorun?: (target: IPublicModelSettingField) => void; + + /** + * default collapsed when display accordion + */ + defaultCollapsed?: boolean; + + /** + * important field + */ + important?: boolean; + + /** + * internal use + */ + forceInline?: number; + + /** + * 是否支持变量配置 + */ + supportVariable?: boolean; + + /** + * compatiable vision display + */ + display?: 'accordion' | 'inline' | 'block' | 'plain' | 'popup' | 'entry'; + + // @todo 这个 omit 是否合理? + /** + * @todo 待补充文档 + */ + liveTextEditing?: Omit<IPublicTypeLiveTextEditingConfig, 'propTarget'>; + + /** + * onChange 事件 + */ + onChange?: (value: any, field: IPublicModelSettingField) => void; +} diff --git a/packages/types/src/shell/type/hotkey-callback-config.ts b/packages/types/src/shell/type/hotkey-callback-config.ts new file mode 100644 index 0000000000..1903d6a845 --- /dev/null +++ b/packages/types/src/shell/type/hotkey-callback-config.ts @@ -0,0 +1,10 @@ +import { IPublicTypeHotkeyCallback } from './'; + +export interface IPublicTypeHotkeyCallbackConfig { + callback: IPublicTypeHotkeyCallback; + modifiers: string[]; + action: string; + seq?: string; + level?: number; + combo?: string; +} \ No newline at end of file diff --git a/packages/types/src/shell/type/hotkey-callback.ts b/packages/types/src/shell/type/hotkey-callback.ts new file mode 100644 index 0000000000..4650b9b5cf --- /dev/null +++ b/packages/types/src/shell/type/hotkey-callback.ts @@ -0,0 +1,2 @@ + +export type IPublicTypeHotkeyCallback = (e: KeyboardEvent, combo?: string) => any | false; diff --git a/packages/types/src/shell/type/hotkey-callbacks.ts b/packages/types/src/shell/type/hotkey-callbacks.ts new file mode 100644 index 0000000000..3fd80a4808 --- /dev/null +++ b/packages/types/src/shell/type/hotkey-callbacks.ts @@ -0,0 +1,5 @@ +import { IPublicTypeHotkeyCallbackConfig } from './'; + +export interface IPublicTypeHotkeyCallbacks { + [key: string]: IPublicTypeHotkeyCallbackConfig[]; +} \ No newline at end of file diff --git a/packages/types/src/shell/type/i18n-map.ts b/packages/types/src/shell/type/i18n-map.ts new file mode 100644 index 0000000000..4d56a20b58 --- /dev/null +++ b/packages/types/src/shell/type/i18n-map.ts @@ -0,0 +1,4 @@ + +export interface IPublicTypeI18nMap { + [lang: string]: { [key: string]: string }; +} diff --git a/packages/types/src/shell/type/i8n-data.ts b/packages/types/src/shell/type/i8n-data.ts new file mode 100644 index 0000000000..764d5f82d0 --- /dev/null +++ b/packages/types/src/shell/type/i8n-data.ts @@ -0,0 +1,7 @@ +import { ReactNode } from 'react'; + +export interface IPublicTypeI18nData { + type: 'i18n'; + intl?: ReactNode; + [key: string]: any; +} diff --git a/packages/types/src/shell/type/icon-config.ts b/packages/types/src/shell/type/icon-config.ts new file mode 100644 index 0000000000..f45fe7d697 --- /dev/null +++ b/packages/types/src/shell/type/icon-config.ts @@ -0,0 +1,6 @@ + +export interface IPublicTypeIconConfig { + type: string; + size?: number | 'small' | 'xxs' | 'xs' | 'medium' | 'large' | 'xl' | 'xxl' | 'xxxl' | 'inherit'; + className?: string; +} diff --git a/packages/types/src/shell/type/icon-type.ts b/packages/types/src/shell/type/icon-type.ts new file mode 100644 index 0000000000..99882556b9 --- /dev/null +++ b/packages/types/src/shell/type/icon-type.ts @@ -0,0 +1,4 @@ +import { ReactElement, ComponentType } from 'react'; +import { IPublicTypeIconConfig } from './'; + +export type IPublicTypeIconType = string | ReactElement | ComponentType<any> | IPublicTypeIconConfig; diff --git a/packages/types/src/shell/type/index.ts b/packages/types/src/shell/type/index.ts new file mode 100644 index 0000000000..76dd389255 --- /dev/null +++ b/packages/types/src/shell/type/index.ts @@ -0,0 +1,96 @@ +// this folder contains all interfaces/types working as type definition +// - some exists as type TypeName +// - some althought exists as interfaces , but there won`t be any class implements them. +// all of above cases will with prefix IPublicType, eg. IPublicTypeSomeName +export * from './location'; +export * from './active-target'; +export * from './component-instance'; +export * from './node-schema'; +export * from './disposable'; +export * from './assets-json'; +export * from './metadata-transducer'; +export * from './component-action'; +export * from './preference-value-type'; +export * from './project-schema'; +export * from './block-schema'; +export * from './component-schema'; +export * from './container-schema'; +export * from './page-schema'; +export * from './root-schema'; +export * from './props-transducer'; +export * from './registered-setter'; +export * from './custom-view'; +export * from './widget-base-config'; +export * from './node-data'; +export * from './icon-type'; +export * from './transformed-component-metadata'; +export * from './i8n-data'; +export * from './npm-info'; +export * from './drag-node-data-object'; +export * from './drag-node-object'; +export * from './prop-change-options'; +export * from './drag-any-object'; +export * from './drag-object'; +export * from './composite-value'; +export * from './props-map'; +export * from './props-list'; +export * from './plugin-config'; +export * from './plugin-declaration-property'; +export * from './plugin-declaration'; +export * from './plugin-meta'; +export * from './plugin-creater'; +export * from './plugin'; +export * from './setter-type'; +export * from './set-value-options'; +export * from './field-config'; +export * from './field-extra-props'; +export * from './component-sort'; +export * from './component-metadata'; +export * from './reference'; +export * from './component-description'; +export * from './remote-component-description'; +export * from './package'; +export * from './action-content-object'; +export * from './title-config'; +export * from './title-content'; +export * from './prop-config'; +export * from './prop-types'; +export * from './snippet'; +export * from './advanced'; +export * from './configure'; +export * from './value-type'; +export * from './tip-content'; +export * from './metadata'; +export * from './dynamic-setter'; +export * from './icon-config'; +export * from './dom-text'; +export * from './i18n-map'; +export * from './app-config'; +export * from './npm'; +export * from './dynamic-props'; +export * from './setter-config'; +export * from './tip-config'; +export * from './widget-config-area'; +export * from './hotkey-callback'; +export * from './plugin-register-options'; +export * from './resource-list'; +export * from './engine-options'; +export * from './on-change-options'; +export * from './slot-schema'; +export * from './node-data-type'; +export * from './node-instance'; +export * from './editor-value-key'; +export * from './editor-get-options'; +export * from './editor-get-result'; +export * from './editor-register-options'; +export * from './editor-view'; +export * from './resource-type'; +export * from './resource-type-config'; +export * from './editor-view-config'; +export * from './hotkey-callback-config'; +export * from './hotkey-callbacks'; +export * from './scrollable'; +export * from './simulator-renderer'; +export * from './config-transducer'; +export * from './context-menu'; +export * from './command'; \ No newline at end of file diff --git a/packages/types/src/shell/type/location.ts b/packages/types/src/shell/type/location.ts new file mode 100644 index 0000000000..4f8b59a7c5 --- /dev/null +++ b/packages/types/src/shell/type/location.ts @@ -0,0 +1,56 @@ +import { IPublicModelNode, IPublicModelLocateEvent } from '../model'; + +// eslint-disable-next-line no-shadow +export enum IPublicTypeLocationDetailType { + Children = 'Children', + Prop = 'Prop', +} + +/** + * @deprecated please use IPublicTypeLocationDetailType + */ +export enum LocationDetailType { + Children = 'Children', + Prop = 'Prop', +} + +export type IPublicTypeRect = DOMRect & { + elements?: Array<Element | Text>; + computed?: boolean; +}; + +export interface IPublicTypeLocationChildrenDetail { + type: IPublicTypeLocationDetailType.Children; + index?: number | null; + + /** + * 是否有效位置 + */ + valid?: boolean; + edge?: DOMRect; + near?: { + node: IPublicModelNode; + pos: 'before' | 'after' | 'replace'; + rect?: IPublicTypeRect; + align?: 'V' | 'H'; + }; + focus?: { type: 'slots' } | { type: 'node'; node: IPublicModelNode }; +} + +export interface IPublicTypeLocationPropDetail { + // cover 形态,高亮 domNode,如果 domNode 为空,取 container 的值 + type: IPublicTypeLocationDetailType.Prop; + name: string; + domNode?: HTMLElement; +} + +export type IPublicTypeLocationDetail = IPublicTypeLocationChildrenDetail | IPublicTypeLocationPropDetail | { [key: string]: any; type: string }; + +export interface IPublicTypeLocationData< + Node = IPublicModelNode +> { + target: Node; // shadowNode | ConditionFlow | ElementNode | RootNode + detail: IPublicTypeLocationDetail; + source: string; + event: IPublicModelLocateEvent; +} \ No newline at end of file diff --git a/packages/types/src/shell/type/metadata-transducer.ts b/packages/types/src/shell/type/metadata-transducer.ts new file mode 100644 index 0000000000..e5e407e368 --- /dev/null +++ b/packages/types/src/shell/type/metadata-transducer.ts @@ -0,0 +1,16 @@ +import { IPublicTypeTransformedComponentMetadata } from './'; + + +export interface IPublicTypeMetadataTransducer { + (prev: IPublicTypeTransformedComponentMetadata): IPublicTypeTransformedComponentMetadata; + /** + * 0 - 9 system + * 10 - 99 builtin-plugin + * 100 - app & plugin + */ + level?: number; + /** + * use to replace TODO + */ + id?: string; +} diff --git a/packages/types/src/shell/type/metadata.ts b/packages/types/src/shell/type/metadata.ts new file mode 100644 index 0000000000..c07d9802e1 --- /dev/null +++ b/packages/types/src/shell/type/metadata.ts @@ -0,0 +1,232 @@ +import { MouseEvent } from 'react'; +import { IPublicTypePropType, IPublicTypeComponentAction } from './'; +import { IPublicModelNode, IPublicModelSettingField } from '../model'; + +/** + * 嵌套控制函数 + */ +export type IPublicTypeNestingFilter = (testNode: any, currentNode: any) => boolean; + +/** + * 嵌套控制 + * 防止错误的节点嵌套,比如 a 嵌套 a, FormField 只能在 Form 容器下,Column 只能在 Table 下等 + */ +export interface IPublicTypeNestingRule { + + /** + * 子级白名单 + */ + childWhitelist?: string[] | string | RegExp | IPublicTypeNestingFilter; + + /** + * 父级白名单 + */ + parentWhitelist?: string[] | string | RegExp | IPublicTypeNestingFilter; + + /** + * 后裔白名单 + */ + descendantWhitelist?: string[] | string | RegExp | IPublicTypeNestingFilter; + + /** + * 后裔黑名单 + */ + descendantBlacklist?: string[] | string | RegExp | IPublicTypeNestingFilter; + + /** + * 祖先白名单 可用来做区域高亮 + */ + ancestorWhitelist?: string[] | string | RegExp | IPublicTypeNestingFilter; +} + +/** + * 组件能力配置 + */ +export interface IPublicTypeComponentConfigure { + + /** + * 是否容器组件 + */ + isContainer?: boolean; + + /** + * 组件是否带浮层,浮层组件拖入设计器时会遮挡画布区域,此时应当辅助一些交互以防止阻挡 + */ + isModal?: boolean; + + /** + * 是否存在渲染的根节点 + */ + isNullNode?: boolean; + + /** + * 组件树描述信息 + */ + descriptor?: string; + + /** + * 嵌套控制:防止错误的节点嵌套 + * 比如 a 嵌套 a, FormField 只能在 Form 容器下,Column 只能在 Table 下等 + */ + nestingRule?: IPublicTypeNestingRule; + + /** + * 是否是最小渲染单元 + * 最小渲染单元下的组件渲染和更新都从单元的根节点开始渲染和更新。如果嵌套了多层最小渲染单元,渲染会从最外层的最小渲染单元开始渲染。 + */ + isMinimalRenderUnit?: boolean; + + /** + * 组件选中框的 cssSelector + */ + rootSelector?: string; + + /** + * 禁用的行为,可以为 `'copy'`, `'move'`, `'remove'` 或它们组成的数组 + */ + disableBehaviors?: string[] | string; + + /** + * 用于详细配置上述操作项的内容 + */ + actions?: IPublicTypeComponentAction[]; +} + +export interface IPublicTypeInitialItem { + name: string; + initial: (target: IPublicModelSettingField, currentValue: any) => any; +} +export interface IPublicTypeFilterItem { + name: string; + filter: (target: IPublicModelSettingField | null, currentValue: any) => any; +} +export interface IPublicTypeAutorunItem { + name: string; + autorun: (target: IPublicModelSettingField | null) => any; +} + +// thinkof Array +/** + * Live Text Editing(如果 children 内容是纯文本,支持双击直接编辑)的可配置项目 + */ +export interface IPublicTypeLiveTextEditingConfig { + + /** + * @todo 待补充文档 + */ + propTarget: string; + + /** + * @todo 待补充文档 + */ + selector?: string; + + /** + * 编辑模式 纯文本 | 段落编辑 | 文章编辑(默认纯文本,无跟随工具条) + * @default 'plaintext' + */ + mode?: 'plaintext' | 'paragraph' | 'article'; + + /** + * 从 contentEditable 获取内容并设置到属性 + */ + onSaveContent?: (content: string, prop: any) => any; +} + +export type ConfigureSupportEvent = string | ConfigureSupportEventConfig; + +export interface ConfigureSupportEventConfig { + name: string; + propType?: IPublicTypePropType; + description?: string; + template?: string; +} + +/** + * 通用扩展面板支持性配置 + */ +export interface ConfigureSupport { + + /** + * 支持事件列表 + */ + events?: ConfigureSupportEvent[]; + + /** + * 支持 className 设置 + */ + className?: boolean; + + /** + * 支持样式设置 + */ + style?: boolean; + + /** + * 支持生命周期设置 + */ + lifecycles?: any[]; + + // general?: boolean; + /** + * 支持循环设置 + */ + loop?: boolean; + + /** + * 支持条件式渲染设置 + */ + condition?: boolean; +} + +/** + * handleResizing + */ + +/** + * 配置 callbacks 可捕获引擎抛出的一些事件,例如 onNodeAdd、onResize 等 + */ +export interface IPublicTypeCallbacks { + // hooks + onMouseDownHook?: (e: MouseEvent, currentNode: IPublicModelNode | null) => any; + onDblClickHook?: (e: MouseEvent, currentNode: IPublicModelNode | null) => any; + onClickHook?: (e: MouseEvent, currentNode: IPublicModelNode | null) => any; + // onLocateHook?: (e: any, currentNode: any) => any; + // onAcceptHook?: (currentNode: any, locationData: any) => any; + onMoveHook?: (currentNode: IPublicModelNode) => boolean; + // thinkof 限制性拖拽 + onHoverHook?: (currentNode: IPublicModelNode) => boolean; + + /** 选中 hook,如果返回值是 false,可以控制组件不可被选中 */ + onSelectHook?: (currentNode: IPublicModelNode) => boolean; + onChildMoveHook?: (childNode: IPublicModelNode, currentNode: IPublicModelNode) => boolean; + + // events + onNodeRemove?: (removedNode: IPublicModelNode | null, currentNode: IPublicModelNode | null) => void; + onNodeAdd?: (addedNode: IPublicModelNode | null, currentNode: IPublicModelNode | null) => void; + onSubtreeModified?: (currentNode: IPublicModelNode, options: any) => void; + onResize?: ( + e: MouseEvent & { + trigger: string; + deltaX?: number; + deltaY?: number; + }, + currentNode: any, + ) => void; + onResizeStart?: ( + e: MouseEvent & { + trigger: string; + deltaX?: number; + deltaY?: number; + }, + currentNode: any, + ) => void; + onResizeEnd?: ( + e: MouseEvent & { + trigger: string; + deltaX?: number; + deltaY?: number; + }, + currentNode: IPublicModelNode, + ) => void; +} diff --git a/packages/types/src/shell/type/node-data-type.ts b/packages/types/src/shell/type/node-data-type.ts new file mode 100644 index 0000000000..d7f68041a9 --- /dev/null +++ b/packages/types/src/shell/type/node-data-type.ts @@ -0,0 +1,3 @@ +import { IPublicTypeNodeData } from './node-data'; + +export type IPublicTypeNodeDataType = IPublicTypeNodeData | IPublicTypeNodeData[]; diff --git a/packages/types/src/shell/type/node-data.ts b/packages/types/src/shell/type/node-data.ts new file mode 100644 index 0000000000..0447c9e2a7 --- /dev/null +++ b/packages/types/src/shell/type/node-data.ts @@ -0,0 +1,3 @@ +import { IPublicTypeJSExpression, IPublicTypeNodeSchema, IPublicTypeDOMText, IPublicTypeI18nData } from './'; + +export type IPublicTypeNodeData = IPublicTypeNodeSchema | IPublicTypeJSExpression | IPublicTypeDOMText | IPublicTypeI18nData; diff --git a/packages/types/src/shell/type/node-instance.ts b/packages/types/src/shell/type/node-instance.ts new file mode 100644 index 0000000000..fab8e672ba --- /dev/null +++ b/packages/types/src/shell/type/node-instance.ts @@ -0,0 +1,11 @@ +import { IPublicTypeComponentInstance, IPublicModelNode } from '..'; + +export interface IPublicTypeNodeInstance< + T = IPublicTypeComponentInstance, + Node = IPublicModelNode +> { + docId: string; + nodeId: string; + instance: T; + node?: Node | null; +} diff --git a/packages/types/src/shell/type/node-schema.ts b/packages/types/src/shell/type/node-schema.ts new file mode 100644 index 0000000000..9cbd0a81ac --- /dev/null +++ b/packages/types/src/shell/type/node-schema.ts @@ -0,0 +1,59 @@ +import { IPublicTypeCompositeValue, IPublicTypePropsMap, IPublicTypeNodeData } from './'; + +// 转换成一个 .jsx 文件内 React Class 类 render 函数返回的 jsx 代码 +/** + * 搭建基础协议 - 单个组件树节点描述 + */ +export interface IPublicTypeNodeSchema { + + id?: string; + + /** + * 组件名称 必填、首字母大写 + */ + componentName: string; + + /** + * 组件属性对象 + */ + props?: { + children?: IPublicTypeNodeData | IPublicTypeNodeData[]; + } & IPublicTypePropsMap; // | PropsList; + + /** + * 渲染条件 + */ + condition?: IPublicTypeCompositeValue; + + /** + * 循环数据 + */ + loop?: IPublicTypeCompositeValue; + + /** + * 循环迭代对象、索引名称 ["item", "index"] + */ + loopArgs?: [string, string]; + + /** + * 子节点 + */ + children?: IPublicTypeNodeData | IPublicTypeNodeData[]; + + /** + * 是否锁定 + */ + isLocked?: boolean; + + // @todo + // ------- future support ----- + conditionGroup?: string; + title?: string; + ignore?: boolean; + locked?: boolean; + hidden?: boolean; + isTopFixed?: boolean; + + /** @experimental 编辑态内部使用 */ + __ctx?: any; +} diff --git a/packages/types/src/shell/type/npm-info.ts b/packages/types/src/shell/type/npm-info.ts new file mode 100644 index 0000000000..e91c39ebc1 --- /dev/null +++ b/packages/types/src/shell/type/npm-info.ts @@ -0,0 +1,33 @@ +/** + * npm 源引入完整描述对象 + */ +export interface IPublicTypeNpmInfo { + /** + * 源码组件名称 + */ + componentName?: string; + /** + * 源码组件库名 + */ + package: string; + /** + * 源码组件版本号 + */ + version?: string; + /** + * 是否解构 + */ + destructuring?: boolean; + /** + * 源码组件名称 + */ + exportName?: string; + /** + * 子组件名 + */ + subName?: string; + /** + * 组件路径 + */ + main?: string; +} diff --git a/packages/types/src/shell/type/npm.ts b/packages/types/src/shell/type/npm.ts new file mode 100644 index 0000000000..2d1396be4f --- /dev/null +++ b/packages/types/src/shell/type/npm.ts @@ -0,0 +1,16 @@ +import { IPublicTypeNpmInfo } from './npm-info'; + +export interface IPublicTypeLowCodeComponent { + /** + * 研发模式 + */ + devMode: 'lowCode'; + /** + * 组件名称 + */ + componentName: string; +} + +export type IPublicTypeProCodeComponent = IPublicTypeNpmInfo; +export type IPublicTypeComponentMap = IPublicTypeProCodeComponent | IPublicTypeLowCodeComponent; +export type IPublicTypeComponentsMap = IPublicTypeComponentMap[]; diff --git a/packages/types/src/shell/type/on-change-options.ts b/packages/types/src/shell/type/on-change-options.ts new file mode 100644 index 0000000000..47b88d72f7 --- /dev/null +++ b/packages/types/src/shell/type/on-change-options.ts @@ -0,0 +1,8 @@ +import { IPublicModelNode } from '..'; + +export interface IPublicTypeOnChangeOptions< + Node = IPublicModelNode +> { + type: string; + node: Node; +} diff --git a/packages/types/src/shell/type/package.ts b/packages/types/src/shell/type/package.ts new file mode 100644 index 0000000000..b33fa3f94a --- /dev/null +++ b/packages/types/src/shell/type/package.ts @@ -0,0 +1,55 @@ +import { EitherOr } from '../../utils'; +import { IPublicTypeComponentSchema, IPublicTypeProjectSchema } from './'; + +/** + * 定义组件大包及 external 资源的信息 + * 应该被编辑器默认加载 + */ +export type IPublicTypePackage = EitherOr<{ + /** + * npm 包名 + */ + package: string; + /** + * 包唯一标识 + */ + id: string; + /** + * 包版本号 + */ + version: string; + /** + * 组件渲染态视图打包后的 CDN url 列表,包含 js 和 css + */ + urls?: string[] | any; + /** + * 组件编辑态视图打包后的 CDN url 列表,包含 js 和 css + */ + editUrls?: string[] | any; + /** + * 作为全局变量引用时的名称,和webpack output.library字段含义一样,用来定义全局变量名 + */ + library: string; + /** + * @experimental + * + * TODO: 需推进提案 @度城 + */ + async?: boolean; + /** + * 标识当前 package 从其他 package 的导出方式 + */ + exportMode?: 'functionCall'; + /** + * 标识当前 package 是从 window 上的哪个属性导出来的 + */ + exportSourceLibrary?: any; + /** + * 组件描述导出名字,可以通过 window[exportName] 获取到组件描述的 Object 内容; + */ + exportName?: string; + /** + * 低代码组件 schema 内容 + */ + schema?: IPublicTypeProjectSchema<IPublicTypeComponentSchema>; +}, 'package', 'id'>; diff --git a/packages/types/src/shell/type/page-schema.ts b/packages/types/src/shell/type/page-schema.ts new file mode 100644 index 0000000000..670c65451b --- /dev/null +++ b/packages/types/src/shell/type/page-schema.ts @@ -0,0 +1,9 @@ +import { IPublicTypeContainerSchema } from './'; + +/** + * 页面容器 + * @see https://lowcode-engine.cn/lowcode + */ +export interface IPublicTypePageSchema extends IPublicTypeContainerSchema { + componentName: 'Page'; +} diff --git a/packages/types/src/shell/type/plugin-config.ts b/packages/types/src/shell/type/plugin-config.ts new file mode 100644 index 0000000000..2d841dd804 --- /dev/null +++ b/packages/types/src/shell/type/plugin-config.ts @@ -0,0 +1,5 @@ +export interface IPublicTypePluginConfig { + init(): Promise<void> | void; + destroy?(): Promise<void> | void; + exports?(): any; +} diff --git a/packages/types/src/shell/type/plugin-creater.ts b/packages/types/src/shell/type/plugin-creater.ts new file mode 100644 index 0000000000..713578752f --- /dev/null +++ b/packages/types/src/shell/type/plugin-creater.ts @@ -0,0 +1,5 @@ +import { IPublicTypePluginConfig } from './'; +import { IPublicModelPluginContext } from '../model'; + +// eslint-disable-next-line max-len +export type IPublicTypePluginCreater = (ctx: IPublicModelPluginContext, options: any) => IPublicTypePluginConfig; diff --git a/packages/types/src/shell/type/plugin-declaration-property.ts b/packages/types/src/shell/type/plugin-declaration-property.ts new file mode 100644 index 0000000000..f07b350a65 --- /dev/null +++ b/packages/types/src/shell/type/plugin-declaration-property.ts @@ -0,0 +1,21 @@ +import { IPublicTypePreferenceValueType } from './'; + +export interface IPublicTypePluginDeclarationProperty { + // shape like 'name' or 'group.name' or 'group.subGroup.name' + key: string; + // must have either one of description & markdownDescription + description: string; + // value in 'number', 'string', 'boolean' + type: string; + // default value + // NOTE! this is only used in configuration UI, won`t affect runtime + default?: IPublicTypePreferenceValueType; + // only works when type === 'string', default value false + useMultipleLineTextInput?: boolean; + // enum values, only works when type === 'string' + enum?: any[]; + // descriptions for enum values + enumDescriptions?: string[]; + // message that describing deprecation of this property + deprecationMessage?: string; +} diff --git a/packages/types/src/shell/type/plugin-declaration.ts b/packages/types/src/shell/type/plugin-declaration.ts new file mode 100644 index 0000000000..4d5e1a4e60 --- /dev/null +++ b/packages/types/src/shell/type/plugin-declaration.ts @@ -0,0 +1,11 @@ +import { IPublicTypePluginDeclarationProperty } from './'; + +/** + * declaration of plugin`s preference + * when strictPluginMode === true, only declared preference can be obtained from inside plugin. + */ +export interface IPublicTypePluginDeclaration { + // this will be displayed on configuration UI, can be plugin name + title: string; + properties: IPublicTypePluginDeclarationProperty[]; +} diff --git a/packages/types/src/shell/type/plugin-meta.ts b/packages/types/src/shell/type/plugin-meta.ts new file mode 100644 index 0000000000..bf7f6212e8 --- /dev/null +++ b/packages/types/src/shell/type/plugin-meta.ts @@ -0,0 +1,37 @@ +import { IPublicTypePluginDeclaration } from './'; + +export interface IPublicTypePluginMeta { + + /** + * define dependencies which the plugin depends on + */ + dependencies?: string[]; + + /** + * specify which engine version is compatible with the plugin + */ + engines?: { + + /** e.g. '^1.0.0' */ + lowcodeEngine?: string; + }; + preferenceDeclaration?: IPublicTypePluginDeclaration; + + /** + * use 'common' as event prefix when eventPrefix is not set. + * strongly recommend using pluginName as eventPrefix + * + * eg. + * case 1, when eventPrefix is not specified + * event.emit('someEventName') is actually sending event with name 'common:someEventName' + * + * case 2, when eventPrefix is 'myEvent' + * event.emit('someEventName') is actually sending event with name 'myEvent:someEventName' + */ + eventPrefix?: string; + + /** + * 如果要使用 command 注册命令,需要在插件 meta 中定义 commandScope + */ + commandScope?: string; +} diff --git a/packages/types/src/shell/type/plugin-register-options.ts b/packages/types/src/shell/type/plugin-register-options.ts new file mode 100644 index 0000000000..7d2377bbe2 --- /dev/null +++ b/packages/types/src/shell/type/plugin-register-options.ts @@ -0,0 +1,13 @@ + +export interface IPublicTypePluginRegisterOptions { + /** + * Will enable plugin registered with auto-initialization immediately + * other than plugin-manager init all plugins at certain time. + * It is helpful when plugin register is later than plugin-manager initialization. + */ + autoInit?: boolean; + /** + * allow overriding existing plugin with same name when override === true + */ + override?: boolean; +} diff --git a/packages/types/src/shell/type/plugin.ts b/packages/types/src/shell/type/plugin.ts new file mode 100644 index 0000000000..f5d7b81e50 --- /dev/null +++ b/packages/types/src/shell/type/plugin.ts @@ -0,0 +1,7 @@ +/* eslint-disable max-len */ +import { IPublicTypePluginMeta, IPublicTypePluginCreater } from './'; + +export interface IPublicTypePlugin extends IPublicTypePluginCreater { + pluginName: string; + meta?: IPublicTypePluginMeta; +} \ No newline at end of file diff --git a/packages/types/src/shell/type/preference-value-type.ts b/packages/types/src/shell/type/preference-value-type.ts new file mode 100644 index 0000000000..75b58824c0 --- /dev/null +++ b/packages/types/src/shell/type/preference-value-type.ts @@ -0,0 +1,2 @@ + +export type IPublicTypePreferenceValueType = string | number | boolean; diff --git a/packages/types/src/shell/type/project-schema.ts b/packages/types/src/shell/type/project-schema.ts new file mode 100644 index 0000000000..271841bfb3 --- /dev/null +++ b/packages/types/src/shell/type/project-schema.ts @@ -0,0 +1,68 @@ +import { InterpretDataSource as DataSource } from '@alilc/lowcode-datasource-types'; +import { IPublicTypeJSONObject, IPublicTypeRootSchema, IPublicTypeI18nMap, IPublicTypeAppConfig, IPublicTypeComponentsMap, IPublicTypeJSExpression, IPublicTypeJSFunction, IPublicTypeNpmInfo } from './'; + +export interface IPublicTypeInternalUtils { + name: string; + type: 'function'; + content: IPublicTypeJSFunction | IPublicTypeJSExpression; +} + +export interface IPublicTypeExternalUtils { + name: string; + type: 'npm' | 'tnpm'; + content: IPublicTypeNpmInfo; +} + +export type IPublicTypeUtilItem = IPublicTypeInternalUtils | IPublicTypeExternalUtils; +export type IPublicTypeUtilsMap = IPublicTypeUtilItem[]; +/** + * 应用描述 + */ + +export interface IPublicTypeProjectSchema<T = IPublicTypeRootSchema> { + id?: string; + /** + * 当前应用协议版本号 + */ + version: string; + /** + * 当前应用所有组件映射关系 + */ + componentsMap: IPublicTypeComponentsMap; + /** + * 描述应用所有页面、低代码组件的组件树 + * 低代码业务组件树描述 + * 是长度固定为 1 的数组,即数组内仅包含根容器的描述(低代码业务组件容器类型) + */ + componentsTree: T[]; + /** + * 国际化语料 + */ + i18n?: IPublicTypeI18nMap; + /** + * 应用范围内的全局自定义函数或第三方工具类扩展 + */ + utils?: IPublicTypeUtilsMap; + /** + * 应用范围内的全局常量 + */ + constants?: IPublicTypeJSONObject; + /** + * 应用范围内的全局样式 + */ + css?: string; + /** + * 当前应用的公共数据源 + */ + dataSource?: DataSource; + /** + * 当前应用配置信息 + * + * TODO: 需要在后续版本中移除 `Record<string, unknown>` 类型签名 + */ + config?: IPublicTypeAppConfig & Record<string, unknown>; + /** + * 当前应用元数据信息 + */ + meta?: Record<string, any>; +} diff --git a/packages/types/src/shell/type/prop-change-options.ts b/packages/types/src/shell/type/prop-change-options.ts new file mode 100644 index 0000000000..b515aec537 --- /dev/null +++ b/packages/types/src/shell/type/prop-change-options.ts @@ -0,0 +1,14 @@ +import { + IPublicModelNode, + IPublicModelProp, +} from '../model'; + +export interface IPublicTypePropChangeOptions< + Node = IPublicModelNode +> { + key?: string | number; + prop?: IPublicModelProp; + node: Node; + newValue: any; + oldValue: any; +} diff --git a/packages/types/src/shell/type/prop-config.ts b/packages/types/src/shell/type/prop-config.ts new file mode 100644 index 0000000000..e7635659cb --- /dev/null +++ b/packages/types/src/shell/type/prop-config.ts @@ -0,0 +1,27 @@ +import { IPublicTypePropType } from './'; + +/** + * 组件属性信息 + */ +export interface IPublicTypePropConfig { + /** + * 属性名称 + */ + name: string; + /** + * 属性类型 + */ + propType: IPublicTypePropType; + /** + * 属性描述 + */ + description?: string; + /** + * 属性默认值 + */ + defaultValue?: any; + /** + * @deprecated 已被弃用 + */ + setter?: any; +} diff --git a/packages/types/src/shell/type/prop-types.ts b/packages/types/src/shell/type/prop-types.ts new file mode 100644 index 0000000000..22d84c86fc --- /dev/null +++ b/packages/types/src/shell/type/prop-types.ts @@ -0,0 +1,48 @@ +/* eslint-disable max-len */ +import { IPublicTypePropConfig } from './'; + +export type IPublicTypePropType = IPublicTypeBasicType | IPublicTypeRequiredType | IPublicTypeComplexType; +export type IPublicTypeBasicType = 'array' | 'bool' | 'func' | 'number' | 'object' | 'string' | 'node' | 'element' | 'any'; +export type IPublicTypeComplexType = IPublicTypeOneOf | IPublicTypeOneOfType | IPublicTypeArrayOf | IPublicTypeObjectOf | IPublicTypeShape | IPublicTypeExact | IPublicTypeInstanceOf; + +export interface IPublicTypeRequiredType { + type: IPublicTypeBasicType; + isRequired?: boolean; +} + +export interface IPublicTypeOneOf { + type: 'oneOf'; + value: string[]; + isRequired?: boolean; +} +export interface IPublicTypeOneOfType { + type: 'oneOfType'; + value: IPublicTypePropType[]; + isRequired?: boolean; +} +export interface IPublicTypeArrayOf { + type: 'arrayOf'; + value: IPublicTypePropType; + isRequired?: boolean; +} +export interface IPublicTypeObjectOf { + type: 'objectOf'; + value: IPublicTypePropType; + isRequired?: boolean; +} +export interface IPublicTypeShape { + type: 'shape'; + value: IPublicTypePropConfig[]; + isRequired?: boolean; +} +export interface IPublicTypeExact { + type: 'exact'; + value: IPublicTypePropConfig[]; + isRequired?: boolean; +} + +export interface IPublicTypeInstanceOf { + type: 'instanceOf'; + value: IPublicTypePropConfig; + isRequired?: boolean; +} diff --git a/packages/types/src/shell/type/props-list.ts b/packages/types/src/shell/type/props-list.ts new file mode 100644 index 0000000000..801c088b64 --- /dev/null +++ b/packages/types/src/shell/type/props-list.ts @@ -0,0 +1,7 @@ +import { IPublicTypeCompositeValue } from './'; + +export type IPublicTypePropsList = Array<{ + spread?: boolean; + name?: string; + value: IPublicTypeCompositeValue; +}>; diff --git a/packages/types/src/shell/type/props-map.ts b/packages/types/src/shell/type/props-map.ts new file mode 100644 index 0000000000..1b93f46252 --- /dev/null +++ b/packages/types/src/shell/type/props-map.ts @@ -0,0 +1,3 @@ +import { IPublicTypeCompositeObject, IPublicTypeNodeData } from './'; + +export type IPublicTypePropsMap = IPublicTypeCompositeObject<IPublicTypeNodeData | IPublicTypeNodeData[]>; diff --git a/packages/types/src/shell/type/props-transducer.ts b/packages/types/src/shell/type/props-transducer.ts new file mode 100644 index 0000000000..b98ec36a6f --- /dev/null +++ b/packages/types/src/shell/type/props-transducer.ts @@ -0,0 +1,11 @@ +import { IPublicEnumTransformStage } from '../enum'; +import { IPublicModelNode } from '../model'; +import { IPublicTypeCompositeObject } from './'; + +export type IPublicTypePropsTransducer = ( + props: IPublicTypeCompositeObject, + node: IPublicModelNode, + ctx?: { + stage: IPublicEnumTransformStage; + }, +) => IPublicTypeCompositeObject; diff --git a/packages/types/src/shell/type/reference.ts b/packages/types/src/shell/type/reference.ts new file mode 100644 index 0000000000..34de153d99 --- /dev/null +++ b/packages/types/src/shell/type/reference.ts @@ -0,0 +1,35 @@ +import { EitherOr } from '../../utils'; + +/** + * 资源引用信息,Npm 的升级版本, + */ +export type IPublicTypeReference = EitherOr<{ + /** + * 引用资源的 id 标识 + */ + id: string; + /** + * 引用资源的包名 + */ + package: string; + /** + * 引用资源的导出对象中的属性值名称 + */ + exportName: string; + /** + * 引用 exportName 上的子对象 + */ + subName: string; + /** + * 引用的资源主入口 + */ + main?: string; + /** + * 是否从引用资源的导出对象中获取属性值 + */ + destructuring?: boolean; + /** + * 资源版本号 + */ + version: string; +}, 'package', 'id'>; diff --git a/packages/types/src/shell/type/registered-setter.ts b/packages/types/src/shell/type/registered-setter.ts new file mode 100644 index 0000000000..55a90465a8 --- /dev/null +++ b/packages/types/src/shell/type/registered-setter.ts @@ -0,0 +1,21 @@ +import { IPublicModelSettingField } from '../model'; +import { IPublicTypeCustomView, IPublicTypeTitleContent } from './'; + +export interface IPublicTypeRegisteredSetter { + component: IPublicTypeCustomView; + defaultProps?: object; + title?: IPublicTypeTitleContent; + + /** + * for MixedSetter to check this setter if available + */ + condition?: (field: IPublicModelSettingField) => boolean; + + /** + * for MixedSetter to manual change to this setter + */ + initialValue?: any | ((field: IPublicModelSettingField) => any); + recommend?: boolean; + // 标识是否为动态 setter,默认为 true + isDynamic?: boolean; +} diff --git a/packages/types/src/shell/type/remote-component-description.ts b/packages/types/src/shell/type/remote-component-description.ts new file mode 100644 index 0000000000..2337203657 --- /dev/null +++ b/packages/types/src/shell/type/remote-component-description.ts @@ -0,0 +1,30 @@ +import { Asset } from '../../assets'; +import { IPublicTypeComponentMetadata, IPublicTypeReference } from './'; + +/** + * 远程物料描述 + */ +export interface IPublicTypeRemoteComponentDescription extends IPublicTypeComponentMetadata { + + /** + * 组件描述导出名字,可以通过 window[exportName] 获取到组件描述的 Object 内容; + */ + exportName?: string; + + /** + * 组件描述的资源链接; + */ + url?: Asset; + + /** + * 组件 (库) 的 npm 信息; + */ + package?: { + npm?: string; + }; + + /** + * 替代 npm 字段的升级版本 + */ + reference?: IPublicTypeReference; +} diff --git a/packages/types/src/shell/type/resource-list.ts b/packages/types/src/shell/type/resource-list.ts new file mode 100644 index 0000000000..1d7c34232a --- /dev/null +++ b/packages/types/src/shell/type/resource-list.ts @@ -0,0 +1,37 @@ +import { ReactElement } from 'react'; + +export interface IPublicResourceData { + + /** 资源名字 */ + resourceName: string; + + /** 资源扩展配置 */ + config?: { + [key: string]: any; + }; + + /** 资源标题 */ + title?: string; + + /** 资源 Id */ + id?: string; + + /** 分类 */ + category?: string; + + /** 资源视图 */ + viewName?: string; + + /** 资源 icon */ + icon?: ReactElement; + + /** 资源其他配置,资源初始化时的第二个参数 */ + options: { + [key: string]: any; + }; + + /** 资源子元素 */ + children?: IPublicResourceData[]; +} + +export type IPublicResourceList = IPublicResourceData[]; \ No newline at end of file diff --git a/packages/types/src/shell/type/resource-type-config.ts b/packages/types/src/shell/type/resource-type-config.ts new file mode 100644 index 0000000000..01b49aa2bd --- /dev/null +++ b/packages/types/src/shell/type/resource-type-config.ts @@ -0,0 +1,41 @@ +import React from 'react'; +import { IPublicTypeEditorView } from './editor-view'; + +export interface IPublicResourceTypeConfig { + + /** 资源描述 */ + description?: string; + + /** 资源 icon 标识 */ + icon?: React.ReactElement | React.FunctionComponent | React.ComponentClass; + + /** + * 默认视图类型 + * @deprecated + */ + defaultViewType?: string; + + /** 默认视图类型 */ + defaultViewName: string; + + /** 资源视图 */ + editorViews: IPublicTypeEditorView[]; + + init?: () => void; + + /** save 钩子 */ + save?: (schema: { + [viewName: string]: any; + }) => Promise<void>; + + /** import 钩子 */ + import?: (schema: any) => Promise<{ + [viewName: string]: any; + }>; + + /** 默认标题 */ + defaultTitle?: string; + + /** resourceType 类型为 'webview' 时渲染的地址 */ + url?: () => Promise<string>; +} diff --git a/packages/types/src/shell/type/resource-type.ts b/packages/types/src/shell/type/resource-type.ts new file mode 100644 index 0000000000..7d64a4463d --- /dev/null +++ b/packages/types/src/shell/type/resource-type.ts @@ -0,0 +1,10 @@ +import { IPublicModelPluginContext } from '../model'; +import { IPublicResourceTypeConfig } from './resource-type-config'; + +export interface IPublicTypeResourceType { + resourceName: string; + + resourceType: 'editor' | 'webview' | string; + + (ctx: IPublicModelPluginContext, options: Object): IPublicResourceTypeConfig; +} \ No newline at end of file diff --git a/packages/types/src/shell/type/root-schema.ts b/packages/types/src/shell/type/root-schema.ts new file mode 100644 index 0000000000..16f3bf94ec --- /dev/null +++ b/packages/types/src/shell/type/root-schema.ts @@ -0,0 +1,7 @@ +import { IPublicTypePageSchema, IPublicTypeComponentSchema, IPublicTypeBlockSchema } from './'; + +/** + * @todo + */ +// eslint-disable-next-line max-len +export type IPublicTypeRootSchema = IPublicTypePageSchema | IPublicTypeComponentSchema | IPublicTypeBlockSchema; diff --git a/packages/types/src/shell/type/scrollable.ts b/packages/types/src/shell/type/scrollable.ts new file mode 100644 index 0000000000..b308637e0c --- /dev/null +++ b/packages/types/src/shell/type/scrollable.ts @@ -0,0 +1,7 @@ +import { IPublicModelScrollTarget } from '../model'; + +export interface IPublicTypeScrollable { + scrollTarget?: IPublicModelScrollTarget | Element; + bounds?: DOMRect | null; + scale?: number; +} diff --git a/packages/types/src/shell/type/set-value-options.ts b/packages/types/src/shell/type/set-value-options.ts new file mode 100644 index 0000000000..814f458459 --- /dev/null +++ b/packages/types/src/shell/type/set-value-options.ts @@ -0,0 +1,7 @@ +import { IPublicEnumPropValueChangedType } from '../enum'; + +export interface IPublicTypeSetValueOptions { + disableMutator?: boolean; + type?: IPublicEnumPropValueChangedType; + fromSetHotValue?: boolean; +} diff --git a/packages/types/src/shell/type/setter-config.ts b/packages/types/src/shell/type/setter-config.ts new file mode 100644 index 0000000000..c0f93679ee --- /dev/null +++ b/packages/types/src/shell/type/setter-config.ts @@ -0,0 +1,64 @@ +import { IPublicTypeCustomView, IPublicTypeCompositeValue, IPublicTypeTitleContent, IPublicModelSettingField } from '..'; +import { IPublicTypeDynamicProps } from './dynamic-props'; + +/** + * 设置器配置 + */ +export interface IPublicTypeSetterConfig { + + // if *string* passed must be a registered Setter Name + /** + * 配置设置器用哪一个 setter + */ + componentName: string | IPublicTypeCustomView; + + /** + * 传递给 setter 的属性 + * + * the props pass to Setter Component + */ + props?: Record<string, unknown> | IPublicTypeDynamicProps; + + /** + * @deprecated + */ + children?: any; + + /** + * 是否必填? + * + * ArraySetter 里有个快捷预览,可以在不打开面板的情况下直接编辑 + */ + isRequired?: boolean; + + /** + * Setter 的初始值 + * + * @todo initialValue 可能要和 defaultValue 二选一 + */ + initialValue?: any | ((target: IPublicModelSettingField) => any); + + defaultValue?: any; + + // for MixedSetter + /** + * 给 MixedSetter 时切换 Setter 展示用的 + */ + title?: IPublicTypeTitleContent; + + // for MixedSetter check this is available + /** + * 给 MixedSetter 用于判断优先选中哪个 + */ + condition?: (target: IPublicModelSettingField) => boolean; + + /** + * 给 MixedSetter,切换值时声明类型 + * + * @todo 物料协议推进 + */ + valueType?: IPublicTypeCompositeValue[]; + + // 标识是否为动态 setter,默认为 true + isDynamic?: boolean; +} diff --git a/packages/types/src/shell/type/setter-type.ts b/packages/types/src/shell/type/setter-type.ts new file mode 100644 index 0000000000..92cb118caf --- /dev/null +++ b/packages/types/src/shell/type/setter-type.ts @@ -0,0 +1,6 @@ +import { IPublicTypeCustomView, IPublicTypeSetterConfig } from './'; + +// if *string* passed must be a registered Setter Name, future support blockSchema + +// eslint-disable-next-line max-len +export type IPublicTypeSetterType = IPublicTypeSetterConfig | IPublicTypeSetterConfig[] | string | IPublicTypeCustomView; diff --git a/packages/types/src/shell/type/simulator-renderer.ts b/packages/types/src/shell/type/simulator-renderer.ts new file mode 100644 index 0000000000..14aa16ab88 --- /dev/null +++ b/packages/types/src/shell/type/simulator-renderer.ts @@ -0,0 +1,32 @@ +import { Asset } from '../../assets'; +import { + IPublicTypeNodeInstance, + IPublicTypeProjectSchema, + IPublicTypeComponentSchema, +} from './'; + +export interface IPublicTypeSimulatorRenderer<Component, ComponentInstance> { + readonly isSimulatorRenderer: true; + autoRepaintNode?: boolean; + components: Record<string, Component>; + rerender: () => void; + createComponent( + schema: IPublicTypeProjectSchema<IPublicTypeComponentSchema>, + ): Component | null; + getComponent(componentName: string): Component; + getClosestNodeInstance( + from: ComponentInstance, + nodeId?: string, + ): IPublicTypeNodeInstance<ComponentInstance> | null; + findDOMNodes(instance: ComponentInstance): Array<Element | Text> | null; + getClientRects(element: Element | Text): DOMRect[]; + setNativeSelection(enableFlag: boolean): void; + setDraggingState(state: boolean): void; + setCopyState(state: boolean): void; + loadAsyncLibrary(asyncMap: { [index: string]: any }): void; + clearState(): void; + stopAutoRepaintNode(): void; + enableAutoRepaintNode(): void; + run(): void; + load(asset: Asset): Promise<any>; +} diff --git a/packages/types/src/shell/type/slot-schema.ts b/packages/types/src/shell/type/slot-schema.ts new file mode 100644 index 0000000000..8928a98247 --- /dev/null +++ b/packages/types/src/shell/type/slot-schema.ts @@ -0,0 +1,18 @@ +import { IPublicTypeNodeData } from './node-data'; +import { IPublicTypeNodeSchema } from './node-schema'; + +/** + * Slot schema 描述 + */ +export interface IPublicTypeSlotSchema extends IPublicTypeNodeSchema { + componentName: 'Slot'; + name?: string; + title?: string; + params?: string[]; + props?: { + slotTitle?: string; + slotName?: string; + slotParams?: string[]; + }; + children?: IPublicTypeNodeData[] | IPublicTypeNodeData; +} diff --git a/packages/types/src/shell/type/snippet.ts b/packages/types/src/shell/type/snippet.ts new file mode 100644 index 0000000000..f844777fb8 --- /dev/null +++ b/packages/types/src/shell/type/snippet.ts @@ -0,0 +1,27 @@ +import { IPublicTypeNodeSchema } from './'; + +/** + * 可用片段 + * + * 内容为组件不同状态下的低代码 schema (可以有多个),用户从组件面板拖入组件到设计器时会向页面 schema 中插入 snippets 中定义的组件低代码 schema + */ +export interface IPublicTypeSnippet { + /** + * 组件分类 title + */ + title?: string; + /** + * snippet 截图 + */ + screenshot?: string; + /** + * snippet 打标 + * + * @deprecated 暂未使用 + */ + label?: string; + /** + * 待插入的 schema + */ + schema?: IPublicTypeNodeSchema; +} diff --git a/packages/types/src/shell/type/tip-config.ts b/packages/types/src/shell/type/tip-config.ts new file mode 100644 index 0000000000..f8b271c909 --- /dev/null +++ b/packages/types/src/shell/type/tip-config.ts @@ -0,0 +1,21 @@ +import { IPublicTypeI18nData } from '..'; +import { ReactNode } from 'react'; + +export interface IPublicTypeTipConfig { + + /** + * className + */ + className?: string; + + /** + * tip 的内容 + */ + children?: IPublicTypeI18nData | ReactNode; + theme?: string; + + /** + * tip 的方向 + */ + direction?: 'top' | 'bottom' | 'left' | 'right'; +} diff --git a/packages/types/src/shell/type/tip-content.ts b/packages/types/src/shell/type/tip-content.ts new file mode 100644 index 0000000000..340d404aba --- /dev/null +++ b/packages/types/src/shell/type/tip-content.ts @@ -0,0 +1,5 @@ +import { IPublicTypeI18nData } from '..'; +import { ReactNode } from 'react'; +import { IPublicTypeTipConfig } from './tip-config'; + +export type TipContent = string | IPublicTypeI18nData | ReactNode | IPublicTypeTipConfig; diff --git a/packages/types/src/shell/type/title-config.ts b/packages/types/src/shell/type/title-config.ts new file mode 100644 index 0000000000..f8de287599 --- /dev/null +++ b/packages/types/src/shell/type/title-config.ts @@ -0,0 +1,53 @@ +import { ReactNode } from 'react'; +import { IPublicTypeI18nData, IPublicTypeIconType, IPublicTypeTitleContent, TipContent } from './'; + +export interface IPublicTypeTitleProps { + + /** + * 标题内容 + */ + title: IPublicTypeTitleContent; + + /** + * className + */ + className?: string; + + /** + * 点击事件 + */ + onClick?: () => void; + match?: boolean; + keywords?: string; +} + +/** + * 描述 props 的 setter title + */ +export interface IPublicTypeTitleConfig { + + /** + * 文字描述 + */ + label?: IPublicTypeI18nData | ReactNode; + + /** + * hover 后的展现内容 + */ + tip?: TipContent; + + /** + * 文档链接,暂未实现 + */ + docUrl?: string; + + /** + * 图标 + */ + icon?: IPublicTypeIconType; + + /** + * CSS 类 + */ + className?: string; +} diff --git a/packages/types/src/shell/type/title-content.ts b/packages/types/src/shell/type/title-content.ts new file mode 100644 index 0000000000..b17a476a5c --- /dev/null +++ b/packages/types/src/shell/type/title-content.ts @@ -0,0 +1,5 @@ +import { ReactElement, ReactNode } from 'react'; +import { IPublicTypeI18nData, IPublicTypeTitleConfig } from './'; + +// eslint-disable-next-line max-len +export type IPublicTypeTitleContent = string | IPublicTypeI18nData | ReactElement | ReactNode | IPublicTypeTitleConfig; \ No newline at end of file diff --git a/packages/types/src/shell/type/transformed-component-metadata.ts b/packages/types/src/shell/type/transformed-component-metadata.ts new file mode 100644 index 0000000000..6baa21c18b --- /dev/null +++ b/packages/types/src/shell/type/transformed-component-metadata.ts @@ -0,0 +1,8 @@ +import { IPublicTypeComponentMetadata, IPublicTypeFieldConfig, IPublicTypeConfigure } from './'; + +/** + * @todo 待补充文档 + */ +export interface IPublicTypeTransformedComponentMetadata extends IPublicTypeComponentMetadata { + configure: IPublicTypeConfigure & { combined?: IPublicTypeFieldConfig[] }; +} diff --git a/packages/types/src/shell/type/value-type.ts b/packages/types/src/shell/type/value-type.ts new file mode 100644 index 0000000000..16fb789a26 --- /dev/null +++ b/packages/types/src/shell/type/value-type.ts @@ -0,0 +1,136 @@ +import { IPublicTypeNodeData, IPublicTypeCompositeValue, IPublicTypeNodeSchema } from './'; + +/** + * 变量表达式 + * + * 表达式内通过 this 对象获取上下文 + */ +export interface IPublicTypeJSExpression { + type: 'JSExpression'; + + /** + * 表达式字符串 + */ + value: string; + + /** + * 模拟值 + * + * @todo 待标准描述 + */ + mock?: any; + + /** + * 源码 + * + * @todo 待标准描述 + */ + compiled?: string; +} + +/** + * 事件函数类型 + * @see https://lowcode-engine.cn/lowcode + * + * 保留与原组件属性、生命周期 ( React / 小程序) 一致的输入参数,并给所有事件函数 binding 统一一致的上下文(当前组件所在容器结构的 this 对象) + */ +export interface IPublicTypeJSFunction { + type: 'JSFunction'; + + /** + * 函数定义,或直接函数表达式 + */ + value: string; + + /** + * 源码 + * + * @todo 待标准描述 + */ + compiled?: string; + + /** + * 模拟值 + * + * @todo 待标准描述 + */ + mock?: any; + + /** + * 额外扩展属性,如 extType、events + * + * @todo 待标准描述 + */ + [key: string]: any; +} + +/** + * Slot 函数类型 + * + * 通常用于描述组件的某一个属性为 ReactNode 或 Function return ReactNode 的场景。 + */ +export interface IPublicTypeJSSlot { + + /** + * type + */ + type: 'JSSlot'; + + /** + * @todo 待标准描述 + */ + title?: string; + + /** + * @todo 待标准描述 + */ + id?: string; + + /** + * 组件的某一个属性为 Function return ReactNode 时,函数的入参 + * + * 其子节点可以通过 this[参数名] 来获取对应的参数。 + */ + params?: string[]; + + /** + * 具体的值。 + */ + value?: IPublicTypeNodeData[] | IPublicTypeNodeData; + + /** + * @todo 待标准描述 + */ + name?: string; +} + +/** + * @deprecated + * + * @todo 待文档描述 + */ +export interface IPublicTypeJSBlock { + type: 'JSBlock'; + value: IPublicTypeNodeSchema; +} + +/** + * JSON 基本类型 + */ +export type IPublicTypeJSONValue = + | boolean + | string + | number + | null + | undefined + | IPublicTypeJSONArray + | IPublicTypeJSONObject; +export type IPublicTypeJSONArray = IPublicTypeJSONValue[]; +export interface IPublicTypeJSONObject { + [key: string]: IPublicTypeJSONValue; +} + +export type IPublicTypeCompositeArray = IPublicTypeCompositeValue[]; +export interface IPublicTypeCompositeObject<T = IPublicTypeCompositeValue> { + [key: string]: IPublicTypeCompositeValue | T; +} \ No newline at end of file diff --git a/packages/types/src/shell/type/widget-base-config.ts b/packages/types/src/shell/type/widget-base-config.ts new file mode 100644 index 0000000000..2764ce1927 --- /dev/null +++ b/packages/types/src/shell/type/widget-base-config.ts @@ -0,0 +1,99 @@ +import { ReactElement, ComponentType } from 'react'; +import { IPublicTypeI18nData, IPublicTypeIconType, IPublicTypeTitleContent, IPublicTypeWidgetConfigArea, TipContent } from './'; + +export type IPublicTypeHelpTipConfig = string | { url?: string; content?: string | ReactElement }; + +export interface IPublicTypePanelConfigProps extends IPublicTypePanelDockPanelProps { + title?: IPublicTypeTitleContent; + icon?: any; // 冗余字段 + description?: string | IPublicTypeI18nData; + help?: IPublicTypeHelpTipConfig; // 显示问号帮助 + hiddenWhenInit?: boolean; // when this is true, by default will be hidden + condition?: (widget: any) => any; + onInit?: (widget: any) => any; + onDestroy?: () => any; + shortcut?: string; // 只有在特定位置,可触发 toggle show + enableDrag?: boolean; // 是否开启通过 drag 调整 宽度 + keepVisibleWhileDragging?: boolean; // 是否在该 panel 范围内拖拽时保持 visible 状态 +} + +export interface IPublicTypePanelConfig extends IPublicTypeWidgetBaseConfig { + type: 'Panel'; + content?: string | ReactElement | ComponentType<any> | IPublicTypePanelConfig[]; // as children + props?: IPublicTypePanelConfigProps; +} + +export interface IPublicTypeWidgetBaseConfig { + [extra: string]: any; + type: string; + name: string; + + /** + * 停靠位置: + * - 当 type 为 'Panel' 时自动为 'leftFloatArea'; + * - 当 type 为 'Widget' 时自动为 'mainArea'; + * - 其他时候自动为 'leftArea'; + */ + area?: IPublicTypeWidgetConfigArea; + props?: Record<string, any>; + content?: string | ReactElement | ComponentType<any> | IPublicTypePanelConfig[]; + contentProps?: Record<string, any>; + + /** + * 优先级,值越小,优先级越高,优先级高的会排在前面 + */ + index?: number; +} + +export interface IPublicTypePanelDockConfig extends IPublicTypeWidgetBaseConfig { + type: 'PanelDock'; + + panelProps?: IPublicTypePanelDockPanelProps; + + props?: IPublicTypePanelDockProps; + + /** 面板 name, 当没有 props.title 时, 会使用 name 作为标题 */ + name: string; +} + +export interface IPublicTypePanelDockProps { + [key: string]: any; + + size?: 'small' | 'medium' | 'large'; + + className?: string; + + /** 详细描述,hover 时在标题上方显示的 tips 内容 */ + description?: TipContent; + + onClick?: () => void; + + /** + * 面板标题前的 icon + */ + icon?: IPublicTypeIconType; + + /** + * 面板标题 + */ + title?: IPublicTypeTitleContent; +} + +export interface IPublicTypePanelDockPanelProps { + [key: string]: any; + + /** 是否隐藏面板顶部条 */ + hideTitleBar?: boolean; + + width?: number; + + height?: number; + + maxWidth?: number; + + maxHeight?: number; + + area?: IPublicTypeWidgetConfigArea; +} + +export type IPublicTypeSkeletonConfig = IPublicTypePanelDockConfig | IPublicTypeWidgetBaseConfig; \ No newline at end of file diff --git a/packages/types/src/shell/type/widget-config-area.ts b/packages/types/src/shell/type/widget-config-area.ts new file mode 100644 index 0000000000..41e71baa26 --- /dev/null +++ b/packages/types/src/shell/type/widget-config-area.ts @@ -0,0 +1,9 @@ +/** + * 所有可能的停靠位置 + */ +export type IPublicTypeWidgetConfigArea = 'leftArea' | 'left' | 'rightArea' | + 'right' | 'topArea' | 'subTopArea' | 'top' | + 'toolbar' | 'mainArea' | 'main' | + 'center' | 'centerArea' | 'bottomArea' | + 'bottom' | 'leftFixedArea' | + 'leftFloatArea' | 'stages'; diff --git a/packages/types/src/tip.ts b/packages/types/src/tip.ts deleted file mode 100644 index 6206aef6be..0000000000 --- a/packages/types/src/tip.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { I18nData } from './i18n'; -import { ReactNode } from 'react'; - -export interface TipConfig { - className?: string; - children?: I18nData | ReactNode; - theme?: string; - direction?: 'top' | 'bottom' | 'left' | 'right'; -} - -export type TipContent = string | I18nData | ReactNode | TipConfig; diff --git a/packages/types/src/title.ts b/packages/types/src/title.ts deleted file mode 100644 index a4ac366144..0000000000 --- a/packages/types/src/title.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { ReactElement, ReactNode } from 'react'; -import { I18nData, isI18nData } from './i18n'; -import { TipContent } from './tip'; -import { IconType } from './icon'; - -/** - * 描述 props 的 setter title - */ -export interface TitleConfig { - /** - * 文字描述 - */ - label?: I18nData | ReactNode; - /** - * hover 后的展现内容 - */ - tip?: TipContent; - /** - * 文档链接,暂未实现 - */ - docUrl?: string; - /** - * 图标 - */ - icon?: IconType; - /** - * CSS 类 - */ - className?: string; -} - -export type TitleContent = string | I18nData | ReactElement | TitleConfig; - -function isPlainObject(value: any): value is Record<string, unknown> { - if (typeof value !== 'object') { - return false; - } - const proto = Object.getPrototypeOf(value); - return proto === Object.prototype || proto === null || Object.getPrototypeOf(proto) === null; -} - -export function isTitleConfig(obj: any): obj is TitleConfig { - return isPlainObject(obj) && !isI18nData(obj); -} diff --git a/packages/types/src/transform-stage.ts b/packages/types/src/transform-stage.ts deleted file mode 100644 index b16cb0ff4d..0000000000 --- a/packages/types/src/transform-stage.ts +++ /dev/null @@ -1,8 +0,0 @@ -export enum TransformStage { - Render = 'render', - Serilize = 'serilize', - Save = 'save', - Clone = 'clone', - Init = 'init', - Upgrade = 'upgrade', -} diff --git a/packages/types/src/utils.ts b/packages/types/src/utils.ts index b8ac406efd..2914597a71 100644 --- a/packages/types/src/utils.ts +++ b/packages/types/src/utils.ts @@ -1,17 +1,34 @@ -import { NpmInfo } from './npm'; -import { JSExpression, JSFunction } from './value-type'; -export type InternalUtils = { - name: string; - type: 'function'; - content: JSFunction | JSExpression; -}; +type FilterOptional<T> = Pick< + T, + Exclude< + { + [K in keyof T]: T extends Record<K, T[K]> ? K : never; + }[keyof T], + undefined + > +>; + +type FilterNotOptional<T> = Pick< + T, + Exclude< + { + [K in keyof T]: T extends Record<K, T[K]> ? never : K; + }[keyof T], + undefined + > +>; + +type PartialEither<T, K extends keyof any> = { [P in Exclude<keyof FilterOptional<T>, K>]-?: T[P] } & + { [P in Exclude<keyof FilterNotOptional<T>, K>]?: T[P] } & + { [P in Extract<keyof T, K>]?: undefined }; -export type ExternalUtils = { - name: string; - type: 'npm' | 'tnpm'; - content: NpmInfo; +type Object = { + [name: string]: any; }; -export type UtilItem = InternalUtils | ExternalUtils; -export type UtilsMap = UtilItem[]; +export type EitherOr<O extends Object, L extends string, R extends string> = + ( + PartialEither<Pick<O, L | R>, L> | + PartialEither<Pick<O, L | R>, R> + ) & Omit<O, L | R>; diff --git a/packages/types/src/value-type.ts b/packages/types/src/value-type.ts deleted file mode 100644 index 390133f44a..0000000000 --- a/packages/types/src/value-type.ts +++ /dev/null @@ -1,145 +0,0 @@ -import { NodeSchema, NodeData } from './schema'; - -/** - * 变量表达式 - * - * 表达式内通过 this 对象获取上下文 - */ -export interface JSExpression { - type: 'JSExpression'; - /** - * 表达式字符串 - */ - value: string; - /** - * 模拟值 - * - * @todo 待标准描述 - */ - mock?: any; - /** - * 源码 - * - * @todo 待标准描述 - */ - compiled?: string; -} - -/** - * 事件函数类型 - * @see https://yuque.antfin-inc.com/mo/spec/spec-low-code-building-schema#feHTW - * - * 保留与原组件属性、生命周期( React / 小程序)一致的输入参数,并给所有事件函数 binding 统一一致的上下文(当前组件所在容器结构的 this 对象) - */ -export interface JSFunction { - type: 'JSFunction'; - /** - * 函数定义,或直接函数表达式 - */ - value: string; - - /** - * 源码 - * - * @todo 待标准描述 - */ - compiled?: string; - - /** - * 模拟值 - * - * @todo 待标准描述 - */ - mock?: any; - - /** - * 额外扩展属性,如 extType、events - * - * @todo 待标准描述 - */ - [key: string]: any; -} - -/** - * Slot 函数类型 - * - * 通常用于描述组件的某一个属性为 ReactNode 或 Function return ReactNode 的场景。 - */ -export interface JSSlot { - type: 'JSSlot'; - /** - * @todo 待标准描述 - */ - title?: string; - /** - * 组件的某一个属性为 Function return ReactNode 时,函数的入参 - * - * 其子节点可以通过this[参数名] 来获取对应的参数。 - */ - params?: string[]; - /** - * 具体的值。 - */ - value?: NodeData[] | NodeData; - /** - * @todo 待标准描述 - */ - name?: string; -} - -/** - * @deprecated - * - * @todo 待文档描述 - */ -export interface JSBlock { - type: 'JSBlock'; - value: NodeSchema; -} - -/** - * JSON 基本类型 - */ -export type JSONValue = - | boolean - | string - | number - | null - | undefined - | JSONArray - | JSONObject; -export type JSONArray = JSONValue[]; -export interface JSONObject { - [key: string]: JSONValue; -} - -/** - * 复合类型 - */ -export type CompositeValue = - | JSONValue - | JSExpression - | JSFunction - | JSSlot - | CompositeArray - | CompositeObject; -export type CompositeArray = CompositeValue[]; -export interface CompositeObject { - [key: string]: CompositeValue; -} - -export function isJSExpression(data: any): data is JSExpression { - return data && data.type === 'JSExpression'; -} - -export function isJSFunction(x: any): x is JSFunction { - return typeof x === 'object' && x && x.type === 'JSFunction'; -} - -export function isJSSlot(data: any): data is JSSlot { - return data && data.type === 'JSSlot'; -} - -export function isJSBlock(data: any): data is JSBlock { - return data && data.type === 'JSBlock'; -} diff --git a/packages/utils/build.json b/packages/utils/build.json index bd5cf18dde..3e92600554 100644 --- a/packages/utils/build.json +++ b/packages/utils/build.json @@ -1,5 +1,5 @@ { "plugins": [ - "build-plugin-component" + "@alilc/build-plugin-lce" ] } diff --git a/packages/utils/build.test.json b/packages/utils/build.test.json new file mode 100644 index 0000000000..9cc30d7463 --- /dev/null +++ b/packages/utils/build.test.json @@ -0,0 +1,6 @@ +{ + "plugins": [ + "@alilc/build-plugin-lce", + "@alilc/lowcode-test-mate/plugin/index.ts" + ] +} diff --git a/packages/utils/jest.config.js b/packages/utils/jest.config.js new file mode 100644 index 0000000000..0631fa00c9 --- /dev/null +++ b/packages/utils/jest.config.js @@ -0,0 +1,21 @@ +const fs = require('fs'); +const { join } = require('path'); +const pkgNames = fs.readdirSync(join('..')).filter(pkgName => !pkgName.startsWith('.')); + +const jestConfig = { + moduleFileExtensions: ['ts', 'tsx', 'js', 'json'], + collectCoverage: false, + collectCoverageFrom: [ + 'src/**/*.ts', + '!src/**/*.d.ts', + '!**/node_modules/**', + '!**/vendor/**', + ], + setupFilesAfterEnv: ['./jest.setup.js'], +}; + +// 只对本仓库内的 pkg 做 mapping +jestConfig.moduleNameMapper = {}; +jestConfig.moduleNameMapper[`^@alilc/lowcode\\-(${pkgNames.join('|')})$`] = '<rootDir>/../$1/src'; + +module.exports = jestConfig; \ No newline at end of file diff --git a/packages/utils/jest.setup.js b/packages/utils/jest.setup.js new file mode 100644 index 0000000000..7b0828bfa8 --- /dev/null +++ b/packages/utils/jest.setup.js @@ -0,0 +1 @@ +import '@testing-library/jest-dom'; diff --git a/packages/utils/package.json b/packages/utils/package.json index bb913041c3..60605d81e7 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -1,6 +1,6 @@ { "name": "@alilc/lowcode-utils", - "version": "1.0.3", + "version": "1.3.2", "description": "Utils for Ali lowCode engine", "files": [ "lib", @@ -9,20 +9,24 @@ "main": "lib/index.js", "module": "es/index.js", "scripts": { - "build": "build-scripts build --skip-demo" + "test": "build-scripts test --config build.test.json --jest-coverage", + "build": "build-scripts build" }, "dependencies": { "@alifd/next": "^1.19.16", - "@alilc/lowcode-types": "1.0.3", + "@alilc/lowcode-types": "1.3.2", "lodash": "^4.17.21", - "react": "^16", - "zen-logger": "^1.1.0" + "mobx": "^6.3.0", + "prop-types": "^15.8.1", + "react": "^16" }, "devDependencies": { "@alib/build-scripts": "^0.1.18", + "@testing-library/jest-dom": "^6.1.4", + "@testing-library/react": "^11.2.7", "@types/node": "^13.7.1", "@types/react": "^16", - "build-plugin-component": "^0.2.10" + "react-dom": "^16.14.0" }, "publishConfig": { "access": "public", @@ -32,5 +36,7 @@ "type": "http", "url": "https://github.com/alibaba/lowcode-engine/tree/main/packages/utils" }, - "gitHead": "2669f179e6f899d395ce1942d0fe04f9c5ed48a6" + "gitHead": "2669f179e6f899d395ce1942d0fe04f9c5ed48a6", + "bugs": "https://github.com/alibaba/lowcode-engine/issues", + "homepage": "https://github.com/alibaba/lowcode-engine/#readme" } diff --git a/packages/utils/src/app-helper.ts b/packages/utils/src/app-helper.ts index d5eb2072b3..86b535c592 100644 --- a/packages/utils/src/app-helper.ts +++ b/packages/utils/src/app-helper.ts @@ -34,18 +34,18 @@ export class AppHelper extends EventEmitter { } } - batchOn(events: Array<string | symbol>, lisenter: (...args: any[]) => void) { + batchOn(events: Array<string | symbol>, listener: (...args: any[]) => void) { if (!Array.isArray(events)) return; - events.forEach((event) => this.on(event, lisenter)); + events.forEach((event) => this.on(event, listener)); } - batchOnce(events: Array<string | symbol>, lisenter: (...args: any[]) => void) { + batchOnce(events: Array<string | symbol>, listener: (...args: any[]) => void) { if (!Array.isArray(events)) return; - events.forEach((event) => this.once(event, lisenter)); + events.forEach((event) => this.once(event, listener)); } - batchOff(events: Array<string | symbol>, lisenter: (...args: any[]) => void) { + batchOff(events: Array<string | symbol>, listener: (...args: any[]) => void) { if (!Array.isArray(events)) return; - events.forEach((event) => this.off(event, lisenter)); + events.forEach((event) => this.off(event, listener)); } } diff --git a/packages/utils/src/asset.ts b/packages/utils/src/asset.ts index bc06f29df1..3400f965b4 100644 --- a/packages/utils/src/asset.ts +++ b/packages/utils/src/asset.ts @@ -1,10 +1,12 @@ -import { AssetItem, AssetType, AssetLevels, Asset, AssetList, AssetBundle, AssetLevel, AssetsJson } from '@alilc/lowcode-types'; +import { AssetType, AssetLevels, AssetLevel } from '@alilc/lowcode-types'; +import type { AssetItem, Asset, AssetList, AssetBundle, IPublicTypeAssetsJson } from '@alilc/lowcode-types'; import { isCSSUrl } from './is-css-url'; import { createDefer } from './create-defer'; import { load, evaluate } from './script'; // API 向下兼容 -export { AssetItem, AssetType, AssetLevels, Asset, AssetList, AssetBundle, AssetLevel, AssetsJson } from '@alilc/lowcode-types'; +export { AssetType, AssetLevels, AssetLevel } from '@alilc/lowcode-types'; +export type { AssetItem, Asset, AssetList, AssetBundle, IPublicTypeAssetsJson } from '@alilc/lowcode-types'; export function isAssetItem(obj: any): obj is AssetItem { return obj && obj.type; @@ -14,7 +16,10 @@ export function isAssetBundle(obj: any): obj is AssetBundle { return obj && obj.type === AssetType.Bundle; } -export function assetBundle(assets?: Asset | AssetList | null, level?: AssetLevel): AssetBundle | null { +export function assetBundle( + assets?: Asset | AssetList | null, + level?: AssetLevel, + ): AssetBundle | null { if (!assets) { return null; } @@ -45,23 +50,22 @@ export function assetItem(type: AssetType, content?: string | null, level?: Asse }; } -export function megreAssets(assets: AssetsJson, incrementalAssets: AssetsJson): AssetsJson { +export function mergeAssets(assets: IPublicTypeAssetsJson, incrementalAssets: IPublicTypeAssetsJson): IPublicTypeAssetsJson { if (incrementalAssets.packages) { assets.packages = [...(assets.packages || []), ...incrementalAssets.packages]; } if (incrementalAssets.components) { - assets.components = [...assets.components, ...incrementalAssets.components]; + assets.components = [...(assets.components || []), ...incrementalAssets.components]; } - - megreAssetsComponentList(assets, incrementalAssets, 'componentList'); - megreAssetsComponentList(assets, incrementalAssets, 'bizComponentList'); + mergeAssetsComponentList(assets, incrementalAssets, 'componentList'); + mergeAssetsComponentList(assets, incrementalAssets, 'bizComponentList'); return assets; } -function megreAssetsComponentList(assets: AssetsJson, incrementalAssets: AssetsJson, listName: keyof AssetsJson): void { +function mergeAssetsComponentList(assets: IPublicTypeAssetsJson, incrementalAssets: IPublicTypeAssetsJson, listName: keyof IPublicTypeAssetsJson): void { if (incrementalAssets[listName]) { if (assets[listName]) { // 根据title进行合并 @@ -210,6 +214,8 @@ function parseAsset(scripts: any, styles: any, asset: Asset | undefined | null, } export class AssetLoader { + private stylePoints = new Map<string, StylePoint>(); + async load(asset: Asset) { const styles: any = {}; const scripts: any = {}; @@ -233,11 +239,9 @@ export class AssetLoader { await Promise.all( styleQueue.map(({ content, level, type, id }) => this.loadStyle(content, level!, type === AssetType.CSSUrl, id)), ); - await Promise.all(scriptQueue.map(({ content, type }) => this.loadScript(content, type === AssetType.JSUrl))); + await Promise.all(scriptQueue.map(({ content, type, scriptType }) => this.loadScript(content, type === AssetType.JSUrl, scriptType))); } - private stylePoints = new Map<string, StylePoint>(); - private loadStyle(content: string | undefined | null, level: AssetLevel, isUrl?: boolean, id?: string) { if (!content) { return; @@ -255,28 +259,35 @@ export class AssetLoader { return isUrl ? point.applyUrl(content) : point.applyText(content); } - private loadScript(content: string | undefined | null, isUrl?: boolean) { + private loadScript(content: string | undefined | null, isUrl?: boolean, scriptType?: string) { if (!content) { return; } - return isUrl ? load(content) : evaluate(content); + return isUrl ? load(content, scriptType) : evaluate(content, scriptType); } // todo 补充类型 async loadAsyncLibrary(asyncLibraryMap: Record<string, any>) { const promiseList: any[] = []; const libraryKeyList: any[] = []; + const pkgs: any[] = []; for (const key in asyncLibraryMap) { // 需要异步加载 if (asyncLibraryMap[key].async) { promiseList.push(window[asyncLibraryMap[key].library]); libraryKeyList.push(asyncLibraryMap[key].library); + pkgs.push(asyncLibraryMap[key]); } } await Promise.all(promiseList).then((mods) => { if (mods.length > 0) { mods.map((item, index) => { - window[libraryKeyList[index]] = item; + const { exportMode, exportSourceLibrary, library } = pkgs[index]; + window[libraryKeyList[index]] = + exportMode === 'functionCall' && + (exportSourceLibrary == null || exportSourceLibrary === library) + ? item() + : item; return item; }); } diff --git a/packages/utils/src/build-components.ts b/packages/utils/src/build-components.ts index 78c5b6c2f7..909248524f 100644 --- a/packages/utils/src/build-components.ts +++ b/packages/utils/src/build-components.ts @@ -1,9 +1,12 @@ import { ComponentType, forwardRef, createElement, FunctionComponent } from 'react'; -import { NpmInfo, ComponentSchema } from '@alilc/lowcode-types'; -import { Component } from '@alilc/lowcode-designer'; +import { IPublicTypeNpmInfo, IPublicTypeComponentSchema, IPublicTypeProjectSchema } from '@alilc/lowcode-types'; import { isESModule } from './is-es-module'; import { isReactComponent, acceptsRef, wrapReactClass } from './is-react'; +import { isObject } from './is-object'; +import { isLowcodeProjectSchema } from './check-types'; +import { isComponentSchema } from './check-types/is-component-schema'; +type Component = ComponentType<any> | object; interface LibraryMap { [key: string]: string; } @@ -35,7 +38,7 @@ export function getSubComponent(library: any, paths: string[]) { const key = paths[i]!; let ex: any; try { - component = library[key]; + component = library[key] || component; } catch (e) { ex = e; component = null; @@ -54,7 +57,7 @@ export function getSubComponent(library: any, paths: string[]) { return component; } -function findComponent(libraryMap: LibraryMap, componentName: string, npm?: NpmInfo) { +function findComponent(libraryMap: LibraryMap, componentName: string, npm?: IPublicTypeNpmInfo) { if (!npm) { return accessLibrary(componentName); } @@ -76,23 +79,49 @@ function findComponent(libraryMap: LibraryMap, componentName: string, npm?: NpmI return getSubComponent(library, paths); } +/** + * 判断是否是一个混合组件,即 components 是一个对象,对象值是 React 组件 + * 示例: + * { + * Button: ReactNode, + * Text: ReactNode, + * } + */ +function isMixinComponent(components: any) { + if (!isObject(components)) { + return false; + } + + return Object.keys(components).some(componentName => isReactComponent(components[componentName])); +} + export function buildComponents(libraryMap: LibraryMap, - componentsMap: { [componentName: string]: NpmInfo | ComponentType<any> | ComponentSchema }, - createComponent: (schema: ComponentSchema) => Component | null) { + componentsMap: { [componentName: string]: IPublicTypeNpmInfo | ComponentType<any> | IPublicTypeComponentSchema }, + createComponent: (schema: IPublicTypeProjectSchema<IPublicTypeComponentSchema>) => Component | null) { const components: any = {}; Object.keys(componentsMap).forEach((componentName) => { let component = componentsMap[componentName]; - if (component && (component as ComponentSchema).componentName === 'Component') { - components[componentName] = createComponent(component as ComponentSchema); + if (component && (isLowcodeProjectSchema(component) || isComponentSchema(component))) { + if (isComponentSchema(component)) { + components[componentName] = createComponent({ + version: '', + componentsMap: [], + componentsTree: [component], + }); + } else { + components[componentName] = createComponent(component); + } } else if (isReactComponent(component)) { if (!acceptsRef(component)) { component = wrapReactClass(component as FunctionComponent); } components[componentName] = component; + } else if (isMixinComponent(component)) { + components[componentName] = component; } else { component = findComponent(libraryMap, componentName, component); if (component) { - if (!acceptsRef(component)) { + if (!acceptsRef(component) && isReactComponent(component)) { component = wrapReactClass(component as FunctionComponent); } components[componentName] = component; diff --git a/packages/utils/src/check-prop-types.ts b/packages/utils/src/check-prop-types.ts new file mode 100644 index 0000000000..dc9ce31ed5 --- /dev/null +++ b/packages/utils/src/check-prop-types.ts @@ -0,0 +1,72 @@ +import * as ReactIs from 'react-is'; +import { default as ReactPropTypesSecret } from 'prop-types/lib/ReactPropTypesSecret'; +import { default as factoryWithTypeCheckers } from 'prop-types/factoryWithTypeCheckers'; +import { IPublicTypePropType } from '@alilc/lowcode-types'; +import { isRequiredPropType } from './check-types/is-required-prop-type'; +import { Logger } from './logger'; + +const PropTypes2 = factoryWithTypeCheckers(ReactIs.isElement, true); +const logger = new Logger({ level: 'warn', bizName: 'utils' }); + +export function transformPropTypesRuleToString(rule: IPublicTypePropType | string): string { + if (!rule) { + return 'PropTypes.any'; + } + + if (typeof rule === 'string') { + return rule.startsWith('PropTypes.') ? rule : `PropTypes.${rule}`; + } + + if (isRequiredPropType(rule)) { + const { type, isRequired } = rule; + return `PropTypes.${type}${isRequired ? '.isRequired' : ''}`; + } + + const { type, value } = rule; + switch (type) { + case 'oneOf': + return `PropTypes.oneOf([${value.map((item: any) => `"${item}"`).join(',')}])`; + case 'oneOfType': + return `PropTypes.oneOfType([${value.map((item: any) => transformPropTypesRuleToString(item)).join(', ')}])`; + case 'arrayOf': + case 'objectOf': + return `PropTypes.${type}(${transformPropTypesRuleToString(value)})`; + case 'shape': + case 'exact': + return `PropTypes.${type}({${value.map((item: any) => `${item.name}: ${transformPropTypesRuleToString(item.propType)}`).join(',')}})`; + default: + logger.error(`Unknown prop type: ${type}`); + } + + return 'PropTypes.any'; +} + +export function checkPropTypes(value: any, name: string, rule: any, componentName: string): boolean { + let ruleFunction = rule; + if (typeof rule === 'object') { + // eslint-disable-next-line no-new-func + ruleFunction = new Function(`"use strict"; const PropTypes = arguments[0]; return ${transformPropTypesRuleToString(rule)}`)(PropTypes2); + } + if (typeof rule === 'string') { + // eslint-disable-next-line no-new-func + ruleFunction = new Function(`"use strict"; const PropTypes = arguments[0]; return ${transformPropTypesRuleToString(rule)}`)(PropTypes2); + } + if (!ruleFunction || typeof ruleFunction !== 'function') { + logger.warn('checkPropTypes should have a function type rule argument'); + return true; + } + const err = ruleFunction( + { + [name]: value, + }, + name, + componentName, + 'prop', + null, + ReactPropTypesSecret, + ); + if (err) { + logger.warn(err); + } + return !err; +} diff --git a/packages/utils/src/check-types/index.ts b/packages/utils/src/check-types/index.ts new file mode 100644 index 0000000000..507259b2c5 --- /dev/null +++ b/packages/utils/src/check-types/index.ts @@ -0,0 +1,28 @@ +// 此模块存放 @alilc/lowcode-types 中类型相关判断工具 +export * from './is-action-content-object'; +export * from './is-custom-view'; +export * from './is-dom-text'; +export * from './is-dynamic-setter'; +export * from './is-i18n-data'; +export * from './is-jsblock'; +export * from './is-jsexpression'; +export * from './is-isfunction'; +export * from './is-jsslot'; +export * from './is-lowcode-component-type'; +export * from './is-node-schema'; +export * from './is-procode-component-type'; +export * from './is-project-schema'; +export * from './is-setter-config'; +export * from './is-title-config'; +export * from './is-drag-node-data-object'; +export * from './is-drag-node-object'; +export * from './is-drag-any-object'; +export * from './is-location-children-detail'; +export * from './is-node'; +export * from './is-location-data'; +export * from './is-setting-field'; +export * from './is-lowcode-component-type'; +export * from './is-lowcode-project-schema'; +export * from './is-component-schema'; +export * from './is-basic-prop-type'; +export * from './is-required-prop-type'; \ No newline at end of file diff --git a/packages/utils/src/check-types/is-action-content-object.ts b/packages/utils/src/check-types/is-action-content-object.ts new file mode 100644 index 0000000000..8fe31b5bd7 --- /dev/null +++ b/packages/utils/src/check-types/is-action-content-object.ts @@ -0,0 +1,6 @@ +import { IPublicTypeActionContentObject } from '@alilc/lowcode-types'; +import { isObject } from '../is-object'; + +export function isActionContentObject(obj: any): obj is IPublicTypeActionContentObject { + return isObject(obj); +} diff --git a/packages/utils/src/check-types/is-basic-prop-type.ts b/packages/utils/src/check-types/is-basic-prop-type.ts new file mode 100644 index 0000000000..fd3b1b1dcb --- /dev/null +++ b/packages/utils/src/check-types/is-basic-prop-type.ts @@ -0,0 +1,8 @@ +import { IPublicTypeBasicType, IPublicTypePropType } from '@alilc/lowcode-types'; + +export function isBasicPropType(propType: IPublicTypePropType): propType is IPublicTypeBasicType { + if (!propType) { + return false; + } + return typeof propType === 'string'; +} \ No newline at end of file diff --git a/packages/utils/src/check-types/is-component-schema.ts b/packages/utils/src/check-types/is-component-schema.ts new file mode 100644 index 0000000000..508d153b93 --- /dev/null +++ b/packages/utils/src/check-types/is-component-schema.ts @@ -0,0 +1,8 @@ +import { IPublicTypeComponentSchema } from "@alilc/lowcode-types"; + +export function isComponentSchema(schema: any): schema is IPublicTypeComponentSchema { + if (typeof schema === 'object') { + return schema.componentName === 'Component'; + } + return false +} diff --git a/packages/utils/src/check-types/is-custom-view.ts b/packages/utils/src/check-types/is-custom-view.ts new file mode 100644 index 0000000000..4cf921d9c5 --- /dev/null +++ b/packages/utils/src/check-types/is-custom-view.ts @@ -0,0 +1,10 @@ +import { isValidElement } from 'react'; +import { isReactComponent } from '../is-react'; +import { IPublicTypeCustomView } from '@alilc/lowcode-types'; + +export function isCustomView(obj: any): obj is IPublicTypeCustomView { + if (!obj) { + return false; + } + return isValidElement(obj) || isReactComponent(obj); +} diff --git a/packages/utils/src/check-types/is-dom-text.ts b/packages/utils/src/check-types/is-dom-text.ts new file mode 100644 index 0000000000..9509544409 --- /dev/null +++ b/packages/utils/src/check-types/is-dom-text.ts @@ -0,0 +1,3 @@ +export function isDOMText(data: any): data is string { + return typeof data === 'string'; +} diff --git a/packages/utils/src/check-types/is-drag-any-object.ts b/packages/utils/src/check-types/is-drag-any-object.ts new file mode 100644 index 0000000000..8711b4e333 --- /dev/null +++ b/packages/utils/src/check-types/is-drag-any-object.ts @@ -0,0 +1,9 @@ +import { IPublicEnumDragObjectType } from '@alilc/lowcode-types'; +import { isObject } from '../is-object'; + +export function isDragAnyObject(obj: any): boolean { + if (!isObject(obj)) { + return false; + } + return obj.type !== IPublicEnumDragObjectType.NodeData && obj.type !== IPublicEnumDragObjectType.Node; +} \ No newline at end of file diff --git a/packages/utils/src/check-types/is-drag-node-data-object.ts b/packages/utils/src/check-types/is-drag-node-data-object.ts new file mode 100644 index 0000000000..aa62f5b1c9 --- /dev/null +++ b/packages/utils/src/check-types/is-drag-node-data-object.ts @@ -0,0 +1,9 @@ +import { IPublicEnumDragObjectType, IPublicTypeDragNodeDataObject } from '@alilc/lowcode-types'; +import { isObject } from '../is-object'; + +export function isDragNodeDataObject(obj: any): obj is IPublicTypeDragNodeDataObject { + if (!isObject(obj)) { + return false; + } + return obj.type === IPublicEnumDragObjectType.NodeData; +} \ No newline at end of file diff --git a/packages/utils/src/check-types/is-drag-node-object.ts b/packages/utils/src/check-types/is-drag-node-object.ts new file mode 100644 index 0000000000..3a29ec967f --- /dev/null +++ b/packages/utils/src/check-types/is-drag-node-object.ts @@ -0,0 +1,9 @@ +import { IPublicEnumDragObjectType, IPublicModelNode, IPublicTypeDragNodeObject } from '@alilc/lowcode-types'; +import { isObject } from '../is-object'; + +export function isDragNodeObject<Node = IPublicModelNode>(obj: any): obj is IPublicTypeDragNodeObject<Node> { + if (!isObject(obj)) { + return false; + } + return obj.type === IPublicEnumDragObjectType.Node; +} \ No newline at end of file diff --git a/packages/utils/src/check-types/is-dynamic-setter.ts b/packages/utils/src/check-types/is-dynamic-setter.ts new file mode 100644 index 0000000000..35f8ff3892 --- /dev/null +++ b/packages/utils/src/check-types/is-dynamic-setter.ts @@ -0,0 +1,10 @@ +import { isFunction } from '../is-function'; +import { isReactClass } from '../is-react'; +import { IPublicTypeDynamicSetter } from '@alilc/lowcode-types'; + +export function isDynamicSetter(obj: any): obj is IPublicTypeDynamicSetter { + if (!isFunction(obj)) { + return false; + } + return !isReactClass(obj); +} diff --git a/packages/utils/src/check-types/is-function.ts b/packages/utils/src/check-types/is-function.ts new file mode 100644 index 0000000000..d7d3b4c27d --- /dev/null +++ b/packages/utils/src/check-types/is-function.ts @@ -0,0 +1,3 @@ +export function isFunction(obj: any): obj is Function { + return obj && typeof obj === 'function'; +} \ No newline at end of file diff --git a/packages/utils/src/check-types/is-i18n-data.ts b/packages/utils/src/check-types/is-i18n-data.ts new file mode 100644 index 0000000000..793295d240 --- /dev/null +++ b/packages/utils/src/check-types/is-i18n-data.ts @@ -0,0 +1,9 @@ +import { IPublicTypeI18nData } from '@alilc/lowcode-types'; +import { isObject } from '../is-object'; + +export function isI18nData(obj: any): obj is IPublicTypeI18nData { + if (!isObject(obj)) { + return false; + } + return obj.type === 'i18n'; +} diff --git a/packages/utils/src/check-types/is-isfunction.ts b/packages/utils/src/check-types/is-isfunction.ts new file mode 100644 index 0000000000..64b8676637 --- /dev/null +++ b/packages/utils/src/check-types/is-isfunction.ts @@ -0,0 +1,26 @@ +import { IPublicTypeJSFunction } from '@alilc/lowcode-types'; +import { isObject } from '../is-object'; + +interface InnerJsFunction { + type: 'JSExpression'; + source: string; + value: string; + extType: 'function'; +} + +/** + * 内部版本 的 { type: 'JSExpression', source: '', value: '', extType: 'function' } 能力上等同于 JSFunction + */ +export function isInnerJsFunction(data: any): data is InnerJsFunction { + if (!isObject(data)) { + return false; + } + return data.type === 'JSExpression' && data.extType === 'function'; +} + +export function isJSFunction(data: any): data is IPublicTypeJSFunction { + if (!isObject(data)) { + return false; + } + return data.type === 'JSFunction' || isInnerJsFunction(data); +} diff --git a/packages/utils/src/check-types/is-jsblock.ts b/packages/utils/src/check-types/is-jsblock.ts new file mode 100644 index 0000000000..858f5c09cd --- /dev/null +++ b/packages/utils/src/check-types/is-jsblock.ts @@ -0,0 +1,9 @@ +import { IPublicTypeJSBlock } from '@alilc/lowcode-types'; +import { isObject } from '../is-object'; + +export function isJSBlock(data: any): data is IPublicTypeJSBlock { + if (!isObject(data)) { + return false; + } + return data.type === 'JSBlock'; +} diff --git a/packages/utils/src/check-types/is-jsexpression.ts b/packages/utils/src/check-types/is-jsexpression.ts new file mode 100644 index 0000000000..16b8f4ac2a --- /dev/null +++ b/packages/utils/src/check-types/is-jsexpression.ts @@ -0,0 +1,19 @@ +import { IPublicTypeJSExpression } from '@alilc/lowcode-types'; +import { isObject } from '../is-object'; + +/** + * 为了避免把 { type: 'JSExpression', extType: 'function' } 误判为表达式,故增加如下逻辑。 + * + * 引擎中关于函数的表达: + * 开源版本:{ type: 'JSFunction', source: '', value: '' } + * 内部版本:{ type: 'JSExpression', source: '', value: '', extType: 'function' } + * 能力是对标的,不过开源的 react-renderer 只认识第一种,而内部只识别第二种(包括 Java 代码、RE)。 + * @param data + * @returns + */ +export function isJSExpression(data: any): data is IPublicTypeJSExpression { + if (!isObject(data)) { + return false; + } + return data.type === 'JSExpression' && data.extType !== 'function'; +} diff --git a/packages/utils/src/check-types/is-jsslot.ts b/packages/utils/src/check-types/is-jsslot.ts new file mode 100644 index 0000000000..1fb1d819d7 --- /dev/null +++ b/packages/utils/src/check-types/is-jsslot.ts @@ -0,0 +1,9 @@ +import { IPublicTypeJSSlot } from '@alilc/lowcode-types'; +import { isObject } from '../is-object'; + +export function isJSSlot(data: any): data is IPublicTypeJSSlot { + if (!isObject(data)) { + return false; + } + return data.type === 'JSSlot'; +} diff --git a/packages/utils/src/check-types/is-location-children-detail.ts b/packages/utils/src/check-types/is-location-children-detail.ts new file mode 100644 index 0000000000..cc093c4e4a --- /dev/null +++ b/packages/utils/src/check-types/is-location-children-detail.ts @@ -0,0 +1,9 @@ +import { IPublicTypeLocationChildrenDetail, IPublicTypeLocationDetailType } from '@alilc/lowcode-types'; +import { isObject } from '../is-object'; + +export function isLocationChildrenDetail(obj: any): obj is IPublicTypeLocationChildrenDetail { + if (!isObject(obj)) { + return false; + } + return obj.type === IPublicTypeLocationDetailType.Children; +} \ No newline at end of file diff --git a/packages/utils/src/check-types/is-location-data.ts b/packages/utils/src/check-types/is-location-data.ts new file mode 100644 index 0000000000..dabd493fa8 --- /dev/null +++ b/packages/utils/src/check-types/is-location-data.ts @@ -0,0 +1,9 @@ +import { IPublicTypeLocationData } from '@alilc/lowcode-types'; +import { isObject } from '../is-object'; + +export function isLocationData(obj: any): obj is IPublicTypeLocationData { + if (!isObject(obj)) { + return false; + } + return 'target' in obj && 'detail' in obj; +} \ No newline at end of file diff --git a/packages/utils/src/check-types/is-lowcode-component-type.ts b/packages/utils/src/check-types/is-lowcode-component-type.ts new file mode 100644 index 0000000000..ce19c23e87 --- /dev/null +++ b/packages/utils/src/check-types/is-lowcode-component-type.ts @@ -0,0 +1,7 @@ +import { isProCodeComponentType } from './is-procode-component-type'; +import { IPublicTypeComponentMap, IPublicTypeLowCodeComponent } from '@alilc/lowcode-types'; + + +export function isLowCodeComponentType(desc: IPublicTypeComponentMap): desc is IPublicTypeLowCodeComponent { + return !isProCodeComponentType(desc); +} diff --git a/packages/utils/src/check-types/is-lowcode-project-schema.ts b/packages/utils/src/check-types/is-lowcode-project-schema.ts new file mode 100644 index 0000000000..230911f0f3 --- /dev/null +++ b/packages/utils/src/check-types/is-lowcode-project-schema.ts @@ -0,0 +1,15 @@ +import { IPublicTypeComponentSchema, IPublicTypeProjectSchema } from '@alilc/lowcode-types'; +import { isComponentSchema } from './is-component-schema'; +import { isObject } from '../is-object'; + +export function isLowcodeProjectSchema(data: any): data is IPublicTypeProjectSchema<IPublicTypeComponentSchema> { + if (!isObject(data)) { + return false; + } + + if (!('componentsTree' in data) || data.componentsTree.length === 0) { + return false; + } + + return isComponentSchema(data.componentsTree[0]); +} diff --git a/packages/utils/src/check-types/is-node-schema.ts b/packages/utils/src/check-types/is-node-schema.ts new file mode 100644 index 0000000000..253c05a080 --- /dev/null +++ b/packages/utils/src/check-types/is-node-schema.ts @@ -0,0 +1,9 @@ +import { IPublicTypeNodeSchema } from '@alilc/lowcode-types'; +import { isObject } from '../is-object'; + +export function isNodeSchema(data: any): data is IPublicTypeNodeSchema { + if (!isObject(data)) { + return false; + } + return 'componentName' in data && !data.isNode; +} diff --git a/packages/utils/src/check-types/is-node.ts b/packages/utils/src/check-types/is-node.ts new file mode 100644 index 0000000000..b4690ddff9 --- /dev/null +++ b/packages/utils/src/check-types/is-node.ts @@ -0,0 +1,9 @@ +import { IPublicModelNode } from '@alilc/lowcode-types'; +import { isObject } from '../is-object'; + +export function isNode<Node = IPublicModelNode>(node: any): node is Node { + if (!isObject(node)) { + return false; + } + return node.isNode; +} \ No newline at end of file diff --git a/packages/utils/src/check-types/is-object.ts b/packages/utils/src/check-types/is-object.ts new file mode 100644 index 0000000000..56ceb7d979 --- /dev/null +++ b/packages/utils/src/check-types/is-object.ts @@ -0,0 +1,3 @@ +export function isObject(obj: any): boolean { + return obj && typeof obj === 'object'; +} \ No newline at end of file diff --git a/packages/utils/src/check-types/is-procode-component-type.ts b/packages/utils/src/check-types/is-procode-component-type.ts new file mode 100644 index 0000000000..46618dcd5a --- /dev/null +++ b/packages/utils/src/check-types/is-procode-component-type.ts @@ -0,0 +1,10 @@ +import { IPublicTypeComponentMap, IPublicTypeProCodeComponent } from '@alilc/lowcode-types'; +import { isObject } from '../is-object'; + +export function isProCodeComponentType(desc: IPublicTypeComponentMap): desc is IPublicTypeProCodeComponent { + if (!isObject(desc)) { + return false; + } + + return 'package' in desc; +} diff --git a/packages/utils/src/check-types/is-project-schema.ts b/packages/utils/src/check-types/is-project-schema.ts new file mode 100644 index 0000000000..d217acd9ee --- /dev/null +++ b/packages/utils/src/check-types/is-project-schema.ts @@ -0,0 +1,9 @@ +import { IPublicTypeProjectSchema } from '@alilc/lowcode-types'; +import { isObject } from '../is-object'; + +export function isProjectSchema(data: any): data is IPublicTypeProjectSchema { + if (!isObject(data)) { + return false; + } + return 'componentsTree' in data; +} diff --git a/packages/utils/src/check-types/is-required-prop-type.ts b/packages/utils/src/check-types/is-required-prop-type.ts new file mode 100644 index 0000000000..106da78a00 --- /dev/null +++ b/packages/utils/src/check-types/is-required-prop-type.ts @@ -0,0 +1,8 @@ +import { IPublicTypePropType, IPublicTypeRequiredType } from '@alilc/lowcode-types'; + +export function isRequiredPropType(propType: IPublicTypePropType): propType is IPublicTypeRequiredType { + if (!propType) { + return false; + } + return typeof propType === 'object' && propType.type && ['array', 'bool', 'func', 'number', 'object', 'string', 'node', 'element', 'any'].includes(propType.type); +} \ No newline at end of file diff --git a/packages/utils/src/check-types/is-setter-config.ts b/packages/utils/src/check-types/is-setter-config.ts new file mode 100644 index 0000000000..98d835f32c --- /dev/null +++ b/packages/utils/src/check-types/is-setter-config.ts @@ -0,0 +1,10 @@ +import { IPublicTypeSetterConfig } from '@alilc/lowcode-types'; +import { isCustomView } from './is-custom-view'; +import { isObject } from '../is-object'; + +export function isSetterConfig(obj: any): obj is IPublicTypeSetterConfig { + if (!isObject(obj)) { + return false; + } + return 'componentName' in obj && !isCustomView(obj); +} diff --git a/packages/utils/src/check-types/is-setting-field.ts b/packages/utils/src/check-types/is-setting-field.ts new file mode 100644 index 0000000000..0d6e21d848 --- /dev/null +++ b/packages/utils/src/check-types/is-setting-field.ts @@ -0,0 +1,10 @@ +import { IPublicModelSettingField } from '@alilc/lowcode-types'; +import { isObject } from '../is-object'; + +export function isSettingField(obj: any): obj is IPublicModelSettingField { + if (!isObject(obj)) { + return false; + } + + return 'isSettingField' in obj && obj.isSettingField; +} diff --git a/packages/utils/src/check-types/is-title-config.ts b/packages/utils/src/check-types/is-title-config.ts new file mode 100644 index 0000000000..460da99790 --- /dev/null +++ b/packages/utils/src/check-types/is-title-config.ts @@ -0,0 +1,7 @@ +import { IPublicTypeTitleConfig } from '@alilc/lowcode-types'; +import { isI18nData } from './is-i18n-data'; +import { isPlainObject } from '../is-plain-object'; + +export function isTitleConfig(obj: any): obj is IPublicTypeTitleConfig { + return isPlainObject(obj) && !isI18nData(obj); +} diff --git a/packages/utils/src/clone-enumerable-property.ts b/packages/utils/src/clone-enumerable-property.ts index 414f8dccdd..eb09e177fc 100644 --- a/packages/utils/src/clone-enumerable-property.ts +++ b/packages/utils/src/clone-enumerable-property.ts @@ -11,8 +11,8 @@ const excludePropertyNames = [ 'arguments', ]; -export function cloneEnumerableProperty(target: any, origin: any) { - const compExtraPropertyNames = Object.keys(origin).filter(d => !excludePropertyNames.includes(d)); +export function cloneEnumerableProperty(target: any, origin: any, excludes = excludePropertyNames) { + const compExtraPropertyNames = Object.keys(origin).filter(d => !excludes.includes(d)); compExtraPropertyNames.forEach((d: string) => { (target as any)[d] = origin[d]; diff --git a/packages/utils/src/context-menu.scss b/packages/utils/src/context-menu.scss new file mode 100644 index 0000000000..0b75ca3ec1 --- /dev/null +++ b/packages/utils/src/context-menu.scss @@ -0,0 +1,50 @@ +.engine-context-menu-tree-wrap { + position: relative; + padding: 4px 10px 4px 32px; +} + +.engine-context-menu-tree-children { + margin-left: 8px; + line-height: 24px; +} + +.engine-context-menu-item { + .engine-context-menu-text { + color: var(--color-context-menu-text, var(--color-text)); + display: flex; + align-items: center; + + .lc-help-tip { + margin-left: 4px; + opacity: 0.8; + } + } + + &.disabled { + &:hover .engine-context-menu-text, .engine-context-menu-text { + color: var(--color-context-menu-text-disabled, var(--color-text-disabled)); + } + } + + &:hover { + .engine-context-menu-text { + color: var(--color-context-menu-text-hover, var(--color-title)); + } + } +} + +.engine-context-menu-title { + color: var(--color-context-menu-text, var(--color-text)); + cursor: pointer; + + &:hover { + background-color: var(--color-block-background-light); + color: var(--color-title); + } +} + +.engine-context-menu-tree-selecte-icon { + position: absolute; + left: 10px; + color: var(--color-icon-active); +} \ No newline at end of file diff --git a/packages/utils/src/context-menu.tsx b/packages/utils/src/context-menu.tsx new file mode 100644 index 0000000000..185abbb343 --- /dev/null +++ b/packages/utils/src/context-menu.tsx @@ -0,0 +1,230 @@ +import { Menu, Icon } from '@alifd/next'; +import { IPublicEnumContextMenuType, IPublicModelNode, IPublicModelPluginContext, IPublicTypeContextMenuAction, IPublicTypeContextMenuItem } from '@alilc/lowcode-types'; +import { Logger } from '@alilc/lowcode-utils'; +import classNames from 'classnames'; +import React from 'react'; +import './context-menu.scss'; + +const logger = new Logger({ level: 'warn', bizName: 'utils' }); +const { Item, Divider, PopupItem } = Menu; + +const MAX_LEVEL = 2; + +interface IOptions { + nodes?: IPublicModelNode[] | null; + destroy?: Function; + pluginContext: IPublicModelPluginContext; +} + +const Tree = (props: { + node?: IPublicModelNode | null; + children?: React.ReactNode; + options: IOptions; +}) => { + const { node } = props; + + if (!node) { + return ( + <div className="engine-context-menu-tree-wrap">{ props.children }</div> + ); + } + + const { common } = props.options.pluginContext || {}; + const { intl } = common?.utils || {}; + const indent = node.zLevel * 8 + 32; + const style = { + paddingLeft: indent, + marginLeft: -indent, + marginRight: -10, + paddingRight: 10, + }; + + return ( + <Tree {...props} node={node.parent} > + <div + className="engine-context-menu-title" + onClick={() => { + props.options.destroy?.(); + node.select(); + }} + style={style} + > + {props.options.nodes?.[0].id === node.id ? (<Icon className="engine-context-menu-tree-selecte-icon" size="small" type="success" />) : null} + {intl(node.title)} + </div> + <div + className="engine-context-menu-tree-children" + > + { props.children } + </div> + </Tree> + ); +}; + +let destroyFn: Function | undefined; + +export function parseContextMenuAsReactNode(menus: IPublicTypeContextMenuItem[], options: IOptions): React.ReactNode[] { + const { common, commonUI } = options.pluginContext || {}; + const { intl = (title: any) => title } = common?.utils || {}; + const { HelpTip } = commonUI || {}; + + const children: React.ReactNode[] = []; + menus.forEach((menu, index) => { + if (menu.type === IPublicEnumContextMenuType.SEPARATOR) { + children.push(<Divider key={menu.name || index} />); + return; + } + + if (menu.type === IPublicEnumContextMenuType.MENU_ITEM) { + if (menu.items && menu.items.length) { + children.push(( + <PopupItem + className={classNames('engine-context-menu-item', { + disabled: menu.disabled, + })} + key={menu.name} + label={<div className="engine-context-menu-text">{intl(menu.title)}</div>} + > + <Menu className="next-context engine-context-menu"> + { parseContextMenuAsReactNode(menu.items, options) } + </Menu> + </PopupItem> + )); + } else { + children.push(( + <Item + className={classNames('engine-context-menu-item', { + disabled: menu.disabled, + })} + disabled={menu.disabled} + onClick={() => { + menu.action?.(); + }} + key={menu.name} + > + <div className="engine-context-menu-text"> + { menu.title ? intl(menu.title) : null } + { menu.help ? <HelpTip size="xs" help={menu.help} direction="right" /> : null } + </div> + </Item> + )); + } + } + + if (menu.type === IPublicEnumContextMenuType.NODE_TREE) { + children.push(( + <Tree node={options.nodes?.[0]} options={options} /> + )); + } + }); + + return children; +} + +export function parseContextMenuProperties(menus: (IPublicTypeContextMenuAction | Omit<IPublicTypeContextMenuAction, 'items'>)[], options: IOptions & { + event?: MouseEvent; +}, level = 1): IPublicTypeContextMenuItem[] { + destroyFn?.(); + + const { nodes, destroy } = options; + if (level > MAX_LEVEL) { + logger.warn('context menu level is too deep, please check your context menu config'); + return []; + } + + return menus + .filter(menu => !menu.condition || (menu.condition && menu.condition(nodes || []))) + .map((menu) => { + const { + name, + title, + type = IPublicEnumContextMenuType.MENU_ITEM, + help, + } = menu; + + const result: IPublicTypeContextMenuItem = { + name, + title, + type, + help, + action: () => { + destroy?.(); + menu.action?.(nodes || [], options.event); + }, + disabled: menu.disabled && menu.disabled(nodes || []) || false, + }; + + if ('items' in menu && menu.items) { + result.items = parseContextMenuProperties( + typeof menu.items === 'function' ? menu.items(nodes || []) : menu.items, + options, + level + 1, + ); + } + + return result; + }) + .reduce((menus: IPublicTypeContextMenuItem[], currentMenu: IPublicTypeContextMenuItem) => { + if (!currentMenu.name) { + return menus.concat([currentMenu]); + } + + const index = menus.find(item => item.name === currentMenu.name); + if (!index) { + return menus.concat([currentMenu]); + } else { + return menus; + } + }, []); +} + +let cachedMenuItemHeight: string | undefined; + +function getMenuItemHeight() { + if (cachedMenuItemHeight) { + return cachedMenuItemHeight; + } + const root = document.documentElement; + const styles = getComputedStyle(root); + const menuItemHeight = styles.getPropertyValue('--context-menu-item-height').trim(); + cachedMenuItemHeight = menuItemHeight; + + return menuItemHeight; +} + +export function createContextMenu(children: React.ReactNode[], { + event, + offset = [0, 0], +}: { + event: MouseEvent | React.MouseEvent; + offset?: [number, number]; +}) { + event.preventDefault(); + event.stopPropagation(); + + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + const dividerCount = React.Children.count(children.filter(child => React.isValidElement(child) && child.type === Divider)); + const popupItemCount = React.Children.count(children.filter(child => React.isValidElement(child) && (child.type === PopupItem || child.type === Item))); + const menuHeight = popupItemCount * parseInt(getMenuItemHeight(), 10) + dividerCount * 8 + 16; + const menuWidthLimit = 200; + let x = event.clientX + offset[0]; + let y = event.clientY + offset[1]; + if (x + menuWidthLimit > viewportWidth) { + x = x - menuWidthLimit; + } + if (y + menuHeight > viewportHeight) { + y = y - menuHeight; + } + + const menuInstance = Menu.create({ + target: document.body, + offset: [x, y], + children, + className: 'engine-context-menu', + }); + + destroyFn = (menuInstance as any).destroy; + + return destroyFn; +} \ No newline at end of file diff --git a/packages/utils/src/create-content.ts b/packages/utils/src/create-content.ts index 211c26f165..09a368d2b9 100644 --- a/packages/utils/src/create-content.ts +++ b/packages/utils/src/create-content.ts @@ -1,7 +1,10 @@ import { ReactNode, ComponentType, isValidElement, cloneElement, createElement } from 'react'; import { isReactComponent } from './is-react'; -export function createContent(content: ReactNode | ComponentType<any>, props?: Record<string, unknown>): ReactNode { +export function createContent( + content: ReactNode | ComponentType<any>, + props?: Record<string, unknown>, + ): ReactNode { if (isValidElement(content)) { return props ? cloneElement(content, props) : content; } diff --git a/packages/utils/src/create-icon.tsx b/packages/utils/src/create-icon.tsx index 0f1e72f4f3..621b5c7ab4 100644 --- a/packages/utils/src/create-icon.tsx +++ b/packages/utils/src/create-icon.tsx @@ -1,12 +1,15 @@ import { isValidElement, ReactNode, createElement, cloneElement } from 'react'; import { Icon } from '@alifd/next'; -import { IconType } from '@alilc/lowcode-types'; +import { IPublicTypeIconType } from '@alilc/lowcode-types'; import { isReactComponent } from './is-react'; import { isESModule } from './is-es-module'; const URL_RE = /^(https?:)\/\//i; -export function createIcon(icon?: IconType | null, props?: Record<string, unknown>): ReactNode { +export function createIcon( + icon?: IPublicTypeIconType | null, + props?: Record<string, unknown>, + ): ReactNode { if (!icon) { return null; } @@ -15,7 +18,11 @@ export function createIcon(icon?: IconType | null, props?: Record<string, unknow } if (typeof icon === 'string') { if (URL_RE.test(icon)) { - return <img src={icon} {...props} />; + return createElement('img', { + src: icon, + class: props?.className, + ...props, + }); } return <Icon type={icon} {...props} />; } @@ -23,7 +30,10 @@ export function createIcon(icon?: IconType | null, props?: Record<string, unknow return cloneElement(icon, { ...props }); } if (isReactComponent(icon)) { - return createElement(icon, { ...props }); + return createElement(icon, { + class: props?.className, + ...props, + }); } return <Icon {...icon} {...props} />; diff --git a/packages/utils/src/css-helper.ts b/packages/utils/src/css-helper.ts index 9858d0d54e..98bf1bbf0c 100644 --- a/packages/utils/src/css-helper.ts +++ b/packages/utils/src/css-helper.ts @@ -10,14 +10,13 @@ const pseudoMap = ['hover', 'focus', 'active', 'visited']; const RE_CAMEL = /[A-Z]/g; const RE_HYPHEN = /[-\s]+(.)?/g; -const CSS_REG = /:root(.*)\{.*/i; const PROPS_REG = /([^:]*):\s?(.*)/i; // 给 css 分组 -function groupingCss(css) { +function groupingCss(css: string) { let stackLength = 0; let startIndex = 0; - const group = []; + const group: string[] = []; css.split('').forEach((char, index) => { if (char === '{') { stackLength++; @@ -33,38 +32,38 @@ function groupingCss(css) { return group; } - -function isString(str) { +function isString(str: any): str is string { return {}.toString.call(str) === '[object String]'; } -function hyphenate(str) { +function hyphenate(str: string): string { return str.replace(RE_CAMEL, w => `-${w}`).toLowerCase(); } -function camelize(str) { +function camelize(str: string): string { return str.replace(RE_HYPHEN, (m, w) => (w ? w.toUpperCase() : '')); } + /** * convert * {background-color: "red"} * to * background-color: red; */ -function runtimeToCss(runtime) { - const css = []; +function runtimeToCss(runtime: Record<string, string>) { + const css: string[] = []; Object.keys(runtime).forEach((key) => { css.push(` ${key}: ${runtime[key]};`); }); return css.join('\n'); } -function toNativeStyle(runtime) { +function toNativeStyle(runtime: Record<string, string> | undefined) { if (!runtime) { return {}; } if (runtime.default) { - const normalized = {}; + const normalized: Record<string, string> = {}; Object.keys(runtime).forEach((pseudo) => { if (pseudo === 'extra') { normalized[pseudo] = runtime[pseudo]; @@ -98,14 +97,13 @@ function normalizeStyle(style) { return normalized; } - const normalized = {}; + const normalized: Record<string, string | Record<string, string>> = {}; Object.keys(style).forEach((key) => { normalized[hyphenate(key)] = style[key]; }); return normalized; } - function toCss(runtime) { if (!runtime) { return ( @@ -115,7 +113,7 @@ function toCss(runtime) { } if (runtime.default) { - const css = []; + const css: string[] = []; Object.keys(runtime).forEach((pseudo) => { if (pseudo === 'extra') { Array.isArray(runtime.extra) && css.push(runtime.extra.join('\n')); @@ -140,11 +138,14 @@ ${runtimeToCss(normalizeStyle(runtime))} ); } -function cssToRuntime(css) { +function cssToRuntime(css: string) { if (!css) { return {}; } - const runtime = {}; + const runtime: { + extra?: string[]; + default?: Record<string, string>; + } = {}; const groups = groupingCss(css); groups.forEach((cssItem) => { if (!cssItem.startsWith(':root')) { @@ -153,7 +154,7 @@ function cssToRuntime(css) { } else { const res = /:root:?(.*)?{(.*)/ig.exec(cssItem.replace(/[\r\n]+/ig, '').trim()); if (res) { - let pseudo; + let pseudo: string | undefined; if (res[1] && res[1].trim() && some(pseudoMap, pse => res[1].indexOf(pse) === 0)) { pseudo = res[1].trim(); @@ -161,8 +162,8 @@ function cssToRuntime(css) { pseudo = res[1]; } - const s = {}; - res[2].split(';').reduce((prev, next) => { + const s: Record<string, string> = {}; + res[2].split(';').reduce<string[]>((prev, next) => { if (next.indexOf('base64') > -1) { prev[prev.length - 1] += `;${next}`; } else { @@ -173,8 +174,8 @@ function cssToRuntime(css) { if (item) { if (PROPS_REG.test(item)) { const props = item.match(PROPS_REG); - const key = props[1]; - const value = props[2]; + const key = props?.[1]; + const value = props?.[2]; if (key && value) { s[key.trim()] = value.trim(); } @@ -182,10 +183,7 @@ function cssToRuntime(css) { } }); - if (!pseudo) { - pseudo = 'default'; - } - runtime[pseudo] = s; + runtime[pseudo || 'default'] = s; } } }); diff --git a/packages/utils/src/cursor.css b/packages/utils/src/cursor.css new file mode 100644 index 0000000000..e13da656ea --- /dev/null +++ b/packages/utils/src/cursor.css @@ -0,0 +1,19 @@ +html.lc-cursor-dragging, +html.lc-cursor-dragging * { + cursor: move !important; +} + +html.lc-cursor-x-resizing, +html.lc-cursor-x-resizing * { + cursor: col-resize; +} + +html.lc-cursor-y-resizing, +html.lc-cursor-y-resizing * { + cursor: row-resize; +} + +html.lc-cursor-copy, +html.lc-cursor-copy * { + cursor: copy !important; +} diff --git a/packages/utils/src/cursor.less b/packages/utils/src/cursor.less deleted file mode 100644 index 30c890862e..0000000000 --- a/packages/utils/src/cursor.less +++ /dev/null @@ -1,15 +0,0 @@ -html.lc-cursor-dragging, html.lc-cursor-dragging * { - cursor: move !important; -} - -html.lc-cursor-x-resizing, html.lc-cursor-x-resizing * { - cursor: col-resize; -} - -html.lc-cursor-y-resizing, html.lc-cursor-y-resizing * { - cursor: row-resize; -} - -html.lc-cursor-copy, html.lc-cursor-copy * { - cursor: copy !important; -} diff --git a/packages/utils/src/cursor.ts b/packages/utils/src/cursor.ts index fea4bce65b..c12ec64b92 100644 --- a/packages/utils/src/cursor.ts +++ b/packages/utils/src/cursor.ts @@ -1,4 +1,4 @@ -import './cursor.less'; +import './cursor.css'; export class Cursor { private states = new Set<string>(); diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 1277f44818..22bad0e36e 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -25,4 +25,11 @@ export * from './schema'; export * from './node-helper'; export * from './clone-enumerable-property'; export * from './logger'; +export * from './is-shaken'; +export * from './is-plugin-event-name'; export * as css from './css-helper'; +export { transactionManager } from './transaction-manager'; +export * from './check-types'; +export * from './workspace'; +export * from './context-menu'; +export { checkPropTypes } from './check-prop-types'; \ No newline at end of file diff --git a/packages/utils/src/is-css-url.ts b/packages/utils/src/is-css-url.ts index 1f900f18ce..af29604825 100644 --- a/packages/utils/src/is-css-url.ts +++ b/packages/utils/src/is-css-url.ts @@ -1,3 +1,3 @@ export function isCSSUrl(url: string): boolean { - return /\.css$/.test(url); + return /\.css(\?.*)?$/.test(url); } diff --git a/packages/utils/src/is-object.ts b/packages/utils/src/is-object.ts index 50b580e5a1..c8d764458b 100644 --- a/packages/utils/src/is-object.ts +++ b/packages/utils/src/is-object.ts @@ -1,4 +1,4 @@ -export function isObject(value: any): value is Record<string, unknown> { +export function isObject(value: any): value is Record<string, any> { return value !== null && typeof value === 'object'; } diff --git a/packages/utils/src/is-plugin-event-name.ts b/packages/utils/src/is-plugin-event-name.ts new file mode 100644 index 0000000000..688eddc6e8 --- /dev/null +++ b/packages/utils/src/is-plugin-event-name.ts @@ -0,0 +1,8 @@ +export function isPluginEventName(eventName: string): boolean { + if (!eventName) { + return false; + } + + const eventSegments = eventName.split(':'); + return (eventSegments.length > 1 && eventSegments[0].length > 0); +} diff --git a/packages/utils/src/is-react.ts b/packages/utils/src/is-react.ts index 02ef50fa67..1d6c939ea1 100644 --- a/packages/utils/src/is-react.ts +++ b/packages/utils/src/is-react.ts @@ -2,28 +2,69 @@ import { ComponentClass, Component, FunctionComponent, ComponentType, createElem import { cloneEnumerableProperty } from './clone-enumerable-property'; const hasSymbol = typeof Symbol === 'function' && Symbol.for; -const REACT_FORWARD_REF_TYPE = hasSymbol ? Symbol.for('react.forward_ref') : 0xead0; +export const REACT_FORWARD_REF_TYPE = hasSymbol ? Symbol.for('react.forward_ref') : 0xead0; +export const REACT_MEMO_TYPE = hasSymbol ? Symbol.for('react.memo') : 0xead3; export function isReactClass(obj: any): obj is ComponentClass<any> { - return obj && obj.prototype && (obj.prototype.isReactComponent || obj.prototype instanceof Component); + if (!obj) { + return false; + } + if (obj.prototype && (obj.prototype.isReactComponent || obj.prototype instanceof Component)) { + return true; + } + return false; } export function acceptsRef(obj: any): boolean { - return obj?.prototype?.isReactComponent || (obj.$$typeof && obj.$$typeof === REACT_FORWARD_REF_TYPE); + if (!obj) { + return false; + } + if (obj?.prototype?.isReactComponent || isForwardOrMemoForward(obj)) { + return true; + } + + return false; +} + +export function isForwardRefType(obj: any): boolean { + if (!obj || !obj?.$$typeof) { + return false; + } + return obj?.$$typeof === REACT_FORWARD_REF_TYPE; } -function isForwardRefType(obj: any): boolean { - return obj?.$$typeof && obj?.$$typeof === REACT_FORWARD_REF_TYPE; +export function isMemoType(obj: any): boolean { + if (!obj || !obj?.$$typeof) { + return false; + } + return obj.$$typeof === REACT_MEMO_TYPE; +} + +export function isForwardOrMemoForward(obj: any): boolean { + if (!obj || !obj?.$$typeof) { + return false; + } + return ( + // React.forwardRef(..) + isForwardRefType(obj) || + // React.memo(React.forwardRef(..)) + (isMemoType(obj) && isForwardRefType(obj.type)) + ); } export function isReactComponent(obj: any): obj is ComponentType<any> { - return obj && (isReactClass(obj) || typeof obj === 'function' || isForwardRefType(obj)); + if (!obj) { + return false; + } + + return Boolean(isReactClass(obj) || typeof obj === 'function' || isForwardRefType(obj) || isMemoType(obj)); } export function wrapReactClass(view: FunctionComponent) { let ViewComponentClass = class extends Component { render() { - return createElement(view, this.props); + const { children, ...other } = this.props; + return createElement(view, other, children); } } as any; ViewComponentClass = cloneEnumerableProperty(ViewComponentClass, view); diff --git a/packages/utils/src/is-shaken.ts b/packages/utils/src/is-shaken.ts new file mode 100644 index 0000000000..6c4606e712 --- /dev/null +++ b/packages/utils/src/is-shaken.ts @@ -0,0 +1,15 @@ +const SHAKE_DISTANCE = 4; +/** + * mouse shake check + */ +export function isShaken(e1: MouseEvent | DragEvent, e2: MouseEvent | DragEvent): boolean { + if ((e1 as any).shaken) { + return true; + } + if (e1.target !== e2.target) { + return true; + } + return ( + Math.pow(e1.clientY - e2.clientY, 2) + Math.pow(e1.clientX - e2.clientX, 2) > SHAKE_DISTANCE + ); +} \ No newline at end of file diff --git a/packages/utils/src/logger.ts b/packages/utils/src/logger.ts index 47ec22c6f1..3eb43eedbe 100644 --- a/packages/utils/src/logger.ts +++ b/packages/utils/src/logger.ts @@ -1,4 +1,195 @@ -import Logger, { Level } from 'zen-logger'; +/* eslint-disable no-console */ +/* eslint-disable no-param-reassign */ +import { isObject } from './is-object'; + +export type Level = 'debug' | 'log' | 'info' | 'warn' | 'error'; +interface Options { + level: Level; + bizName: string; +} + +const levels: Record<string, number> = { + debug: -1, + log: 0, + info: 0, + warn: 1, + error: 2, +}; +const bizNameColors = [ + '#daa569', + '#00ffff', + '#385e0f', + '#7fffd4', + '#00c957', + '#b0e0e6', + '#4169e1', + '#6a5acd', + '#87ceeb', + '#ffff00', + '#e3cf57', + '#ff9912', + '#eb8e55', + '#ffe384', + '#40e0d0', + '#a39480', + '#d2691e', + '#ff7d40', + '#f0e68c', + '#bc8f8f', + '#c76114', + '#734a12', + '#5e2612', + '#0000ff', + '#3d59ab', + '#1e90ff', + '#03a89e', + '#33a1c9', + '#a020f0', + '#a066d3', + '#da70d6', + '#dda0dd', + '#688e23', + '#2e8b57', +]; +const bodyColors: Record<string, string> = { + debug: '#fadb14', + log: '#8c8c8c', + info: '#52c41a', + warn: '#fa8c16', + error: '#ff4d4f', +}; +const levelMarks: Record<string, string> = { + debug: 'debug', + log: 'log', + info: 'info', + warn: 'warn', + error: 'error', +}; +const outputFunction: Record<string, any> = { + debug: console.log, + log: console.log, + info: console.log, + warn: console.warn, + error: console.error, +}; + +const bizNameColorConfig: Record<string, string> = {}; + +const shouldOutput = ( + logLevel: string, + targetLevel: string = 'warn', + bizName: string, + targetBizName: string, + ): boolean => { + const isLevelFit = (levels as any)[targetLevel] <= (levels as any)[logLevel]; + const isBizNameFit = targetBizName === '*' || bizName.indexOf(targetBizName) > -1; + return isLevelFit && isBizNameFit; +}; + +const output = (logLevel: string, bizName: string) => { + return (...args: any[]) => { + return outputFunction[logLevel]?.apply(console, getLogArgs(args, bizName, logLevel)); + }; +}; + +const getColor = (bizName: string) => { + if (!bizNameColorConfig[bizName]) { + const color = bizNameColors[Object.keys(bizNameColorConfig).length % bizNameColors.length]; + bizNameColorConfig[bizName] = color; + } + return bizNameColorConfig[bizName]; +}; + +const getLogArgs = (args: any, bizName: string, logLevel: string) => { + const color = getColor(bizName); + const bodyColor = bodyColors[logLevel]; + + const argsArray = args[0]; + let prefix = `%c[${bizName}]%c[${levelMarks[logLevel]}]:`; + argsArray.forEach((arg: any) => { + if (isObject(arg)) { + prefix += '%o'; + } else { + prefix += '%s'; + } + }); + let processedArgs = [prefix, `color: ${color}`, `color: ${bodyColor}`]; + processedArgs = processedArgs.concat(argsArray); + return processedArgs; +}; +const parseLogConf = (logConf: string, options: Options): { level: string; bizName: string} => { + if (!logConf) { + return { + level: options.level, + bizName: options.bizName, + }; + } + if (logConf.indexOf(':') > -1) { + const pair = logConf.split(':'); + return { + level: pair[0], + bizName: pair[1] || '*', + }; + } + return { + level: logConf, + bizName: '*', + }; +}; + +const defaultOptions: Options = { + level: 'warn', + bizName: '*', +}; + +class Logger { + bizName: string; + targetBizName: string; + targetLevel: string; + constructor(options: Options) { + options = { ...defaultOptions, ...options }; + const _location = location || {} as any; + // __logConf__ 格式为 logLevel[:bizName], bizName is used as: targetBizName like '%bizName%' + // 1. __logConf__=log or __logConf__=warn, etc. + // 2. __logConf__=log:* or __logConf__=warn:*, etc. + // 2. __logConf__=log:bizName or __logConf__=warn:partOfBizName, etc. + const logConf = (((/__(?:logConf|logLevel)__=([^#/&]*)/.exec(_location.href)) || [])[1]); + const targetOptions = parseLogConf(logConf, options); + this.bizName = options.bizName; + this.targetBizName = targetOptions.bizName; + this.targetLevel = targetOptions.level; + } + debug(...args: any[]): void { + if (!shouldOutput('debug', this.targetLevel, this.bizName, this.targetBizName)) { + return; + } + return output('debug', this.bizName)(args); + } + log(...args: any[]): void { + if (!shouldOutput('log', this.targetLevel, this.bizName, this.targetBizName)) { + return; + } + return output('log', this.bizName)(args); + } + info(...args: any[]): void { + if (!shouldOutput('info', this.targetLevel, this.bizName, this.targetBizName)) { + return; + } + return output('info', this.bizName)(args); + } + warn(...args: any[]): void { + if (!shouldOutput('warn', this.targetLevel, this.bizName, this.targetBizName)) { + return; + } + return output('warn', this.bizName)(args); + } + error(...args: any[]): void { + if (!shouldOutput('error', this.targetLevel, this.bizName, this.targetBizName)) { + return; + } + return output('error', this.bizName)(args); + } +} export { Logger }; diff --git a/packages/utils/src/misc.ts b/packages/utils/src/misc.ts index a97c123899..28833ef321 100644 --- a/packages/utils/src/misc.ts +++ b/packages/utils/src/misc.ts @@ -1,8 +1,11 @@ import { isI18NObject } from './is-object'; import { get } from 'lodash'; -import { ComponentMeta } from '@alilc/lowcode-designer'; -import { TransformStage } from '@alilc/lowcode-types'; +import { IPublicEnumTransformStage, IPublicModelComponentMeta } from '@alilc/lowcode-types'; +import { Logger } from './logger'; + +const logger = new Logger({ level: 'warn', bizName: 'utils' }); + interface Variable { type: 'variable'; variable: string; @@ -10,7 +13,10 @@ interface Variable { } export function isVariable(obj: any): obj is Variable { - return obj && obj.type === 'variable'; + if (!obj || typeof obj !== 'object') { + return false; + } + return obj.type === 'variable'; } export function isUseI18NSetter(prototype: any, propName: string) { @@ -23,7 +29,7 @@ export function isUseI18NSetter(prototype: any, propName: string) { return false; } -export function convertToI18NObject(v: string | any, locale: string = 'zh_CN') { +export function convertToI18NObject(v: string | any, locale: string = 'zh-CN') { if (isI18NObject(v)) return v; return { type: 'i18n', use: locale, [locale]: v }; } @@ -65,7 +71,7 @@ export function arrShallowEquals(arr1: any[], arr2: any[]): boolean { * 判断当前 meta 是否从 vc prototype 转换而来 * @param meta */ - export function isFromVC(meta: ComponentMeta) { + export function isFromVC(meta: IPublicModelComponentMeta) { return !!meta?.getMetadata().configure?.advanced; } @@ -81,17 +87,18 @@ const stageList = [ 'init', 'upgrade', ]; + /** * 兼容原来的数字版本的枚举对象 * @param stage * @returns */ -export function compatStage(stage: TransformStage | number): TransformStage { +export function compatStage(stage: IPublicEnumTransformStage | number): IPublicEnumTransformStage { if (typeof stage === 'number') { - console.warn('stage 直接指定为数字的使用方式已经过时,将在下一版本移除,请直接使用 TransformStage.Render|Serilize|Save|Clone|Init|Upgrade'); - return stageList[stage - 1] as TransformStage; + console.warn('stage 直接指定为数字的使用方式已经过时,将在下一版本移除,请直接使用 IPublicEnumTransformStage.Render|Serilize|Save|Clone|Init|Upgrade'); + return stageList[stage - 1] as IPublicEnumTransformStage; } - return stage as TransformStage; + return stage as IPublicEnumTransformStage; } export function invariant(check: any, message: string, thing?: any) { @@ -102,6 +109,27 @@ export function invariant(check: any, message: string, thing?: any) { export function deprecate(fail: any, message: string, alterative?: string) { if (fail) { - console.warn(`Deprecation: ${message}` + (alterative ? `, use ${alterative} instead.'` : '')); + logger.warn(`Deprecation: ${message}` + (alterative ? `, use ${alterative} instead.` : '')); + } +} + +export function isRegExp(obj: any): obj is RegExp { + if (!obj || typeof obj !== 'object') { + return false; } + return 'test' in obj && 'exec' in obj && 'compile' in obj; +} + +/** + * The prop supportVariable SHOULD take precedence over default global supportVariable. + * @param propSupportVariable prop supportVariable + * @param globalSupportVariable global supportVariable + * @returns + */ +export function shouldUseVariableSetter( + propSupportVariable: boolean | undefined, + globalSupportVariable: boolean, +) { + if (propSupportVariable === false) return false; + return propSupportVariable || globalSupportVariable; } \ No newline at end of file diff --git a/packages/utils/src/navtive-selection.ts b/packages/utils/src/navtive-selection.ts index 76f51f48aa..b8e5257734 100644 --- a/packages/utils/src/navtive-selection.ts +++ b/packages/utils/src/navtive-selection.ts @@ -1,4 +1,5 @@ -let nativeSelectionEnabled = true; +export let nativeSelectionEnabled = true; + const preventSelection = (e: Event) => { if (nativeSelectionEnabled) { return null; diff --git a/packages/utils/src/node-helper.ts b/packages/utils/src/node-helper.ts index 5f05472744..60102d6794 100644 --- a/packages/utils/src/node-helper.ts +++ b/packages/utils/src/node-helper.ts @@ -1,7 +1,11 @@ // 仅使用类型 -import { Node } from '@alilc/lowcode-designer'; +import { IPublicModelNode } from '@alilc/lowcode-types'; +import { MouseEvent } from 'react'; -export const getClosestNode = (node: Node, until: (node: Node) => boolean): Node | undefined => { +export const getClosestNode = <Node extends IPublicModelNode = IPublicModelNode>( + node: Node, + until: (n: Node) => boolean, + ): Node | undefined => { if (!node) { return undefined; } @@ -9,7 +13,7 @@ export const getClosestNode = (node: Node, until: (node: Node) => boolean): Node return node; } else { // @ts-ignore - return getClosestNode(node.getParent(), until); + return getClosestNode(node.parent, until); } }; @@ -19,8 +23,8 @@ export const getClosestNode = (node: Node, until: (node: Node) => boolean): Node * @param {unknown} e 点击事件 * @returns {boolean} 是否可点击,true表示可点击 */ -export const canClickNode = (node: Node, e: unknown): boolean => { - const onClickHook = node.componentMeta?.getMetadata().configure?.advanced?.callbacks?.onClickHook; - const canClick = typeof onClickHook === 'function' ? onClickHook(e as MouseEvent, node) : true; +export function canClickNode<Node extends IPublicModelNode = IPublicModelNode>(node: Node, e: MouseEvent): boolean { + const onClickHook = node.componentMeta?.advanced?.callbacks?.onClickHook; + const canClick = typeof onClickHook === 'function' ? onClickHook(e, node) : true; return canClick; -}; +} diff --git a/packages/utils/src/schema.ts b/packages/utils/src/schema.ts index cc89838d78..2e7dec70fa 100644 --- a/packages/utils/src/schema.ts +++ b/packages/utils/src/schema.ts @@ -1,7 +1,17 @@ -import { isJSBlock, isJSSlot, ActivityType, NodeSchema, PageSchema, RootSchema } from '@alilc/lowcode-types'; +import { ActivityType, IPublicTypeNodeSchema, IPublicTypeRootSchema } from '@alilc/lowcode-types'; +import { isJSBlock, isJSSlot } from './check-types'; import { isVariable } from './misc'; import { isPlainObject } from './is-plain-object'; +function isJsObject(props: any) { + if (typeof props === 'object' && props !== null) { + return props.type && props.source && props.compiled; + } +} +function isActionRef(props: any): boolean { + return props.type && props.type === 'actionRef'; +} + /** * 将「乐高版本」协议升级成 JSExpression / JSSlot 等标准协议的结构 * @param props @@ -26,7 +36,7 @@ export function compatibleLegaoSchema(props: any): any { type: 'JSSlot', title: (props.value.props as any)?.slotTitle, name: (props.value.props as any)?.slotName, - value: props.value.children, + value: compatibleLegaoSchema(props.value.children), params: (props.value.props as any)?.slotParams, }; } else { @@ -40,6 +50,19 @@ export function compatibleLegaoSchema(props: any): any { mock: props.value, }; } + if (isJsObject(props)) { + return { + type: 'JSExpression', + value: props.compiled, + extType: 'function', + }; + } + if (isActionRef(props)) { + return { + type: 'JSExpression', + value: `${props.id}.bind(this)`, + }; + } const newProps: any = {}; Object.keys(props).forEach((key) => { if (/^__slot__/.test(key) && props[key] === true) { @@ -55,8 +78,8 @@ export function compatibleLegaoSchema(props: any): any { return newProps; } -export function getNodeSchemaById(schema: NodeSchema, nodeId: string): NodeSchema | undefined { - let found: NodeSchema | undefined; +export function getNodeSchemaById(schema: IPublicTypeNodeSchema, nodeId: string): IPublicTypeNodeSchema | undefined { + let found: IPublicTypeNodeSchema | undefined; if (schema.id === nodeId) { return schema; } @@ -64,7 +87,7 @@ export function getNodeSchemaById(schema: NodeSchema, nodeId: string): NodeSchem // 查找 children if (Array.isArray(children)) { for (const child of children) { - found = getNodeSchemaById(child as NodeSchema, nodeId); + found = getNodeSchemaById(child as IPublicTypeNodeSchema, nodeId); if (found) return found; } } @@ -75,19 +98,19 @@ export function getNodeSchemaById(schema: NodeSchema, nodeId: string): NodeSchem } } -function getNodeSchemaFromPropsById(props: any, nodeId: string): NodeSchema | undefined { - let found: NodeSchema | undefined; - for (const [key, value] of Object.entries(props)) { +function getNodeSchemaFromPropsById(props: any, nodeId: string): IPublicTypeNodeSchema | undefined { + let found: IPublicTypeNodeSchema | undefined; + for (const [_key, value] of Object.entries(props)) { if (isJSSlot(value)) { - // value 是数组类型 { type: 'JSSlot', value: NodeSchema[] } + // value 是数组类型 { type: 'JSSlot', value: IPublicTypeNodeSchema[] } if (Array.isArray(value.value)) { for (const child of value.value) { - found = getNodeSchemaById(child as NodeSchema, nodeId); + found = getNodeSchemaById(child as IPublicTypeNodeSchema, nodeId); if (found) return found; } } - // value 是对象类型 { type: 'JSSlot', value: NodeSchema } - found = getNodeSchemaById(value.value as NodeSchema, nodeId); + // value 是对象类型 { type: 'JSSlot', value: IPublicTypeNodeSchema } + found = getNodeSchemaById(value.value as IPublicTypeNodeSchema, nodeId); if (found) return found; } else if (isPlainObject(value)) { found = getNodeSchemaFromPropsById(value, nodeId); @@ -96,12 +119,16 @@ function getNodeSchemaFromPropsById(props: any, nodeId: string): NodeSchema | un } } -export function applyActivities(pivotSchema: RootSchema, activities: any, options?: any): RootSchema { +/** + * TODO: not sure if this is used anywhere + * @deprecated + */ +export function applyActivities(pivotSchema: IPublicTypeRootSchema, activities: any): IPublicTypeRootSchema { let schema = { ...pivotSchema }; if (!Array.isArray(activities)) { activities = [activities]; } - return activities.reduce((accSchema: RootSchema, activity: any) => { + return activities.reduce((accSchema: IPublicTypeRootSchema, activity: any) => { if (activity.type === ActivityType.MODIFIED) { const found = getNodeSchemaById(accSchema, activity.payload.schema.id); if (!found) return accSchema; diff --git a/packages/utils/src/script.ts b/packages/utils/src/script.ts index 81841ff6d2..c4c476fac4 100644 --- a/packages/utils/src/script.ts +++ b/packages/utils/src/script.ts @@ -1,14 +1,18 @@ import { createDefer } from './create-defer'; +import { Logger } from './logger'; -export function evaluate(script: string) { +const logger = new Logger({ level: 'warn', bizName: 'utils' }); + +export function evaluate(script: string, scriptType?: string) { const scriptEl = document.createElement('script'); + scriptType && (scriptEl.type = scriptType); scriptEl.text = script; document.head.appendChild(scriptEl); document.head.removeChild(scriptEl); } -export function load(url: string) { - const node: any = document.createElement('script'); +export function load(url: string, scriptType?: string) { + const node = document.createElement('script'); // node.setAttribute('crossorigin', 'anonymous'); @@ -29,9 +33,13 @@ export function load(url: string) { // node = null; } - // node.async = true; node.src = url; + // `async=false` is required to make sure all js resources execute sequentially. + node.async = false; + + scriptType && (node.type = scriptType); + document.head.appendChild(node); return i.promise(); @@ -48,7 +56,7 @@ export function newFunction(args: string, code: string) { // eslint-disable-next-line no-new-func return new Function(args, code); } catch (e) { - console.warn('Caught error, Cant init func'); + logger.warn('Caught error, Cant init func'); return null; } } diff --git a/packages/utils/src/svg-icon.tsx b/packages/utils/src/svg-icon.tsx index f75724b064..2513f7bcab 100644 --- a/packages/utils/src/svg-icon.tsx +++ b/packages/utils/src/svg-icon.tsx @@ -1,4 +1,4 @@ -import { ReactNode } from 'react'; +import React, { ReactNode } from 'react'; const SizePresets: any = { xsmall: 8, diff --git a/packages/utils/src/transaction-manager.ts b/packages/utils/src/transaction-manager.ts new file mode 100644 index 0000000000..85161eff9f --- /dev/null +++ b/packages/utils/src/transaction-manager.ts @@ -0,0 +1,29 @@ +import { IPublicEnumTransitionType } from '@alilc/lowcode-types'; +import { runInAction } from 'mobx'; +import EventEmitter from 'events'; + +class TransactionManager { + emitter = new EventEmitter(); + + executeTransaction = (fn: () => void, type: IPublicEnumTransitionType = IPublicEnumTransitionType.REPAINT): void => { + this.emitter.emit(`[${type}]startTransaction`); + runInAction(fn); + this.emitter.emit(`[${type}]endTransaction`); + }; + + onStartTransaction = (fn: () => void, type: IPublicEnumTransitionType = IPublicEnumTransitionType.REPAINT): () => void => { + this.emitter.on(`[${type}]startTransaction`, fn); + return () => { + this.emitter.off(`[${type}]startTransaction`, fn); + }; + }; + + onEndTransaction = (fn: () => void, type: IPublicEnumTransitionType = IPublicEnumTransitionType.REPAINT): () => void => { + this.emitter.on(`[${type}]endTransaction`, fn); + return () => { + this.emitter.off(`[${type}]endTransaction`, fn); + }; + }; +} + +export const transactionManager = new TransactionManager(); diff --git a/packages/utils/src/workspace.tsx b/packages/utils/src/workspace.tsx new file mode 100644 index 0000000000..446530ce8e --- /dev/null +++ b/packages/utils/src/workspace.tsx @@ -0,0 +1,54 @@ +import React, { useEffect, useState, useCallback } from 'react'; +import { IPublicModelPluginContext, IPublicEnumPluginRegisterLevel, IPublicModelWindow, IPublicModelEditorView } from '@alilc/lowcode-types'; + +/** + * 高阶组件(HOC):为组件提供 view 插件上下文。 + * + * @param {React.ComponentType} Component - 需要被封装的组件。 + * @param {string|string[]} viewName - 视图名称或视图名称数组,用于过滤特定的视图插件上下文。 + * @returns {React.ComponentType} 返回封装后的组件。 + * + * @example + * // 用法示例(函数组件): + * const EnhancedComponent = ProvideViewPluginContext(MyComponent, "viewName"); + */ +export const ProvideViewPluginContext = (Component: any, viewName?: string | string[]) => { + // 创建一个新的函数组件,以便在其中使用 Hooks + return function WithPluginContext(props: { + [key: string]: any; + + pluginContext?: IPublicModelPluginContext; + }) { + const getPluginContextFun = useCallback((editorWindow?: IPublicModelWindow | null) => { + if (!editorWindow?.currentEditorView) { + return null; + } + if (viewName) { + const items = editorWindow?.editorViews.filter(d => (d as any).viewName === viewName || (Array.isArray(viewName) && viewName.includes((d as any).viewName))); + return items[0]; + } else { + return editorWindow.currentEditorView; + } + }, []); + + const { workspace } = props.pluginContext || {}; + const [pluginContext, setPluginContext] = useState<IPublicModelEditorView | null>(getPluginContextFun(workspace?.window)); + + useEffect(() => { + if (workspace?.window) { + const ctx = getPluginContextFun(workspace.window); + ctx && setPluginContext(ctx); + } + return workspace?.onChangeActiveEditorView(() => { + const ctx = getPluginContextFun(workspace.window); + ctx && setPluginContext(ctx); + }); + }, [workspace, getPluginContextFun]); + + if (props.pluginContext?.registerLevel !== IPublicEnumPluginRegisterLevel.Workspace || !props.pluginContext) { + return <Component {...props} />; + } + + return <Component {...props} pluginContext={pluginContext} />; + }; +}; diff --git a/packages/utils/test/src/__snapshots__/is-react.test.tsx.snap b/packages/utils/test/src/__snapshots__/is-react.test.tsx.snap new file mode 100644 index 0000000000..14ef394533 --- /dev/null +++ b/packages/utils/test/src/__snapshots__/is-react.test.tsx.snap @@ -0,0 +1,10 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`wrapReactClass should render the FunctionComponent with props 1`] = ` +<FunctionComponent + prop1="value1" + prop2="value2" +> + Child Text +</FunctionComponent> +`; diff --git a/packages/utils/test/src/__snapshots__/schema.test.ts.snap b/packages/utils/test/src/__snapshots__/schema.test.ts.snap new file mode 100644 index 0000000000..e926c89b3d --- /dev/null +++ b/packages/utils/test/src/__snapshots__/schema.test.ts.snap @@ -0,0 +1,23 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Schema Ut props 1`] = ` +Object { + "props": Object { + "mobileSlot": Object { + "name": undefined, + "params": undefined, + "title": undefined, + "type": "JSSlot", + "value": Array [ + Object { + "loop": Object { + "mock": undefined, + "type": "JSExpression", + "value": "props.content", + }, + }, + ], + }, + }, +} +`; diff --git a/packages/utils/test/src/build-components/buildComponents.test.tsx b/packages/utils/test/src/build-components/buildComponents.test.tsx new file mode 100644 index 0000000000..a50a68b396 --- /dev/null +++ b/packages/utils/test/src/build-components/buildComponents.test.tsx @@ -0,0 +1,616 @@ +import React from 'react'; +import { + accessLibrary, + generateHtmlComp, + getSubComponent, + buildComponents, + getProjectUtils, +} from "../../../src/build-components"; + +function Button() {}; + +function WrapButton() {}; + +function ButtonGroup() {}; + +function WrapButtonGroup() {}; + +ButtonGroup.Button = Button; + +Button.displayName = "Button"; +ButtonGroup.displayName = "ButtonGroup"; +ButtonGroup.prototype.isReactComponent = true; +Button.prototype.isReactComponent = true; + +jest.mock('../../../src/is-react', () => { + const original = jest.requireActual('../../../src/is-react'); + return { + ...original, + wrapReactClass(view) { + return view; + } + } +}); + +describe('accessLibrary', () => { + it('should return a library object when given a library object', () => { + const libraryObject = { key: 'value' }; + const result = accessLibrary(libraryObject); + expect(result).toEqual(libraryObject); + }); + + it('should generate an HTML component when given a string library name', () => { + const libraryName = 'div'; + const result = accessLibrary(libraryName); + + // You can write more specific assertions to validate the generated component + expect(result).toBeDefined(); + }); + + // Add more test cases to cover other scenarios +}); + +describe('generateHtmlComp', () => { + it('should generate an HTML component for valid HTML tags', () => { + const htmlTags = ['a', 'img', 'div', 'span', 'svg']; + htmlTags.forEach((tag) => { + const result = generateHtmlComp(tag); + + // You can write more specific assertions to validate the generated component + expect(result).toBeDefined(); + }); + }); + + it('should return undefined for an invalid HTML tag', () => { + const invalidTag = 'invalidtag'; + const result = generateHtmlComp(invalidTag); + expect(result).toBeUndefined(); + }); + + // Add more test cases to cover other scenarios +}); + +describe('getSubComponent', () => { + it('should return the root library if paths are empty', () => { + const library = { component: 'RootComponent' }; + const paths = []; + const result = getSubComponent(library, paths); + expect(result).toEqual(library); + }); + + it('should return the specified sub-component', () => { + const library = { + components: { + Button: 'ButtonComponent', + Text: 'TextComponent', + }, + }; + const paths = ['components', 'Button']; + const result = getSubComponent(library, paths); + expect(result).toEqual('ButtonComponent'); + }); + + it('should handle missing keys in the path', () => { + const library = { + components: { + Button: 'ButtonComponent', + }, + }; + const paths = ['components', 'Text']; + const result = getSubComponent(library, paths); + expect(result).toEqual({ + Button: 'ButtonComponent', + }); + }); + + it('should handle exceptions and return null', () => { + const library = 'ButtonComponent'; + const paths = ['components', 'Button']; + // Simulate an exception by providing a non-object in place of 'ButtonComponent' + const result = getSubComponent(library, paths); + expect(result).toBeNull(); + }); + + it('should handle the "default" key as the first path element', () => { + const library = { + default: 'DefaultComponent', + }; + const paths = ['default']; + const result = getSubComponent(library, paths); + expect(result).toEqual('DefaultComponent'); + }); +}); + +describe('getProjectUtils', () => { + it('should return an empty object when given empty metadata and library map', () => { + const libraryMap = {}; + const utilsMetadata = []; + const result = getProjectUtils(libraryMap, utilsMetadata); + expect(result).toEqual({}); + }); + + it('should return project utilities based on metadata and library map', () => { + const libraryMap = { + 'package1': 'library1', + 'package2': 'library2', + }; + + const utilsMetadata = [ + { + name: 'util1', + npm: { + package: 'package1', + }, + }, + { + name: 'util2', + npm: { + package: 'package2', + }, + }, + ]; + + global['library1'] = { name: 'library1' }; + global['library2'] = { name: 'library2' }; + + const result = getProjectUtils(libraryMap, utilsMetadata); + + // Define the expected output based on the mocked accessLibrary + const expectedOutput = { + 'util1': { name: 'library1' }, + 'util2': { name: 'library2' }, + }; + + expect(result).toEqual(expectedOutput); + + global['library1'] = null; + global['library1'] = null; + }); + + it('should handle metadata with destructuring', () => { + const libraryMap = { + 'package1': { destructuring: true, util1: 'library1', util2: 'library2' }, + }; + + const utilsMetadata = [ + { + name: 'util1', + npm: { + package: 'package1', + destructuring: true, + }, + }, + ]; + + const result = getProjectUtils(libraryMap, utilsMetadata); + + // Define the expected output based on the mocked accessLibrary + const expectedOutput = { + 'util1': 'library1', + 'util2': 'library2', + }; + + expect(result).toEqual(expectedOutput); + }); +}); + +describe('buildComponents', () => { + it('should create components from component map with React components', () => { + const libraryMap = {}; + const componentsMap = { + Button: () => <button>Button</button>, + Text: () => <p>Text</p>, + }; + + const createComponent = (schema) => { + // Mock createComponent function + return schema.componentsTree.map((component) => component.component); + }; + + const result = buildComponents(libraryMap, componentsMap, createComponent); + + expect(result.Button).toBeDefined(); + expect(result.Text).toBeDefined(); + }); + + it('should create components from component map with component schemas', () => { + const libraryMap = {}; + const componentsMap = { + Button: { + componentsTree: [ + { + componentName: 'Component' + } + ] + }, + Text: { + componentsTree: [ + { + componentName: 'Component' + } + ] + }, + }; + + const createComponent = (schema) => { + // Mock createComponent function + return schema.componentsTree.map((component) => component.component); + }; + + const result = buildComponents(libraryMap, componentsMap, createComponent); + + expect(result.Button).toBeDefined(); + expect(result.Text).toBeDefined(); + }); + + it('should create components from component map with React components and schemas', () => { + const libraryMap = {}; + const componentsMap = { + Button: () => <button>Button</button>, + Text: { + type: 'ComponentSchema', + // Add component schema properties here + }, + }; + + const createComponent = (schema) => { + // Mock createComponent function + return schema.componentsTree.map((component) => component.component); + }; + + const result = buildComponents(libraryMap, componentsMap, createComponent); + + expect(result.Button).toBeDefined(); + expect(result.Text).toBeDefined(); + }); + + it('should create components from component map with library mappings', () => { + const libraryMap = { + 'libraryName1': 'library1', + 'libraryName2': 'library2', + }; + const componentsMap = { + Button: { + package: 'libraryName1', + version: '1.0', + exportName: 'ButtonComponent', + }, + Text: { + package: 'libraryName2', + version: '2.0', + exportName: 'TextComponent', + }, + }; + + const createComponent = (schema) => { + // Mock createComponent function + return schema.componentsTree.map((component) => component.component); + }; + + global['library1'] = () => <button>ButtonComponent</button>; + global['library2'] = () => () => <p>TextComponent</p>; + + const result = buildComponents(libraryMap, componentsMap, createComponent); + + expect(result.Button).toBeDefined(); + expect(result.Text).toBeDefined(); + + global['library1'] = null; + global['library2'] = null; + }); +}); + +describe('build-component', () => { + it('basic button', () => { + expect( + buildComponents( + { + '@alilc/button': { + Button, + } + }, + { + Button: { + componentName: 'Button', + package: '@alilc/button', + destructuring: true, + exportName: 'Button', + subName: 'Button', + } + }, + () => {}, + )) + .toEqual({ + Button, + }); + }); + + it('component is a __esModule', () => { + expect( + buildComponents( + { + '@alilc/button': { + __esModule: true, + default: Button, + } + }, + { + Button: { + componentName: 'Button', + package: '@alilc/button', + } + }, + () => {}, + )) + .toEqual({ + Button, + }); + }) + + it('basic warp button', () => { + expect( + buildComponents( + { + '@alilc/button': { + WrapButton, + } + }, + { + WrapButton: { + componentName: 'WrapButton', + package: '@alilc/button', + destructuring: true, + exportName: 'WrapButton', + subName: 'WrapButton', + } + }, + () => {}, + )) + .toEqual({ + WrapButton, + }); + }); + + it('destructuring is false button', () => { + expect( + buildComponents( + { + '@alilc/button': Button + }, + { + Button: { + componentName: 'Button', + package: '@alilc/button', + destructuring: false, + } + }, + () => {}, + )) + .toEqual({ + Button, + }); + }); + + it('Button and ButtonGroup', () => { + expect( + buildComponents( + { + '@alilc/button': { + Button, + ButtonGroup, + } + }, + { + Button: { + componentName: 'Button', + package: '@alilc/button', + destructuring: true, + exportName: 'Button', + subName: 'Button', + }, + ButtonGroup: { + componentName: 'ButtonGroup', + package: '@alilc/button', + destructuring: true, + exportName: 'ButtonGroup', + subName: 'ButtonGroup', + } + }, + () => {}, + )) + .toEqual({ + Button, + ButtonGroup, + }); + }); + + it('ButtonGroup and ButtonGroup.Button', () => { + expect( + buildComponents( + { + '@alilc/button': { + ButtonGroup, + } + }, + { + Button: { + componentName: 'Button', + package: '@alilc/button', + destructuring: true, + exportName: 'ButtonGroup', + subName: 'ButtonGroup.Button', + }, + ButtonGroup: { + componentName: 'ButtonGroup', + package: '@alilc/button', + destructuring: true, + exportName: 'ButtonGroup', + subName: 'ButtonGroup', + } + }, + () => {}, + )) + .toEqual({ + Button, + ButtonGroup, + }); + }); + + it('ButtonGroup.default and ButtonGroup.Button', () => { + expect( + buildComponents( + { + '@alilc/button': ButtonGroup, + }, + { + Button: { + componentName: 'Button', + package: '@alilc/button', + destructuring: true, + exportName: 'Button', + subName: 'Button', + }, + ButtonGroup: { + componentName: 'ButtonGroup', + package: '@alilc/button', + destructuring: true, + exportName: 'default', + subName: 'default', + } + }, + () => {}, + )) + .toEqual({ + Button, + ButtonGroup, + }); + }); + + it('no npm component', () => { + expect( + buildComponents( + { + '@alilc/button': Button, + }, + { + Button: null, + }, + () => {}, + )) + .toEqual({}); + }); + + it('no npm component and global button', () => { + window.Button = Button; + expect( + buildComponents( + {}, + { + Button: null, + }, + () => {}, + )) + .toEqual({ + Button, + }); + window.Button = null; + }); + + it('componentsMap value is component funtion', () => { + expect( + buildComponents( + {}, + { + Button, + }, + () => {}, + )) + .toEqual({ + Button, + }); + }); + + + it('componentsMap value is component', () => { + expect( + buildComponents( + {}, + { + Button: WrapButton, + }, + () => {}, + )) + .toEqual({ + Button: WrapButton, + }); + }); + + it('componentsMap value is mix component', () => { + expect( + buildComponents( + {}, + { + Button: { + WrapButton, + Button, + ButtonGroup, + }, + }, + () => {}, + )) + .toEqual({ + Button: { + WrapButton, + Button, + ButtonGroup, + }, + }); + }); + + it('componentsMap value is Lowcode Component', () => { + expect( + buildComponents( + {}, + { + Button: { + componentName: 'Component', + schema: {}, + }, + }, + (component) => { + return component as any; + }, + )) + .toEqual({ + Button: { + componentsMap: [], + componentsTree: [ + { + componentName: 'Component', + schema: {}, + } + ], + version: "", + }, + }); + }) +}); + +describe('build div component', () => { + it('build div component', () => { + const components = buildComponents( + { + '@alilc/div': 'div' + }, + { + div: { + componentName: 'div', + package: '@alilc/div' + } + }, + () => {}, + ); + + expect(components['div']).not.toBeNull(); + }) +}) \ No newline at end of file diff --git a/packages/utils/test/src/build-components/getProjectUtils.test.ts b/packages/utils/test/src/build-components/getProjectUtils.test.ts new file mode 100644 index 0000000000..216f3db427 --- /dev/null +++ b/packages/utils/test/src/build-components/getProjectUtils.test.ts @@ -0,0 +1,43 @@ +import { getProjectUtils } from "../../../src/build-components"; + +const sampleUtil = () => 'I am a sample util'; +const sampleUtil2 = () => 'I am a sample util 2'; + +describe('get project utils', () => { + it('get utils with destructuring true', () => { + expect(getProjectUtils( + { + '@alilc/utils': { + destructuring: true, + sampleUtil, + sampleUtil2, + } + }, + [{ + name: 'sampleUtils', + npm: { + package: '@alilc/utils' + } + }] + )).toEqual({ + sampleUtil, + sampleUtil2, + }) + }); + + it('get utils with name', () => { + expect(getProjectUtils( + { + '@alilc/utils': sampleUtil + }, + [{ + name: 'sampleUtil', + npm: { + package: '@alilc/utils' + } + }] + )).toEqual({ + sampleUtil, + }) + }); +}) \ No newline at end of file diff --git a/packages/utils/test/src/build-components/getSubComponent.test.ts b/packages/utils/test/src/build-components/getSubComponent.test.ts new file mode 100644 index 0000000000..ca91bb2304 --- /dev/null +++ b/packages/utils/test/src/build-components/getSubComponent.test.ts @@ -0,0 +1,85 @@ +import { getSubComponent } from '../../../src/build-components'; + +function Button() {} + +function ButtonGroup() {} + +ButtonGroup.Button = Button; + +function OnlyButtonGroup() {} + +describe('getSubComponent library is object', () => { + it('get Button from Button', () => { + expect(getSubComponent({ + Button, + }, ['Button'])).toBe(Button); + }); + + it('get ButtonGroup.Button from ButtonGroup', () => { + expect(getSubComponent({ + ButtonGroup, + }, ['ButtonGroup', 'Button'])).toBe(Button); + }); + + it('get ButtonGroup from ButtonGroup', () => { + expect(getSubComponent({ + ButtonGroup, + }, ['ButtonGroup'])).toBe(ButtonGroup); + }); + + it('get ButtonGroup.Button from OnlyButtonGroup', () => { + expect(getSubComponent({ + ButtonGroup: OnlyButtonGroup, + }, ['ButtonGroup', 'Button'])).toBe(OnlyButtonGroup); + }); +}); + +describe('getSubComponent library is null', () => { + it('getSubComponent library is null', () => { + expect(getSubComponent(null, ['ButtonGroup', 'Button'])).toBeNull(); + }); +}) + +describe('getSubComponent paths is []', () => { + it('getSubComponent paths is []', () => { + expect(getSubComponent(Button, [])).toBe(Button); + }); +}); + +describe('getSubComponent make error', () => { + it('library is string', () => { + expect(getSubComponent(true, ['Button'])).toBe(null); + }); + + it('library is boolean', () => { + expect(getSubComponent('I am a string', ['Button'])).toBe(null); + }); + + it('library is number', () => { + expect(getSubComponent(123, ['Button'])).toBe(null); + }); + + it('library ButtonGroup is null', () => { + expect(getSubComponent({ + ButtonGroup: null, + }, ['ButtonGroup', 'Button'])).toBe(null); + }); + + it('library ButtonGroup.Button is null', () => { + expect(getSubComponent({ + ButtonGroup: null, + }, ['ButtonGroup', 'Button', 'SubButton'])).toBe(null); + }); + + it('path s is [[]]', () => { + expect(getSubComponent({ + ButtonGroup: null, + }, [['ButtonGroup'] as any, 'Button'])).toBe(null); + }); + + it('ButtonGroup is undefined', () => { + expect(getSubComponent({ + ButtonGroup: undefined, + }, ['ButtonGroup', 'Button'])).toBe(null); + }); +}) \ No newline at end of file diff --git a/packages/utils/test/src/check-prop-types.test.ts b/packages/utils/test/src/check-prop-types.test.ts new file mode 100644 index 0000000000..74146f2d94 --- /dev/null +++ b/packages/utils/test/src/check-prop-types.test.ts @@ -0,0 +1,255 @@ +import { checkPropTypes, transformPropTypesRuleToString } from '../../src/check-prop-types'; +import PropTypes from 'prop-types'; + +describe('checkPropTypes', () => { + it('should validate correctly with valid prop type', () => { + expect(checkPropTypes(123, 'age', PropTypes.number, 'TestComponent')).toBe(true); + expect(checkPropTypes('123', 'age', PropTypes.string, 'TestComponent')).toBe(true); + }); + + it('should log a warning and return false with invalid prop type', () => { + expect(checkPropTypes(123, 'age', PropTypes.string, 'TestComponent')).toBe(false); + expect(checkPropTypes('123', 'age', PropTypes.number, 'TestComponent')).toBe(false); + }); + + it('should validate correctly with valid object prop type', () => { + expect(checkPropTypes({ a: 123 }, 'age', PropTypes.object, 'TestComponent')).toBe(true); + expect(checkPropTypes({ a: '123' }, 'age', PropTypes.object, 'TestComponent')).toBe(true); + }); + + it('should validate correctly with valid object string prop type', () => { + expect(checkPropTypes({ a: 123 }, 'age', 'object', 'TestComponent')).toBe(true); + expect(checkPropTypes({ a: '123' }, 'age', 'object', 'TestComponent')).toBe(true); + }); + + it('should validate correctly with valid isRequired prop type', () => { + const rule = { + type: 'string', + isRequired: true, + }; + expect(transformPropTypesRuleToString(rule)).toBe('PropTypes.string.isRequired'); + expect(checkPropTypes('News', 'type', rule, 'TestComponent')).toBe(true); + expect(checkPropTypes(undefined, 'type', rule, 'TestComponent')).toBe(false); + }); + + it('should handle custom rule functions correctly', () => { + const customRule = (props, propName) => { + if (props[propName] !== 123) { + return new Error('Invalid value'); + } + }; + const result = checkPropTypes(123, 'customProp', customRule, 'TestComponent'); + expect(result).toBe(true); + }); + + + it('should interpret and validate a rule given as a string', () => { + const result = checkPropTypes(123, 'age', 'PropTypes.number', 'TestComponent'); + expect(result).toBe(true); + }); + + it('should interpret and validate a rule given as a string', () => { + expect(checkPropTypes(123, 'age', 'number', 'TestComponent')).toBe(true); + expect(checkPropTypes('123', 'age', 'string', 'TestComponent')).toBe(true); + }); + + it('should log a warning for invalid rule type', () => { + const result = checkPropTypes(123, 'age', 123, 'TestComponent'); + expect(result).toBe(true); + }); + + // oneOf + it('should validate correctly with valid oneOf prop type', () => { + const rule = { + type: 'oneOf', + value: ['News', 'Photos'], + } + expect(transformPropTypesRuleToString(rule)).toBe(`PropTypes.oneOf(["News","Photos"])`); + expect(checkPropTypes('News', 'type', rule, 'TestComponent')).toBe(true); + expect(checkPropTypes('Others', 'type', rule, 'TestComponent')).toBe(false); + }); + + // oneOfType + it('should validate correctly with valid oneOfType prop type', () => { + const rule = { + type: 'oneOfType', + value: ['string', 'number', { + type: 'array', + isRequired: true, + }], + }; + expect(transformPropTypesRuleToString(rule)).toBe('PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.array.isRequired])'); + expect(checkPropTypes(['News', 'Photos'], 'type', rule, 'TestComponent')).toBe(true); + expect(checkPropTypes('News', 'type', rule, 'TestComponent')).toBe(true); + expect(checkPropTypes(123, 'type', rule, 'TestComponent')).toBe(true); + expect(checkPropTypes({}, 'type', rule, 'TestComponent')).toBe(false); + }); + + it('should validate correctly with valid oneOfType prop type', () => { + const rule = { + type: 'oneOfType', + value: [ + 'bool', + { + type: 'shape', + value: [ + { + name: 'type', + propType: { + type: 'oneOf', + value: ['JSExpression'], + } + }, + { + name: 'value', + propType: 'string', + }, + ], + }, + ], + }; + expect(transformPropTypesRuleToString(rule)).toBe('PropTypes.oneOfType([PropTypes.bool, PropTypes.shape({type: PropTypes.oneOf(["JSExpression"]),value: PropTypes.string})])'); + expect(checkPropTypes(true, 'type', rule, 'TestComponent')).toBe(true); + expect(checkPropTypes({ type: 'JSExpression', value: '1 + 1 === 2' }, 'type', rule, 'TestComponent')).toBe(true); + expect(checkPropTypes({ type: 'JSExpression' }, 'type', rule, 'TestComponent')).toBe(true); + expect(checkPropTypes({ type: 'JSExpression', value: 123 }, 'type', rule, 'TestComponent')).toBe(false); + }); + + it('should log a warning for invalid type', () => { + const rule = { + type: 'inval', + value: ['News', 'Photos'], + } + expect(transformPropTypesRuleToString(rule)).toBe('PropTypes.any'); + expect(checkPropTypes('News', 'type', rule, 'TestComponent')).toBe(true); + expect(checkPropTypes('Others', 'type', rule, 'TestComponent')).toBe(true); + }); + + // arrayOf + it('should validate correctly with valid arrayOf prop type', () => { + const rule = { + type: 'arrayOf', + value: { + type: 'string', + isRequired: true, + }, + }; + expect(transformPropTypesRuleToString(rule)).toBe('PropTypes.arrayOf(PropTypes.string.isRequired)'); + expect(checkPropTypes(['News', 'Photos'], 'type', rule, 'TestComponent')).toBe(true); + expect(checkPropTypes(['News', 123], 'type', rule, 'TestComponent')).toBe(false); + }); + + // objectOf + it('should validate correctly with valid objectOf prop type', () => { + const rule = { + type: 'objectOf', + value: { + type: 'string', + isRequired: true, + }, + }; + expect(transformPropTypesRuleToString(rule)).toBe('PropTypes.objectOf(PropTypes.string.isRequired)'); + expect(checkPropTypes({ a: 'News', b: 'Photos' }, 'type', rule, 'TestComponent')).toBe(true); + expect(checkPropTypes({ a: 'News', b: 123 }, 'type', rule, 'TestComponent')).toBe(false); + }); + + // shape + it('should validate correctly with valid shape prop type', () => { + const rule = { + type: 'shape', + value: [ + { + name: 'a', + propType: { + type: 'string', + isRequired: true, + }, + }, + { + name: 'b', + propType: { + type: 'number', + isRequired: true, + }, + }, + ], + }; + expect(transformPropTypesRuleToString(rule)).toBe('PropTypes.shape({a: PropTypes.string.isRequired,b: PropTypes.number.isRequired})'); + expect(checkPropTypes({ a: 'News', b: 123 }, 'type', rule, 'TestComponent')).toBe(true); + expect(checkPropTypes({ a: 'News', b: 'Photos' }, 'type', rule, 'TestComponent')).toBe(false); + + // isRequired + const rule2 = { + type: 'shape', + value: [ + { + name: 'a', + propType: { + type: 'string', + isRequired: true, + }, + }, + { + name: 'b', + propType: { + type: 'number', + isRequired: false, + }, + }, + ], + }; + expect(transformPropTypesRuleToString(rule2)).toBe('PropTypes.shape({a: PropTypes.string.isRequired,b: PropTypes.number})'); + expect(checkPropTypes({ a: 'News', b: 123 }, 'type', rule2, 'TestComponent')).toBe(true); + expect(checkPropTypes({ b: 123 }, 'type', rule2, 'TestComponent')).toBe(false); + }); + + // exact + it('should validate correctly with valid exact prop type', () => { + const rule = { + type: 'exact', + value: [ + { + name: 'a', + propType: { + type: 'string', + isRequired: true, + }, + }, + { + name: 'b', + propType: { + type: 'number', + isRequired: true, + }, + }, + ], + }; + expect(transformPropTypesRuleToString(rule)).toBe('PropTypes.exact({a: PropTypes.string.isRequired,b: PropTypes.number.isRequired})'); + expect(checkPropTypes({ a: 'News', b: 123 }, 'type', rule, 'TestComponent')).toBe(true); + expect(checkPropTypes({ a: 'News', b: 'Photos' }, 'type', rule, 'TestComponent')).toBe(false); + + // isRequired + const rule2 = { + type: 'exact', + value: [ + { + name: 'a', + propType: { + type: 'string', + isRequired: true, + }, + }, + { + name: 'b', + propType: { + type: 'number', + isRequired: false, + }, + }, + ], + }; + expect(transformPropTypesRuleToString(rule2)).toBe('PropTypes.exact({a: PropTypes.string.isRequired,b: PropTypes.number})'); + expect(checkPropTypes({ a: 'News', b: 123 }, 'type', rule2, 'TestComponent')).toBe(true); + expect(checkPropTypes({ b: 123 }, 'type', rule2, 'TestComponent')).toBe(false); + }); +}); \ No newline at end of file diff --git a/packages/utils/test/src/check-types/is-action-content-object.test.ts b/packages/utils/test/src/check-types/is-action-content-object.test.ts new file mode 100644 index 0000000000..08b95788d1 --- /dev/null +++ b/packages/utils/test/src/check-types/is-action-content-object.test.ts @@ -0,0 +1,20 @@ +import { isActionContentObject } from '../../../src/check-types/is-action-content-object'; + +describe('isActionContentObject', () => { + test('should return true for an object', () => { + const obj = { prop: 'value' }; + expect(isActionContentObject(obj)).toBe(true); + }); + + test('should return false for a non-object', () => { + expect(isActionContentObject('not an object')).toBe(false); + expect(isActionContentObject(123)).toBe(false); + expect(isActionContentObject(null)).toBe(false); + expect(isActionContentObject(undefined)).toBe(false); + }); + + test('should return false for an empty object', () => { + const obj = {}; + expect(isActionContentObject(obj)).toBe(true); + }); +}); diff --git a/packages/utils/test/src/check-types/is-basic-prop-type.test.ts b/packages/utils/test/src/check-types/is-basic-prop-type.test.ts new file mode 100644 index 0000000000..81a1bf0d34 --- /dev/null +++ b/packages/utils/test/src/check-types/is-basic-prop-type.test.ts @@ -0,0 +1,11 @@ +import { isBasicPropType } from '../../../src'; + +describe('test isBasicPropType ', () => { + it('should work', () => { + expect(isBasicPropType(null)).toBeFalsy(); + expect(isBasicPropType(undefined)).toBeFalsy(); + expect(isBasicPropType({})).toBeFalsy(); + expect(isBasicPropType({ type: 'any other type' })).toBeFalsy(); + expect(isBasicPropType('string')).toBeTruthy(); + }); +}); \ No newline at end of file diff --git a/packages/utils/test/src/check-types/is-custom-view.test.tsx b/packages/utils/test/src/check-types/is-custom-view.test.tsx new file mode 100644 index 0000000000..62c08780e6 --- /dev/null +++ b/packages/utils/test/src/check-types/is-custom-view.test.tsx @@ -0,0 +1,26 @@ +import React from 'react'; +import { isCustomView } from '../../../src/check-types/is-custom-view'; +import { IPublicTypeCustomView } from '@alilc/lowcode-types'; + +describe('isCustomView', () => { + test('should return true when obj is a valid React element', () => { + const obj: IPublicTypeCustomView = <div>Hello, World!</div>; + expect(isCustomView(obj)).toBe(true); + }); + + test('should return true when obj is a valid React component', () => { + const MyComponent: React.FC = () => <div>Hello, World!</div>; + const obj: IPublicTypeCustomView = MyComponent; + expect(isCustomView(obj)).toBe(true); + }); + + test('should return false when obj is null or undefined', () => { + expect(isCustomView(null)).toBe(false); + expect(isCustomView(undefined)).toBe(false); + }); + + test('should return false when obj is not a valid React element or component', () => { + const obj: IPublicTypeCustomView = 'not a valid object'; + expect(isCustomView(obj)).toBe(false); + }); +}); diff --git a/packages/utils/test/src/check-types/is-dom-text.test.ts b/packages/utils/test/src/check-types/is-dom-text.test.ts new file mode 100644 index 0000000000..50dce0fb7a --- /dev/null +++ b/packages/utils/test/src/check-types/is-dom-text.test.ts @@ -0,0 +1,13 @@ +import { isDOMText } from '../../../src/check-types/is-dom-text'; + +describe('isDOMText', () => { + it('should return true when the input is a string', () => { + const result = isDOMText('Hello World'); + expect(result).toBe(true); + }); + + it('should return false when the input is not a string', () => { + const result = isDOMText(123); + expect(result).toBe(false); + }); +}); diff --git a/packages/utils/test/src/check-types/is-drag-any-object.test.ts b/packages/utils/test/src/check-types/is-drag-any-object.test.ts new file mode 100644 index 0000000000..6a835f2be3 --- /dev/null +++ b/packages/utils/test/src/check-types/is-drag-any-object.test.ts @@ -0,0 +1,32 @@ +import { isDragAnyObject } from '../../../src/check-types/is-drag-any-object'; +import { IPublicEnumDragObjectType } from '@alilc/lowcode-types'; + +describe('isDragAnyObject', () => { + it('should return false if obj is null', () => { + const result = isDragAnyObject(null); + expect(result).toBe(false); + }); + + it('should return false if obj is number', () => { + const result = isDragAnyObject(2); + expect(result).toBe(false); + }); + + it('should return false if obj.type is NodeData', () => { + const obj = { type: IPublicEnumDragObjectType.NodeData }; + const result = isDragAnyObject(obj); + expect(result).toBe(false); + }); + + it('should return false if obj.type is Node', () => { + const obj = { type: IPublicEnumDragObjectType.Node }; + const result = isDragAnyObject(obj); + expect(result).toBe(false); + }); + + it('should return true if obj.type is anything else', () => { + const obj = { type: 'SomeOtherType' }; + const result = isDragAnyObject(obj); + expect(result).toBe(true); + }); +}); diff --git a/packages/utils/test/src/check-types/is-drag-node-data-object.test.ts b/packages/utils/test/src/check-types/is-drag-node-data-object.test.ts new file mode 100644 index 0000000000..92867843a2 --- /dev/null +++ b/packages/utils/test/src/check-types/is-drag-node-data-object.test.ts @@ -0,0 +1,29 @@ +import { IPublicEnumDragObjectType, IPublicTypeDragNodeDataObject } from '@alilc/lowcode-types'; +import { isDragNodeDataObject } from '../../../src/check-types/is-drag-node-data-object'; + +describe('isDragNodeDataObject', () => { + test('should return true for valid IPublicTypeDragNodeDataObject', () => { + const obj: IPublicTypeDragNodeDataObject = { + type: IPublicEnumDragObjectType.NodeData, + // 其他属性... + }; + + expect(isDragNodeDataObject(obj)).toBe(true); + }); + + test('should return false for invalid IPublicTypeDragNodeDataObject', () => { + const obj: any = { + type: 'InvalidType', + // 其他属性... + }; + + expect(isDragNodeDataObject(obj)).toBe(false); + }); + + test('should return false for null or undefined', () => { + expect(isDragNodeDataObject(null)).toBe(false); + expect(isDragNodeDataObject(undefined)).toBe(false); + }); + + // 可以添加更多测试用例... +}); diff --git a/packages/utils/test/src/check-types/is-drag-node-object.test.ts b/packages/utils/test/src/check-types/is-drag-node-object.test.ts new file mode 100644 index 0000000000..3561c87885 --- /dev/null +++ b/packages/utils/test/src/check-types/is-drag-node-object.test.ts @@ -0,0 +1,36 @@ +import { IPublicEnumDragObjectType } from '@alilc/lowcode-types'; +import { isDragNodeObject } from '../../../src/check-types/is-drag-node-object'; + +describe('isDragNodeObject', () => { + it('should return true if the object is of IPublicTypeDragNodeObject type and has type IPublicEnumDragObjectType.Node', () => { + const obj = { + type: IPublicEnumDragObjectType.Node, + //... other properties + }; + + expect(isDragNodeObject(obj)).toBe(true); + }); + + it('should return false if the object is not of IPublicTypeDragNodeObject type', () => { + const obj = { + type: IPublicEnumDragObjectType.OtherType, + //... other properties + }; + + expect(isDragNodeObject(obj)).toBe(false); + }); + + it('should return false if the object is of IPublicTypeDragNodeObject type but type is not IPublicEnumDragObjectType.Node', () => { + const obj = { + type: IPublicEnumDragObjectType.OtherType, + //... other properties + }; + + expect(isDragNodeObject(obj)).toBe(false); + }); + + it('should return false if the object is null or undefined', () => { + expect(isDragNodeObject(null)).toBe(false); + expect(isDragNodeObject(undefined)).toBe(false); + }); +}); diff --git a/packages/utils/test/src/check-types/is-dynamic-setter.test.ts b/packages/utils/test/src/check-types/is-dynamic-setter.test.ts new file mode 100644 index 0000000000..72f55367d0 --- /dev/null +++ b/packages/utils/test/src/check-types/is-dynamic-setter.test.ts @@ -0,0 +1,28 @@ +import { Component } from 'react'; +import { isDynamicSetter } from '../../../src/check-types/is-dynamic-setter'; + +describe('isDynamicSetter', () => { + it('returns true if input is a dynamic setter function', () => { + const dynamicSetter = (value: any) => { + // some implementation + }; + + expect(isDynamicSetter(dynamicSetter)).toBeTruthy(); + }); + + it('returns false if input is not a dynamic setter function', () => { + expect(isDynamicSetter('not a function')).toBeFalsy(); + expect(isDynamicSetter(null)).toBeFalsy(); + expect(isDynamicSetter(undefined)).toBeFalsy(); + expect(isDynamicSetter(2)).toBeFalsy(); + expect(isDynamicSetter(0)).toBeFalsy(); + }); + + it('returns false if input is a React class', () => { + class ReactClass extends Component { + // some implementation + } + + expect(isDynamicSetter(ReactClass)).toBeFalsy(); + }); +}); diff --git a/packages/utils/test/src/check-types/is-i18n-data.test.ts b/packages/utils/test/src/check-types/is-i18n-data.test.ts new file mode 100644 index 0000000000..2e903a2ed2 --- /dev/null +++ b/packages/utils/test/src/check-types/is-i18n-data.test.ts @@ -0,0 +1,27 @@ +import { isI18nData } from '../../../src/check-types/is-i18n-data'; +import { IPublicTypeI18nData } from "@alilc/lowcode-types"; + +describe('isI18nData', () => { + it('should return true for valid i18n data', () => { + const i18nData: IPublicTypeI18nData = { + type: 'i18n', + // add any other required properties here + }; + + expect(isI18nData(i18nData)).toBe(true); + }); + + it('should return false for invalid i18n data', () => { + const invalidData = { + type: 'some-other-type', + // add any other properties here + }; + + expect(isI18nData(invalidData)).toBe(false); + }); + + it('should return false for undefined or null', () => { + expect(isI18nData(undefined)).toBe(false); + expect(isI18nData(null)).toBe(false); + }); +}); diff --git a/packages/utils/test/src/check-types/is-isfunction.test.ts b/packages/utils/test/src/check-types/is-isfunction.test.ts new file mode 100644 index 0000000000..5154282285 --- /dev/null +++ b/packages/utils/test/src/check-types/is-isfunction.test.ts @@ -0,0 +1,61 @@ +import { isInnerJsFunction, isJSFunction } from '../../../src/check-types/is-isfunction'; + +describe('isInnerJsFunction', () => { + test('should return true for valid input', () => { + const data = { + type: 'JSExpression', + source: '', + value: '', + extType: 'function' + }; + + expect(isInnerJsFunction(data)).toBe(true); + }); + + test('should return false for invalid input', () => { + const data = { + type: 'JSExpression', + source: '', + value: '', + extType: 'object' + }; + + expect(isInnerJsFunction(data)).toBe(false); + expect(isInnerJsFunction(null)).toBe(false); + expect(isInnerJsFunction(undefined)).toBe(false); + expect(isInnerJsFunction(1)).toBe(false); + expect(isInnerJsFunction(0)).toBe(false); + expect(isInnerJsFunction('string')).toBe(false); + expect(isInnerJsFunction('')).toBe(false); + }); +}); + +describe('isJSFunction', () => { + test('should return true for valid input', () => { + const data = { + type: 'JSFunction', + }; + + expect(isJSFunction(data)).toBe(true); + }); + + test('should return true for inner js function', () => { + const data = { + type: 'JSExpression', + source: '', + value: '', + extType: 'function' + }; + + expect(isJSFunction(data)).toBe(true); + }); + + test('should return false for invalid input', () => { + expect(isJSFunction(null)).toBe(false); + expect(isJSFunction(undefined)).toBe(false); + expect(isJSFunction('string')).toBe(false); + expect(isJSFunction('')).toBe(false); + expect(isJSFunction(0)).toBe(false); + expect(isJSFunction(2)).toBe(false); + }); +}); diff --git a/packages/utils/test/src/check-types/is-jsblock.test.ts b/packages/utils/test/src/check-types/is-jsblock.test.ts new file mode 100644 index 0000000000..e44e9eb705 --- /dev/null +++ b/packages/utils/test/src/check-types/is-jsblock.test.ts @@ -0,0 +1,22 @@ +import { isJSBlock } from '../../../src/check-types/is-jsblock'; + +describe('isJSBlock', () => { + it('should return false if data is null or undefined', () => { + expect(isJSBlock(null)).toBe(false); + expect(isJSBlock(undefined)).toBe(false); + }); + + it('should return false if data is not an object', () => { + expect(isJSBlock('JSBlock')).toBe(false); + expect(isJSBlock(123)).toBe(false); + expect(isJSBlock(true)).toBe(false); + }); + + it('should return false if data.type is not "JSBlock"', () => { + expect(isJSBlock({ type: 'InvalidType' })).toBe(false); + }); + + it('should return true if data is an object and data.type is "JSBlock"', () => { + expect(isJSBlock({ type: 'JSBlock' })).toBe(true); + }); +}); diff --git a/packages/utils/test/src/check-types/is-jsexpression.test.ts b/packages/utils/test/src/check-types/is-jsexpression.test.ts new file mode 100644 index 0000000000..dd8509a3b3 --- /dev/null +++ b/packages/utils/test/src/check-types/is-jsexpression.test.ts @@ -0,0 +1,39 @@ +import { isJSExpression } from '../../../src/check-types/is-jsexpression'; + +describe('isJSExpression', () => { + it('should return true if the input is a valid JSExpression object', () => { + const validJSExpression = { + type: 'JSExpression', + extType: 'variable', + }; + + const result = isJSExpression(validJSExpression); + + expect(result).toBe(true); + }); + + it('should return false if the input is not a valid JSExpression object', () => { + const invalidJSExpression = { + type: 'JSExpression', + extType: 'function', + }; + + const result = isJSExpression(invalidJSExpression); + + expect(result).toBe(false); + }); + + it('should return false if the input is null', () => { + const result = isJSExpression(null); + + expect(result).toBe(false); + }); + + it('should return false if the input is undefined', () => { + const result = isJSExpression(undefined); + + expect(result).toBe(false); + }); + + // 添加其他需要的测试 +}); diff --git a/packages/utils/test/src/check-types/is-jsslot.test.ts b/packages/utils/test/src/check-types/is-jsslot.test.ts new file mode 100644 index 0000000000..5c130cddfd --- /dev/null +++ b/packages/utils/test/src/check-types/is-jsslot.test.ts @@ -0,0 +1,37 @@ +import { isJSSlot } from '../../../src/check-types/is-jsslot'; +import { IPublicTypeJSSlot } from '@alilc/lowcode-types'; + +describe('isJSSlot', () => { + it('should return true when input is of type IPublicTypeJSSlot', () => { + const input: IPublicTypeJSSlot = { + type: 'JSSlot', + // other properties of IPublicTypeJSSlot + }; + + const result = isJSSlot(input); + + expect(result).toBe(true); + }); + + it('should return false when input is not of type IPublicTypeJSSlot', () => { + const input = { + type: 'OtherType', + // other properties + }; + + const result = isJSSlot(input); + + expect(result).toBe(false); + }); + + it('should return false when input is null or undefined', () => { + const input1 = null; + const input2 = undefined; + + const result1 = isJSSlot(input1); + const result2 = isJSSlot(input2); + + expect(result1).toBe(false); + expect(result2).toBe(false); + }); +}); diff --git a/packages/utils/test/src/check-types/is-location-children-detail.test.ts b/packages/utils/test/src/check-types/is-location-children-detail.test.ts new file mode 100644 index 0000000000..f209e8e63f --- /dev/null +++ b/packages/utils/test/src/check-types/is-location-children-detail.test.ts @@ -0,0 +1,27 @@ +import { isLocationChildrenDetail } from '../../../src/check-types/is-location-children-detail'; +import { IPublicTypeLocationChildrenDetail, IPublicTypeLocationDetailType } from '@alilc/lowcode-types'; + +describe('isLocationChildrenDetail', () => { + it('should return true when obj is IPublicTypeLocationChildrenDetail', () => { + const obj: IPublicTypeLocationChildrenDetail = { + type: IPublicTypeLocationDetailType.Children, + // 添加其他必要的属性 + }; + + expect(isLocationChildrenDetail(obj)).toBe(true); + }); + + it('should return false when obj is not IPublicTypeLocationChildrenDetail', () => { + const obj = { + type: 'other', + // 添加其他必要的属性 + }; + + expect(isLocationChildrenDetail(obj)).toBe(false); + expect(isLocationChildrenDetail(null)).toBe(false); + expect(isLocationChildrenDetail(undefined)).toBe(false); + expect(isLocationChildrenDetail('string')).toBe(false); + expect(isLocationChildrenDetail(0)).toBe(false); + expect(isLocationChildrenDetail(2)).toBe(false); + }); +}); diff --git a/packages/utils/test/src/check-types/is-location-data.test.ts b/packages/utils/test/src/check-types/is-location-data.test.ts new file mode 100644 index 0000000000..ba2e2c8be0 --- /dev/null +++ b/packages/utils/test/src/check-types/is-location-data.test.ts @@ -0,0 +1,44 @@ +import { isLocationData } from '../../../src/check-types/is-location-data'; +import { IPublicTypeLocationData } from '@alilc/lowcode-types'; + +describe('isLocationData', () => { + it('should return true when obj is valid location data', () => { + const obj: IPublicTypeLocationData = { + target: 'some target', + detail: 'some detail', + }; + + const result = isLocationData(obj); + + expect(result).toBe(true); + }); + + it('should return false when obj is missing target or detail', () => { + const obj1 = { + target: 'some target', + // missing detail + }; + + const obj2 = { + // missing target + detail: 'some detail', + }; + + const result1 = isLocationData(obj1); + const result2 = isLocationData(obj2); + + expect(result1).toBe(false); + expect(result2).toBe(false); + }); + + it('should return false when obj is null or undefined', () => { + const obj1 = null; + const obj2 = undefined; + + const result1 = isLocationData(obj1); + const result2 = isLocationData(obj2); + + expect(result1).toBe(false); + expect(result2).toBe(false); + }); +}); diff --git a/packages/utils/test/src/check-types/is-lowcode-component-type.test.ts b/packages/utils/test/src/check-types/is-lowcode-component-type.test.ts new file mode 100644 index 0000000000..35b76f00b5 --- /dev/null +++ b/packages/utils/test/src/check-types/is-lowcode-component-type.test.ts @@ -0,0 +1,21 @@ +import { isLowCodeComponentType } from '../../../src/check-types/is-lowcode-component-type'; +import { IPublicTypeLowCodeComponent, IPublicTypeProCodeComponent } from '@alilc/lowcode-types'; + +describe('isLowCodeComponentType', () => { + test('should return true for a low code component type', () => { + const desc: IPublicTypeLowCodeComponent = { + // create a valid low code component description + }; + + expect(isLowCodeComponentType(desc)).toBe(true); + }); + + test('should return false for a pro code component type', () => { + const desc: IPublicTypeProCodeComponent = { + // create a valid pro code component description + package: 'pro-code' + }; + + expect(isLowCodeComponentType(desc)).toBe(false); + }); +}); diff --git a/packages/utils/test/src/check-types/is-lowcode-project-schema.test.ts b/packages/utils/test/src/check-types/is-lowcode-project-schema.test.ts new file mode 100644 index 0000000000..bb750ed88b --- /dev/null +++ b/packages/utils/test/src/check-types/is-lowcode-project-schema.test.ts @@ -0,0 +1,42 @@ +import { isLowcodeProjectSchema } from "../../../src/check-types/is-lowcode-project-schema"; + +describe("isLowcodeProjectSchema", () => { + it("should return false when data is null", () => { + const result = isLowcodeProjectSchema(null); + expect(result).toBe(false); + }); + + it("should return false when data is undefined", () => { + const result = isLowcodeProjectSchema(undefined); + expect(result).toBe(false); + }); + + it("should return false when data is not an object", () => { + const result = isLowcodeProjectSchema("not an object"); + expect(result).toBe(false); + }); + + it("should return false when componentsTree is missing", () => { + const data = { someKey: "someValue" }; + const result = isLowcodeProjectSchema(data); + expect(result).toBe(false); + }); + + it("should return false when componentsTree is an empty array", () => { + const data = { componentsTree: [] }; + const result = isLowcodeProjectSchema(data); + expect(result).toBe(false); + }); + + it("should return false when the first element of componentsTree is not a component schema", () => { + const data = { componentsTree: [{}] }; + const result = isLowcodeProjectSchema(data); + expect(result).toBe(false); + }); + + it("should return true when all conditions are met", () => { + const data = { componentsTree: [{ prop: "value", componentName: 'Component' }] }; + const result = isLowcodeProjectSchema(data); + expect(result).toBe(true); + }); +}); diff --git a/packages/utils/test/src/check-types/is-node-schema.test.ts b/packages/utils/test/src/check-types/is-node-schema.test.ts new file mode 100644 index 0000000000..b5a4e39acb --- /dev/null +++ b/packages/utils/test/src/check-types/is-node-schema.test.ts @@ -0,0 +1,43 @@ +import { isNodeSchema } from '../../../src/check-types/is-node-schema'; + +describe('isNodeSchema', () => { + // 测试正常情况 + it('should return true for valid IPublicTypeNodeSchema', () => { + const validData = { + componentName: 'Component', + isNode: false, + }; + expect(isNodeSchema(validData)).toBe(true); + }); + + // 测试 null 或 undefined + it('should return false for null or undefined', () => { + expect(isNodeSchema(null)).toBe(false); + expect(isNodeSchema(undefined)).toBe(false); + }); + + // 测试没有componentName属性的情况 + it('should return false if componentName is missing', () => { + const invalidData = { + isNode: false, + }; + expect(isNodeSchema(invalidData)).toBe(false); + }); + + // 测试isNode为true的情况 + it('should return false if isNode is true', () => { + const invalidData = { + componentName: 'Component', + isNode: true, + }; + expect(isNodeSchema(invalidData)).toBe(false); + }); + + // 测试其他数据类型的情况 + it('should return false for other data types', () => { + expect(isNodeSchema('string')).toBe(false); + expect(isNodeSchema(123)).toBe(false); + expect(isNodeSchema([])).toBe(false); + expect(isNodeSchema({})).toBe(false); + }); +}); diff --git a/packages/utils/test/src/check-types/is-node.test.ts b/packages/utils/test/src/check-types/is-node.test.ts new file mode 100644 index 0000000000..d6d8dfc03d --- /dev/null +++ b/packages/utils/test/src/check-types/is-node.test.ts @@ -0,0 +1,19 @@ +import { isNode } from '../../../src/check-types/is-node'; + +describe('isNode', () => { + it('should return true for a valid node', () => { + const node = { isNode: true }; + expect(isNode(node)).toBeTruthy(); + }); + + it('should return false for an invalid node', () => { + const node = { isNode: false }; + expect(isNode(node)).toBeFalsy(); + }); + + it('should return false for an undefined node', () => { + expect(isNode(undefined)).toBeFalsy(); + }); + + // Add more test cases if needed +}); diff --git a/packages/utils/test/src/check-types/is-procode-component-type.test.ts b/packages/utils/test/src/check-types/is-procode-component-type.test.ts new file mode 100644 index 0000000000..58f435b98a --- /dev/null +++ b/packages/utils/test/src/check-types/is-procode-component-type.test.ts @@ -0,0 +1,13 @@ +import { isProCodeComponentType } from '../../../src/check-types/is-procode-component-type'; + +describe('isProCodeComponentType', () => { + it('should return true if the given desc object contains "package" property', () => { + const desc = { package: 'packageName' }; + expect(isProCodeComponentType(desc)).toBe(true); + }); + + it('should return false if the given desc object does not contain "package" property', () => { + const desc = { name: 'componentName' }; + expect(isProCodeComponentType(desc)).toBe(false); + }); +}); diff --git a/packages/utils/test/src/check-types/is-project-schema.test.ts b/packages/utils/test/src/check-types/is-project-schema.test.ts new file mode 100644 index 0000000000..0ec3f47408 --- /dev/null +++ b/packages/utils/test/src/check-types/is-project-schema.test.ts @@ -0,0 +1,28 @@ +import { IPublicTypeProjectSchema } from "@alilc/lowcode-types"; +import { isProjectSchema } from "../../../src/check-types/is-project-schema"; + +describe("isProjectSchema", () => { + it("should return true if data has componentsTree property", () => { + const data: IPublicTypeProjectSchema = { + // ... + componentsTree: { + // ... + }, + }; + expect(isProjectSchema(data)).toBe(true); + }); + + it("should return false if data does not have componentsTree property", () => { + const data = { + // ... + }; + expect(isProjectSchema(data)).toBe(false); + }); + + it("should return false if data is null or undefined", () => { + expect(isProjectSchema(null)).toBe(false); + expect(isProjectSchema(undefined)).toBe(false); + }); + + // 更多的测试用例... +}); diff --git a/packages/utils/test/src/check-types/is-required-prop-type.test.ts b/packages/utils/test/src/check-types/is-required-prop-type.test.ts new file mode 100644 index 0000000000..25515f9aab --- /dev/null +++ b/packages/utils/test/src/check-types/is-required-prop-type.test.ts @@ -0,0 +1,13 @@ +import { isRequiredPropType } from '../../../src'; + +describe('test isRequiredType', () => { + it('should work', () => { + expect(isRequiredPropType(null)).toBeFalsy(); + expect(isRequiredPropType(undefined)).toBeFalsy(); + expect(isRequiredPropType({})).toBeFalsy(); + expect(isRequiredPropType({ type: 'any other type' })).toBeFalsy(); + expect(isRequiredPropType('string')).toBeFalsy(); + expect(isRequiredPropType({ type: 'string' })).toBeTruthy(); + expect(isRequiredPropType({ type: 'string', isRequired: true })).toBeTruthy(); + }); +}) diff --git a/packages/utils/test/src/check-types/is-setter-config.test.ts b/packages/utils/test/src/check-types/is-setter-config.test.ts new file mode 100644 index 0000000000..eee234658d --- /dev/null +++ b/packages/utils/test/src/check-types/is-setter-config.test.ts @@ -0,0 +1,26 @@ +import { isSetterConfig } from '../../../src/check-types/is-setter-config'; + +describe('isSetterConfig', () => { + test('should return true for valid setter config', () => { + const config = { + componentName: 'MyComponent', + // Add other required properties here + }; + + expect(isSetterConfig(config)).toBe(true); + }); + + test('should return false for invalid setter config', () => { + const config = { + // Missing componentName property + }; + + expect(isSetterConfig(config)).toBe(false); + expect(isSetterConfig(null)).toBe(false); + expect(isSetterConfig(undefined)).toBe(false); + expect(isSetterConfig(0)).toBe(false); + expect(isSetterConfig(2)).toBe(false); + }); + + // Add more test cases for different scenarios you want to cover +}); diff --git a/packages/utils/test/src/check-types/is-setting-field.test.ts b/packages/utils/test/src/check-types/is-setting-field.test.ts new file mode 100644 index 0000000000..5f9bbd6239 --- /dev/null +++ b/packages/utils/test/src/check-types/is-setting-field.test.ts @@ -0,0 +1,18 @@ +import { isSettingField } from "../../../src/check-types/is-setting-field"; + +describe("isSettingField", () => { + it("should return true for an object that has isSettingField property", () => { + const obj = { isSettingField: true }; + expect(isSettingField(obj)).toBe(true); + }); + + it("should return false for an object that does not have isSettingField property", () => { + const obj = { foo: "bar" }; + expect(isSettingField(obj)).toBe(false); + }); + + it("should return false for a falsy value", () => { + const obj = null; + expect(isSettingField(obj)).toBe(false); + }); +}); diff --git a/packages/utils/test/src/check-types/is-title-config.test.ts b/packages/utils/test/src/check-types/is-title-config.test.ts new file mode 100644 index 0000000000..4aa6d219cb --- /dev/null +++ b/packages/utils/test/src/check-types/is-title-config.test.ts @@ -0,0 +1,18 @@ +import { isTitleConfig } from '../../../src/check-types/is-title-config'; + +describe('isTitleConfig', () => { + it('should return true for valid config object', () => { + const config = { title: 'My Title' }; + expect(isTitleConfig(config)).toBe(true); + }); + + it('should return false for invalid config object', () => { + const config = { title: 'My Title', type: 'i18n' , i18nData: {} }; + expect(isTitleConfig(config)).toBe(false); + }); + + it('should return false for non-object input', () => { + const config = 'invalid'; + expect(isTitleConfig(config)).toBe(false); + }); +}); diff --git a/packages/utils/test/src/clone-deep.test.ts b/packages/utils/test/src/clone-deep.test.ts new file mode 100644 index 0000000000..58fabc6f68 --- /dev/null +++ b/packages/utils/test/src/clone-deep.test.ts @@ -0,0 +1,30 @@ +import { cloneDeep } from '../../src/clone-deep'; + +describe('cloneDeep', () => { + it('should clone null', () => { + const src = null; + expect(cloneDeep(src)).toBeNull(); + }); + + it('should clone undefined', () => { + const src = undefined; + expect(cloneDeep(src)).toBeUndefined(); + }); + + it('should clone an array', () => { + const src = [1, 2, 3, 4]; + expect(cloneDeep(src)).toEqual(src); + }); + + it('should clone an object', () => { + const src = { name: 'John', age: 25 }; + expect(cloneDeep(src)).toEqual(src); + }); + + it('should deep clone nested objects', () => { + const src = { person: { name: 'John', age: 25 } }; + const cloned = cloneDeep(src); + expect(cloned).toEqual(src); + expect(cloned.person).not.toBe(src.person); + }); +}); \ No newline at end of file diff --git a/packages/utils/test/src/clone-enumerable-property.test.ts b/packages/utils/test/src/clone-enumerable-property.test.ts new file mode 100644 index 0000000000..2eff09e44c --- /dev/null +++ b/packages/utils/test/src/clone-enumerable-property.test.ts @@ -0,0 +1,30 @@ +import { cloneEnumerableProperty } from '../../src/clone-enumerable-property'; + +describe('cloneEnumerableProperty', () => { + test('should clone enumerable properties from origin to target', () => { + // Arrange + const target = {}; + const origin = { prop1: 1, prop2: 'hello', prop3: true }; + + // Act + const result = cloneEnumerableProperty(target, origin); + + // Assert + expect(result).toBe(target); + expect(result).toEqual(origin); + }); + + test('should exclude properties specified in excludePropertyNames', () => { + // Arrange + const target = {}; + const origin = { prop1: 1, prop2: 'hello', prop3: true }; + const excludePropertyNames = ['prop2']; + + // Act + const result = cloneEnumerableProperty(target, origin, excludePropertyNames); + + // Assert + expect(result).toBe(target); + expect(result).toEqual({ prop1: 1, prop3: true }); + }); +}); \ No newline at end of file diff --git a/packages/utils/test/src/create-content.test.tsx b/packages/utils/test/src/create-content.test.tsx new file mode 100644 index 0000000000..c41fb0f0da --- /dev/null +++ b/packages/utils/test/src/create-content.test.tsx @@ -0,0 +1,38 @@ +import React from 'react'; +import { createContent } from '../../src/create-content'; + +const MyComponent = () => { + return <div>MyComponent</div> +} +describe('createContent', () => { + test('should return the same content if it is a valid React element', () => { + const content = <div>Hello</div>; + const result = createContent(content); + + expect(result).toEqual(content); + }); + + test('should clone the element with props if props are provided', () => { + const content = <div></div>; + const props = { className: 'my-class' }; + const result = createContent(content, props); + + expect(result.props).toEqual(props); + }); + + test('should create an element with props if the content is a React component', () => { + const content = MyComponent; + const props = { className: 'my-class' }; + const result = createContent(content, props); + + expect(result.type).toEqual(content); + expect(result.props).toEqual(props); + }); + + test('should return the content if it is not a React element or a React component', () => { + const content = 'Hello'; + const result = createContent(content); + + expect(result).toEqual(content); + }); +}); diff --git a/packages/utils/test/src/create-defer.test.ts b/packages/utils/test/src/create-defer.test.ts new file mode 100644 index 0000000000..c6ab9207a9 --- /dev/null +++ b/packages/utils/test/src/create-defer.test.ts @@ -0,0 +1,16 @@ +import { createDefer } from '../../src/create-defer'; + +describe('createDefer', () => { + it('should resolve with given value', async () => { + const defer = createDefer<number>(); + defer.resolve(42); + const result = await defer.promise(); + expect(result).toBe(42); + }); + + it('should reject with given reason', async () => { + const defer = createDefer<number>(); + defer.reject('error'); + await expect(defer.promise()).rejects.toEqual('error'); + }); +}); diff --git a/packages/utils/test/src/is-object.test.ts b/packages/utils/test/src/is-object.test.ts new file mode 100644 index 0000000000..7ae984b8f8 --- /dev/null +++ b/packages/utils/test/src/is-object.test.ts @@ -0,0 +1,45 @@ +import { isObject, isI18NObject } from '../../src/is-object'; + +describe('isObject', () => { + it('should return true for an object', () => { + const obj = { key: 'value' }; + const result = isObject(obj); + expect(result).toBe(true); + }); + + it('should return false for null', () => { + const result = isObject(null); + expect(result).toBe(false); + }); + + it('should return false for a non-object value', () => { + const value = 42; // Not an object + const result = isObject(value); + expect(result).toBe(false); + }); +}); + +describe('isI18NObject', () => { + it('should return true for an I18N object', () => { + const i18nObject = { type: 'i18n', data: 'some data' }; + const result = isI18NObject(i18nObject); + expect(result).toBe(true); + }); + + it('should return false for a non-I18N object', () => { + const nonI18nObject = { type: 'other', data: 'some data' }; + const result = isI18NObject(nonI18nObject); + expect(result).toBe(false); + }); + + it('should return false for null', () => { + const result = isI18NObject(null); + expect(result).toBe(false); + }); + + it('should return false for a non-object value', () => { + const value = 42; // Not an object + const result = isI18NObject(value); + expect(result).toBe(false); + }); +}); diff --git a/packages/utils/test/src/is-react.test.tsx b/packages/utils/test/src/is-react.test.tsx new file mode 100644 index 0000000000..9ed2bd6c38 --- /dev/null +++ b/packages/utils/test/src/is-react.test.tsx @@ -0,0 +1,316 @@ +import React, { Component, createElement } from "react"; +import { + isReactComponent, + wrapReactClass, + isForwardOrMemoForward, + isMemoType, + isForwardRefType, + acceptsRef, + isReactClass, + REACT_MEMO_TYPE, + REACT_FORWARD_REF_TYPE, + } from "../../src/is-react"; + +class reactDemo extends React.Component { + +} + +const reactMemo = React.memo(reactDemo); + +const reactForwardRef = React.forwardRef((props, ref): any => { + return ''; +}); + +describe('is-react-ut', () => { + it('isReactComponent', () => { + expect(isReactComponent(null)).toBeFalsy(); + expect(isReactComponent(() => {})).toBeTruthy(); + expect(isReactComponent({ + $$typeof: Symbol.for('react.memo') + })).toBeTruthy(); + expect(isReactComponent({ + $$typeof: Symbol.for('react.forward_ref') + })).toBeTruthy(); + expect(isReactComponent(reactDemo)).toBeTruthy(); + expect(isReactComponent(reactMemo)).toBeTruthy(); + expect(isReactComponent(reactForwardRef)).toBeTruthy(); + + }); + + it('wrapReactClass', () => { + const wrap = wrapReactClass(() => {}); + expect(isReactComponent(wrap)).toBeTruthy(); + + const fun = () => {}; + fun.displayName = 'mock'; + expect(wrapReactClass(fun).displayName).toBe('mock'); + }) +}) + +describe('wrapReactClass', () => { + it('should wrap a FunctionComponent', () => { + // Create a mock FunctionComponent + const MockComponent: React.FunctionComponent = (props) => { + return <div>{props.children}</div>; + }; + + // Wrap the FunctionComponent using wrapReactClass + const WrappedComponent = wrapReactClass(MockComponent); + const instance = new WrappedComponent(); + + // Check if the WrappedComponent extends Component + expect(instance instanceof React.Component).toBe(true); + }); + + it('should render the FunctionComponent with props', () => { + // Create a mock FunctionComponent + const MockComponent: React.FunctionComponent = (props) => { + return <div>{props.children}</div>; + }; + + MockComponent.displayName = 'FunctionComponent'; + + // Wrap the FunctionComponent using wrapReactClass + const WrappedComponent = wrapReactClass(MockComponent); + + // Create some test props + const testProps = { prop1: 'value1', prop2: 'value2' }; + + // Render the WrappedComponent with test props + const rendered = createElement(WrappedComponent, testProps, 'Child Text'); + + // Check if the WrappedComponent renders the FunctionComponent with props + expect(rendered).toMatchSnapshot(); + }); +}); + +describe('isReactComponent', () => { + it('should identify a class component as a React component', () => { + class ClassComponent extends React.Component { + render() { + return <div>Class Component</div>; + } + } + + expect(isReactComponent(ClassComponent)).toBe(true); + }); + + it('should identify a functional component as a React component', () => { + const FunctionalComponent = () => { + return <div>Functional Component</div>; + }; + + expect(isReactComponent(FunctionalComponent)).toBe(true); + }); + + it('should identify a forward ref component as a React component', () => { + const ForwardRefComponent = React.forwardRef((props, ref) => { + return <div ref={ref}>Forward Ref Component</div>; + }); + + expect(isReactComponent(ForwardRefComponent)).toBe(true); + }); + + it('should identify a memo component as a React component', () => { + const MemoComponent = React.memo(() => { + return <div>Memo Component</div>; + }); + + expect(isReactComponent(MemoComponent)).toBe(true); + }); + + it('should return false for non-React components', () => { + const plainObject = { prop: 'value' }; + const notAComponent = 'Not a component'; + + expect(isReactComponent(plainObject)).toBe(false); + expect(isReactComponent(notAComponent)).toBe(false); + }); + + it('should return false for null or undefined', () => { + const nullValue = null; + const undefinedValue = undefined; + + expect(isReactComponent(nullValue)).toBe(false); + expect(isReactComponent(undefinedValue)).toBe(false); + }); +}); + +describe('isForwardOrMemoForward', () => { + it('should return true for a forwardRef component', () => { + const forwardRefComponent = React.forwardRef(() => { + return <div>ForwardRef Component</div>; + }); + + expect(isForwardOrMemoForward(forwardRefComponent)).toBe(true); + }); + + it('should return true for a memoized forwardRef component', () => { + const forwardRefComponent = React.forwardRef(() => { + return <div>ForwardRef Component</div>; + }); + + const memoizedComponent = React.memo(forwardRefComponent); + + expect(isForwardOrMemoForward(memoizedComponent)).toBe(true); + }); + + it('should return false for a memoized component that is not a forwardRef', () => { + const memoizedComponent = React.memo(() => { + return <div>Memoized Component</div>; + }); + + expect(isForwardOrMemoForward(memoizedComponent)).toBe(false); + }); + + it('should return false for a plain object', () => { + const plainObject = { prop: 'value' }; + + expect(isForwardOrMemoForward(plainObject)).toBe(false); + }); + + it('should return false for null or undefined', () => { + const nullValue = null; + const undefinedValue = undefined; + + expect(isForwardOrMemoForward(nullValue)).toBe(false); + expect(isForwardOrMemoForward(undefinedValue)).toBe(false); + }); +}); + +describe('isMemoType', () => { + it('should return true for an object with $$typeof matching REACT_MEMO_TYPE', () => { + const memoTypeObject = { $$typeof: REACT_MEMO_TYPE }; + + expect(isMemoType(memoTypeObject)).toBe(true); + }); + + it('should return false for an object with $$typeof not matching REACT_MEMO_TYPE', () => { + const otherTypeObject = { $$typeof: Symbol.for('other.type') }; + + expect(isMemoType(otherTypeObject)).toBe(false); + }); + + it('should return false for an object with no $$typeof property', () => { + const noTypeObject = { key: 'value' }; + + expect(isMemoType(noTypeObject)).toBe(false); + }); + + it('should return false for null or undefined', () => { + const nullValue = null; + const undefinedValue = undefined; + + expect(isMemoType(nullValue)).toBe(false); + expect(isMemoType(undefinedValue)).toBe(false); + }); +}); + +describe('isForwardRefType', () => { + it('should return true for an object with $$typeof matching REACT_FORWARD_REF_TYPE', () => { + const forwardRefTypeObject = { $$typeof: REACT_FORWARD_REF_TYPE }; + + expect(isForwardRefType(forwardRefTypeObject)).toBe(true); + }); + + it('should return false for an object with $$typeof not matching REACT_FORWARD_REF_TYPE', () => { + const otherTypeObject = { $$typeof: Symbol.for('other.type') }; + + expect(isForwardRefType(otherTypeObject)).toBe(false); + }); + + it('should return false for an object with no $$typeof property', () => { + const noTypeObject = { key: 'value' }; + + expect(isForwardRefType(noTypeObject)).toBe(false); + }); + + it('should return false for null or undefined', () => { + const nullValue = null; + const undefinedValue = undefined; + + expect(isForwardRefType(nullValue)).toBe(false); + expect(isForwardRefType(undefinedValue)).toBe(false); + }); +}); + +describe('acceptsRef', () => { + it('should return true for an object with isReactComponent in its prototype', () => { + const objWithIsReactComponent = { + prototype: { + isReactComponent: true, + }, + }; + + expect(acceptsRef(objWithIsReactComponent)).toBe(true); + }); + + it('should return true for an object that is forwardRef or memoized forwardRef', () => { + const forwardRefObject = React.forwardRef(() => { + return null; + }); + + const memoizedForwardRefObject = React.memo(forwardRefObject); + + expect(acceptsRef(forwardRefObject)).toBe(true); + expect(acceptsRef(memoizedForwardRefObject)).toBe(true); + }); + + it('should return false for an object without isReactComponent in its prototype', () => { + const objWithoutIsReactComponent = { + prototype: { + someOtherProperty: true, + }, + }; + + expect(acceptsRef(objWithoutIsReactComponent)).toBe(false); + }); + + it('should return false for null or undefined', () => { + const nullValue = null; + const undefinedValue = undefined; + + expect(acceptsRef(nullValue)).toBe(false); + expect(acceptsRef(undefinedValue)).toBe(false); + }); +}); + +describe('isReactClass', () => { + it('should return true for an object with isReactComponent in its prototype', () => { + class ReactClassComponent extends Component { + render() { + return null; + } + } + + expect(isReactClass(ReactClassComponent)).toBe(true); + }); + + it('should return true for an object with Component in its prototype chain', () => { + class CustomComponent extends Component { + render() { + return null; + } + } + + expect(isReactClass(CustomComponent)).toBe(true); + }); + + it('should return false for an object without isReactComponent in its prototype', () => { + class NonReactComponent { + render() { + return null; + } + } + + expect(isReactClass(NonReactComponent)).toBe(false); + }); + + it('should return false for null or undefined', () => { + const nullValue = null; + const undefinedValue = undefined; + + expect(isReactClass(nullValue)).toBe(false); + expect(isReactClass(undefinedValue)).toBe(false); + }); +}); \ No newline at end of file diff --git a/packages/utils/test/src/is-shaken.test.ts b/packages/utils/test/src/is-shaken.test.ts new file mode 100644 index 0000000000..35a27af5f6 --- /dev/null +++ b/packages/utils/test/src/is-shaken.test.ts @@ -0,0 +1,45 @@ +import { isShaken } from '../../src/is-shaken'; + +describe('isShaken', () => { + it('should return true if e1 has shaken property', () => { + const e1: any = { shaken: true }; + const e2: MouseEvent | DragEvent = { target: null } as MouseEvent | DragEvent; + + expect(isShaken(e1, e2)).toBe(true); + }); + + it('should return true if e1.target and e2.target are different', () => { + const e1: MouseEvent | DragEvent = { target: {} } as MouseEvent | DragEvent; + const e2: MouseEvent | DragEvent = { target: {} } as MouseEvent | DragEvent; + + expect(isShaken(e1, e2)).toBe(true); + }); + + it('should return false if e1 and e2 targets are the same and distance is less than SHAKE_DISTANCE', () => { + const target = {}; + const e1: MouseEvent | DragEvent = { target: target } as MouseEvent | DragEvent; + const e2: MouseEvent | DragEvent = { target: target } as MouseEvent | DragEvent; + + // Assuming SHAKE_DISTANCE is 100 + e1.clientY = 50; + e2.clientY = 50; + + e1.clientX = 60; + e2.clientX = 60; + + expect(isShaken(e1, e2)).toBe(false); + }); + + it('should return true if e1 and e2 targets are the same and distance is greater than SHAKE_DISTANCE', () => { + const e1: MouseEvent | DragEvent = { target: {} } as MouseEvent | DragEvent; + const e2: MouseEvent | DragEvent = { target: {} } as MouseEvent | DragEvent; + + // Assuming SHAKE_DISTANCE is 100 + e1.clientY = 50; + e1.clientX = 50; + e2.clientY = 200; + e2.clientX = 200; + + expect(isShaken(e1, e2)).toBe(true); + }); +}); diff --git a/packages/utils/test/src/misc.test.ts b/packages/utils/test/src/misc.test.ts new file mode 100644 index 0000000000..2514661508 --- /dev/null +++ b/packages/utils/test/src/misc.test.ts @@ -0,0 +1,326 @@ +import { + isVariable, + isUseI18NSetter, + convertToI18NObject, + isString, + waitForThing, + arrShallowEquals, + isFromVC, + executePendingFn, + compatStage, + invariant, + isRegExp, + shouldUseVariableSetter, +} from '../../src/misc'; +import { IPublicModelComponentMeta } from '@alilc/lowcode-types'; + +describe('isVariable', () => { + it('should return true for a variable object', () => { + const variable = { type: 'variable', variable: 'foo', value: 'bar' }; + const result = isVariable(variable); + expect(result).toBe(true); + }); + + it('should return false for non-variable objects', () => { + const obj = { type: 'object' }; + const result = isVariable(obj); + expect(result).toBe(false); + }); +}); + +describe('isUseI18NSetter', () => { + it('should return true for a property with I18nSetter', () => { + const prototype = { options: { configure: [{ name: 'propName', setter: { type: { displayName: 'I18nSetter' } } }] } }; + const propName = 'propName'; + const result = isUseI18NSetter(prototype, propName); + expect(result).toBe(true); + }); + + it('should return false for a property without I18nSetter', () => { + const prototype = { options: { configure: [{ name: 'propName', setter: { type: { displayName: 'OtherSetter' } } }] } }; + const propName = 'propName'; + const result = isUseI18NSetter(prototype, propName); + expect(result).toBe(false); + }); +}); + +describe('convertToI18NObject', () => { + it('should return the input if it is already an I18N object', () => { + const i18nObject = { type: 'i18n', use: 'en', en: 'Hello' }; + const result = convertToI18NObject(i18nObject); + expect(result).toEqual(i18nObject); + }); + + it('should convert a string to an I18N object', () => { + const inputString = 'Hello'; + const result = convertToI18NObject(inputString); + const expectedOutput = { type: 'i18n', use: 'zh-CN', 'zh-CN': inputString }; + expect(result).toEqual(expectedOutput); + }); +}); + +describe('isString', () => { + it('should return true for a string', () => { + const stringValue = 'Hello, world!'; + const result = isString(stringValue); + expect(result).toBe(true); + }); + + it('should return true for an empty string', () => { + const emptyString = ''; + const result = isString(emptyString); + expect(result).toBe(true); + }); + + it('should return false for a number', () => { + const numberValue = 42; // Not a string + const result = isString(numberValue); + expect(result).toBe(false); + }); + + it('should return false for an object', () => { + const objectValue = { key: 'value' }; // Not a string + const result = isString(objectValue); + expect(result).toBe(false); + }); + + it('should return false for null', () => { + const result = isString(null); + expect(result).toBe(false); + }); + + it('should return false for undefined', () => { + const undefinedValue = undefined; + const result = isString(undefinedValue); + expect(result).toBe(false); + }); + + it('should return false for a boolean', () => { + const booleanValue = true; // Not a string + const result = isString(booleanValue); + expect(result).toBe(false); + }); +}); + +describe('waitForThing', () => { + it('should resolve immediately if the thing is available', async () => { + const obj = { prop: 'value' }; + const path = 'prop'; + const result = await waitForThing(obj, path); + expect(result).toBe('value'); + }); + + it('should resolve after a delay if the thing becomes available', async () => { + const obj = { prop: undefined }; + const path = 'prop'; + const delay = 100; // Adjust the delay as needed + setTimeout(() => { + obj.prop = 'value'; + }, delay); + + const result = await waitForThing(obj, path); + expect(result).toBe('value'); + }); +}); + +describe('arrShallowEquals', () => { + it('should return true for two empty arrays', () => { + const arr1 = []; + const arr2 = []; + const result = arrShallowEquals(arr1, arr2); + expect(result).toBe(true); + }); + + it('should return true for two arrays with the same elements in the same order', () => { + const arr1 = [1, 2, 3]; + const arr2 = [1, 2, 3]; + const result = arrShallowEquals(arr1, arr2); + expect(result).toBe(true); + }); + + it('should return true for two arrays with the same elements in a different order', () => { + const arr1 = [1, 2, 3]; + const arr2 = [3, 2, 1]; + const result = arrShallowEquals(arr1, arr2); + expect(result).toBe(true); + }); + + it('should return false for two arrays with different lengths', () => { + const arr1 = [1, 2, 3]; + const arr2 = [1, 2]; + const result = arrShallowEquals(arr1, arr2); + expect(result).toBe(false); + }); + + it('should return false for one array and a non-array', () => { + const arr1 = [1, 2, 3]; + const nonArray = 'not an array'; + const result = arrShallowEquals(arr1, nonArray); + expect(result).toBe(false); + }); + + it('should return false for two arrays with different elements', () => { + const arr1 = [1, 2, 3]; + const arr2 = [3, 4, 5]; + const result = arrShallowEquals(arr1, arr2); + expect(result).toBe(false); + }); + + it('should return true for arrays with duplicate elements', () => { + const arr1 = [1, 2, 2, 3]; + const arr2 = [2, 3, 3, 1]; + const result = arrShallowEquals(arr1, arr2); + expect(result).toBe(true); + }); +}); + +describe('isFromVC', () => { + it('should return true when advanced configuration is present', () => { + // Create a mock meta object with advanced configuration + const meta: IPublicModelComponentMeta = { + getMetadata: () => ({ configure: { advanced: true } }), + }; + + const result = isFromVC(meta); + + expect(result).toBe(true); + }); + + it('should return false when advanced configuration is not present', () => { + // Create a mock meta object without advanced configuration + const meta: IPublicModelComponentMeta = { + getMetadata: () => ({ configure: { advanced: false } }), + }; + + const result = isFromVC(meta); + + expect(result).toBe(false); + }); + + it('should return false when meta is undefined', () => { + const meta: IPublicModelComponentMeta | undefined = undefined; + + const result = isFromVC(meta); + + expect(result).toBe(false); + }); + + it('should return false when meta does not have configure information', () => { + // Create a mock meta object without configure information + const meta: IPublicModelComponentMeta = { + getMetadata: () => ({}), + }; + + const result = isFromVC(meta); + + expect(result).toBe(false); + }); + + it('should return false when configure.advanced is not present', () => { + // Create a mock meta object with incomplete configure information + const meta: IPublicModelComponentMeta = { + getMetadata: () => ({ configure: {} }), + }; + + const result = isFromVC(meta); + + expect(result).toBe(false); + }); +}); + +describe('executePendingFn', () => { + it('should execute the provided function after the specified timeout', async () => { + // Mock the function to execute + const fn = jest.fn(); + + // Call executePendingFn with the mocked function and a short timeout + executePendingFn(fn, 100); + + // Ensure the function has not been called immediately + expect(fn).not.toHaveBeenCalled(); + + // Wait for the specified timeout + await new Promise(resolve => setTimeout(resolve, 100)); + + // Ensure the function has been called after the timeout + expect(fn).toHaveBeenCalled(); + }); + + it('should execute the provided function with a default timeout if not specified', async () => { + // Mock the function to execute + const fn = jest.fn(); + + // Call executePendingFn with the mocked function without specifying a timeout + executePendingFn(fn); + + // Ensure the function has not been called immediately + expect(fn).not.toHaveBeenCalled(); + + // Wait for the default timeout (2000 milliseconds) + await new Promise(resolve => setTimeout(resolve, 2000)); + + // Ensure the function has been called after the default timeout + expect(fn).toHaveBeenCalled(); + }); +}); + +describe('compatStage', () => { + it('should convert a number to an enum stage', () => { + const result = compatStage(3); + expect(result).toBe('save'); + }); + + it('should warn about the deprecated usage', () => { + const warnSpy = jest.spyOn(console, 'warn'); + const result = compatStage(2); + expect(result).toBe('serilize'); + expect(warnSpy).toHaveBeenCalledWith( + 'stage 直接指定为数字的使用方式已经过时,将在下一版本移除,请直接使用 IPublicEnumTransformStage.Render|Serilize|Save|Clone|Init|Upgrade' + ); + warnSpy.mockRestore(); + }); + + it('should return the enum stage if it is already an enum', () => { + const result = compatStage('render'); + expect(result).toBe('render'); + }); +}); + +describe('invariant', () => { + it('should not throw an error if the check is true', () => { + expect(() => invariant(true, 'Test invariant', 'thing')).not.toThrow(); + }); + + it('should throw an error if the check is false', () => { + expect(() => invariant(false, 'Test invariant', 'thing')).toThrowError( + "Invariant failed: Test invariant in 'thing'" + ); + }); +}); + +describe('isRegExp', () => { + it('should return true for a valid RegExp', () => { + const regex = /test/; + const result = isRegExp(regex); + expect(result).toBe(true); + }); + + it('should return false for a non-RegExp object', () => { + const nonRegExp = { test: /test/ }; + const result = isRegExp(nonRegExp); + expect(result).toBe(false); + }); + + it('should return false for null', () => { + const result = isRegExp(null); + expect(result).toBe(false); + }); +}); + +it('shouldUseVariableSetter', () => { + expect(shouldUseVariableSetter(false, true)).toBeFalsy(); + expect(shouldUseVariableSetter(true, true)).toBeTruthy(); + expect(shouldUseVariableSetter(true, false)).toBeTruthy(); + expect(shouldUseVariableSetter(undefined, false)).toBeFalsy(); + expect(shouldUseVariableSetter(undefined, true)).toBeTruthy(); +}); \ No newline at end of file diff --git a/packages/utils/test/src/navtive-selection.test.ts b/packages/utils/test/src/navtive-selection.test.ts new file mode 100644 index 0000000000..f45d0e1b2a --- /dev/null +++ b/packages/utils/test/src/navtive-selection.test.ts @@ -0,0 +1,18 @@ +import { setNativeSelection, nativeSelectionEnabled } from '../../src/navtive-selection'; + +describe('setNativeSelection', () => { + beforeEach(() => { + // 在每个测试运行之前重置nativeSelectionEnabled的值 + setNativeSelection(true); + }); + + test('should enable native selection', () => { + setNativeSelection(true); + expect(nativeSelectionEnabled).toBe(true); + }); + + test('should disable native selection', () => { + setNativeSelection(false); + expect(nativeSelectionEnabled).toBe(false); + }); +}); diff --git a/packages/utils/test/src/schema.test.ts b/packages/utils/test/src/schema.test.ts new file mode 100644 index 0000000000..8d03f58118 --- /dev/null +++ b/packages/utils/test/src/schema.test.ts @@ -0,0 +1,155 @@ +import { + compatibleLegaoSchema, + getNodeSchemaById, + applyActivities, +} from '../../src/schema'; +import { ActivityType } from '@alilc/lowcode-types'; + +describe('compatibleLegaoSchema', () => { + it('should handle null input', () => { + const result = compatibleLegaoSchema(null); + expect(result).toBeNull(); + }); + + it('should convert Legao schema to JSExpression', () => { + // Create your test input + const legaoSchema = { + type: 'LegaoType', + source: 'LegaoSource', + compiled: 'LegaoCompiled', + }; + const result = compatibleLegaoSchema(legaoSchema); + + // Define the expected output + const expectedOutput = { + type: 'JSExpression', + value: 'LegaoCompiled', + extType: 'function', + }; + + // Assert that the result matches the expected output + expect(result).toEqual(expectedOutput); + }); + + // Add more test cases for other scenarios +}); + +describe('getNodeSchemaById', () => { + it('should find a node in the schema', () => { + // Create your test schema and node ID + const testSchema = { + id: 'root', + children: [ + { + id: 'child1', + children: [ + { + id: 'child1.1', + }, + ], + }, + ], + }; + const nodeId = 'child1.1'; + + const result = getNodeSchemaById(testSchema, nodeId); + + // Define the expected output + const expectedOutput = { + id: 'child1.1', + }; + + // Assert that the result matches the expected output + expect(result).toEqual(expectedOutput); + }); + + // Add more test cases for other scenarios +}); + +describe('applyActivities', () => { + it('should apply ADD activity', () => { + // Create your test schema and activities + const testSchema = { + id: 'root', + children: [ + { + id: 'child1', + children: [ + { + id: 'child1.1', + }, + ], + }, + ], + }; + const activities = [ + { + type: ActivityType.ADDED, + payload: { + location: { + parent: { + nodeId: 'child1', + index: 0, + }, + }, + schema: { + id: 'newChild', + }, + }, + }, + ]; + + const result = applyActivities(testSchema, activities); + + // Define the expected output + const expectedOutput = { + id: 'root', + children: [ + { + id: 'child1', + children: [ + { + id: 'newChild', + }, + { + id: 'child1.1', + }, + ], + }, + ], + }; + + // Assert that the result matches the expected output + expect(result).toEqual(expectedOutput); + }); + + // Add more test cases for other activity types and scenarios +}); + + +describe('Schema Ut', () => { + it('props', () => { + const schema = { + props: { + mobileSlot: { + type: "JSBlock", + value: { + componentName: "Slot", + children: [ + { + loop: { + variable: "props.content", + type: "variable" + }, + } + ], + } + }, + }, + }; + + const result = compatibleLegaoSchema(schema); + expect(result).toMatchSnapshot(); + expect(result.props.mobileSlot.value[0].loop.type).toBe('JSExpression'); + }); +}) \ No newline at end of file diff --git a/packages/utils/test/src/script.test.ts b/packages/utils/test/src/script.test.ts new file mode 100644 index 0000000000..d3d4ffd59a --- /dev/null +++ b/packages/utils/test/src/script.test.ts @@ -0,0 +1,47 @@ +import { + evaluate, + evaluateExpression, + newFunction, +} from '../../src/script'; + +describe('evaluate', () => { + test('should evaluate the given script', () => { + const script = 'console.log("Hello, world!");'; + global.console = { log: jest.fn() }; + + evaluate(script); + + expect(global.console.log).toHaveBeenCalledWith('Hello, world!'); + }); +}); + +describe('evaluateExpression', () => { + test('should evaluate the given expression', () => { + const expr = 'return 1 + 2'; + + const result = evaluateExpression(expr); + + expect(result).toBe(3); + }); +}); + +describe('newFunction', () => { + test('should create a new function with the given arguments and code', () => { + const args = 'a, b'; + const code = 'return a + b'; + + const result = newFunction(args, code); + + expect(result).toBeInstanceOf(Function); + expect(result(1, 2)).toBe(3); + }); + + test('should return null if an error occurs', () => { + const args = 'a, b'; + const code = 'return a +;'; // Invalid code + + const result = newFunction(args, code); + + expect(result).toBeNull(); + }); +}); diff --git a/packages/utils/test/src/svg-icon.test.tsx b/packages/utils/test/src/svg-icon.test.tsx new file mode 100644 index 0000000000..bbb6e18b7c --- /dev/null +++ b/packages/utils/test/src/svg-icon.test.tsx @@ -0,0 +1,35 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import { SVGIcon, IconProps } from '../../src/svg-icon'; + +describe('SVGIcon', () => { + it('should render SVG element with correct size', () => { + const iconProps: IconProps = { + size: 'small', + viewBox: '0 0 24 24', + }; + + const { container } = render(<SVGIcon {...iconProps} />); + + const svgElement = container.querySelector('svg'); + + expect(svgElement).toHaveAttribute('width', '12'); + expect(svgElement).toHaveAttribute('height', '12'); + }); + + it('should render SVG element with custom size', () => { + const iconProps: IconProps = { + size: 24, + viewBox: '0 0 24 24', + }; + + const { container } = render(<SVGIcon {...iconProps} />); + + const svgElement = container.querySelector('svg'); + + expect(svgElement).toHaveAttribute('width', '24'); + expect(svgElement).toHaveAttribute('height', '24'); + }); + + // Add more tests for other scenarios if needed +}); diff --git a/packages/utils/test/src/transaction-manager.test.ts b/packages/utils/test/src/transaction-manager.test.ts new file mode 100644 index 0000000000..42c7fa8bf0 --- /dev/null +++ b/packages/utils/test/src/transaction-manager.test.ts @@ -0,0 +1,58 @@ +import { transactionManager } from '../../src/transaction-manager'; +import { IPublicEnumTransitionType } from '@alilc/lowcode-types'; + +const type = IPublicEnumTransitionType.REPAINT; + +describe('TransactionManager', () => { + let fn1: jest.Mock; + let fn2: jest.Mock; + + beforeEach(() => { + fn1 = jest.fn(); + fn2 = jest.fn(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + test('executeTransaction should emit startTransaction and endTransaction events', () => { + const startTransactionSpy = jest.spyOn(transactionManager.emitter, 'emit'); + const endTransactionSpy = jest.spyOn(transactionManager.emitter, 'emit'); + + transactionManager.executeTransaction(() => { + // Perform some action within the transaction + }); + + expect(startTransactionSpy).toHaveBeenCalledWith(`[${type}]startTransaction`); + expect(endTransactionSpy).toHaveBeenCalledWith(`[${type}]endTransaction`); + }); + + test('onStartTransaction should register the provided function for startTransaction event', () => { + const offSpy = jest.spyOn(transactionManager.emitter, 'off'); + + const offFunction = transactionManager.onStartTransaction(fn1); + + expect(transactionManager.emitter.listenerCount(`[${type}]startTransaction`)).toBe(1); + expect(offSpy).not.toHaveBeenCalled(); + + offFunction(); + + expect(transactionManager.emitter.listenerCount(`[${type}]startTransaction`)).toBe(0); + expect(offSpy).toHaveBeenCalledWith(`[${type}]startTransaction`, fn1); + }); + + test('onEndTransaction should register the provided function for endTransaction event', () => { + const offSpy = jest.spyOn(transactionManager.emitter, 'off'); + + const offFunction = transactionManager.onEndTransaction(fn2); + + expect(transactionManager.emitter.listenerCount(`[${type}]endTransaction`)).toBe(1); + expect(offSpy).not.toHaveBeenCalled(); + + offFunction(); + + expect(transactionManager.emitter.listenerCount(`[${type}]endTransaction`)).toBe(0); + expect(offSpy).toHaveBeenCalledWith(`[${type}]endTransaction`, fn2); + }); +}); diff --git a/packages/utils/test/src/unique-id.test.ts b/packages/utils/test/src/unique-id.test.ts new file mode 100644 index 0000000000..2b4b6e9e04 --- /dev/null +++ b/packages/utils/test/src/unique-id.test.ts @@ -0,0 +1,11 @@ +import { uniqueId } from '../../src/unique-id'; + +test('uniqueId should return a unique id with prefix', () => { + const id = uniqueId('test'); + expect(id.startsWith('test')).toBeTruthy(); +}); + +test('uniqueId should return a unique id without prefix', () => { + const id = uniqueId(); + expect(id).not.toBeFalsy(); +}); diff --git a/packages/workspace/build.json b/packages/workspace/build.json new file mode 100644 index 0000000000..3e92600554 --- /dev/null +++ b/packages/workspace/build.json @@ -0,0 +1,5 @@ +{ + "plugins": [ + "@alilc/build-plugin-lce" + ] +} diff --git a/packages/workspace/build.test.json b/packages/workspace/build.test.json new file mode 100644 index 0000000000..9cc30d7463 --- /dev/null +++ b/packages/workspace/build.test.json @@ -0,0 +1,6 @@ +{ + "plugins": [ + "@alilc/build-plugin-lce", + "@alilc/lowcode-test-mate/plugin/index.ts" + ] +} diff --git a/packages/workspace/jest.config.js b/packages/workspace/jest.config.js new file mode 100644 index 0000000000..0e05687d78 --- /dev/null +++ b/packages/workspace/jest.config.js @@ -0,0 +1,9 @@ +module.exports = { + moduleFileExtensions: ['ts', 'tsx', 'js', 'json'], + collectCoverage: true, + collectCoverageFrom: [ + 'src/**/*.{ts,tsx}', + '!**/node_modules/**', + '!**/vendor/**', + ], +}; diff --git a/packages/workspace/package.json b/packages/workspace/package.json new file mode 100644 index 0000000000..778b8167f8 --- /dev/null +++ b/packages/workspace/package.json @@ -0,0 +1,55 @@ +{ + "name": "@alilc/lowcode-workspace", + "version": "1.3.2", + "description": "Shell Layer for AliLowCodeEngine", + "main": "lib/index.js", + "module": "es/index.js", + "files": [ + "lib", + "es" + ], + "scripts": { + "build": "build-scripts build", + "test": "build-scripts test --config build.test.json", + "test:cov": "build-scripts test --config build.test.json --jest-coverage" + }, + "license": "MIT", + "dependencies": { + "@alilc/lowcode-designer": "1.3.2", + "@alilc/lowcode-editor-core": "1.3.2", + "@alilc/lowcode-editor-skeleton": "1.3.2", + "@alilc/lowcode-types": "1.3.2", + "@alilc/lowcode-utils": "1.3.2", + "classnames": "^2.2.6", + "enzyme": "^3.11.0", + "enzyme-adapter-react-16": "^1.15.5", + "react": "^16", + "react-dom": "^16.7.0" + }, + "devDependencies": { + "@alib/build-scripts": "^0.1.29", + "@testing-library/react": "^11.2.2", + "@types/classnames": "^2.2.7", + "@types/jest": "^26.0.16", + "@types/lodash": "^4.14.165", + "@types/medium-editor": "^5.0.3", + "@types/node": "^13.7.1", + "@types/react": "^16", + "@types/react-dom": "^16", + "jest": "^26.6.3", + "lodash": "^4.17.20", + "moment": "^2.29.1", + "typescript": "^4.0.3" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, + "repository": { + "type": "http", + "url": "https://github.com/alibaba/lowcode-engine/tree/main/packages/workspace" + }, + "gitHead": "2669f179e6f899d395ce1942d0fe04f9c5ed48a6", + "bugs": "https://github.com/alibaba/lowcode-engine/issues", + "homepage": "https://github.com/alibaba/lowcode-engine/#readme" +} diff --git a/packages/workspace/src/context/base-context.ts b/packages/workspace/src/context/base-context.ts new file mode 100644 index 0000000000..445677a618 --- /dev/null +++ b/packages/workspace/src/context/base-context.ts @@ -0,0 +1,200 @@ +import { + Editor, + engineConfig, Setters as InnerSetters, + Hotkey as InnerHotkey, + commonEvent, + IEngineConfig, + IHotKey, + Command as InnerCommand, +} from '@alilc/lowcode-editor-core'; +import { + Designer, + ILowCodePluginContextApiAssembler, + LowCodePluginManager, + ILowCodePluginContextPrivate, + IProject, + IDesigner, + ILowCodePluginManager, +} from '@alilc/lowcode-designer'; +import { + ISkeleton, + Skeleton as InnerSkeleton, +} from '@alilc/lowcode-editor-skeleton'; +import { + Hotkey, + Plugins, + Project, + Skeleton, + Setters, + Material, + Event, + Common, + Logger, + Workspace, + Window, + Canvas, + CommonUI, + Command, +} from '@alilc/lowcode-shell'; +import { + IPluginPreferenceMananger, + IPublicApiCanvas, + IPublicApiCommon, + IPublicApiEvent, + IPublicApiHotkey, + IPublicApiMaterial, + IPublicApiPlugins, + IPublicApiProject, + IPublicApiSetters, + IPublicApiSkeleton, + IPublicEnumPluginRegisterLevel, + IPublicModelPluginContext, + IPublicTypePluginMeta, +} from '@alilc/lowcode-types'; +import { getLogger, Logger as InnerLogger } from '@alilc/lowcode-utils'; +import { IWorkspace } from '../workspace'; +import { IEditorWindow } from '../window'; + +export interface IBasicContext extends Omit<IPublicModelPluginContext, 'workspace'> { + skeleton: IPublicApiSkeleton; + plugins: IPublicApiPlugins; + project: IPublicApiProject; + setters: IPublicApiSetters; + material: IPublicApiMaterial; + common: IPublicApiCommon; + config: IEngineConfig; + event: IPublicApiEvent; + logger: InnerLogger; + hotkey: IPublicApiHotkey; + innerProject: IProject; + editor: Editor; + designer: IDesigner; + registerInnerPlugins: () => Promise<void>; + innerSetters: InnerSetters; + innerSkeleton: ISkeleton; + innerHotkey: IHotKey; + innerPlugins: ILowCodePluginManager; + canvas: IPublicApiCanvas; + pluginEvent: IPublicApiEvent; + preference: IPluginPreferenceMananger; + workspace: IWorkspace; +} + +export class BasicContext implements IBasicContext { + skeleton: IPublicApiSkeleton; + plugins: IPublicApiPlugins; + project: IPublicApiProject; + setters: IPublicApiSetters; + material: IPublicApiMaterial; + common: IPublicApiCommon; + config: IEngineConfig; + event: IPublicApiEvent; + logger: InnerLogger; + hotkey: IPublicApiHotkey; + innerProject: IProject; + editor: Editor; + designer: IDesigner; + registerInnerPlugins: () => Promise<void>; + innerSetters: InnerSetters; + innerSkeleton: ISkeleton; + innerHotkey: IHotKey; + innerPlugins: ILowCodePluginManager; + canvas: IPublicApiCanvas; + pluginEvent: IPublicApiEvent; + preference: IPluginPreferenceMananger; + workspace: IWorkspace; + + constructor(innerWorkspace: IWorkspace, viewName: string, readonly registerLevel: IPublicEnumPluginRegisterLevel, public editorWindow?: IEditorWindow) { + const editor = new Editor(viewName, true); + + const innerSkeleton = new InnerSkeleton(editor, viewName); + editor.set('skeleton' as any, innerSkeleton); + + const designer: Designer = new Designer({ + editor, + viewName, + shellModelFactory: innerWorkspace?.shellModelFactory, + }); + editor.set('designer' as any, designer); + + const { project: innerProject } = designer; + const workspace = new Workspace(innerWorkspace); + const innerHotkey = new InnerHotkey(viewName); + const hotkey = new Hotkey(innerHotkey, true); + const innerSetters = new InnerSetters(viewName); + const setters = new Setters(innerSetters, true); + const material = new Material(editor, true); + const project = new Project(innerProject, true); + const config = engineConfig; + const event = new Event(commonEvent, { prefix: 'common' }); + const logger = getLogger({ level: 'warn', bizName: 'common' }); + const skeleton = new Skeleton(innerSkeleton, 'any', true); + const canvas = new Canvas(editor, true); + const commonUI = new CommonUI(editor); + const innerCommand = new InnerCommand(); + editor.set('setters', setters); + editor.set('project', project); + editor.set('material', material); + editor.set('hotkey', hotkey); + editor.set('innerHotkey', innerHotkey); + this.innerSetters = innerSetters; + this.innerSkeleton = innerSkeleton; + this.skeleton = skeleton; + this.innerProject = innerProject; + this.project = project; + this.setters = setters; + this.material = material; + this.config = config; + this.event = event; + this.logger = logger; + this.hotkey = hotkey; + this.innerHotkey = innerHotkey; + this.editor = editor; + this.designer = designer; + this.canvas = canvas; + const common = new Common(editor, innerSkeleton); + this.common = common; + let plugins: IPublicApiPlugins; + + const pluginContextApiAssembler: ILowCodePluginContextApiAssembler = { + assembleApis: (context: ILowCodePluginContextPrivate, pluginName: string, meta: IPublicTypePluginMeta) => { + context.workspace = workspace; + context.hotkey = hotkey; + context.project = project; + context.skeleton = new Skeleton(innerSkeleton, pluginName, true); + context.setters = setters; + context.material = material; + const eventPrefix = meta?.eventPrefix || 'common'; + const commandScope = meta?.commandScope; + context.event = new Event(commonEvent, { prefix: eventPrefix }); + context.config = config; + context.common = common; + context.plugins = plugins; + context.logger = new Logger({ level: 'warn', bizName: `plugin:${pluginName}` }); + context.canvas = canvas; + context.commonUI = commonUI; + if (editorWindow) { + context.editorWindow = new Window(editorWindow); + } + context.command = new Command(innerCommand, context as IPublicModelPluginContext, { + commandScope, + }); + context.registerLevel = registerLevel; + context.isPluginRegisteredInWorkspace = registerLevel === IPublicEnumPluginRegisterLevel.Workspace; + editor.set('pluginContext', context); + }, + }; + + const innerPlugins = new LowCodePluginManager(pluginContextApiAssembler, viewName); + this.innerPlugins = innerPlugins; + plugins = new Plugins(innerPlugins, true).toProxy(); + editor.set('plugins' as any, plugins); + editor.set('innerPlugins' as any, innerPlugins); + this.plugins = plugins; + + // 注册一批内置插件 + this.registerInnerPlugins = async function registerPlugins() { + await innerWorkspace?.registryInnerPlugin(designer, editor, plugins); + }; + } +} \ No newline at end of file diff --git a/packages/workspace/src/context/view-context.ts b/packages/workspace/src/context/view-context.ts new file mode 100644 index 0000000000..0542f83a95 --- /dev/null +++ b/packages/workspace/src/context/view-context.ts @@ -0,0 +1,70 @@ +import { computed, makeObservable, obx } from '@alilc/lowcode-editor-core'; +import { IPublicEditorViewConfig, IPublicEnumPluginRegisterLevel, IPublicTypeEditorView } from '@alilc/lowcode-types'; +import { flow } from 'mobx'; +import { IWorkspace } from '../workspace'; +import { BasicContext, IBasicContext } from './base-context'; +import { IEditorWindow } from '../window'; +import { getWebviewPlugin } from '../inner-plugins/webview'; + +export interface IViewContext extends IBasicContext { + editorWindow: IEditorWindow; + + viewName: string; + + viewType: 'editor' | 'webview'; +} + +export class Context extends BasicContext implements IViewContext { + viewName = 'editor-view'; + + instance: IPublicEditorViewConfig; + + viewType: 'editor' | 'webview'; + + @obx _activate = false; + + @obx isInit: boolean = false; + + init = flow(function* (this: Context) { + if (this.viewType === 'webview') { + const url = yield this.instance?.url?.(); + yield this.plugins.register(getWebviewPlugin(url, this.viewName)); + } else { + yield this.registerInnerPlugins(); + } + yield this.instance?.init?.(); + yield this.innerPlugins.init(); + this.isInit = true; + }); + + constructor(public workspace: IWorkspace, public editorWindow: IEditorWindow, public editorView: IPublicTypeEditorView, options: Object | undefined) { + super(workspace, editorView.viewName, IPublicEnumPluginRegisterLevel.EditorView, editorWindow); + this.viewType = editorView.viewType || 'editor'; + this.viewName = editorView.viewName; + this.instance = editorView(this.innerPlugins._getLowCodePluginContext({ + pluginName: 'any', + }), options); + makeObservable(this); + } + + @computed get active() { + return this._activate; + } + + onSimulatorRendererReady = (): Promise<void> => { + return new Promise((resolve) => { + this.project.onSimulatorRendererReady(() => { + resolve(); + }); + }); + }; + + setActivate = (_activate: boolean) => { + this._activate = _activate; + this.innerHotkey.activate(this._activate); + }; + + async save() { + return await this.instance?.save?.(); + } +} \ No newline at end of file diff --git a/packages/workspace/src/index.ts b/packages/workspace/src/index.ts new file mode 100644 index 0000000000..6c437fad0a --- /dev/null +++ b/packages/workspace/src/index.ts @@ -0,0 +1,7 @@ +export { Workspace } from './workspace'; +export type { IWorkspace } from './workspace'; +export * from './window'; +export * from './layouts/workbench'; +export { Resource } from './resource'; +export type { IResource } from './resource'; +export type { IViewContext } from './context/view-context'; diff --git a/packages/workspace/src/inner-plugins/webview.tsx b/packages/workspace/src/inner-plugins/webview.tsx new file mode 100644 index 0000000000..820b843ab8 --- /dev/null +++ b/packages/workspace/src/inner-plugins/webview.tsx @@ -0,0 +1,49 @@ +import { IPublicModelPluginContext } from '@alilc/lowcode-types'; + +export function DesignerView(props: { + url: string; + viewName?: string; +}) { + return ( + <div className="lc-designer lowcode-plugin-designer"> + <div className="lc-project"> + <div className="lc-simulator-shell"> + <iframe + name={`webview-view-${props.viewName}`} + className="lc-simulator-content-frame" + style={{ + height: '100%', + width: '100%', + }} + src={props.url} + /> + </div> + </div> + </div> + ); +} + +export function getWebviewPlugin(url: string, viewName: string) { + function webviewPlugin(ctx: IPublicModelPluginContext) { + const { skeleton } = ctx; + return { + init() { + skeleton.add({ + area: 'mainArea', + name: 'designer', + type: 'Widget', + content: DesignerView, + contentProps: { + ctx, + url, + viewName, + }, + }); + }, + }; + } + + webviewPlugin.pluginName = '___webview_plugin___'; + + return webviewPlugin; +} diff --git a/packages/workspace/src/layouts/workbench.tsx b/packages/workspace/src/layouts/workbench.tsx new file mode 100644 index 0000000000..2913576e1c --- /dev/null +++ b/packages/workspace/src/layouts/workbench.tsx @@ -0,0 +1,84 @@ +import { Component } from 'react'; +import { TipContainer, engineConfig, observer } from '@alilc/lowcode-editor-core'; +import { WindowView } from '../view/window-view'; +import classNames from 'classnames'; +import { SkeletonContext } from '../skeleton-context'; +import { EditorConfig, PluginClassSet } from '@alilc/lowcode-types'; +import { Workspace } from '../workspace'; +import { BottomArea, LeftArea, LeftFixedPane, LeftFloatPane, MainArea, SubTopArea, TopArea } from '@alilc/lowcode-editor-skeleton'; + +@observer +export class Workbench extends Component<{ + workspace: Workspace; + config?: EditorConfig; + components?: PluginClassSet; + className?: string; + topAreaItemClassName?: string; +}, { + workspaceEmptyComponent: any; + theme?: string; +}> { + constructor(props: any) { + super(props); + const { config, components, workspace } = this.props; + const { skeleton } = workspace; + skeleton.buildFromConfig(config, components); + engineConfig.onGot('theme', (theme) => { + this.setState({ + theme, + }); + }); + engineConfig.onGot('workspaceEmptyComponent', (workspaceEmptyComponent) => { + this.setState({ + workspaceEmptyComponent, + }); + }); + this.state = { + workspaceEmptyComponent: engineConfig.get('workspaceEmptyComponent'), + theme: engineConfig.get('theme'), + }; + } + + render() { + const { workspace, className, topAreaItemClassName } = this.props; + const { skeleton } = workspace; + const { workspaceEmptyComponent: WorkspaceEmptyComponent, theme } = this.state; + + return ( + <div className={classNames('lc-workspace-workbench', className, theme)}> + <SkeletonContext.Provider value={skeleton}> + <TopArea className="lc-workspace-top-area" area={skeleton.topArea} itemClassName={topAreaItemClassName} /> + <div className="lc-workspace-workbench-body"> + <LeftArea className="lc-workspace-left-area lc-left-area" area={skeleton.leftArea} /> + <LeftFloatPane area={skeleton.leftFloatArea} /> + <LeftFixedPane area={skeleton.leftFixedArea} /> + <div className="lc-workspace-workbench-center"> + <div className="lc-workspace-workbench-center-content"> + <SubTopArea area={skeleton.subTopArea} itemClassName={topAreaItemClassName} /> + <div className="lc-workspace-workbench-window"> + { + workspace.windows.map(d => ( + <WindowView + active={d.id === workspace.window?.id} + window={d} + key={d.id} + /> + )) + } + + { + !workspace.windows.length && WorkspaceEmptyComponent ? <WorkspaceEmptyComponent /> : null + } + </div> + </div> + <MainArea area={skeleton.mainArea} /> + <BottomArea area={skeleton.bottomArea} /> + </div> + {/* <RightArea area={skeleton.rightArea} /> */} + </div> + <TipContainer /> + </SkeletonContext.Provider> + </div> + ); + } +} diff --git a/packages/workspace/src/less-variables.less b/packages/workspace/src/less-variables.less new file mode 100644 index 0000000000..017e432ce6 --- /dev/null +++ b/packages/workspace/src/less-variables.less @@ -0,0 +1,215 @@ +/* + * 基础的 DPL 定义使用了 kuma base 的定义,参考: + * https://github.com/uxcore/kuma-base/tree/master/variables + */ + +/** + * =========================================================== + * ==================== Font Family ========================== + * =========================================================== + */ + +/* + * @font-family: "STHeiti", "Microsoft Yahei", "Lucida Grande", "Lucida Sans Unicode", Helvetica, Arial, Verdana, sans-serif; + */ + +@font-family: 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', Helvetica, Arial, sans-serif; +@font-family-code: Monaco, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', Helvetica, Arial, + sans-serif; + +/** + * =========================================================== + * ===================== Color DPL =========================== + * =========================================================== + */ + +@brand-color-1: rgba(0, 108, 255, 1); +@brand-color-2: rgba(25, 122, 255, 1); +@brand-color-3: rgba(0, 96, 229, 1); + +@brand-color-1-3: rgba(0, 108, 255, 0.6); +@brand-color-1-4: rgba(0, 108, 255, 0.4); +@brand-color-1-5: rgba(0, 108, 255, 0.3); +@brand-color-1-6: rgba(0, 108, 255, 0.2); +@brand-color-1-7: rgba(0, 108, 255, 0.1); + +@brand-color: @brand-color-1; + +@white-alpha-1: rgb(255, 255, 255); // W-1 +@white-alpha-2: rgba(255, 255, 255, 0.8); // W-2 A80 +@white-alpha-3: rgba(255, 255, 255, 0.6); // W-3 A60 +@white-alpha-4: rgba(255, 255, 255, 0.4); // W-4 A40 +@white-alpha-5: rgba(255, 255, 255, 0.3); // W-5 A30 +@white-alpha-6: rgba(255, 255, 255, 0.2); // W-6 A20 +@white-alpha-7: rgba(255, 255, 255, 0.1); // W-7 A10 +@white-alpha-8: rgba(255, 255, 255, 0.06); // W-8 A6 + +@dark-alpha-1: rgba(0, 0, 0, 1); // D-1 A100 +@dark-alpha-2: rgba(0, 0, 0, 0.8); // D-2 A80 +@dark-alpha-3: rgba(0, 0, 0, 0.6); // D-3 A60 +@dark-alpha-4: rgba(0, 0, 0, 0.4); // D-4 A40 +@dark-alpha-5: rgba(0, 0, 0, 0.3); // D-5 A30 +@dark-alpha-6: rgba(0, 0, 0, 0.2); // D-6 A20 +@dark-alpha-7: rgba(0, 0, 0, 0.1); // D-7 A10 +@dark-alpha-8: rgba(0, 0, 0, 0.06); // D-8 A6 +@dark-alpha-9: rgba(0, 0, 0, 0.04); // D-9 A4 + +@normal-alpha-1: rgba(31, 56, 88, 1); // N-1 A100 +@normal-alpha-2: rgba(31, 56, 88, 0.8); // N-2 A80 +@normal-alpha-3: rgba(31, 56, 88, 0.6); // N-3 A60 +@normal-alpha-4: rgba(31, 56, 88, 0.4); // N-4 A40 +@normal-alpha-5: rgba(31, 56, 88, 0.3); // N-5 A30 +@normal-alpha-6: rgba(31, 56, 88, 0.2); // N-6 A20 +@normal-alpha-7: rgba(31, 56, 88, 0.1); // N-7 A10 +@normal-alpha-8: rgba(31, 56, 88, 0.06); // N-8 A6 +@normal-alpha-9: rgba(31, 56, 88, 0.04); // N-9 A4 + +@normal-3: #77879c; +@normal-4: #a3aebd; +@normal-5: #bac3cc; +@normal-6: #d1d7de; + +@gray-dark: #333; // N2_4 +@gray: #666; // N2_3 +@gray-light: #999; // N2_2 +@gray-lighter: #ccc; // N2_1 + +@brand-secondary: #2c2f33; // B2_3 +// 补色 +@brand-complement: #00b3e8; // B3_1 +// 复合 +@brand-comosite: #00c587; // B3_2 +// 浓度 +@brand-deep: #73461d; // B3_3 + +// F1-1 +@brand-danger: rgb(240, 70, 49); +// F1-2 (10% white) +@brand-danger-hover: rgba(240, 70, 49, 0.9); +// F1-3 (5% black) +@brand-danger-focus: rgba(240, 70, 49, 0.95); + +// F2-1 +@brand-warning: rgb(250, 189, 14); +// F3-1 +@brand-success: rgb(102, 188, 92); +// F4-1 +@brand-link: rgb(102, 188, 92); +// F4-2 +@brand-link-hover: #2e76a6; + +// F1-1-7 A10 +@brand-danger-alpha-7: rgba(240, 70, 49, 0.1); +// F1-1-8 A6 +@brand-danger-alpha-8: rgba(240, 70, 49, 0.8); +// F2-1-2 A80 +@brand-warning-alpha-2: rgba(250, 189, 14, 0.8); +// F2-1-7 A10 +@brand-warning-alpha-7: rgba(250, 189, 14, 0.1); +// F3-1-2 A80 +@brand-success-alpha-2: rgba(102, 188, 92, 0.8); +// F3-1-7 A10 +@brand-success-alpha-7: rgba(102, 188, 92, 0.1); +// F4-1-7 A10 +@brand-link-alpha-7: rgba(102, 188, 92, 0.1); + +// 文本色 +@text-primary-color: @dark-alpha-3; +@text-secondary-color: @normal-alpha-3; +@text-thirdary-color: @dark-alpha-4; +@text-disabled-color: @normal-alpha-5; +@text-helper-color: @dark-alpha-4; +@text-danger-color: @brand-danger; +@text-ali-color: #ec6c00; + +/** + * =========================================================== + * =================== Shadow Box ============================ + * =========================================================== + */ + +@box-shadow-1: 0 1px 4px 0 rgba(31, 56, 88, 0.15); // 1 级阴影,物体由原来存在于底面的物体展开,物体和底面关联紧密 +@box-shadow-2: 0 2px 10px 0 rgba(31, 56, 88, 0.15); // 2 级阴影,hover状态,物体层级较高 +@box-shadow-3: 0 4px 15px 0 rgba(31, 56, 88, 0.15); // 3 级阴影,当物体层级高于所有界面元素,弹窗用 + +/** + * =========================================================== + * ================= FontSize of Level ======================= + * =========================================================== + */ + +@fontSize-1: 26px; +@fontSize-2: 20px; +@fontSize-3: 16px; +@fontSize-4: 14px; +@fontSize-5: 12px; + +@fontLineHeight-1: 38px; +@fontLineHeight-2: 30px; +@fontLineHeight-3: 26px; +@fontLineHeight-4: 24px; +@fontLineHeight-5: 20px; + +/** + * =========================================================== + * ================= FontSize of Level ======================= + * =========================================================== + */ + +@global-border-radius: 3px; +@input-border-radius: 3px; +@popup-border-radius: 6px; + +/** + * =========================================================== + * ===================== Transistion ========================= + * =========================================================== + */ + +@transition-duration: 0.3s; +@transition-ease: cubic-bezier(0.23, 1, 0.32, 1); +@transition-delay: 0s; + +/** + * =========================================================== + * ================ Global Configruations ==================== + * =========================================================== + */ + +@topPaneHeight: 48px; +@actionpane-height: 48px; +@tabPaneWidth: 260px; +@input-standard-height: 32px; +@dockpane-width: 48px; + +/** + * =========================================================== + * =================== Deprecated Items ====================== + * =========================================================== + */ + +@head-bgcolor: @white-alpha-1; +@pane-bgcolor: @white-alpha-1; +@pane-dark-bgcolor: @white-alpha-1; +@pane-bdcolor: @normal-4; +@blank-bgcolor: @normal-5; +@title-bgcolor: @white-alpha-1; +@title-bdcolor: transparent; +@section-bgcolor: transparent; +@section-bdcolor: @white-alpha-1; +@button-bgcolor: @white-alpha-1; +@button-bdcolor: transparent; +@button-blue-color: @brand-color; +@button-blue-hover-color: @brand-color; +@sub-title-bgcolor: @white-alpha-1; +@sub-title-bdcolor: transparent; +@text-color: @text-primary-color; +@icon-color: @gray; +@icon-color-active: @gray-light; +@ghost-bgcolor: @dark-alpha-3; +@input-bgcolor: transparent; +@input-bdcolor: @normal-alpha-5; +@hover-color: #5a99cc; +@active-color: #5a99cc; +@disabled-color: #666; +@setter-popup-bg: rgb(80, 86, 109); diff --git a/packages/workspace/src/resource-type.ts b/packages/workspace/src/resource-type.ts new file mode 100644 index 0000000000..28d54e56b3 --- /dev/null +++ b/packages/workspace/src/resource-type.ts @@ -0,0 +1,22 @@ +import { IPublicTypeResourceType } from '@alilc/lowcode-types'; + +export interface IResourceType extends Omit<IPublicTypeResourceType, 'resourceName' | 'resourceType'> { + name: string; + + type: 'editor' | 'webview'; + + resourceTypeModel: IPublicTypeResourceType; +} + +export class ResourceType implements IResourceType { + constructor(readonly resourceTypeModel: IPublicTypeResourceType) { + } + + get name() { + return this.resourceTypeModel.resourceName; + } + + get type() { + return this.resourceTypeModel.resourceType; + } +} \ No newline at end of file diff --git a/packages/workspace/src/resource.ts b/packages/workspace/src/resource.ts new file mode 100644 index 0000000000..6e85183853 --- /dev/null +++ b/packages/workspace/src/resource.ts @@ -0,0 +1,130 @@ +import { ISkeleton } from '@alilc/lowcode-editor-skeleton'; +import { IPublicTypeEditorView, IPublicResourceData, IPublicResourceTypeConfig, IBaseModelResource, IPublicEnumPluginRegisterLevel } from '@alilc/lowcode-types'; +import { Logger } from '@alilc/lowcode-utils'; +import { BasicContext, IBasicContext } from './context/base-context'; +import { ResourceType, IResourceType } from './resource-type'; +import { IWorkspace } from './workspace'; + +const logger = new Logger({ level: 'warn', bizName: 'workspace:resource' }); + +export interface IBaseResource<T> extends IBaseModelResource<T> { + readonly resourceType: ResourceType; + + skeleton: ISkeleton; + + description?: string; + + get editorViews(): IPublicTypeEditorView[]; + + get defaultViewName(): string | undefined; + + getEditorView(name: string): IPublicTypeEditorView | undefined; + + import(schema: any): Promise<any>; + + save(value: any): Promise<any>; + + url(): Promise<string | undefined>; +} + +export type IResource = IBaseResource<IResource>; + +export class Resource implements IResource { + private context: IBasicContext; + + resourceTypeInstance: IPublicResourceTypeConfig; + + editorViewMap: Map<string, IPublicTypeEditorView> = new Map<string, IPublicTypeEditorView>(); + + get name() { + return this.resourceType.name; + } + + get viewName() { + return this.resourceData.viewName || (this.resourceData as any).viewType || this.defaultViewName; + } + + get description() { + return this.resourceTypeInstance?.description; + } + + get icon() { + return this.resourceData.icon || this.resourceTypeInstance?.icon; + } + + get type() { + return this.resourceType.type; + } + + get title(): string | undefined { + return this.resourceData.title || this.resourceTypeInstance.defaultTitle; + } + + get id(): string | undefined { + return this.resourceData.id; + } + + get options() { + return this.resourceData.options; + } + + get category() { + return this.resourceData?.category; + } + + get skeleton() { + return this.context.innerSkeleton; + } + + children: IResource[]; + + get config() { + return this.resourceData.config; + } + + constructor(readonly resourceData: IPublicResourceData, readonly resourceType: IResourceType, readonly workspace: IWorkspace) { + this.context = new BasicContext(workspace, `resource-${resourceData.resourceName || resourceType.name}`, IPublicEnumPluginRegisterLevel.Resource); + this.resourceTypeInstance = resourceType.resourceTypeModel(this.context.innerPlugins._getLowCodePluginContext({ + pluginName: '', + }), this.options); + this.init(); + if (this.resourceTypeInstance.editorViews) { + this.resourceTypeInstance.editorViews.forEach((d: any) => { + this.editorViewMap.set(d.viewName, d); + }); + } + if (!resourceType) { + logger.error(`resourceType[${resourceType}] is unValid.`); + } + this.children = this.resourceData?.children?.map(d => new Resource(d, this.workspace.getResourceType(d.resourceName || this.resourceType.name), this.workspace)) || []; + } + + async init() { + await this.resourceTypeInstance.init?.(); + await this.context.innerPlugins.init(); + } + + async import(schema: any) { + return await this.resourceTypeInstance.import?.(schema); + } + + async url() { + return await this.resourceTypeInstance.url?.(); + } + + async save(value: any) { + return await this.resourceTypeInstance.save?.(value); + } + + get editorViews() { + return this.resourceTypeInstance.editorViews; + } + + get defaultViewName() { + return this.resourceTypeInstance.defaultViewName || this.resourceTypeInstance.defaultViewType; + } + + getEditorView(name: string) { + return this.editorViewMap.get(name); + } +} \ No newline at end of file diff --git a/packages/workspace/src/skeleton-context.ts b/packages/workspace/src/skeleton-context.ts new file mode 100644 index 0000000000..781ba9ae8c --- /dev/null +++ b/packages/workspace/src/skeleton-context.ts @@ -0,0 +1,3 @@ +import { createContext } from 'react'; + +export const SkeletonContext = createContext<any>({} as any); diff --git a/packages/workspace/src/view/editor-view.tsx b/packages/workspace/src/view/editor-view.tsx new file mode 100644 index 0000000000..7ada5c911e --- /dev/null +++ b/packages/workspace/src/view/editor-view.tsx @@ -0,0 +1,33 @@ +import { BuiltinLoading } from '@alilc/lowcode-designer'; +import { engineConfig, observer } from '@alilc/lowcode-editor-core'; +import { + Workbench, +} from '@alilc/lowcode-editor-skeleton'; +import { PureComponent } from 'react'; +import { Context } from '../context/view-context'; + +export * from '../context/base-context'; + +@observer +export class EditorView extends PureComponent<{ + editorView: Context; + active: boolean; +}, any> { + render() { + const { active } = this.props; + const editorView = this.props.editorView; + const skeleton = editorView.innerSkeleton; + if (!editorView.isInit) { + const Loading = engineConfig.get('loadingComponent', BuiltinLoading); + return <Loading />; + } + + return ( + <Workbench + skeleton={skeleton} + className={active ? 'active engine-editor-view' : 'engine-editor-view'} + topAreaItemClassName="engine-actionitem" + /> + ); + } +} diff --git a/packages/workspace/src/view/resource-view.less b/packages/workspace/src/view/resource-view.less new file mode 100644 index 0000000000..4c281f8d8f --- /dev/null +++ b/packages/workspace/src/view/resource-view.less @@ -0,0 +1,14 @@ +.workspace-resource-view { + display: flex; + position: absolute; + flex-direction: column; + top: 0; + bottom: 0; + left: 0; + right: 0; +} + +.workspace-editor-body { + position: relative; + height: 100%; +} \ No newline at end of file diff --git a/packages/workspace/src/view/resource-view.tsx b/packages/workspace/src/view/resource-view.tsx new file mode 100644 index 0000000000..e2204dd505 --- /dev/null +++ b/packages/workspace/src/view/resource-view.tsx @@ -0,0 +1,36 @@ +import { PureComponent } from 'react'; +import { EditorView } from './editor-view'; +import { observer } from '@alilc/lowcode-editor-core'; +import { IResource } from '../resource'; +import { IEditorWindow } from '../window'; +import './resource-view.less'; +import { TopArea } from '@alilc/lowcode-editor-skeleton'; + +@observer +export class ResourceView extends PureComponent<{ + window: IEditorWindow; + resource: IResource; +}, any> { + render() { + const { skeleton } = this.props.resource; + const { editorViews } = this.props.window; + return ( + <div className="workspace-resource-view"> + <TopArea area={skeleton.topArea} itemClassName="engine-actionitem" /> + <div className="workspace-editor-body"> + { + Array.from(editorViews.values()).map((editorView: any) => { + return ( + <EditorView + key={editorView.name} + active={editorView.active} + editorView={editorView} + /> + ); + }) + } + </div> + </div> + ); + } +} \ No newline at end of file diff --git a/packages/workspace/src/view/window-view.tsx b/packages/workspace/src/view/window-view.tsx new file mode 100644 index 0000000000..65378bc9c4 --- /dev/null +++ b/packages/workspace/src/view/window-view.tsx @@ -0,0 +1,39 @@ +import { PureComponent } from 'react'; +import { ResourceView } from './resource-view'; +import { engineConfig, observer } from '@alilc/lowcode-editor-core'; +import { EditorWindow } from '../window'; +import { BuiltinLoading } from '@alilc/lowcode-designer'; +import { DesignerView } from '../inner-plugins/webview'; + +@observer +export class WindowView extends PureComponent<{ + window: EditorWindow; + active: boolean; +}, any> { + render() { + const { active } = this.props; + const { resource, initReady, url } = this.props.window; + + if (!initReady) { + const Loading = engineConfig.get('loadingComponent', BuiltinLoading); + return ( + <div className={`workspace-engine-main ${active ? 'active' : ''}`}> + <Loading /> + </div> + ); + } + + if (resource.type === 'webview' && url) { + return <DesignerView url={url} viewName={resource.name} />; + } + + return ( + <div className={`workspace-engine-main ${active ? 'active' : ''}`}> + <ResourceView + resource={resource} + window={this.props.window} + /> + </div> + ); + } +} \ No newline at end of file diff --git a/packages/workspace/src/window.ts b/packages/workspace/src/window.ts new file mode 100644 index 0000000000..cd64a9b112 --- /dev/null +++ b/packages/workspace/src/window.ts @@ -0,0 +1,253 @@ +import { uniqueId } from '@alilc/lowcode-utils'; +import { createModuleEventBus, IEventBus, makeObservable, obx } from '@alilc/lowcode-editor-core'; +import { Context, IViewContext } from './context/view-context'; +import { IWorkspace } from './workspace'; +import { IResource } from './resource'; +import { IPublicModelWindow, IPublicTypeDisposable } from '@alilc/lowcode-types'; + +interface IWindowCOnfig { + title: string | undefined; + options?: Object; + viewName?: string | undefined; + sleep?: boolean; +} + +export interface IEditorWindow extends Omit<IPublicModelWindow<IResource>, 'changeViewType' | 'currentEditorView' | 'editorViews'> { + readonly resource: IResource; + + editorViews: Map<string, IViewContext>; + + _editorView: IViewContext; + + changeViewName: (name: string, ignoreEmit?: boolean) => void; + + initReady: boolean; + + sleep?: boolean; + + init(): void; + + updateState(state: WINDOW_STATE): void; +} + +export enum WINDOW_STATE { + // 睡眠 + sleep = 'sleep', + + // 激活 + active = 'active', + + // 未激活 + inactive = 'inactive', + + // 销毁 + destroyed = 'destroyed' +} + +export class EditorWindow implements IEditorWindow { + id: string = uniqueId('window'); + icon: React.ReactElement | undefined; + + private emitter: IEventBus = createModuleEventBus('Project'); + + title: string | undefined; + + url: string | undefined; + + @obx.ref _editorView: Context; + + @obx editorViews: Map<string, Context> = new Map<string, Context>(); + + @obx initReady = false; + + sleep: boolean | undefined; + + get editorView() { + if (!this._editorView) { + return this.editorViews.values().next().value; + } + return this._editorView; + } + + constructor(readonly resource: IResource, readonly workspace: IWorkspace, private config: IWindowCOnfig) { + makeObservable(this); + this.title = config.title; + this.icon = resource.icon; + this.sleep = config.sleep; + if (config.sleep) { + this.updateState(WINDOW_STATE.sleep); + } + } + + updateState(state: WINDOW_STATE): void { + switch (state) { + case WINDOW_STATE.active: + this._editorView?.setActivate(true); + break; + case WINDOW_STATE.inactive: + this._editorView?.setActivate(false); + break; + case WINDOW_STATE.destroyed: + break; + } + } + + async importSchema(schema: any) { + const newSchema = await this.resource.import(schema); + + if (!newSchema) { + return; + } + + Object.keys(newSchema).forEach(key => { + const view = this.editorViews.get(key); + view?.project.importSchema(newSchema[key]); + }); + } + + async save() { + const value: any = {}; + const editorViews = this.resource.editorViews; + if (!editorViews) { + return; + } + for (let i = 0; i < editorViews.length; i++) { + const name = editorViews[i].viewName; + const saveResult = await this.editorViews.get(name)?.save(); + value[name] = saveResult; + } + const result = await this.resource.save(value); + this.emitter.emit('handle.save'); + + return result; + } + + onSave(fn: () => void) { + this.emitter.on('handle.save', fn); + + return () => { + this.emitter.off('handle.save', fn); + }; + } + + async init() { + await this.initViewTypes(); + await this.execViewTypesInit(); + Promise.all(Array.from(this.editorViews.values()).map((d) => d.onSimulatorRendererReady())) + .then(() => { + this.workspace.emitWindowRendererReady(); + }); + this.url = await this.resource.url(); + this.setDefaultViewName(); + this.initReady = true; + this.workspace.checkWindowQueue(); + this.sleep = false; + this.updateState(WINDOW_STATE.active); + } + + initViewTypes = async () => { + const editorViews = this.resource.editorViews; + if (!editorViews) { + return; + } + for (let i = 0; i < editorViews.length; i++) { + const name = editorViews[i].viewName; + await this.initViewType(name); + if (!this._editorView) { + this.changeViewName(name); + } + } + }; + + onChangeViewType(fn: (viewName: string) => void): IPublicTypeDisposable { + this.emitter.on('window.change.view.type', fn); + + return () => { + this.emitter.off('window.change.view.type', fn); + }; + } + + execViewTypesInit = async () => { + const editorViews = this.resource.editorViews; + if (!editorViews) { + return; + } + for (let i = 0; i < editorViews.length; i++) { + const name = editorViews[i].viewName; + this.changeViewName(name); + await this.editorViews.get(name)?.init(); + } + }; + + setDefaultViewName = () => { + this.changeViewName(this.config.viewName ?? this.resource.defaultViewName!); + }; + + get resourceType() { + return this.resource.resourceType.type; + } + + initViewType = async (name: string) => { + const viewInfo = this.resource.getEditorView(name); + if (this.editorViews.get(name)) { + return; + } + const editorView = new Context(this.workspace, this, viewInfo as any, this.config.options); + this.editorViews.set(name, editorView); + }; + + changeViewName = (name: string, ignoreEmit: boolean = true) => { + this._editorView?.setActivate(false); + this._editorView = this.editorViews.get(name)!; + + if (!this._editorView) { + return; + } + + this._editorView.setActivate(true); + + if (!ignoreEmit) { + this.emitter.emit('window.change.view.type', name); + + if (this.id === this.workspace.window.id) { + this.workspace.emitChangeActiveEditorView(); + } + } + }; + + get project() { + return this.editorView?.project; + } + + get innerProject() { + return this.editorView?.innerProject; + } + + get innerSkeleton() { + return this.editorView?.innerSkeleton; + } + + get innerSetters() { + return this.editorView?.innerSetters; + } + + get innerHotkey() { + return this.editorView?.innerHotkey; + } + + get editor() { + return this.editorView?.editor; + } + + get designer() { + return this.editorView?.designer; + } + + get plugins() { + return this.editorView?.plugins; + } + + get innerPlugins() { + return this.editorView?.innerPlugins; + } +} \ No newline at end of file diff --git a/packages/workspace/src/workspace.ts b/packages/workspace/src/workspace.ts new file mode 100644 index 0000000000..9f1abaa0fb --- /dev/null +++ b/packages/workspace/src/workspace.ts @@ -0,0 +1,379 @@ +import { IDesigner, ILowCodePluginManager, LowCodePluginManager } from '@alilc/lowcode-designer'; +import { createModuleEventBus, Editor, IEditor, IEventBus, makeObservable, obx } from '@alilc/lowcode-editor-core'; +import { IPublicApiPlugins, IPublicApiWorkspace, IPublicEnumPluginRegisterLevel, IPublicResourceList, IPublicTypeDisposable, IPublicTypeResourceType, IShellModelFactory } from '@alilc/lowcode-types'; +import { BasicContext } from './context/base-context'; +import { EditorWindow, WINDOW_STATE } from './window'; +import type { IEditorWindow } from './window'; +import { IResource, Resource } from './resource'; +import { IResourceType, ResourceType } from './resource-type'; +import { ISkeleton } from '@alilc/lowcode-editor-skeleton'; + +enum EVENT { + CHANGE_WINDOW = 'change_window', + + CHANGE_ACTIVE_WINDOW = 'change_active_window', + + WINDOW_RENDER_READY = 'window_render_ready', + + CHANGE_ACTIVE_EDITOR_VIEW = 'change_active_editor_view', +} + +const CHANGE_EVENT = 'resource.list.change'; + +export interface IWorkspace extends Omit<IPublicApiWorkspace< + LowCodePluginManager, + IEditorWindow +>, 'resourceList' | 'plugins' | 'openEditorWindow' | 'removeEditorWindow'> { + readonly registryInnerPlugin: (designer: IDesigner, editor: Editor, plugins: IPublicApiPlugins) => Promise<IPublicTypeDisposable>; + + readonly shellModelFactory: IShellModelFactory; + + enableAutoOpenFirstWindow: boolean; + + window: IEditorWindow; + + plugins: ILowCodePluginManager; + + skeleton: ISkeleton; + + resourceTypeMap: Map<string, ResourceType>; + + getResourceList(): IResource[]; + + getResourceType(resourceName: string): IResourceType; + + checkWindowQueue(): void; + + emitWindowRendererReady(): void; + + initWindow(): void; + + setActive(active: boolean): void; + + onChangeActiveEditorView(fn: () => void): IPublicTypeDisposable; + + emitChangeActiveEditorView(): void; + + openEditorWindowByResource(resource: IResource, sleep: boolean): Promise<void>; + + /** + * @deprecated + */ + removeEditorWindow(resourceName: string, id: string): void; + + removeEditorWindowByResource(resource: IResource): void; + + /** + * @deprecated + */ + openEditorWindow(name: string, title: string, options: Object, viewName?: string, sleep?: boolean): Promise<void>; +} + +export class Workspace implements IWorkspace { + context: BasicContext; + + enableAutoOpenFirstWindow: boolean; + + resourceTypeMap: Map<string, ResourceType> = new Map(); + + private emitter: IEventBus = createModuleEventBus('workspace'); + + private _isActive = false; + + private resourceList: IResource[] = []; + + get skeleton() { + return this.context.innerSkeleton; + } + + get plugins() { + return this.context.innerPlugins; + } + + get isActive() { + return this._isActive; + } + + get defaultResourceType(): ResourceType | null { + if (this.resourceTypeMap.size >= 1) { + return Array.from(this.resourceTypeMap.values())[0]; + } + + return null; + } + + @obx.ref windows: IEditorWindow[] = []; + + editorWindowMap: Map<string, IEditorWindow> = new Map<string, IEditorWindow>(); + + @obx.ref window: IEditorWindow; + + windowQueue: ({ + name: string; + title: string; + options: Object; + viewName?: string; + } | IResource)[] = []; + + constructor( + readonly registryInnerPlugin: (designer: IDesigner, editor: IEditor, plugins: IPublicApiPlugins) => Promise<IPublicTypeDisposable>, + readonly shellModelFactory: any, + ) { + this.context = new BasicContext(this, '', IPublicEnumPluginRegisterLevel.Workspace); + this.context.innerHotkey.activate(true); + makeObservable(this); + } + + checkWindowQueue() { + if (!this.windowQueue || !this.windowQueue.length) { + return; + } + + const windowInfo = this.windowQueue.shift(); + if (windowInfo instanceof Resource) { + this.openEditorWindowByResource(windowInfo); + } else if (windowInfo) { + this.openEditorWindow(windowInfo.name, windowInfo.title, windowInfo.options, windowInfo.viewName); + } + } + + async initWindow() { + if (!this.defaultResourceType || this.enableAutoOpenFirstWindow === false) { + return; + } + const resourceName = this.defaultResourceType.name; + const resource = new Resource({ + resourceName, + options: {}, + }, this.defaultResourceType, this); + this.window = new EditorWindow(resource, this, { + title: resource.title, + }); + await this.window.init(); + this.editorWindowMap.set(this.window.id, this.window); + this.windows = [...this.windows, this.window]; + this.emitChangeWindow(); + this.emitChangeActiveWindow(); + } + + setActive(value: boolean) { + this._isActive = value; + } + + async registerResourceType(resourceTypeModel: IPublicTypeResourceType): Promise<void> { + const resourceType = new ResourceType(resourceTypeModel); + this.resourceTypeMap.set(resourceTypeModel.resourceName, resourceType); + + if (!this.window && this.defaultResourceType && this._isActive) { + this.initWindow(); + } + } + + getResourceList() { + return this.resourceList; + } + + setResourceList(resourceList: IPublicResourceList) { + this.resourceList = resourceList.map(d => new Resource(d, this.getResourceType(d.resourceName), this)); + this.emitter.emit(CHANGE_EVENT, resourceList); + } + + onResourceListChange(fn: (resourceList: IPublicResourceList) => void): () => void { + this.emitter.on(CHANGE_EVENT, fn); + return () => { + this.emitter.off(CHANGE_EVENT, fn); + }; + } + + onWindowRendererReady(fn: () => void): IPublicTypeDisposable { + this.emitter.on(EVENT.WINDOW_RENDER_READY, fn); + return () => { + this.emitter.off(EVENT.WINDOW_RENDER_READY, fn); + }; + } + + emitWindowRendererReady() { + this.emitter.emit(EVENT.WINDOW_RENDER_READY); + } + + getResourceType(resourceName: string): IResourceType { + return this.resourceTypeMap.get(resourceName)!; + } + + removeResourceType(resourceName: string) { + if (this.resourceTypeMap.has(resourceName)) { + this.resourceTypeMap.delete(resourceName); + } + } + + removeEditorWindowById(id: string) { + const index = this.windows.findIndex(d => (d.id === id)); + this.remove(index); + } + + private async remove(index: number) { + if (index < 0) { + return; + } + const window = this.windows[index]; + this.windows.splice(index, 1); + this.window?.updateState(WINDOW_STATE.destroyed); + if (this.window === window) { + this.window = this.windows[index] || this.windows[index + 1] || this.windows[index - 1]; + if (this.window?.sleep) { + await this.window.init(); + } + this.emitChangeActiveWindow(); + } + this.emitChangeWindow(); + this.window?.updateState(WINDOW_STATE.active); + } + + removeEditorWindow(resourceName: string, id: string) { + const index = this.windows.findIndex(d => (d.resource?.name === resourceName && (d.title === id || d.resource.id === id))); + this.remove(index); + } + + removeEditorWindowByResource(resource: IResource) { + const index = this.windows.findIndex(d => (d.resource?.id === resource.id)); + this.remove(index); + } + + async openEditorWindowById(id: string) { + const window = this.editorWindowMap.get(id); + this.window?.updateState(WINDOW_STATE.inactive); + if (window) { + this.window = window; + if (window.sleep) { + await window.init(); + } + this.emitChangeActiveWindow(); + } + this.window?.updateState(WINDOW_STATE.active); + } + + async openEditorWindowByResource(resource: IResource, sleep: boolean = false): Promise<void> { + if (this.window && !this.window.sleep && !this.window?.initReady && !sleep) { + this.windowQueue.push(resource); + return; + } + + this.window?.updateState(WINDOW_STATE.inactive); + + const filterWindows = this.windows.filter(d => (d.resource?.id === resource.id)); + if (filterWindows && filterWindows.length) { + this.window = filterWindows[0]; + if (!sleep && this.window.sleep) { + await this.window.init(); + } else { + this.checkWindowQueue(); + } + this.emitChangeActiveWindow(); + this.window?.updateState(WINDOW_STATE.active); + return; + } + + const window = new EditorWindow(resource, this, { + title: resource.title, + options: resource.options, + viewName: resource.viewName, + sleep, + }); + + this.windows = [...this.windows, window]; + this.editorWindowMap.set(window.id, window); + if (sleep) { + this.emitChangeWindow(); + return; + } + this.window = window; + await this.window.init(); + this.emitChangeWindow(); + this.emitChangeActiveWindow(); + this.window?.updateState(WINDOW_STATE.active); + } + + async openEditorWindow(name: string, title: string, options: Object, viewName?: string, sleep?: boolean) { + if (this.window && !this.window.sleep && !this.window?.initReady && !sleep) { + this.windowQueue.push({ + name, title, options, viewName, + }); + return; + } + const resourceType = this.resourceTypeMap.get(name); + if (!resourceType) { + console.error(`${name} resourceType is not available`); + return; + } + this.window?.updateState(WINDOW_STATE.inactive); + const filterWindows = this.windows.filter(d => (d.resource?.name === name && d.resource.title == title) || (d.resource.id == title)); + if (filterWindows && filterWindows.length) { + this.window = filterWindows[0]; + if (!sleep && this.window.sleep) { + await this.window.init(); + } else { + this.checkWindowQueue(); + } + this.emitChangeActiveWindow(); + this.window?.updateState(WINDOW_STATE.active); + return; + } + const resource = new Resource({ + resourceName: name, + title, + options, + id: title?.toString(), + }, resourceType, this); + const window = new EditorWindow(resource, this, { + title, + options, + viewName, + sleep, + }); + this.windows = [...this.windows, window]; + this.editorWindowMap.set(window.id, window); + if (sleep) { + this.emitChangeWindow(); + return; + } + this.window = window; + await this.window.init(); + this.emitChangeWindow(); + this.emitChangeActiveWindow(); + this.window?.updateState(WINDOW_STATE.active); + } + + onChangeWindows(fn: () => void) { + this.emitter.on(EVENT.CHANGE_WINDOW, fn); + return () => { + this.emitter.removeListener(EVENT.CHANGE_WINDOW, fn); + }; + } + + onChangeActiveEditorView(fn: () => void) { + this.emitter.on(EVENT.CHANGE_ACTIVE_EDITOR_VIEW, fn); + return () => { + this.emitter.removeListener(EVENT.CHANGE_ACTIVE_EDITOR_VIEW, fn); + }; + } + + emitChangeActiveEditorView() { + this.emitter.emit(EVENT.CHANGE_ACTIVE_EDITOR_VIEW); + } + + emitChangeWindow() { + this.emitter.emit(EVENT.CHANGE_WINDOW); + } + + emitChangeActiveWindow() { + this.emitter.emit(EVENT.CHANGE_ACTIVE_WINDOW); + this.emitChangeActiveEditorView(); + } + + onChangeActiveWindow(fn: () => void) { + this.emitter.on(EVENT.CHANGE_ACTIVE_WINDOW, fn); + return () => { + this.emitter.removeListener(EVENT.CHANGE_ACTIVE_WINDOW, fn); + }; + } +} diff --git a/packages/workspace/tsconfig.json b/packages/workspace/tsconfig.json new file mode 100644 index 0000000000..c37b76ecc6 --- /dev/null +++ b/packages/workspace/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "lib" + }, + "include": [ + "./src/" + ] +} diff --git a/scripts/build.sh b/scripts/build.sh index 20bf34a711..751e9094fe 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -1,5 +1,7 @@ #!/usr/bin/env bash +set -e + lerna run build \ --scope @alilc/lowcode-types \ --scope @alilc/lowcode-utils \ @@ -8,18 +10,20 @@ lerna run build \ --scope @alilc/lowcode-editor-skeleton \ --scope @alilc/lowcode-designer \ --scope @alilc/lowcode-plugin-designer \ + --scope @alilc/lowcode-plugin-command \ --scope @alilc/lowcode-plugin-outline-pane \ - --scope @alilc/lowcode-rax-renderer \ - --scope @alilc/lowcode-rax-simulator-renderer \ --scope @alilc/lowcode-react-renderer \ --scope @alilc/lowcode-react-simulator-renderer \ --scope @alilc/lowcode-renderer-core \ + --scope @alilc/lowcode-workspace \ --scope @alilc/lowcode-engine \ --stream lerna run build:umd \ --scope @alilc/lowcode-engine \ - --scope @alilc/lowcode-rax-simulator-renderer \ --scope @alilc/lowcode-react-simulator-renderer \ --scope @alilc/lowcode-react-renderer \ - --stream \ No newline at end of file + --stream + +cp ./packages/react-simulator-renderer/dist/js/* ./packages/engine/dist/js/ +cp ./packages/react-simulator-renderer/dist/css/* ./packages/engine/dist/css/ diff --git a/scripts/set-repo.js b/scripts/set-repo.js new file mode 100644 index 0000000000..9bae66d053 --- /dev/null +++ b/scripts/set-repo.js @@ -0,0 +1,45 @@ +#!/usr/bin/env node + +const path = require('path'); +const fs = require('fs-extra'); + +(async () => { + const root = path.join(__dirname, '../'); + const workspaces = ['modules', 'packages']; + for (const workspace of workspaces) { + const pkgDir = path.join(root, workspace); + const pkgs = await fs.readdir(pkgDir); + for (const pkg of pkgs) { + if (pkg.charAt(0) === '.') continue; + if (!(await fs.statSync(path.join(pkgDir, pkg))).isDirectory()) continue; + await setRepo({ + workspace, + pkgDir, + pkg, + }); + } + } + + async function setRepo(opts) { + const pkgDir = path.join(opts.pkgDir, opts.pkg); + const pkgPkgJSONPath = path.join(pkgDir, 'package.json'); + if (!fs.existsSync(pkgPkgJSONPath)) { + console.log(`${opts.pkg} exists`); + } else { + const pkgPkgJSON = require(pkgPkgJSONPath); + fs.writeJSONSync( + pkgPkgJSONPath, + Object.assign(pkgPkgJSON, { + repository: { + type: 'http', + url: `https://github.com/alibaba/lowcode-engine/tree/main/${opts.workspace}/${opts.pkg}`, + }, + bugs: 'https://github.com/alibaba/lowcode-engine/issues', + homepage: 'https://github.com/alibaba/lowcode-engine/#readme', + }), + { spaces: ' ' }, + ); + console.log(`[Write] ${opts.pkg}`); + } + } +})(); diff --git a/scripts/setup-skip-build.sh b/scripts/setup-skip-build.sh index d51c33c417..7c0ff6a273 100755 --- a/scripts/setup-skip-build.sh +++ b/scripts/setup-skip-build.sh @@ -1,6 +1,9 @@ #!/usr/bin/env bash rm -rf node_modules package-lock.json yarn.lock + +npm i lerna@4.0.0 + lerna clean -y find ./packages -type f -name "package-lock.json" -exec rm -f {} \; diff --git a/scripts/setup.js b/scripts/setup.js new file mode 100644 index 0000000000..77da2291f4 --- /dev/null +++ b/scripts/setup.js @@ -0,0 +1,26 @@ +#!/usr/bin/env node +const os = require('os'); +const del = require('del'); +const gulp = require('gulp'); +const execa = require('execa'); + +async function deleteRootDirLockFile() { + await del('package-lock.json'); + await del('yarn.lock'); +} + +async function clean() { + await execa.command('lerna clean -y', { stdio: 'inherit', encoding: 'utf-8' }); +} + +async function deletePackagesDirLockFile() { + await del('packages/**/package-lock.json'); +} + +async function bootstrap() { + await execa.command('lerna bootstrap --force-local', { stdio: 'inherit', encoding: 'utf-8' }); +} + +const setup = gulp.series(deleteRootDirLockFile, clean, deletePackagesDirLockFile, bootstrap); + +os.type() === 'Windows_NT' ? setup() : execa.command('scripts/setup.sh', { stdio: 'inherit', encoding: 'utf-8' }); \ No newline at end of file diff --git a/scripts/start.js b/scripts/start.js new file mode 100644 index 0000000000..e1a83ea732 --- /dev/null +++ b/scripts/start.js @@ -0,0 +1,10 @@ +#!/usr/bin/env node +const os = require('os'); +const execa = require('execa'); + +async function start() { + const [, , pkgName = '@alilc/lowcode-ignitor'] = process.argv; + await execa.command(`lerna exec --scope ${pkgName} -- npm start`, { stdio: 'inherit', encoding: 'utf-8' }); +} + +os.type() === 'Windows_NT' ? start() : execa.command('scripts/start.sh', { stdio: 'inherit', encoding: 'utf-8' }); diff --git a/scripts/sync-oss.js b/scripts/sync-oss.js new file mode 100644 index 0000000000..2108e676d2 --- /dev/null +++ b/scripts/sync-oss.js @@ -0,0 +1,47 @@ +#!/usr/bin/env node +const http = require('http'); +const package = require('../packages/engine/package.json'); +const { version, name } = package; +const options = { + method: 'PUT', + hostname: 'uipaas-node.alibaba-inc.com', + path: '/staticAssets/cdn/packages', + headers: { + 'Content-Type': 'application/json', + Cookie: 'locale=en-us', + }, + maxRedirects: 20, +}; + +const onResponse = function (res) { + const chunks = []; + res.on('data', (chunk) => { + chunks.push(chunk); + }); + + res.on('end', () => { + const body = Buffer.concat(chunks); + console.table(JSON.stringify(JSON.parse(body.toString()), null, 2)); + }); + + res.on('error', (error) => { + console.error(error); + }); +}; + +const req = http.request(options, onResponse); + +const postData = JSON.stringify({ + packages: [ + { + packageName: name, + version, + }, + ], + // 可以发布指定源的 npm 包,默认公网 npm + useTnpm: true, +}); + +req.write(postData); + +req.end(); diff --git a/scripts/sync.sh b/scripts/sync.sh new file mode 100755 index 0000000000..3edac03845 --- /dev/null +++ b/scripts/sync.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash + +# sync all packages to alibaba intranet registry +tnpm sync @alilc/lowcode-types +tnpm sync @alilc/lowcode-utils +tnpm sync @alilc/lowcode-shell +tnpm sync @alilc/lowcode-editor-core +tnpm sync @alilc/lowcode-editor-skeleton +tnpm sync @alilc/lowcode-designer +tnpm sync @alilc/lowcode-plugin-designer +tnpm sync @alilc/lowcode-plugin-outline-pane +tnpm sync @alilc/lowcode-renderer-core +tnpm sync @alilc/lowcode-react-renderer +tnpm sync @alilc/lowcode-react-simulator-renderer +tnpm sync @alilc/lowcode-engine +tnpm sync @alilc/lowcode-workspace +tnpm sync @alilc/lowcode-plugin-command \ No newline at end of file