diff --git a/.gitee/PULL_REQUEST_TEMPLATE_CN.md b/.gitee/PULL_REQUEST_TEMPLATE_CN.md index 8e1f3393..e0708035 100644 --- a/.gitee/PULL_REQUEST_TEMPLATE_CN.md +++ b/.gitee/PULL_REQUEST_TEMPLATE_CN.md @@ -1,17 +1,8 @@ ---- -name: 模板名称,在新建pr时候能看到 -about: 模板描述,对应的pr模板卡片展示时候能看到,介绍模板 ---- **PR 描述:** [请描述提交此 PR 的背景、目的、所做的更改以及如何测试此 PR] **关联的 Issues:** [请列出与此 PR 相关的 issue 编号] -**TODO(可选)** -- [ ] 任务1 -- [ ] ... - -**检查项:** - +**检查项(无需修改,提交后界面上可勾选):** - [ ] 代码已经被检视 - [ ] 代码符合项目的代码标准和最佳实践 - [ ] 代码已经通过所有测试用例 diff --git a/.gitee/PULL_REQUEST_TEMPLATE_EN.md b/.gitee/PULL_REQUEST_TEMPLATE_EN.md index 7f8acc9b..7f2c7abf 100644 --- a/.gitee/PULL_REQUEST_TEMPLATE_EN.md +++ b/.gitee/PULL_REQUEST_TEMPLATE_EN.md @@ -1,17 +1,8 @@ ---- -name: The template name can be seen when creating a new PR -about: Template description, which can be seen when displaying the corresponding PR template card ---- **Description:** [Please describe the background, purpose, changes made, and how to test this PR] **Related Issues:** [List the issue numbers related to this PR] -**TODO(optional)** -- [ ] task1 -- [ ] ... - **Checklist:** - - [ ] Code has been reviewed - [ ] Code complies with the project's code standards and best practices - [ ] Code has passed all tests diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..46d6888f --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,53 @@ +## 代码注释 + +为提高代码的可读性,我们希望你能按照以下几条规则编写注释, + +- 核心:```不要解释是什么,而是回答为什么``` + +- 统一的注释风格 + + ``` tsx + /** + * 这是块级注释 + */ + + // 这是行内注释 + + //============================== 分隔符 ============================== + ``` + +- 注释不要重复代码内容 + + ``` tsx + // 注释和代码含义相同,无需添加 + // error是个promise + if (isPromise(error)) { + ... + } + ``` + +- 注释不要解释模糊代码 + + ``` tsx + // 最佳节点 + let n: Node | null = null; + ``` + + 直接在代码中通过名称表达清楚,可以省略掉注释 + + ``` tsx + let bestNode: Node | null = null; + ``` + + 如果添加注释则是补充性的额外信息 + + ```tsx + // 全局指针,在遍历时指向当前最佳的节点 + let bestNode: VNode | null = null; + ``` + +- 巧妙或复杂的代码要添加注释解释逻辑 + +- 对所有导出的顶层模块进行注释 + +- 注释应该是直接的,不要有不明确的表达或符号 diff --git a/Lisence/License b/Licenses/License similarity index 100% rename from Lisence/License rename to Licenses/License diff --git a/Lisence/Third Party Open Source Software Notice.docx b/Licenses/Third Party Open Source Software Notice.docx similarity index 100% rename from Lisence/Third Party Open Source Software Notice.docx rename to Licenses/Third Party Open Source Software Notice.docx diff --git a/README.md b/README.md index e155d9c2..38051ca8 100644 --- a/README.md +++ b/README.md @@ -6,75 +6,82 @@ ## 技术架构 -![输入图片说明](https://gitee.com/openInula/inula-docs/raw/master/static/img/structure.png) +![](https://openinula-website.obs.ap-southeast-1.myhuaweicloud.com/misc/structure.png) -### 核心能力 +## 核心能力 -**响应式API** +### 响应式API -* openInula 通过最小化重新渲染的范围,从而进行高效的UI渲染。这种方式避免了虚拟 DOM 的开销,使得 openInula 在性能方面表现出色。 -* openInula 通过比较变化前后的 JavaScript 对象以细粒度的依赖追踪机制来实现响应式更新,无需用户过度关注性能优化。 -* 简洁API: - 1. openInula 提供了两组简洁直观的API--响应式 API 和与 React 一致的传统 API,使得开发者可以轻松地构建复杂的交互式界面。 - 2. openInula 简洁的 API 极大降低了开发者的学习成本,开发者使用响应式API可以快速构建高效的前端界面。 +openInula 通过监听状态变量的变化,以细粒度的依赖追踪机制来实现响应式更新,避免了虚拟 DOM 的开销。通过最小化重新渲染的范围,从而进行高效的UI渲染。无需用户过度关注性能优化。 -**兼容 ReactAPI** +>(实验性功能,可在 `reactive` 分支查看代码或使用 npm 仓中 experimental 版本体验) -* 与 React 保持一致 API 的特性、可以无缝支持 React 生态。 -* 使用传统 API 可以无缝将 React 项目切换至 openInula,React 应用可零修改切换至 openInula。 +### 兼容 React API + +提供与 React 一致的 API,完全支持 React 生态,可将 React 应用可零修改切换至 openInula。 ### openInula 配套组件 -**状态管理器/inula-X** +#### 状态管理器 inula-X -inula-X 是 openInula 默认提供的状态管理器,无需额外引入三方库,就可以简单实现跨组件/页面共享状态。 -inula-X 与 Redux 比可创建多个 Store,不需要在 Reducer 中返回 state 并且简化了 Action 和 Reducer 的创建步骤,原生支持异步能力,组件能做到精准重渲染。inula-X 均可使用函数组件、class 组件,能提供 redux 的适配接口及支持响应式的特点。 +inula-X 是 openInula 默认提供的状态管理器。无需额外引入三方库,就可以简单实现跨组件/页面共享状态。 -**路由/inula-router** +inula-X 与 Redux 相比,可创建多个 Store,不需要在 Reducer 中返回 state 并且简化了 Action 和 Reducer 的创建步骤,原生支持异步能力,组件能做到精准重渲染。inula-X 均可使用函数组件、class 组件,能提供 redux 的适配接口及支持响应式的特点。 -inula-router 是 openInula 生态组建的一部分,为 openInula 提供前端路由的能力,是构建大型应用必要组件。 -inula-router 涵盖 react-router、history、connect-react-router 的功能。 +#### 路由 inula-router -**请求/inula-request** +inula-router 为 openInula 提供前端路由的能力,是构建大型应用必要组件,涵盖 react-router、history、connect-react-router 的功能。 -inula-request 是 openInula 生态组件,涵盖常见的网络请求方式,并提供动态轮询钩子函数给用户更便捷的定制化请求体验。 +#### 请求 inula-request -**国际化/inula-intl** +inula-request 是 openInula 的网络请求组件,不仅涵盖常见的网络请求方式,还提供动态轮询钩子函数给用户更便捷的定制化请求体验。 -inula-intl 是基于 openInula 生态组件,其主要提供了国际化功能,涵盖了基本的国际化组件和钩子函数,便于用户在构建国际化能力时方便操作。 +#### 国际化 inula-intl -**调试工具/inula-dev-tools** +inula-intl 是基于 openInula 的国际化组件,涵盖了基本的国际化组件和钩子函数,允许用户更方便地构建国际化能力。 + +#### 调试工具 inula-dev-tools inula-dev-tools 是一个为 openInula 开发者提供的强大工具集,能够方便地查看和编辑组件树、管理应用状态以及进行性能分析,极大提高了开发效率和诊断问题的便捷性。 -**脚手架/inula-cli** +#### 脚手架 create-inula -inula-cli 是一套针对 openInula 的编译期插件,它支持代码优化、JSX 语法转换以及代码分割,有助于提高应用的性能、可读性和可维护性。 +create-inula 是一套用于创建 openInula 项目的脚手架工具。它预置了一系列项目模板,允许开发者通过命令行按需快速生成可运行的项目代码。 -## openInula 文档 +## 参与贡献 + +我们鼓励开发者以各种方式参与代码贡献、生态拓展或文档反馈,献您的原创内容,详细请参考[贡献指南](https://docs.openinula.net/docs/%E8%B4%A1%E7%8C%AE%E6%8C%87%E5%8D%97)。 + +### 官方链接 欢迎访问 openInula 官网与文档仓库,参与 openInula 开发者文档开源项目,与我们一起完善开发者文档。 -openInula 官网地址:[https://www.openinula.net/](https://www.openinula.net/) -openInula 文档站地址:[https://docs.openinula.net/](https://docs.openinula.net/) +* openInula 官网:[https://www.openinula.net/](https://www.openinula.net/) +* openInula 文档:[https://docs.openinula.net/](https://docs.openinula.net/) +* openInula 仓库地址:[https://gitee.com/openinula/inula](https://gitee.com/openinula/inula) +* openInula 社提案备忘录(RFC):[https://gitee.com/openInula/rfcs](https://gitee.com/openInula/rfcs) -## 代码仓地址 +### 社区贡献者案例 -openInula 仓库地址:[https://gitee.com/openinula](https://gitee.com/openinula) +**[`umi-inula`](https://gitee.com/congxiaochen/inula)** -## 如何参与 +基于 umijs 与 openInula 的开发框架,集成官方组件与UI、AIGC等功能,开箱即用。 -**参与贡献** -欢迎您参与贡献,我们鼓励开发者以各种方式参与文档反馈和贡献。 +**[`VoerkaI18n`](https://github.com/zhangfisher/voerka-i18n/)** -您可以对现有文档进行评价、简单更改、反馈文档质量问题、贡献您的原创内容,详细请参考[贡献指南](https://docs.openinula.net/docs/%E8%B4%A1%E7%8C%AE%E6%8C%87%E5%8D%97)。 +适用于多框架的 JavaScript 国际化解决方案,提供对 openInula 的适配。 + +- [适配示例](https://gitee.com/link?target=https%3A%2F%2Fgithub.com%2Fzhangfisher%2Fvoerka-i18n%2Ftree%2Fmaster%2Fexamples%2Fopeninula) +- [适配文档](https://gitee.com/link?target=https%3A%2F%2Fzhangfisher.github.io%2Fvoerka-i18n%2F%23%2Fzh%2Fguide%2Fintegration%2Fopeninula) ## 许可协议 -openInula 主要遵循 Mulan Permissive Software License v2 协议,详情请参考各代码仓 LICENSE 声明。 +openInula 主要遵循 [Mulan Permissive Software License v2](http://license.coscl.org.cn/MulanPSL2) 协议,详情请参考各代码仓 LICENSE 声明。 ## 联系方式 -team@inulajs.org +* 官方邮箱: [team@inulajs.org](mailto:team@inulajs.org) +* 微信公众号: +![](https://www.openinula.net/assets/qrcode.inula-02f99d58.jpg) diff --git a/RELEASE-NOTES.md b/RELEASE-NOTES.md index a3f44bd7..f2eb7051 100644 --- a/RELEASE-NOTES.md +++ b/RELEASE-NOTES.md @@ -1,3 +1,28 @@ +# 0.0.2 版本 + +## 新特性 + +- **inula-request** 新增响应体中获取完整 URL 能力。 + +## API变更 + +无 + +## Bug修复 + +- **inula** 解决事件卸载失败问题。 +- **inula** 解决 mouseover 重复触发 mouseEnter 事件问题。 +- **inula** 大数组合并使用 concat。 +- **inula** 事件支持 defaultPrevented 属性 + +## CVE漏洞修复 + +无 + +## 已知问题 + +无 + # 0.0.1 版本 ## 新特性 diff --git a/package.json b/package.json index 717a5b85..cda89608 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "openinula", + "name": "inula", "description": "OpenInula is a JavaScript framework library.", "version": "0.0.1", "private": true, diff --git a/packages/create-inula/lib/BasicGenerator.js b/packages/create-inula/lib/BasicGenerator.js index 0486b42c..972b2044 100644 --- a/packages/create-inula/lib/BasicGenerator.js +++ b/packages/create-inula/lib/BasicGenerator.js @@ -128,9 +128,9 @@ class BasicGenerator extends Generator { if (fs.existsSync(fullpath)) { this.traverseDirCapture(fullpath, dirCallback, fileCallback); } - continue; + } else { + fileCallback(fullpath); } - fileCallback(fullpath); } } @@ -140,9 +140,10 @@ class BasicGenerator extends Generator { if (fs.lstatSync(fullpath).isDirectory()) { this.traverseDirBubble(fullpath, dirCallback, fileCallback); dirCallback(fullpath); - continue; } - fileCallback(fullpath); + else{ + fileCallback(fullpath); + } } } diff --git a/packages/create-inula/lib/generators/Simple-app/templates/vite/README.md b/packages/create-inula/lib/generators/Simple-app/templates/vite/README.md new file mode 100644 index 00000000..542c363a --- /dev/null +++ b/packages/create-inula/lib/generators/Simple-app/templates/vite/README.md @@ -0,0 +1,4 @@ +# openinula + vite + +该模板提供了 `openinula` 工作在 `vite`的基础配置。 +> 请注意由于Vite插件有node版本限制,请使用`node -v`命令确认node版本大于等于node v18。 \ No newline at end of file diff --git a/packages/create-inula/lib/generators/Simple-app/templates/vite/package.json b/packages/create-inula/lib/generators/Simple-app/templates/vite/package.json index 706ca751..33f0b06e 100644 --- a/packages/create-inula/lib/generators/Simple-app/templates/vite/package.json +++ b/packages/create-inula/lib/generators/Simple-app/templates/vite/package.json @@ -11,7 +11,7 @@ "author": "", "license": "ISC", "dependencies": { - "openinula": "^0.0.11" + "openinula": "^0.1.1" }, "devDependencies": { "@babel/core": "^7.21.4", diff --git a/packages/create-inula/lib/generators/Simple-app/templates/webpack/package.json b/packages/create-inula/lib/generators/Simple-app/templates/webpack/package.json index c8d7813d..047a0788 100644 --- a/packages/create-inula/lib/generators/Simple-app/templates/webpack/package.json +++ b/packages/create-inula/lib/generators/Simple-app/templates/webpack/package.json @@ -10,7 +10,7 @@ "author": "", "license": "ISC", "dependencies": { - "openinula": "^0.0.11" + "openinula": "^0.1.1" }, "devDependencies": { "@babel/core": "^7.21.4", diff --git a/packages/create-inula/lib/generators/Simple-app/templates/webpack/src/App.js b/packages/create-inula/lib/generators/Simple-app/templates/webpack/src/App.jsx similarity index 100% rename from packages/create-inula/lib/generators/Simple-app/templates/webpack/src/App.js rename to packages/create-inula/lib/generators/Simple-app/templates/webpack/src/App.jsx diff --git a/packages/create-inula/lib/generators/Simple-app/templates/webpack/src/index.js b/packages/create-inula/lib/generators/Simple-app/templates/webpack/src/index.jsx similarity index 100% rename from packages/create-inula/lib/generators/Simple-app/templates/webpack/src/index.js rename to packages/create-inula/lib/generators/Simple-app/templates/webpack/src/index.jsx diff --git a/packages/create-inula/lib/generators/Simple-app/templates/webpack/webpack.config.js b/packages/create-inula/lib/generators/Simple-app/templates/webpack/webpack.config.js index 10c148b9..8b46f7a5 100644 --- a/packages/create-inula/lib/generators/Simple-app/templates/webpack/webpack.config.js +++ b/packages/create-inula/lib/generators/Simple-app/templates/webpack/webpack.config.js @@ -17,7 +17,7 @@ const path = require('path'); const HtmlWebpackPlugin = require('html-webpack-plugin'); module.exports = { - entry: './src/index.js', + entry: './src/index.jsx', output: { path: path.resolve(__dirname, 'dist'), filename: 'bundle.js', @@ -78,4 +78,7 @@ module.exports = { port: 9000, open: true, }, + resolve: { + extensions: ['.mjs', '.js', '.mts', '.ts', '.jsx', '.tsx', '.json'], + }, }; diff --git a/packages/create-inula/lib/generators/Simple-reactive-app/index.js b/packages/create-inula/lib/generators/Simple-reactive-app/index.js new file mode 100644 index 00000000..2d52bc4b --- /dev/null +++ b/packages/create-inula/lib/generators/Simple-reactive-app/index.js @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2023 Huawei Technologies Co.,Ltd. + * + * openInula is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * + * http://license.coscl.org.cn/MulanPSL2 + * + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +const BasicGenerator = require('../../BasicGenerator'); + +class Generator extends BasicGenerator { + prompting() { + return this.prompt([ + { + type: 'list', + name: 'bundlerType', + message: 'Please select the build type', + choices: ['webpack', 'vite'], + }, + ]).then(props => { + this.prompts = props; + }); + } + + writing() { + const src = this.templatePath(this.prompts.bundlerType); + const dest = this.destinationPath(); + this.writeFiles(src, dest, { + context: { + ...this.prompts, + }, + }); + } +} + +module.exports = Generator; diff --git a/packages/create-inula/lib/generators/Simple-reactive-app/meta.json b/packages/create-inula/lib/generators/Simple-reactive-app/meta.json new file mode 100644 index 00000000..8f8f9e66 --- /dev/null +++ b/packages/create-inula/lib/generators/Simple-reactive-app/meta.json @@ -0,0 +1,3 @@ +{ + "description": "simple reactive app template." +} diff --git a/packages/create-inula/lib/generators/Simple-reactive-app/templates/vite/README.md b/packages/create-inula/lib/generators/Simple-reactive-app/templates/vite/README.md new file mode 100644 index 00000000..542c363a --- /dev/null +++ b/packages/create-inula/lib/generators/Simple-reactive-app/templates/vite/README.md @@ -0,0 +1,4 @@ +# openinula + vite + +该模板提供了 `openinula` 工作在 `vite`的基础配置。 +> 请注意由于Vite插件有node版本限制,请使用`node -v`命令确认node版本大于等于node v18。 \ No newline at end of file diff --git a/packages/create-inula/lib/generators/Simple-reactive-app/templates/vite/index.html b/packages/create-inula/lib/generators/Simple-reactive-app/templates/vite/index.html new file mode 100644 index 00000000..9f37946a --- /dev/null +++ b/packages/create-inula/lib/generators/Simple-reactive-app/templates/vite/index.html @@ -0,0 +1,11 @@ + + + + + My Inula App + + +
+ + + diff --git a/packages/create-inula/lib/generators/Simple-reactive-app/templates/vite/package.json b/packages/create-inula/lib/generators/Simple-reactive-app/templates/vite/package.json new file mode 100644 index 00000000..f74bdda6 --- /dev/null +++ b/packages/create-inula/lib/generators/Simple-reactive-app/templates/vite/package.json @@ -0,0 +1,25 @@ +{ + "name": "inula-vite-app", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "start": "vite", + "build": "vite build" + }, + "keywords": [], + "author": "", + "license": "ISC", + "dependencies": { + "openinula": "0.0.0-experimental-20231201" + }, + "devDependencies": { + "@babel/core": "^7.21.4", + "@babel/preset-env": "^7.21.4", + "@babel/preset-react": "^7.18.6", + "@vitejs/plugin-react": "^3.1.0", + "@vitejs/plugin-react-refresh": "^1.3.6", + "babel-plugin-import": "^1.13.6", + "vite": "^4.2.1" + } +} diff --git a/packages/create-inula/lib/generators/Simple-reactive-app/templates/vite/src/ReactiveComponent.jsx b/packages/create-inula/lib/generators/Simple-reactive-app/templates/vite/src/ReactiveComponent.jsx new file mode 100644 index 00000000..cca83f05 --- /dev/null +++ b/packages/create-inula/lib/generators/Simple-reactive-app/templates/vite/src/ReactiveComponent.jsx @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2023 Huawei Technologies Co.,Ltd. + * + * openInula is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * + * http://license.coscl.org.cn/MulanPSL2 + * + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +import Inula, { useComputed, useReactive, useRef } from 'openinula'; + +function ReactiveComponent() { + const renderCount = ++useRef(0).current; + + const data = useReactive({ count: 0 }); + const countText = useComputed(() => { + return `计时: ${data.count.get()}`; + }); + + setInterval(() => { + data.count.set(c => c + 1); + }, 1000); + + return ( +
+
{countText}
+
组件渲染次数:{renderCount}
+
+ ); +} + +export default ReactiveComponent; diff --git a/packages/create-inula/lib/generators/Simple-reactive-app/templates/vite/src/index.css b/packages/create-inula/lib/generators/Simple-reactive-app/templates/vite/src/index.css new file mode 100644 index 00000000..f08fdb8a --- /dev/null +++ b/packages/create-inula/lib/generators/Simple-reactive-app/templates/vite/src/index.css @@ -0,0 +1,57 @@ +* { + box-sizing: border-box; +} + +body, +html { + margin: 0; + padding: 0; + font-family: 'Montserrat', sans-serif; + line-height: 1.6; + color: #fff; + background: linear-gradient(120deg, #6a11cb 0%, #2575fc 100%); + height: 100vh; + display: flex; + align-items: center; + justify-content: center; +} + +.container { + text-align: center; +} + +.hero-title { + font-size: 3em; + margin-bottom: 20px; +} + +.hero-subtitle { + font-size: 1.5em; + margin-bottom: 50px; +} + +.content { + display: flex; + justify-content: space-between; + align-items: center; + margin: 1vh; +} + +.card { + background: rgba(255, 255, 255, 0.1); + border-radius: 5px; + padding: 20px; + width: 100%; + box-shadow: 0px 8px 15px rgba(0, 0, 0, 0.1); + backdrop-filter: blur(4px); +} + +.card h2, +.card p { + color: #fff; +} + +.card a { + color: #fff; + text-decoration: underline; +} diff --git a/packages/create-inula/lib/generators/Simple-reactive-app/templates/vite/src/index.jsx b/packages/create-inula/lib/generators/Simple-reactive-app/templates/vite/src/index.jsx new file mode 100644 index 00000000..42179269 --- /dev/null +++ b/packages/create-inula/lib/generators/Simple-reactive-app/templates/vite/src/index.jsx @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2023 Huawei Technologies Co.,Ltd. + * + * openInula is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * + * http://license.coscl.org.cn/MulanPSL2 + * + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +import Inula from 'openinula'; +import ReactiveComponent from './ReactiveComponent'; +import './index.css'; + +function App() { + return ( +
+
+

欢迎来到 Inula 项目!

+

你已成功创建你的第一个响应式 Inula 项目

+
+
+
+ +
+
+
+
+

了解更多

+

+ 要了解 Inula,查看{' '} + Inula 官网 +

+
+
+
+ ); +} + +Inula.render(, document.getElementById('root')); diff --git a/packages/create-inula/lib/generators/Simple-reactive-app/templates/vite/vite.config.js b/packages/create-inula/lib/generators/Simple-reactive-app/templates/vite/vite.config.js new file mode 100644 index 00000000..43065ece --- /dev/null +++ b/packages/create-inula/lib/generators/Simple-reactive-app/templates/vite/vite.config.js @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2023 Huawei Technologies Co.,Ltd. + * + * openInula is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * + * http://license.coscl.org.cn/MulanPSL2 + * + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +import react from '@vitejs/plugin-react'; + +let alias = { + react: 'openinula', // 新增 + 'react-dom': 'openinula', // 新增 + 'react/jsx-dev-runtime': 'openinula/jsx-dev-runtime', +}; + +export default { + plugins: [react()], + resolve: { + alias, + }, +}; diff --git a/packages/create-inula/lib/generators/Simple-reactive-app/templates/webpack/package.json b/packages/create-inula/lib/generators/Simple-reactive-app/templates/webpack/package.json new file mode 100644 index 00000000..783f626a --- /dev/null +++ b/packages/create-inula/lib/generators/Simple-reactive-app/templates/webpack/package.json @@ -0,0 +1,29 @@ +{ + "name": "inula-webpack-app", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "start": "webpack serve --mode development", + "build": "webpack --mode production" + }, + "author": "", + "license": "ISC", + "dependencies": { + "openinula": "0.0.0-experimental-20231201" + }, + "devDependencies": { + "@babel/core": "^7.21.4", + "@babel/preset-env": "^7.21.4", + "@babel/preset-react": "^7.18.6", + "babel-loader": "^9.1.2", + "css-loader": "^6.7.3", + "file-loader": "^6.2.0", + "html-webpack-plugin": "^5.5.0", + "style-loader": "^3.3.2", + "url-loader": "^4.1.1", + "webpack": "^5.77.0", + "webpack-cli": "^5.0.1", + "webpack-dev-server": "^4.13.2" + } +} diff --git a/packages/create-inula/lib/generators/Simple-reactive-app/templates/webpack/src/App.jsx b/packages/create-inula/lib/generators/Simple-reactive-app/templates/webpack/src/App.jsx new file mode 100644 index 00000000..5a294011 --- /dev/null +++ b/packages/create-inula/lib/generators/Simple-reactive-app/templates/webpack/src/App.jsx @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2023 Huawei Technologies Co.,Ltd. + * + * openInula is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * + * http://license.coscl.org.cn/MulanPSL2 + * + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +import Inula from 'openinula'; +import ReactiveComponent from './ReactiveComponent'; +import './styles.css'; + +class App extends Inula.Component { + render() { + return ( +
+
+

欢迎来到 Inula 项目!

+

你已成功创建你的第一个响应式 Inula 项目

+
+
+
+ +
+
+
+
+

了解更多

+

+ 要了解 Inula,查看{' '} + Inula 官网 +

+
+
+
+ ); + } +} + +export default App; diff --git a/packages/create-inula/lib/generators/Simple-reactive-app/templates/webpack/src/ReactiveComponent.jsx b/packages/create-inula/lib/generators/Simple-reactive-app/templates/webpack/src/ReactiveComponent.jsx new file mode 100644 index 00000000..cca83f05 --- /dev/null +++ b/packages/create-inula/lib/generators/Simple-reactive-app/templates/webpack/src/ReactiveComponent.jsx @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2023 Huawei Technologies Co.,Ltd. + * + * openInula is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * + * http://license.coscl.org.cn/MulanPSL2 + * + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +import Inula, { useComputed, useReactive, useRef } from 'openinula'; + +function ReactiveComponent() { + const renderCount = ++useRef(0).current; + + const data = useReactive({ count: 0 }); + const countText = useComputed(() => { + return `计时: ${data.count.get()}`; + }); + + setInterval(() => { + data.count.set(c => c + 1); + }, 1000); + + return ( +
+
{countText}
+
组件渲染次数:{renderCount}
+
+ ); +} + +export default ReactiveComponent; diff --git a/packages/create-inula/lib/generators/Simple-reactive-app/templates/webpack/src/index.html b/packages/create-inula/lib/generators/Simple-reactive-app/templates/webpack/src/index.html new file mode 100644 index 00000000..c966e725 --- /dev/null +++ b/packages/create-inula/lib/generators/Simple-reactive-app/templates/webpack/src/index.html @@ -0,0 +1,11 @@ + + + + + Inula App + + +
+ + + diff --git a/packages/create-inula/lib/generators/Simple-reactive-app/templates/webpack/src/index.jsx b/packages/create-inula/lib/generators/Simple-reactive-app/templates/webpack/src/index.jsx new file mode 100644 index 00000000..c384f05a --- /dev/null +++ b/packages/create-inula/lib/generators/Simple-reactive-app/templates/webpack/src/index.jsx @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2023 Huawei Technologies Co.,Ltd. + * + * openInula is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * + * http://license.coscl.org.cn/MulanPSL2 + * + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +import Inula from 'openinula'; +import App from './App'; + +Inula.render(, document.getElementById('root')); diff --git a/packages/create-inula/lib/generators/Simple-reactive-app/templates/webpack/src/styles.css b/packages/create-inula/lib/generators/Simple-reactive-app/templates/webpack/src/styles.css new file mode 100644 index 00000000..f08fdb8a --- /dev/null +++ b/packages/create-inula/lib/generators/Simple-reactive-app/templates/webpack/src/styles.css @@ -0,0 +1,57 @@ +* { + box-sizing: border-box; +} + +body, +html { + margin: 0; + padding: 0; + font-family: 'Montserrat', sans-serif; + line-height: 1.6; + color: #fff; + background: linear-gradient(120deg, #6a11cb 0%, #2575fc 100%); + height: 100vh; + display: flex; + align-items: center; + justify-content: center; +} + +.container { + text-align: center; +} + +.hero-title { + font-size: 3em; + margin-bottom: 20px; +} + +.hero-subtitle { + font-size: 1.5em; + margin-bottom: 50px; +} + +.content { + display: flex; + justify-content: space-between; + align-items: center; + margin: 1vh; +} + +.card { + background: rgba(255, 255, 255, 0.1); + border-radius: 5px; + padding: 20px; + width: 100%; + box-shadow: 0px 8px 15px rgba(0, 0, 0, 0.1); + backdrop-filter: blur(4px); +} + +.card h2, +.card p { + color: #fff; +} + +.card a { + color: #fff; + text-decoration: underline; +} diff --git a/packages/create-inula/lib/generators/Simple-reactive-app/templates/webpack/webpack.config.js b/packages/create-inula/lib/generators/Simple-reactive-app/templates/webpack/webpack.config.js new file mode 100644 index 00000000..8b46f7a5 --- /dev/null +++ b/packages/create-inula/lib/generators/Simple-reactive-app/templates/webpack/webpack.config.js @@ -0,0 +1,84 @@ +/* + * Copyright (c) 2023 Huawei Technologies Co.,Ltd. + * + * openInula is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * + * http://license.coscl.org.cn/MulanPSL2 + * + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +const path = require('path'); +const HtmlWebpackPlugin = require('html-webpack-plugin'); + +module.exports = { + entry: './src/index.jsx', + output: { + path: path.resolve(__dirname, 'dist'), + filename: 'bundle.js', + }, + module: { + rules: [ + { + test: /\.(js|jsx)$/, + exclude: /node_modules/, + use: { + loader: 'babel-loader', + options: { + presets: [ + '@babel/preset-env', + [ + '@babel/preset-react', + { + runtime: 'automatic', // 新增 + importSource: 'openinula', // 新增 + }, + ], + ], + }, + }, + }, + { + test: /\.css$/, + use: ['style-loader', 'css-loader'], + }, + { + test: /\.(png|jpe?g|gif)$/i, + use: [ + { + loader: 'file-loader', + options: { + name: '[name].[ext]', + outputPath: 'images/', + publicPath: 'images/', + }, + }, + ], + }, + { + test: /\.(woff|woff2|eot|ttf|otf)$/, + use: ['file-loader'], + }, + ], + }, + plugins: [ + new HtmlWebpackPlugin({ + template: path.resolve(__dirname, 'src/index.html'), + filename: 'index.html', + }), + ], + devServer: { + static: path.join(__dirname, 'dist'), + compress: true, + port: 9000, + open: true, + }, + resolve: { + extensions: ['.mjs', '.js', '.mts', '.ts', '.jsx', '.tsx', '.json'], + }, +}; diff --git a/packages/create-inula/lib/run.js b/packages/create-inula/lib/run.js index 15099589..268022ed 100644 --- a/packages/create-inula/lib/run.js +++ b/packages/create-inula/lib/run.js @@ -62,7 +62,17 @@ const run = async config => { } process.emit('message', { type: 'prompt' }); - let { type } = config; + let { type, name } = config; + if (!name) { + const answers = await inquirer.prompt([ + { + name: 'projectName', + message: 'Project name', + type: 'input' + }, + ]); + config.name = answers.projectName; + } if (!type) { const answers = await inquirer.prompt([ { diff --git a/packages/create-inula/package.json b/packages/create-inula/package.json index 5cea2800..251ecd60 100644 --- a/packages/create-inula/package.json +++ b/packages/create-inula/package.json @@ -1,11 +1,14 @@ { "name": "create-inula", - "version": "0.0.2", + "version": "0.0.8", "description": "", "main": "index.js", "bin": { "create-inula": "bin/cli.js" }, + "engines": { + "node": ">= 18.0.0" + }, "files": [ "bin", "lib", diff --git a/packages/inula-cli/README.md b/packages/inula-cli/README.md index 5e26db29..bebd176e 100644 --- a/packages/inula-cli/README.md +++ b/packages/inula-cli/README.md @@ -2,23 +2,23 @@ ## 一、安装使用 -### 安装Nodejs +### 安装Node.js -inula-cli的运行需要依赖Nodejs,使用前请确保您的电脑已安装Nodejs,并且版本在16以上。您可以通过在控制台执行以下命令来确认您的版本。 +inula-cli的运行需要依赖Node.js,使用前请确保您的电脑已安装Node.js,并且版本在16以上。您可以通过在控制台执行以下命令来确认您的版本。 -``` +```shell >node -v v16.4.0 ``` -如果您没有安装Nodejs,或者Nodejs版本不满足条件,推荐使用nvm工具安装和管理Nodejs版本。 +如果您没有安装Node.js,或者Node.js版本不满足条件,推荐使用nvm工具安装和管理Node.js版本。 nvm最新版本下载: [https://github.com/coreybutler/nvm-windows/releases](https://links.jianshu.com/go?to=https%3A%2F%2Fgithub.com%2Fcoreybutler%2Fnvm-windows%2Freleases) -安装nvm之后,可以通过如下命令安装Nodejs: +安装nvm之后,可以通过如下命令安装Node.js: -``` +```shell >node install 16 >node use 16 @@ -28,15 +28,15 @@ nvm最新版本下载: [https://github.com/coreybutler/nvm-windows/releases](htt ### 安装inula-cli -为了方便使用inula-cli的功能,推荐您全局安装inula-cli。Nodejs安装会自带npm工具用于管理模块,您可以直接运行如下命令: +为了方便使用inula-cli的功能,推荐您全局安装inula-cli。Node.js安装会自带npm工具用于管理模块,您可以直接运行如下命令: -``` +```shell >npm install -g inula-cli ``` 安装完成后,使用inula-cli version命令确认安装是否完成。 -``` +```shell >inula-cli version 1.1.0 ``` @@ -93,7 +93,7 @@ inula-cli支持用户通过项目根目录下的.inula.ts或者.inula.js文件 在配置文件中,您需要默认导出一个配置,以下为一个简单的配置文件示例: -``` +```typescript // .inula.ts export default { @@ -113,7 +113,7 @@ export default { 对于TypeScript类型,我们也提供了类型定义以供开发时自动补全: -``` +```typescript // .inula.ts import { defineConfig } from "inula-cli" @@ -164,8 +164,8 @@ inula-cli的所有功能都围绕插件展开,插件可以很方便地让用 内置插件在inula-cli运行时会自动加载,用户可以直接调用这些内置命令,当前支持的内置插件功能如下: -| 序号 | 插件功能 | 触发命令 | -| ---- | :----------------------- | ------------------- | +| 序号 | 插件功能 | 触发命令 | +| ---- | :----------------------- | ----------------- | | 1、 | 本地开发构建 | inula-cli dev | | 2、 | 生产构建 | inula-cli build | | 3、 | 接口mock能力 | inula-cli dev | @@ -180,13 +180,13 @@ inula-cli支持用户集成已发布在npm仓库的插件,用户可以按需 安装可以通过npm安装,这里以插件@inula/add为例: -``` +```shell npm i --save-dev @inula/add ``` 如果需要运行插件,需要在配置文件中配置对应的插件路径 -``` +```typescript // .inula.ts export default { @@ -205,7 +205,7 @@ export default { 1、编写命令插件文件,这里我们自定义了一个conf命令用于展示当前项目的配置信息。 -``` +```typescript // conf.ts import { API } from "inula-cli"; @@ -224,7 +224,7 @@ export default (api: API) => { 2、在配置文件中加入对插件的引用 -``` +```typescript // .inula.ts export default { @@ -232,9 +232,9 @@ export default { } ``` -3、在项目根目录下执行inula-cli conf即可触发插件运行。 +3、在项目根目录下执行`inula-cli conf`即可触发插件运行。 -``` +```shell > inula-cli conf current user config is: { plugins: [ './conf', './showConf' ], @@ -247,7 +247,7 @@ inula-cli提供了hook机制可以让开发者在执行命令时实现事件监 1、使用插件注册hook -``` +```typescript // modifyConfig.ts import { API } from "inula-cli"; @@ -267,7 +267,7 @@ export default (api: API) => { 2、在插件中触发hook -``` +```typescript // conf.ts import { API } from "inula-cli"; @@ -287,7 +287,7 @@ export default (api: API) => { 3、在配置文件中加入插件 -``` +```typescript // .inula.ts export default { @@ -297,7 +297,7 @@ export default { 4、触发命令 -``` +```shell > inula-cli conf current user config is: { plugins: [ './conf', './showConf' ], @@ -327,7 +327,7 @@ current user config is: { registerCommand方法允许用户自定义inula-cli的执行命令, -``` +```typescript api.registerCommand({ name: string, description?: string, @@ -343,7 +343,7 @@ registerCommand方法允许用户自定义inula-cli的执行命令, 使用示例: -``` +```typescript import { API } from "inula-cli"; export default (api: API) => { @@ -374,7 +374,7 @@ api.registerHook({ 使用示例: -``` +```typescript import { API } from "inula-cli"; export default (api: API) => { @@ -403,7 +403,7 @@ applyHook(name: string, value?: any}) 使用示例: -``` +```typescript import { API } from "inula-cli"; export default (api: API) => { @@ -427,7 +427,7 @@ export default (api: API) => { inula-cli默认集成生产构建能力,用户可以通过在.inula.ts中配置buildConfig字段启用功能。配置示例如下: -``` +```typescript // .inula.ts // 使用webpack构建 @@ -460,7 +460,7 @@ export default { 生产构建支持传入多个配置文件路径,使用webpack构建还支持配置文件以函数方式导出,inula-cli会将配置中的env和args作为参数传递到函数中执行以获取最后的构建配置。 -``` +```typescript // webpack.config.js module.exports = function (env, argv) { @@ -497,7 +497,7 @@ export default { inula-cli默认也支持项目本地构建,用户可以通过在.inula.ts中配置devBuildConfig字段启用功能。配置示例如下: -``` +```typescript // .inula.ts // 使用webpack构建 @@ -552,7 +552,7 @@ inula-cli自动将项目根路径里/Mock目录下所有文件视为mock文件 如果您想修改Mock目录位置,可以在配置文件中修改mockPath。如果不配置该参数,默认使用"./mock"。 -``` +```typescript // .inula.ts export default { ... @@ -569,7 +569,7 @@ export default { Mock文件需要默认导出一个对象,key为"请求方式 接口名",值为接口实现。示例如下: -``` +```typescript export default { "GET /api/user": (req, res) => { res.status(200).json("admin") @@ -579,7 +579,7 @@ export default { 如果想要一次mock多个接口,可以在导出对象中设置多个key,例如: -``` +```typescript export default { "GET /api/user": (req, res) => { res.status(200).json("admin"); @@ -597,7 +597,7 @@ export default { Mock文件默认导出一个数组,数组每一个成员示例如下: -``` +```typescript export default [ { url: '/api/get', @@ -654,7 +654,7 @@ export default [ 在框架配置文件中,开发者需要配置远端服务器地址以及编写自定义的matcher函数提供给框架: -``` +```typescript // .inula.ts const matcher = (pathname, request) => { @@ -689,7 +689,7 @@ export default { 用户可以在.inula.ts中配置remoteProxy字段开启远端静态接口代理能力,完成配置后,使用后执行inula-cli proxy启动该功能。 -``` +```typescript // .inula.ts export default { @@ -710,6 +710,3 @@ export default { } } ``` - - - diff --git a/packages/inula-cli/src/types/types.ts b/packages/inula-cli/src/types/types.ts index 222c8e05..23bde194 100644 --- a/packages/inula-cli/src/types/types.ts +++ b/packages/inula-cli/src/types/types.ts @@ -165,4 +165,3 @@ export interface Arguments { '--'?: Array; [argName: string]: any; } - diff --git a/packages/inula-dev-tools/README.md b/packages/inula-dev-tools/README.md new file mode 100644 index 00000000..e69de29b diff --git a/packages/inula-dev-tools/babel.config.js b/packages/inula-dev-tools/babel.config.js new file mode 100644 index 00000000..10a19b67 --- /dev/null +++ b/packages/inula-dev-tools/babel.config.js @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2023 Huawei Technologies Co.,Ltd. + * + * openInula is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * + * http://license.coscl.org.cn/MulanPSL2 + * + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +module.exports = api => { + const isTest = api.env('test'); + console.log('isTest', isTest); + + const plugins = [ + ['@babel/plugin-proposal-class-properties', { loose: false }], + ]; + + if (process.env.NODE_ENV !== 'production') { + plugins.push(['@babel/plugin-transform-react-jsx-source']); + } + + return { + presets: [ + '@babel/preset-env', + '@babel/preset-typescript', + [ + '@babel/preset-react', { + runtime: 'classic', + 'pragma': 'Inula.createElement', + 'pragmaFrag': 'Inula.Fragment', + }] + ], + plugins, + }; +}; diff --git a/packages/inula-dev-tools/externals.d.ts b/packages/inula-dev-tools/externals.d.ts new file mode 100644 index 00000000..72bdaf9d --- /dev/null +++ b/packages/inula-dev-tools/externals.d.ts @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2023 Huawei Technologies Co.,Ltd. + * + * openInula is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * + * http://license.coscl.org.cn/MulanPSL2 + * + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +declare module '*.less' { + const resource: {[key: string]: string}; + export = resource; +} diff --git a/packages/inula-dev-tools/global.d.ts b/packages/inula-dev-tools/global.d.ts new file mode 100644 index 00000000..646c0399 --- /dev/null +++ b/packages/inula-dev-tools/global.d.ts @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2023 Huawei Technologies Co.,Ltd. + * + * openInula is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * + * http://license.coscl.org.cn/MulanPSL2 + * + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +/* + 区分是否开发者模式 + */ +declare var isDev: boolean; +declare var isTest: boolean; +declare const __VERSION__: string; diff --git a/packages/inula-dev-tools/package.json b/packages/inula-dev-tools/package.json new file mode 100644 index 00000000..46f655f8 --- /dev/null +++ b/packages/inula-dev-tools/package.json @@ -0,0 +1,54 @@ +{ + "name": "inula-dev-tools", + "version": "0.0.1", + "description": "Inula chrome dev extension", + "main": "", + "scripts": { + "build": "webpack --config ./webpack.config.js", + "watch": "webpack --config ./webpack.config.js --watch", + "build-dev": "webpack --config ./webpack.dev.js", + "start": "npm run build && webpack serve --config ./webpack.dev.js", + "test": "jest" + }, + "keywords": ["inula-dev-tools"], + "license": "MulanPSL2", + "devDependencies": { + "@babel/core": "^7.12.3", + "@babel/plugin-proposal-class-properties": "^7.16.7", + "@babel/plugin-transform-react-jsx-source": "^7.18.6", + "@babel/preset-env": "^7.21.1", + "@babel/preset-react": "^7.12.1", + "@babel/preset-typescript": "^7.16.7", + "@types/chrome": "0.0.190", + "@types/jest": "27.4.1", + "@typescript-eslint/eslint-plugin": "4.8.0", + "@typescript-eslint/parser": "4.8.0", + "babel-jest": "^27.5.1", + "eslint": "7.13.0", + "eslint-config-prettier": "^6.9.0", + "eslint-plugin-jest": "^22.15.0", + "eslint-plugin-no-for-of-loops": "^1.0.0", + "eslint-plugin-no-function-declare-after-return": "^1.0.0", + "eslint-plugin-react": "7.14.3", + "babel-loader": "8.1.0", + "css-loader": "^6.7.1", + "html-webpack-plugin": "5.5.0", + "jest": "27.5.1", + "less": "4.1.2", + "less-loader": "10.2.0", + "style-loader": "^3.3.1", + "ts-jest": "27.1.4", + "ts-loader": "^9.3.1", + "typescript": "4.2.3", + "webpack": "5.70.0", + "webpack-cli": "4.9.2", + "webpack-dev-server": "^4.7.4" + }, + "dependencies": { + "openinula": "^0.1.1", + "flatted-object": "^0.1.2", + "json-decycle": "^2.0.1", + "lodash": "^4.17.21", + "object-assign": "^4.1.1" + } +} diff --git a/packages/inula-dev-tools/src/background/index.ts b/packages/inula-dev-tools/src/background/index.ts new file mode 100644 index 00000000..0efe312b --- /dev/null +++ b/packages/inula-dev-tools/src/background/index.ts @@ -0,0 +1,80 @@ +/* + * Copyright (c) 2023 Huawei Technologies Co.,Ltd. + * + * openInula is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * + * http://license.coscl.org.cn/MulanPSL2 + * + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +import { checkMessage, packagePayload, changeSource } from "../utils/transferUtils"; +import { RequestAllVNodeTreeInfos, InitDevToolPageConnection, DevToolBackground } from "../utils/constants"; +import { DevToolPanel, DevToolContentScript } from "../utils/constants"; + +// 多个页面 tab 页共享一个 background,需要建立连接池,给每个 tab 建立连接 +export const connections = {}; + +// panel 代码中调用 let backgroundPageConnection = chrome.runtime.connect({...}) 会触发回调函数 +chrome.runtime.onConnect.addListener(function (port) { + function extensionListener(message) { + const isInulaMessage = checkMessage(message, DevToolPanel); + if (isInulaMessage) { + const { payload } = message; + // tabId 值指当前浏览器分配给 web_page 的 id 值。是 panel 页面查询得到,指定向页面发送消息 + const { type, data, tabId } = payload; + let passMessage; + if (type === InitDevToolPageConnection) { + // 记录 panel 所在 tab 页的 tabId,如果已经记录了,覆盖原有 port,因为原有 port 可能关闭了 + // 可能这次是 panel 发起的重新建立请求 + connections[tabId] = port; + passMessage = packagePayload({ type: RequestAllVNodeTreeInfos }, DevToolBackground); + } else { + passMessage = packagePayload({ type, data }, DevToolBackground); + } + chrome.tabs.sendMessage(tabId, passMessage); + } + } + + // 监听 dev tools 页面发送的消息 + port.onMessage.addListener(extensionListener); + + port.onDisconnect.addListener(function (port) { + port.onMessage.removeListener(extensionListener); + + const tabs = Object.keys(connections); + for (let i = 0, len = tabs.length; i < len; i++) { + if (connections[tabs[i]] == port) { + delete connections[tabs[i]]; + break; + } + } + }); +}); + +// 监听来自 content script 的消息,并将消息发送给对应的 dev tools 页面 +chrome.runtime.onMessage.addListener(function (message, sender, sendResponse) { + // 来自 content script 的消息需要预先设置 sender.tab + if (sender.tab) { + const tabId = sender.tab.id; + if (message.payload.type.startsWith('inulax')) { + return; + } + if (tabId && tabId in connections && checkMessage(message, DevToolContentScript)) { + changeSource(message, DevToolBackground); + connections[tabId].postMessage(message); + } else { + // TODO: 如果查询失败,发送 chrome message ,请求 panel 主动建立连接 + console.log('Tab is not found in connection'); + } + } else { + console.log('sender.tab is not defined'); + } + // 需要返回消息告知完成通知,否则会出现报错 message port closed before a response was received + sendResponse({ status: 'ok' }); +}); diff --git a/packages/inula-dev-tools/src/background/inulaXHandler.ts b/packages/inula-dev-tools/src/background/inulaXHandler.ts new file mode 100644 index 00000000..4c7e9c2b --- /dev/null +++ b/packages/inula-dev-tools/src/background/inulaXHandler.ts @@ -0,0 +1,296 @@ +/* + * Copyright (c) 2023 Huawei Technologies Co.,Ltd. + * + * openInula is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * + * http://license.coscl.org.cn/MulanPSL2 + * + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +import { DevToolBackground, DevToolContentScript, DevToolPanel } from '../utils/constants'; +import { connections } from './index'; +import { packagePayload } from '../utils/transferUtils'; + +// 监听来自 content script 的消息,并将消息发送给对应的 dev tools page +const eventsPerTab = {}; +const storesPerTab = {}; +const observedComponents = {}; +const eventPersistencePerTab = {}; +let idGenerator = 1; + +// 当 tab 重新加载,需要对该 tab 所监听的 stores 进行重置 +chrome.tabs.onUpdated.addListener(function (tabId, changeInfo) { + if (changeInfo.status === 'loading') { + if (!eventPersistencePerTab[tabId]) { + eventsPerTab[tabId] = []; + } + storesPerTab[tabId] = []; + } +}); + +function sendTo(connectionId, message) { + if (connections[connectionId]) { + connections[connectionId].postMessage(message); + } +} + +function requestObservedComponents(tabId) { + setTimeout(() => { + chrome.tabs.sendMessage( + tabId, + packagePayload( + { + type: 'inulax request observed components', + data: {} + }, + 'dev tool background' + ) + ); + }, 1); +} + +function executeAction(tabId, storeId, action, params) { + chrome.tabs.sendMessage( + tabId, + packagePayload( + { + type: 'inulax execute action', + data: { + action, + storeId, + params, + } + }, + 'dev tool background' + ) + ); +} + +function queueAction(tabId, storeId, action, params) { + chrome.tabs.sendMessage( + tabId, + packagePayload( + { + type: 'inulax queue action', + data: { + action, + storeId, + params, + } + }, + 'sev tool background' + ) + ); +} + +function getObservedComponents(storeId, tabId) { + if (!observedComponents[tabId]) { + observedComponents[tabId] = {}; + } + if (!observedComponents[tabId][storeId]) { + return []; + } + return observedComponents[tabId][storeId]; +} + +// 来自 content script 的消息 +chrome.runtime.onMessage.addListener(function (message, sender) { + if (message.payload.type.startsWith('inulax')) { + console.log('inulaXHandler message from content script', { + payload: { ...message.payload }, + }); + + if (message.from === DevToolContentScript && sender.tab?.id) { + if (message.payload.type === 'inulax observed components') { + observedComponents[sender.tab.id] = message.payload.data; + + sendTo(sender.tab.id, { + type: 'INULA_DEV_TOOLS', + payload: { + type: 'inulax observed components', + data: message.payload.data, + }, + from: DevToolBackground, + }); + return; + } + requestObservedComponents(sender.tab.id); + + // content script -> inulaXHandler + if (!eventsPerTab[sender.tab.id]) { + eventsPerTab[sender.tab.id] = []; + } + eventsPerTab[sender.tab.id].push({ + id: idGenerator++, + timestamp: Date.now(), + message: message.payload, + }); + + sendTo(sender.tab.id, { + type: 'INULA_DEV_TOOLS', + payload: { + type: 'inulax events', + events: eventsPerTab[sender.tab.id], + }, + from: DevToolBackground, + }); + + // 如果当前 tab 没有 store data,则初始化 + if (!storesPerTab[sender.tab.id]) { + storesPerTab[sender.tab.id] = []; + } + + let found = false; + storesPerTab[sender.tab.id]?.some((store, index) => { + if (store.id === message.payload.data.store.id) { + found = true; + storesPerTab[sender.tab!.id!][index] = message.payload.data.store; + requestObservedComponents(sender.tab?.id); + return true; + } + return false; + }); + + if (!found) { + const tabId = sender.tab.id; + if (!storesPerTab[tabId]) { + storesPerTab[tabId] = []; + } + storesPerTab[tabId].push(message.payload.data.store); + sendTo(tabId, { + type: 'INULA_DEV_TOOLS', + payload: { + type: 'inulax stores', + stores: storesPerTab[tabId]?.map(store => { + // 连接被监测的组件 + requestObservedComponents(tabId); + const observedComponents = getObservedComponents(store, tabId); + return { ...store, observedComponents }; + }) || [], + newStore: message.payload.data.store.id, + }, + from: DevToolBackground, + }); + return; + } + + sendTo(sender.tab.id, { + type: 'INULA_DEV_TOOLS', + payload: { + type: 'inulax stores', + stores: storesPerTab[sender.tab.id]?.map(store => { + // 连接被监测的组件 + const observedComponents = getObservedComponents(store, sender.tab?.id); + return { ...store, observedComponents }; + }) || [], + updated: message.payload.data.store.id, + }, + from: DevToolBackground, + }); + + requestObservedComponents(message.payload.tabId); + } + + if (message.from === DevToolPanel) { + // panel -> inulaXHandler + if (message.payload.type === 'inulax run action') { + executeAction( + message.payload.tabId, + message.payload.storeId, + message.payload.action, + message.payload.args + ); + return; + } + + if (message.payload.type === 'inulax change state') { + chrome.tabs.sendMessage( + message.payload.tabId, + packagePayload(message.payload, 'dev tool background') + ); + return; + } + + if (message.payload.type === 'inulax queue action') { + queueAction( + message.payload.tabId, + message.payload.storeId, + message.payload.action, + message.payload.args + ); + return; + } + + if (message.payload.type === 'inulax resetEvents') { + eventsPerTab[message.payload.tabId] = []; + sendTo(message.payload.tabId, { + type: 'INULA_DEV_TOOLS', + payload: { + type: 'inulax events', + events: eventsPerTab[message.payload.tabId], + }, + from: DevToolBackground, + }); + return; + } + + if (message.payload.type === 'inula setPersistent'){ + const { tabId, persistent } = message.payload; + eventPersistencePerTab[tabId] = persistent; + return; + } + + if (message.payload.type === 'inulax getPersistence') { + sendTo(message.payload.tabId, { + type: 'INULA_DEV_TOOLS', + payload: { + type: 'inulax persistence', + persistent: !!eventPersistencePerTab[message.payload.tabId], + }, + from: DevToolBackground, + }); + return; + } + + if (message.payload.type === 'inulax getEvents') { + if (!eventsPerTab[message.payload.tabId]) { + eventsPerTab[message.payload.tabId] = []; + } + sendTo(message.payload.tabId, { + type: 'INULA_DEV_TOOLS', + payload: { + type: 'inulax events', + events: eventsPerTab[message.payload.tabId], + }, + from: DevToolBackground, + }); + return; + } + + if (message.payload.type === 'inulax getStores') { + sendTo(message.payload.tabId, { + type: 'INULA_DEV_TOOLS', + payload: { + type: 'inulax stores', + stores: storesPerTab[message.payload.tabId]?.map(store => { + requestObservedComponents(message.payload.tabId); + const observedComponents = getObservedComponents( + store.id, + message.payload.tabId + ); + return { ...store, observedComponents }; + }) || [], + }, + from: DevToolBackground, + }); + return; + } + } + } +}); diff --git a/packages/inula-dev-tools/src/components/ComponentInfo.less b/packages/inula-dev-tools/src/components/ComponentInfo.less new file mode 100644 index 00000000..d8b8f432 --- /dev/null +++ b/packages/inula-dev-tools/src/components/ComponentInfo.less @@ -0,0 +1,279 @@ +/* + * Copyright (c) 2023 Huawei Technologies Co.,Ltd. + * + * openInula is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * + * http://license.coscl.org.cn/MulanPSL2 + * + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +@import 'assets.less'; + +.infoContainer { + display: flex; + flex-direction: column; + height: 100%; + + .button { + border: none; + padding: 0; + border-radius: 0.25rem; + flex: 0 0 auto; + cursor: pointer; + color: #5f6673; + } + + .button :hover { + color: #23272f; + } + + .componentInfoHead { + flex: 0 0 @top-height; + display: flex; + align-items: center; + border-bottom: @divider-style; + + .name { + flex: 1 1 auto; + padding: 0 1rem 0 1rem; + .text { + display: block; + } + } + + .eye { + flex: 0 0 1rem; + cursor: pointer; + display: inline-flex; + align-items: center; + padding: 0.25rem 0.5rem 0.25rem 0.25rem; + } + + .debug { + flex: 0 0 1rem; + cursor: pointer; + display: inline-flex; + align-items: center; + padding: 0.25rem 0.5rem 0.25rem 0; + } + + .location { + flex: 0 0 1rem; + cursor: pointer; + display: inline-flex; + align-items: center; + padding: 0.25rem 0.5rem 0.25rem 0; + } + } + + .componentInfoMain { + overflow-y: auto; + + > :last-child { + border-bottom: unset; + } + + > div { + border-bottom: @divider-style; + } + + .attrContainer { + flex: 0 0; + + .attrHead { + display: flex; + flex-direction: row; + align-items: center; + padding: 0.5rem 0.5rem 0 0.5rem; + + .attrType { + flex: 1 1 0; + } + + .attrCopy { + flex: 0 0 1rem; + padding-right: 1rem; + } + } + + .attrDetail { + padding-bottom: 0.5rem; + + .attrArrow { + color: @arrow-color; + width: 12px; + display: inline-block; + } + + .attrName { + margin-top: 1px; + color: @attr-name-color; + font-family: @attr-name-font-family; + } + + .colon { + margin-top: 1px; + transform: translateY(-8%); + margin-right: 0.5rem; + } + + .info { + display: flex; + &:hover { + .operation { + visibility: visible; + .operationIcon :hover { + border: none; + border-radius: 5px; + background-color: lightskyblue; + } + } + } + } + + .attrValue { + width: 26rem; + height: 1rem; + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, + Courier, monospace; + &:focus { + color: unset; + background-color: #f0f0f0; + } + } + .attrValue[data-type='string'] { + color: #009906; + } + .attrValue[data-type='function'] { + color: royalblue; + } + .attrValue[data-type='number'] { + color: #ff5722; + } + .attrValue[data-type='boolean'] { + color: #03a9f4; + } + + .operation { + cursor: pointer; + visibility: hidden; + } + + .checkBox { + margin: 2px 3px 0 auto; + justify-content: flex-end; + } + } + } + + .dropdown.active { + display: unset; + top: var(--content-top); + left: var(--content-left); + position: absolute; + ul { + margin-block-start: 0; + padding-inline-start: 0; + li { + padding: 10px; + border-top: 1px lighten(#333, 2%) solid; + height: auto; + overflow: auto; + opacity: 1; + } + } + } + + .dropdown { + display: none; + + ul { + display: block; + position: relative; + list-style: none; + } + + li { + padding: 0 10px; + background: darken(#333, 2%); + color: darken(#EEE, 40%); + text-align: left; + border: 0; + width: 100%; + height: 0; + overflow: hidden; + cursor: pointer; + opacity: 0; + transition-property: all, background-color; + transition-duration: 0.2s, 0.4s; + + &:hover, &.selected { + background-color: darken(#333, 10%); + } + + &:active { + background: #03a9f4; + } + + &:first-child { + border-radius: 5px 5px 0 0; + } + + &:last-child { + border-radius: 0 0 5px 5px; + } + + &:before { + margin-top: -2px; + margin-right: 10px; + display: inline-block; + border-radius: 5px; + vertical-align: middle; + width: 16px; + height: 16px; + } + + &:nth-child(1) { + &:before { + content: url('../svgs/copy.svg'); + } + } + + &:nth-child(2) { + &:before { + content: url('../svgs/storage.svg'); + } + } + } + } + } + + .parentsInfo { + flex: 1 1 0; + + .parentName { + padding: 0.5rem 0.5rem 0 0.5rem; + } + + .parent { + margin-left: 1.4rem; + display: block; + cursor: pointer; + text-align: left; + color: @component-name-color; + + &:hover { + background-color: @select-color; + } + } + } +} diff --git a/packages/inula-dev-tools/src/components/ComponentInfo.tsx b/packages/inula-dev-tools/src/components/ComponentInfo.tsx new file mode 100644 index 00000000..0f2b658e --- /dev/null +++ b/packages/inula-dev-tools/src/components/ComponentInfo.tsx @@ -0,0 +1,435 @@ +/* + * Copyright (c) 2023 Huawei Technologies Co.,Ltd. + * + * openInula is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * + * http://license.coscl.org.cn/MulanPSL2 + * + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +import styles from './ComponentInfo.less'; +import Eye from '../svgs/Eye'; +import Debug from '../svgs/Debug'; +import Location from '../svgs/Location'; +import Triangle from '../svgs/Triangle'; +import { memo, useContext, useEffect, useState, useRef, useMemo, createRef } from 'openinula'; +import { IData } from './VTree'; +import { buildAttrModifyData, IAttr } from '../parser/parseAttr'; +import { postMessageToBackground } from '../panelConnection'; +import { CopyToConsole, InspectDom, LogComponentData, ModifyAttrs, StorageValue } from '../utils/constants'; +import type { Source } from '../../../inula/src/renderer/Types'; +import ViewSourceContext from '../utils/ViewSource'; +import PickElementContext from '../utils/PickElement'; +import Operation from '../svgs/Operation'; + +type IComponentInfo = { + name: string; + attrs: { + parsedProps?: IAttr[]; + parsedState?: IAttr[]; + parsedHooks?: IAttr[]; + }; + parents: IData[]; + id: number; + source?: Source; + onClickParent: (item: IData) => void; +}; + +const ComponentAttr = memo(function ComponentAttr({ + attrsName, + attrsType, + attrs, + id, + dropdownRef, + }: { + attrsName: string; + attrsType: string; + attrs: IAttr[]; + id: number; + dropdownRef: null | HTMLElement; +}) { + const [editableAttrs, setEditableAttrs] = useState(attrs); + const [expandNodes, setExpandNodes] = useState([]); + + useEffect(() => { + setEditableAttrs(attrs); + }, [attrs]); + + const handleCollapse = (item: IAttr) => { + const nodes = [...expandNodes]; + const expandItem = `${item.name}_${editableAttrs.indexOf(item)}`; + const i = nodes.indexOf(expandItem); + if (i === -1) { + nodes.push(expandItem); + } else { + nodes.splice(i, 1); + } + setExpandNodes(nodes); + }; + + // props 展示的 key: value 中的 value 值 + const getShowName = item => { + let retStr; + if (item === undefined) { + retStr = String(item); + } else if (typeof item === 'number') { + retStr = item; + } else if (typeof item === 'string') { + retStr = item.endsWith('>') ? `<${item}` : item; + } else { + retStr = `"${item}"`; + } + return retStr; + }; + + /** + * 拿到 props 或 hooks 在 VNode 里的路径 + * + * @param {Array} editableAttrs 所有 props 与 hooks 的值 + * @param {number} index 此值在 editableAttrs 的下标位置 + * @param {string} attrsType 此值属于 props 还是 hooks + * @return {Array} 值在 vNode 里的路径 + */ + const getPath = (editableAttrs: IAttr[], index: number, attrsType: string): Array => { + const path: Array = []; + let local = editableAttrs[index].indentation; + if (local === 1) { + path.push(attrsType === 'Hooks' ? editableAttrs[index].hIndex : editableAttrs[index].name); + } else { + let location = local; + let id = index; + while (location > 0) { + // local === 1 时处于 vNode.hooks 的子元素最外层 + if (location < local || id === index || local === 1) { + if (local === 1) { + attrsType === 'Hooks' + ? path.unshift(editableAttrs[id + 1].hIndex, 'state') + : path.unshift(editableAttrs[id + 1].name); + break; + } else { + if (editableAttrs[id]?.indentation === 1) { + if (editableAttrs[id]?.name === 'State') { + path.unshift('stateValue'); + } + if (editableAttrs[id]?.name === 'Ref') { + path.unshift('current'); + } + } else { + path.unshift(editableAttrs[id].name); + } + } + // 跳过同级 + local = location; + } + location = id >= 1 ? editableAttrs[id - 1].indentation : -1; + id = -1; + } + } + return path; + }; + + const showAttr = []; + let currentIndentation = null; + + // 为每一行数据添加一个 ref + const refsById = useMemo(() => { + const refs = {}; + editableAttrs.forEach((item, index) => { + refs[index] = createRef(); + }); + return refs; + }, [editableAttrs]); + + editableAttrs.forEach((item, index) => { + const operationRef = refsById[index]; + const indentation = item.indentation; + if (currentIndentation !== null) { + if (indentation > currentIndentation) { + return; + } else { + currentIndentation = null; + } + } + const nextItem = editableAttrs[index + 1]; + const hasChild = nextItem ? nextItem.indentation - item.indentation > 0 : false; + const isCollapsed = !expandNodes.includes(`${item.name}_${index}`); + + // 按钮点击事件 + const operationClick = (e: Event, operationRef: any) => { + // 防止点击按钮触发展开或者合起数据 + e.stopPropagation(); + if (operationRef.current) { + const operationRect = operationRef.current.getBoundingClientRect(); + // 19.2 为图标按钮高度,85 为弹框高度的一半 + dropdownRef.style.setProperty('--content-top', `${operationRect.top + 19.2}px`); + dropdownRef.style.setProperty('--content-left', `${operationRect.left - 85}px`); + } + dropdownRef.classList.toggle(styles['active']); + const attrInfo = { + id: { id }, + itemName: item.name, + attrsName: attrsName, + path: getPath(editableAttrs, index, attrsName), + }; + (dropdownRef as any).attrInfo = attrInfo; + console.log(dropdownRef); + }; + + showAttr.push( +
handleCollapse(item)} + > + {hasChild && } + {`${item.name}`} +
{':'}
+ {item.type === 'string' || item.type === 'number' || item.type === 'undefined' || item.type === 'null' ? ( + <> + { + const nextAttrs = [...editableAttrs]; + const nextItem = { ...item }; + nextItem.value = event.target.value; + nextAttrs[index] = nextItem; + setEditableAttrs(nextAttrs); + }} + onKeyUp={event => { + const value = (event.target as HTMLInputElement).value; + if (event.key === 'Enter') { + if (isDev) { + console.log('post attr change', value); + } else { + const data = buildAttrModifyData(attrsType, attrs, value, item, index, id); + postMessageToBackground(ModifyAttrs, data); + } + } + }} + /> +
+ operationClick(event, operationRef)}> + + +
+ + ) : item.type === 'boolean' ? ( + <> + + {item.value.toString()} + + { + const nextAttrs = [...editableAttrs]; + const nextItem = { ...item }; + nextItem.value = event.target.checked; + nextAttrs[index] = nextItem; + setEditableAttrs(nextAttrs); + if (!isDev) { + const data = buildAttrModifyData(attrsType, attrs, nextItem.value, item, index, id); + postMessageToBackground(ModifyAttrs, data); + } + }} + /> + + ) : ( + <> + + {item.value} + +
+ operationClick(event, operationRef)}> + + +
+ + )} +
+ ); + if (isCollapsed) { + currentIndentation = indentation; + } + }); + + return ( +
+
+ {attrsName} +
+
{showAttr}
+
+ ); +}); + +function ComponentInfo({ name, attrs, parents, id, source, onClickParent }: IComponentInfo) { + const view = useContext(ViewSourceContext) as any; + const viewSource = view.viewSourceFunction.viewSource; + + const pick = useContext(PickElementContext) as any; + const inspectVNode = pick.pickElementFunction.inspectVNode; + const dropdownRef = useRef(null); + + const doViewSource = (id: number) => { + postMessageToBackground(InspectDom, { id }); + setTimeout(function () { + inspectVNode(); + }, 100); + }; + + const doInspectDom = (id: number) => { + postMessageToBackground(InspectDom, { id }); + setTimeout(function () { + inspectVNode(); + }, 100); + }; + + const sourceFormatted = (fileName: string, lineNumber: number) => { + const pathWithoutLastName = /^(.*)[\\/]/; + + let realName = fileName.replace(pathWithoutLastName, ''); + if (/^index\./.test(realName)) { + const fileNameMatch = fileName.match(pathWithoutLastName); + if (fileNameMatch) { + const pathBeforeName = fileNameMatch[1]; + if (pathBeforeName) { + const folderName = pathBeforeName.replace(pathWithoutLastName, ''); + realName = folderName + '/' + realName; + } + } + } + + return `${realName}:${lineNumber}`; + }; + + const copyToConsole = (itemName: string | number, attrsName: string, path: Array) => { + postMessageToBackground(CopyToConsole, { id, itemName, attrsName, path }); + dropdownRef.current.classList.toggle(styles['active']); + }; + + const storeVariable = (attrsName: string, path: Array) => { + postMessageToBackground(StorageValue, { id, attrsName, path }); + dropdownRef.current.classList.toggle(styles['active']); + }; + + return ( +
+
+ {name && ( + <> +
+
{name}
+
+ + + + + + + + )} +
+
+ {Object.keys(attrs).map(attrsType => { + const parsedAttrs = attrs[attrsType]; + if (parsedAttrs && parsedAttrs.length !== 0) { + const attrsName = attrsType.slice(6); // parsedState => State + return ( + + ); + } + return null; + })} +
+ {name && ( +
+
Parents
+ {parents.map(item => ( + + ))} +
+ )} +
+
+ {source && ( + <> +
source: {''}
+
{sourceFormatted(source.fileName, source.lineNumber)}
+ + )} +
+
+
    +
  • + copyToConsole( + (dropdownRef.current as any).attrInfo.itemName, + (dropdownRef.current as any).attrInfo.attrsName, + (dropdownRef.current as any).attrInfo.path + ) + } + > + Copy value to console +
  • +
  • storeVariable((dropdownRef.current as any).attrInfo.attrsName, (dropdownRef.current as any).attrInfo.path)} + > + Store as global variable +
  • +
+
+
+
+ ); +} + +export default memo(ComponentInfo); diff --git a/packages/inula-dev-tools/src/components/Search.less b/packages/inula-dev-tools/src/components/Search.less new file mode 100644 index 00000000..72752465 --- /dev/null +++ b/packages/inula-dev-tools/src/components/Search.less @@ -0,0 +1,18 @@ +/* + * Copyright (c) 2023 Huawei Technologies Co.,Ltd. + * + * openInula is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * + * http://license.coscl.org.cn/MulanPSL2 + * + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +.search { + width: 100%; +} diff --git a/packages/inula-dev-tools/src/components/Search.tsx b/packages/inula-dev-tools/src/components/Search.tsx new file mode 100644 index 00000000..ea1b48c8 --- /dev/null +++ b/packages/inula-dev-tools/src/components/Search.tsx @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2023 Huawei Technologies Co.,Ltd. + * + * openInula is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * + * http://license.coscl.org.cn/MulanPSL2 + * + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +import styles from './Search.less'; + +interface SearchProps { + onKeyUp: () => void; + onChange: (event: any) => void; + value: string; +} + +export default function Search(props: SearchProps) { + const { onChange, value, onKeyUp } = props; + const handleChange = event => { + onChange(event.target.value); + }; + const handleKeyUp = event => { + if (event.key === 'Enter') { + onKeyUp(); + } + }; + + return ( + + ); +} diff --git a/packages/inula-dev-tools/src/components/SizeObserver.tsx b/packages/inula-dev-tools/src/components/SizeObserver.tsx new file mode 100644 index 00000000..32d92058 --- /dev/null +++ b/packages/inula-dev-tools/src/components/SizeObserver.tsx @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2023 Huawei Technologies Co.,Ltd. + * + * openInula is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * + * http://license.coscl.org.cn/MulanPSL2 + * + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +import { useEffect, useState, useRef } from 'openinula'; +import { addResizeListener, removeResizeListener } from './resizeEvent'; + +export function SizeObserver(props) { + const { children, ...rest } = props; + const containerRef = useRef(); + const [size, setSize] = useState<{ width: number; height: number }>(); + const notifyChild = (element) => { + setSize({ + width: element.offsetWidth, + height: element.offsetHeight, + }); + }; + + useEffect(() => { + const element = containerRef.current!; + setSize({ + width: element.offsetWidth, + height: element.offsetHeight, + }); + addResizeListener(element, notifyChild); + return () => { + removeResizeListener(element, notifyChild); + }; + }, []); + const myChild = size ? children(size.width, size.height) : null; + + return ( +
+ {myChild} +
+ ); +} diff --git a/packages/inula-dev-tools/src/components/VList/ItemMap.ts b/packages/inula-dev-tools/src/components/VList/ItemMap.ts new file mode 100644 index 00000000..e4c43bc3 --- /dev/null +++ b/packages/inula-dev-tools/src/components/VList/ItemMap.ts @@ -0,0 +1,80 @@ +/* + * Copyright (c) 2023 Huawei Technologies Co.,Ltd. + * + * openInula is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * + * http://license.coscl.org.cn/MulanPSL2 + * + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +/** + * 用于在滚动的过程中,对比上一次渲染的结果和本次需要渲染项 + * 确保继续渲染项在新渲染数组中的位置和旧渲染数组中的位置不发生改变 + */ +export default class ItemMap { + + // 不要用 indexOf 进行位置计算,它会遍历数组 + private lastRenderItemToIndexMap: Map; + + constructor() { + this.lastRenderItemToIndexMap = new Map(); + } + + public calculateReSortedItems(nextItems: T[]): (T|undefined)[] { + if (this.lastRenderItemToIndexMap.size === 0) { + nextItems.forEach((item, index) => { + this.lastRenderItemToIndexMap.set(item, index); + }); + return nextItems; + } + + const nextRenderItems: (T | undefined)[] = []; + const length = nextItems.length; + const nextRenderItemToIndexMap = new Map(); + const addItems: T[] = []; + + // 遍历 nextItems 找到复用 item 和新增 item + nextItems.forEach(item => { + const lastIndex = this.lastRenderItemToIndexMap.get(item); + // 处理旧 item + if (lastIndex !== undefined) { + // 使用上一次的位置 + nextRenderItems[lastIndex] = item; + // 记录位置 + nextRenderItemToIndexMap.set(item, lastIndex); + } else { + // 记录新的 item + addItems.push(item); + } + }); + + // 处理新增 item,翻转数组,后面在调用 pop 时拿到的时最后一个,以确保顺序 + addItems.reverse(); + for (let i = 0; i < length; i++) { + // 优先将新增 item 放置在空位置上 + if (!nextRenderItems[i]) { + const item = addItems.pop(); + nextRenderItems[i] = item; + nextRenderItemToIndexMap.set(item, i); + } + } + + // 剩余新 item 补在数组后面 + for (let i = addItems.length - 1; i >= 0; i--) { + const item = addItems[i]; + nextRenderItemToIndexMap.set(item, nextRenderItems.length); + nextRenderItems.push(item); + } + + // 如果 nextRenderItems 中存在空 index,nextItems 已经耗尽,不用处理 + // 确保新旧数组中 item 的 index 值不会发生变化 + this.lastRenderItemToIndexMap = nextRenderItemToIndexMap; + return nextRenderItems; + } +} diff --git a/packages/inula-dev-tools/src/components/VList/VList.less b/packages/inula-dev-tools/src/components/VList/VList.less new file mode 100644 index 00000000..27d47999 --- /dev/null +++ b/packages/inula-dev-tools/src/components/VList/VList.less @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2023 Huawei Technologies Co.,Ltd. + * + * openInula is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * + * http://license.coscl.org.cn/MulanPSL2 + * + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +.container { + position: relative; + overflow-y: auto; + height: 100%; + width: 100%; +} + +.item { + position: absolute; + width: 100%; +} diff --git a/packages/inula-dev-tools/src/components/VList/VList.tsx b/packages/inula-dev-tools/src/components/VList/VList.tsx new file mode 100644 index 00000000..9efbbf89 --- /dev/null +++ b/packages/inula-dev-tools/src/components/VList/VList.tsx @@ -0,0 +1,135 @@ +/* + * Copyright (c) 2023 Huawei Technologies Co.,Ltd. + * + * openInula is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * + * http://license.coscl.org.cn/MulanPSL2 + * + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +/** + * 内部只记录滚动位置状态值 + * data 数组更新后不修改滚动位置,只有修改 scrollToItem 才会修改滚动位置 + */ +import { useState, useRef, useEffect, useMemo } from 'openinula'; +import styles from './VList.less'; +import ItemMap from './ItemMap'; +import { debounceFunc } from '../../utils/publicUtil'; + +interface IProps { + data: T[]; + maxDeep: number; + width: number; // 暂时未用到,当需要支持横向滚动时使用 + height: number; // VList 的高度 + children?: any; // inula 组件 + itemHeight: number; + scrollToItem?: T; // 滚动到指定项位置,如果该项在可见区域内,不滚动,如果补在,则滚动到中间位置 + onRendered: (renderInfo: RenderInfoType) => void; + filter?: (data: T) => boolean; // false 表示该行不显示 +} + +export type RenderInfoType = { + visibleItems: T[]; +} + +function parseTranslate(data: T[], itemHeight: number) { + const map = new Map(); + data.forEach((item, index) => { + map.set(item, index * itemHeight); + }) + return map; +} + +export function VList(props: IProps) { + const { data, maxDeep, height, width, children, itemHeight, scrollToItem, onRendered } = props; + const [scrollTop, setScrollTop] = useState(Math.max(data.indexOf(scrollToItem!), 0) * itemHeight); + const renderInfoRef: { current: RenderInfoType } = useRef({ + visibleItems: [], + }); + const [indentationLength, setIndentationLength] = useState(0); + + // 每个 item 的 translateY 值固定不变 + const itemToTranslateYMap = useMemo(() => parseTranslate(data, itemHeight), [data]); + const itemIndexMap = useMemo(() => new ItemMap(), []); + const containerRef = useRef(); + + useEffect(() => { + onRendered(renderInfoRef.current); + }); + + useEffect(() => { + debounceFunc(() => setIndentationLength(Math.min(12, Math.round(width / (2 * maxDeep))))); + }, [width]); + + useEffect(() => { + if (scrollToItem) { + const renderInfo = renderInfoRef.current; + // 在显示区域,不滚动 + if (!renderInfo.visibleItems.includes(scrollToItem)) { + const index = data.indexOf(scrollToItem); + // 显示在页面中间 + const top = Math.max(index * itemHeight - height / 2, 0); + containerRef.current?.scrollTo({ top: top }); + } + } + }, [scrollToItem]); + + // 滚动事件会频繁触发,通过框架提供的代理会有大量计算寻找 dom 元素,直接绑定到原生事件上减少计算量 + useEffect(() => { + const handleScroll = event => { + const scrollTop = event.target.scrollTop; + setScrollTop(scrollTop); + }; + const container = containerRef.current; + container?.addEventListener('scroll', handleScroll); + return () => { + container?.removeEventListener('scroll', handleScroll); + }; + }, []); + + const totalHeight = itemHeight * data.length; + const maxIndex = data.length; // slice 截取渲染 item 数组时最大位置不能超过自然长度 + // 第一个可见 item index + const firstInViewItemIndex = Math.floor(scrollTop / itemHeight); + // 可见区域前最多冗余 4 个 item + const startRenderIndex = Math.max(firstInViewItemIndex - 4, 0); // index 不能小于 0 + // 最多可见数量 + const maxInViewCount = Math.floor(height / itemHeight); + // 最后可见 item index + const lastInViewIndex = Math.min(firstInViewItemIndex + maxInViewCount, maxIndex); + // 记录可见 items + renderInfoRef.current.visibleItems = data.slice(firstInViewItemIndex, lastInViewIndex); + // 可见区域后冗余 4 个 item + const lastRenderIndex = Math.min(lastInViewIndex + 4, maxIndex); + // 需要渲染的 item + const renderItems = data.slice(startRenderIndex, lastRenderIndex); + // 给 items 重新排序,确保未移出渲染数组的 item 在新的渲染数组中位置不变,这样在 diff 算法比较后,这部分的 dom 不会发生更新 + const nextRenderList = itemIndexMap.calculateReSortedItems(renderItems); + const list = nextRenderList.map((item, index) => { + if (!item) { + return null; + } + return ( +
+ {children(item,indentationLength)} +
+ ); + }); + + return ( +
+ {list} +
+
+ ); +} diff --git a/packages/inula-dev-tools/src/components/VList/index.ts b/packages/inula-dev-tools/src/components/VList/index.ts new file mode 100644 index 00000000..944e8eb8 --- /dev/null +++ b/packages/inula-dev-tools/src/components/VList/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2023 Huawei Technologies Co.,Ltd. + * + * openInula is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * + * http://license.coscl.org.cn/MulanPSL2 + * + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +export { VList } from './VList'; +export type { RenderInfoType } from './VList'; diff --git a/packages/inula-dev-tools/src/components/VTree.less b/packages/inula-dev-tools/src/components/VTree.less new file mode 100644 index 00000000..54d23bb7 --- /dev/null +++ b/packages/inula-dev-tools/src/components/VTree.less @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2023 Huawei Technologies Co.,Ltd. + * + * openInula is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * + * http://license.coscl.org.cn/MulanPSL2 + * + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +@import 'assets.less'; + +.treeContainer { + height: 100%; + + .treeItem { + width: 100%; + position: absolute; + line-height: 1.125rem; + align-items: center; + display: inline-flex; + &:hover { + background-color: @select-color; + } + + .treeIcon { + color: @arrow-color; + display: inline-block; + width: 12px; + padding-left: 0.5rem; + } + + .componentName { + white-space: nowrap; + color: @component-name-color; + display: inline-flex; + } + + .componentKeyName { + color: @component-key-color; + } + + .componentKeyValue { + color: @component-key-value-color; + max-width: 100px; + overflow-x: hidden; + text-overflow: ellipsis; + display: inline-flex; + white-space: nowrap; + } + } + + .selectedItemChild { + background-color: @select-item-child-color; + } + + .select { + background-color: @select-color; + } +} + +.Badge { + display: inline-block; + background-color: rgba(0, 0, 0, 0.1); + color: #000000; + padding: 0 0.25rem; + line-height: normal; + border-radius: 0.125rem; + margin-left: 0.25rem; + font-family: @attr-name-font-family; + font-size: 9px; + height: 1rem; +} diff --git a/packages/inula-dev-tools/src/components/VTree.tsx b/packages/inula-dev-tools/src/components/VTree.tsx new file mode 100644 index 00000000..77918715 --- /dev/null +++ b/packages/inula-dev-tools/src/components/VTree.tsx @@ -0,0 +1,329 @@ +/* + * Copyright (c) 2023 Huawei Technologies Co.,Ltd. + * + * openInula is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * + * http://license.coscl.org.cn/MulanPSL2 + * + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +import { useState, useEffect, useCallback, memo } from 'openinula'; +import styles from './VTree.less'; +import Triangle from '../svgs/Triangle'; +import { createRegExp } from '../utils/regExpUtil'; +import { SizeObserver } from './SizeObserver'; +import { RenderInfoType, VList } from './VList'; +import { postMessageToBackground } from '../panelConnection'; +import { Highlight, RemoveHighlight } from '../utils/constants'; +import { NameObj } from '../parser/parseVNode'; + +export interface IData { + id: number; + name: NameObj; + indentation: number; + userKey: string; +} + +interface IItem { + indentationLength: number; + hasChild: boolean; + onCollapse: (data: IData) => void; + onClick: (id: IData) => void; + onMouseEnter: (id: IData) => void; + onMouseLeave: (id: IData) => void; + isCollapsed: boolean; + isSelect: boolean; + highlightValue: string; + data: IData; + isSelectedItemChild: boolean; +} + +function Item(props: IItem) { + const { + hasChild, + onCollapse, + isCollapsed, + data, + onClick, + indentationLength, + onMouseEnter, + onMouseLeave, + isSelect, + highlightValue = '', + isSelectedItemChild, + } = props; + + const { name, userKey, indentation } = data; + + const isShowKey = userKey !== ''; + const showIcon = hasChild ? : ''; + const handleClickCollapse = () => { + onCollapse(data); + }; + const handleClick = () => { + onClick(data); + }; + const handleMouseEnter = () => { + onMouseEnter(data); + }; + const handleMouseLeave = () => { + onMouseLeave(data); + }; + + const itemAttr: Record = { + className: isSelectedItemChild ? styles.selectedItemChild : styles.treeItem, + onClick: handleClick, + onMouseEnter: handleMouseEnter, + onMouseLeave: handleMouseLeave, + }; + + if (isSelect) { + itemAttr.tabIndex = 0; + itemAttr.className = styles.treeItem + ' ' + styles.select; + } + + if (isSelectedItemChild) { + itemAttr.className = styles.treeItem + ' ' + styles.selectedItemChild; + } + + const pushBadge = (showName: Array, badgeName: string) => { + showName.push(' '); + showName.push(
{badgeName}
); + }; + + const pushItemName = (showName: Array, cutName: string, char: string) => { + const index = cutName.search(char); + if (index > -1) { + const notHighlightStr = cutName.slice(0, index); + showName.push(`<${notHighlightStr}`); + showName.push({char}); + showName.push(`${cutName.slice(index + char.length)}>`); + } else { + showName.push(`<${cutName}`); + } + }; + + const pushBadgeName = (showName: Array, cutName: string, char: string) => { + const index = cutName.search(char); + if (index > -1) { + const notHighlightStr = cutName.slice(0, index); + showName.push( +
+ {notHighlightStr} + {{char}} + {cutName.slice(index + char.length)} +
+ ); + } else { + pushBadge(showName, cutName); + } + }; + + const reg = createRegExp(highlightValue); + const heightCharacters = name.itemName.match(reg); + const showName = []; + + const addShowName = (showName: Array, name: NameObj) => { + showName.push(`<${name.itemName}>`); + name.badge.forEach(key => { + showName.push(
{key}
); + }); + }; + + if (heightCharacters) { + // 高亮第一次匹配即可 + const char = heightCharacters[0]; + pushItemName(showName, name.itemName, char); + if (name.badge.length > 0) { + name.badge.forEach(key => { + pushBadgeName(showName, key, char); + }); + } + } else { + addShowName(showName, name); + } + + return ( +
+
+ {showIcon} +
+ {showName} + {isShowKey && ( + <> +  key + {'="'} + {userKey} + {'"'} + + )} +
+ ); +} + +function VTree(props: { + data: IData[]; + maxDeep: number; + highlightValue: string; + scrollToItem: IData; + onRendered: (renderInfo: RenderInfoType) => void; + collapsedNodes?: IData[]; + onCollapseNode?: (item: IData[]) => void; + selectItem: IData; + onSelectItem: (item: IData) => void; +}) { + const { + data, + maxDeep, + highlightValue, + scrollToItem, + onRendered, + onCollapseNode, + onSelectItem + } = props; + const [collapseNode, setCollapseNode] = useState(props.collapsedNodes || []); + const [selectItem, setSelectItem] = useState(props.selectItem); + const [childItems, setChildItems] = useState>([]); + + useEffect(() => { + setSelectItem(scrollToItem); + }, [scrollToItem]); + + useEffect(() => { + if (props.selectItem !== selectItem) { + setSelectItem(props.selectItem); + } + }, [props.selectItem]); + + useEffect(() => { + setCollapseNode(props.collapsedNodes || []); + }, [props.collapsedNodes]); + + const changeCollapseNode = (item: IData) => { + const nodes: IData[] = [...collapseNode]; + const index = nodes.indexOf(item); + if (index === -1) { + nodes.push(item); + } else { + nodes.splice(index, 1); + } + + setCollapseNode(nodes); + + if (onCollapseNode) { + onCollapseNode(nodes); + } + }; + + const getChildItem = (item: IData): Array => { + const index = data.indexOf(item); + const childList: Array = []; + + for (let i = index + 1; i < data.length; i++) { + if (data[i].indentation > item.indentation) { + childList.push(data[i]); + } else { + break; + } + } + return childList; + }; + + const handleClickItem = useCallback( + (item: IData) => { + const childItem = getChildItem(item); + setSelectItem(item); + setChildItems(childItem); + if (onSelectItem) { + onSelectItem(item); + } + }, + [onSelectItem] + ); + + const handleMouseEnterItem = useCallback( + item => { + postMessageToBackground(Highlight, item); + }, + null + ); + + const handleMouseLeaveItem = () => { + postMessageToBackground(RemoveHighlight); + }; + + let currentCollapseIndentation: null | number = null; + // 过滤掉折叠的 item,不展示在 VList 中 + const filter = (item: IData) => { + if (currentCollapseIndentation !== null) { + // 缩进更大,不显示 + if (item.indentation > currentCollapseIndentation) { + return false; + } else { + // 缩进小,说明完成了收起节点的子节点处理 + currentCollapseIndentation = null; + } + } + const isCollapsed = collapseNode.includes(item); + if (isCollapsed) { + // 该节点需要收起子节点 + currentCollapseIndentation = item.indentation; + } + return true; + }; + + const showList = data.filter(filter); + + return ( + + {(width: number, height: number) => { + return ( + + {(item: IData, indentationLength: number) => { + const isCollapsed = collapseNode.includes(item); + const index = showList.indexOf(item); + // 如果收起,一定有 child + // 不收起场景,如果存在下一个节点,并且节点缩进比自己大,说明下个节点是子节点,节点本身需要显示展开收起图标 + const hasChild = isCollapsed || showList[index + 1]?.indentation > item.indentation; + return ( + + ); + }} + + ); + }} + + ); +} + +export default memo(VTree); diff --git a/packages/inula-dev-tools/src/components/assets.less b/packages/inula-dev-tools/src/components/assets.less new file mode 100644 index 00000000..918c1291 --- /dev/null +++ b/packages/inula-dev-tools/src/components/assets.less @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2023 Huawei Technologies Co.,Ltd. + * + * openInula is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * + * http://license.coscl.org.cn/MulanPSL2 + * + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +@arrow-color: rgb(95, 99, 104); +@divider-color: rgb(202, 205, 209); +@attr-name-color: rgb(200, 0, 0); +@component-name-color: rgb(136, 18, 128); +@component-key-color: rgb(153, 69, 0); +@component-key-value-color: rgb(26, 26, 166); +@component-attr-color: rgb(200, 0, 0); +@select-color: rgb(144 199 248 / 60%); +@select-item-child-color: rgb(141 199 248 / 40%); +@hover-color: black; + +@top-height: 2.625rem; +@divider-width: 0.2px; +@common-font-size: 12px; + +@divider-style: @divider-color solid @divider-width; +@attr-name-font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, Courier, monospace; diff --git a/packages/inula-dev-tools/src/components/resizeEvent.ts b/packages/inula-dev-tools/src/components/resizeEvent.ts new file mode 100644 index 00000000..747faef3 --- /dev/null +++ b/packages/inula-dev-tools/src/components/resizeEvent.ts @@ -0,0 +1,95 @@ +/* + * Copyright (c) 2023 Huawei Technologies Co.,Ltd. + * + * openInula is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * + * http://license.coscl.org.cn/MulanPSL2 + * + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +/** + * + * 由于 ResizeObserver 对 IE 和低版本主流浏览器不兼容,需要自己实现一套兼容方案 + * 这是一个不依赖任何框架的监听 dom 元素尺寸变化的解决方案 + * 浏览器出于性能考虑,只有 window 的 resize 事件会触发,我们通过 object 标签可以得到 + * 一个 window 对象,让 object dom 元素成为待观测 dom 的子元素,并且和待观测 dom 大小一致。 + * 这样一旦待观测 dom 的大小发生变化,window 的大小也会发生变化,我们就可以通过监听 window + * 大小变化的方式监听待观测 dom 的大小变化 + * + *
{ + // 只监听来自本页面的消息 + if (event.source !== window) { + return; + } + + const data = event.data; + if (checkMessage(data, DevToolHook)) { + changeSource(data, DevToolContentScript); + // 传递给 background + chrome.runtime.sendMessage(data); + } + }, + false +); + +// 监听来自 background 的消息 +chrome.runtime.onMessage.addListener(function (message, sender, sendResponse) { + // 该方法可以监听页面 contentScript 和插件的消息 + // 没有 tab 信息说明消息来自插件 + if (!sender.tab && checkMessage(message, DevToolBackground)) { + changeSource(message, DevToolContentScript); + // 传递消息给页面 + window.postMessage(message, '*'); + } + sendResponse({ status: 'ok' }); +}); diff --git a/packages/inula-dev-tools/src/highlight/index.ts b/packages/inula-dev-tools/src/highlight/index.ts new file mode 100644 index 00000000..78ae260b --- /dev/null +++ b/packages/inula-dev-tools/src/highlight/index.ts @@ -0,0 +1,274 @@ +/* + * Copyright (c) 2023 Huawei Technologies Co.,Ltd. + * + * openInula is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * + * http://license.coscl.org.cn/MulanPSL2 + * + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +import assign from 'object-assign'; +import { VNode } from '../../../inula/src/renderer/vnode/VNode'; + +const overlayStyles = { + background: 'rgba(120, 170, 210, 0.7)', + padding: 'rgba(77, 200, 0, 0.3)', + margin: 'rgba(255, 155, 0, 0.3)', + border: 'rgba(255, 200, 50, 0.3)' +}; + +type Rect = { + bottom: number; + height: number; + left: number; + right: number; + top: number; + width: number; +}; + +function setBoxStyle(eleStyle, boxArea, node) { + assign(node.style, { + borderTopWidth: eleStyle[boxArea + 'Top'] + 'px', + borderLeftWidth: eleStyle[boxArea + 'Left'] + 'px', + borderRightWidth: eleStyle[boxArea + 'Right'] + 'px', + borderBottomWidth: eleStyle[boxArea + 'Bottom'] + 'px', + }); +} + +function getOwnerWindow(node: Element): typeof window | null { + if (!node.ownerDocument) { + return null; + } + return node.ownerDocument.defaultView; +} + +function getOwnerIframe(node: Element): Element | null { + const nodeWindow = getOwnerWindow(node); + if (nodeWindow) { + return nodeWindow.frameElement; + } + return null; +} + +function getElementStyle(domElement: Element) { + const style = window.getComputedStyle(domElement); + return{ + marginLeft: parseInt(style.marginLeft, 10), + marginRight: parseInt(style.marginRight, 10), + marginTop: parseInt(style.marginTop, 10), + marginBottom: parseInt(style.marginBottom, 10), + borderLeft: parseInt(style.borderLeftWidth, 10), + borderRight: parseInt(style.borderRightWidth, 10), + borderTop: parseInt(style.borderTopWidth, 10), + borderBottom: parseInt(style.borderBottomWidth, 10), + paddingLeft: parseInt(style.paddingLeft, 10), + paddingRight: parseInt(style.paddingRight, 10), + paddingTop: parseInt(style.paddingTop, 10), + paddingBottom: parseInt(style.paddingBottom, 10) + }; +} + +function mergeRectOffsets(rects: Array): Rect { + return rects.reduce((previousRect, rect) => { + if (previousRect == null) { + return rect; + } + + return { + top: previousRect.top + rect.top, + left: previousRect.left + rect.left, + width: previousRect.width + rect.width, + height: previousRect.height + rect.height, + bottom: previousRect.bottom + rect.bottom, + right: previousRect.right + rect.right + }; + }); +} + +function getBoundingClientRectWithBorderOffset(node: Element) { + const dimensions = getElementStyle(node); + return mergeRectOffsets([ + node.getBoundingClientRect(), + { + top: dimensions.borderTop, + left: dimensions.borderLeft, + bottom: dimensions.borderBottom, + right:dimensions.borderRight, + // 高度和宽度不会被使用 + width: 0, + height: 0 + } + ]); +} + +function getNestedBoundingClientRect( + node: HTMLElement, + boundaryWindow +): Rect { + const ownerIframe = getOwnerIframe(node); + if (ownerIframe && ownerIframe !== boundaryWindow) { + const rects = [node.getBoundingClientRect()] as Rect[]; + let currentIframe = ownerIframe; + let onlyOneMore = false; + while (currentIframe) { + const rect = getBoundingClientRectWithBorderOffset(currentIframe); + rects.push(rect); + currentIframe = getOwnerIframe(currentIframe); + + if (onlyOneMore) { + break; + } + + if (currentIframe &&getOwnerWindow(currentIframe) === boundaryWindow) { + onlyOneMore = true; + } + } + + return mergeRectOffsets(rects); + } else { + return node.getBoundingClientRect(); + } +} + +// 用来遮罩 +class OverlayRect { + node: HTMLElement; + border: HTMLElement; + padding: HTMLElement; + content: HTMLElement; + + constructor(doc: Document, container: HTMLElement) { + this.node = doc.createElement('div'); + this.border = doc.createElement('div'); + this.padding = doc.createElement('div'); + this.content = doc.createElement('div'); + + this.border.style.borderColor = overlayStyles.border; + this.padding.style.borderColor = overlayStyles.padding; + this.content.style.backgroundColor = overlayStyles.background; + + assign(this.node.style, { + borderColor: overlayStyles.margin, + pointerEvents: 'none', + position: 'fixed' + }); + + this.node.style.zIndex = '10000000'; + + this.node.appendChild(this.border); + this.border.appendChild(this.padding); + this.padding.appendChild(this.content); + container.appendChild(this.node); + } + + remove() { + if (this.node.parentNode) { + this.node.parentNode.removeChild(this.node); + } + } + + update(boxRect: Rect, eleStyle: any) { + setBoxStyle(eleStyle, 'margin', this.node); + setBoxStyle(eleStyle, 'border', this.border); + setBoxStyle(eleStyle, 'padding', this.padding); + + assign(this.content.style, { + height: boxRect.height - eleStyle.borderTop - eleStyle.borderBottom - eleStyle.paddingTop - eleStyle.paddingBottom + 'px', + width: boxRect.width - eleStyle.borderLeft - eleStyle.borderRight - eleStyle.paddingLeft - eleStyle.paddingRight + 'px' + }); + + assign(this.node.style, { + top: boxRect.top - eleStyle.marginTop + 'px', + left: boxRect.left - eleStyle.marginLeft + 'px' + }); + } +} + +class ElementOverlay { + window: typeof window; + container: HTMLElement; + rects: Array; + + constructor() { + this.window = window; + const doc = window.document; + this.container = doc.createElement('div'); + this.container.style.zIndex = '10000000'; + this.rects = []; + + doc.body.appendChild(this.container); + } + + remove() { + this.rects.forEach(rect => { + rect.remove(); + }); + this.rects.length = 0; + if (this.container.parentNode) { + this.container.parentNode.removeChild(this.container); + } + } + + execute(nodes: Array) { + const elements = nodes.filter(node => node.tag === 'DomComponent'); + + // 有几个 element 就添加几个 OverlayRect + while (this.rects.length > elements.length) { + const rect = this.rects.pop(); + rect.remove(); + } + if (elements.length === 0) { + return; + } + + while (this.rects.length < elements.length) { + this.rects.push(new OverlayRect(this.window.document, this.container)); + } + + const outerBox = { + top: Number.POSITIVE_INFINITY, + right: Number.NEGATIVE_INFINITY, + bottom: Number.NEGATIVE_INFINITY, + left: Number.POSITIVE_INFINITY + }; + + elements.forEach((element, index) => { + const eleStyle = getElementStyle(element.realNode); + const boxRect = getNestedBoundingClientRect(element.realNode, this.window); + + outerBox.top = Math.min(outerBox.top, boxRect.top - eleStyle.marginTop); + outerBox.right = Math.max(outerBox.right, boxRect.left + boxRect.width + eleStyle.marginRight); + outerBox.bottom = Math.max(outerBox.bottom, boxRect.top + boxRect.height + eleStyle.marginBottom); + outerBox.left = Math.min(outerBox.left, boxRect.left - eleStyle.marginLeft); + + const rect = this.rects[index]; + rect.update(boxRect, eleStyle); + }); + } +} + +let elementOverlay: ElementOverlay | null = null; +export function hideHighlight() { + if (elementOverlay !== null) { + elementOverlay.remove(); + elementOverlay = null; + } +} + +export function showHighlight(elements: Array | null) { + if (window.document == null || elements == null) { + return; + } + + if (elementOverlay === null) { + elementOverlay = new ElementOverlay(); + } + + elementOverlay.execute(elements); +} diff --git a/packages/inula-dev-tools/src/hooks/FilterTree.ts b/packages/inula-dev-tools/src/hooks/FilterTree.ts new file mode 100644 index 00000000..6caf8640 --- /dev/null +++ b/packages/inula-dev-tools/src/hooks/FilterTree.ts @@ -0,0 +1,171 @@ +/* + * Copyright (c) 2023 Huawei Technologies Co.,Ltd. + * + * openInula is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * + * http://license.coscl.org.cn/MulanPSL2 + * + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +/** + * 过滤树的抽象逻辑 + * 需要知道渲染了哪些数据,过滤的字符串/正则表达式 + * 控制 Tree 组件位置跳转,告知匹配结果 + * 清空搜索框,告知搜索框当前是第几个结果,转跳搜索结果 + * + * 转跳搜索结果的交互逻辑: + * 如果当前页面存在匹配项,页面不动 + * 如果当前页面不存在匹配项,页面转跳到第一个匹配项位置 + * 如果匹配项被折叠,需要展开其父节点。注意只展开当前匹配项的父节点,其他匹配项的父节点不展开 + * 转跳到上一个匹配或下一个匹配项时,如果匹配项被折叠,需要展开其父节点 + * + * 寻找父节点 + * 找到该节点的缩进值和 index 值,在 data 中向上遍历,通过缩进值判断父节点 + */ +import { useState, useRef } from 'openinula'; +import { createRegExp } from '../utils/regExpUtil'; +import { NameObj } from '../parser/parseVNode'; + +type BaseType = { + id: number; + name: NameObj; + indentation: number; +}; + +/** + * 把节点的父节点从收起节点数组中删除,并返回新的收起节点数组 + * + * @param item 需要展开父节点的节点 + * @param data 全部数据 + * @param collapsedNodes 收起节点数据 + * @returns 新的收起节点数组 + */ +function expandItemParent(item: BaseType, data: BaseType[], collapsedNodes: BaseType[]): BaseType[] { + const index = data.indexOf(item); + let currentIndentation = item.indentation; + // 不对原始数据进行修改 + const newCollapsedNodes = [...collapsedNodes]; + for (let i = index - 1; i >= 0; i--) { + const lastData = data[i]; + const lastIndentation = lastData.indentation; + // 缩进更小,找到了父节点 + if (lastIndentation < currentIndentation) { + // 更新缩进值,只招父节点的父节点,避免修改父节点的兄弟节点的展开状态 + currentIndentation = lastIndentation; + const cIndex = newCollapsedNodes.indexOf(lastData); + if (cIndex !== -1) { + newCollapsedNodes.splice(cIndex, 1); + } + } + } + return newCollapsedNodes; +} + +export function FilterTree(props: { data: T[] }) { + const { data } = props; + const [filterValue, setFilterValue] = useState(''); + const [currentItem, setCurrentItem] = useState(null); + const showItemsRef = useRef([]); // 页面展示的 items + const matchItemsRef = useRef([]); //匹配过滤条件的 items + const collapsedNodesRef = useRef([]); // 折叠节点,如果匹配 item 被折叠了,需要展开 + + const matchItems = matchItemsRef.current; + const collapsedNodes = collapsedNodesRef.current; + + const updateCollapsedNodes = (item: BaseType) => { + const newCollapsedNodes = expandItemParent(item, data, collapsedNodes); + // 如果新旧收起节点数组长度不一致,说明存在收起节点 + if (newCollapsedNodes.length !== collapsedNodes.length) { + // 更新引用,确保 VTree 拿到新的 collapsedNodes + collapsedNodesRef.current = newCollapsedNodes; + } + }; + + const onChangeSearchValue = (search: string) => { + const reg = createRegExp(search); + let newCurrentItem = null; + let newMatchItems = []; + if (search !== '') { + const showItems: T[] = showItemsRef.current; + newMatchItems = data.reduce((pre, current) => { + const { name } = current; + if (name && reg && name.itemName.match(reg)) { + pre.push(current); + // 如果当前页面显示的 item 存在匹配项,则把他设置为 currentItem + if (newCurrentItem === null && showItems.includes(current)) { + newCurrentItem = current; + } + } + return pre; + }, []); + + if (newMatchItems.length === 0) { + setCurrentItem(null); + } else { + if (newCurrentItem === null) { + const item = newMatchItems[0]; + // 不处于当前展示页面,需要展开父节点 + updateCollapsedNodes(item); + setCurrentItem(item); + } else { + setCurrentItem(newCurrentItem); + } + } + } else { + setCurrentItem(null); + } + matchItemsRef.current = newMatchItems; + setFilterValue(search); + }; + + const onSelectNext = () => { + const index = matchItems.indexOf(currentItem); + const nextIndex = index + 1; + const item = nextIndex < matchItemsRef.current.length ? matchItems[nextIndex] : matchItems[0]; + // 可能不处于当前展示页面,需要展开父节点 + updateCollapsedNodes(item); + setCurrentItem(item); + }; + + const onSelectLast = () => { + const index = matchItems.indexOf(currentItem); + const last = index - 1; + const item = last >= 0 ? matchItems[last] : matchItems[matchItems.length - 1]; + // 可能不处于当前展示页面,需要展开父节点 + updateCollapsedNodes(item); + setCurrentItem(item); + }; + + const setShowItems = items => { + showItemsRef.current = [...items]; + }; + + const onClear = () => { + onChangeSearchValue(''); + }; + + const setCollapsedNodes = items => { + // 不更新引用,避免子组件的重复渲染 + collapsedNodesRef.current.length = 0; + collapsedNodesRef.current.push(...items); + }; + + return { + filterValue, + onChangeSearchValue, + onClear, + currentItem, + matchItems, + onSelectNext, + onSelectLast, + setShowItems, + collapsedNodes, + setCollapsedNodes, + }; +} diff --git a/packages/inula-dev-tools/src/injector/index.ts b/packages/inula-dev-tools/src/injector/index.ts new file mode 100644 index 00000000..3cf3cf2d --- /dev/null +++ b/packages/inula-dev-tools/src/injector/index.ts @@ -0,0 +1,485 @@ +/* + * Copyright (c) 2023 Huawei Technologies Co.,Ltd. + * + * openInula is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * + * http://license.coscl.org.cn/MulanPSL2 + * + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +import parseTreeRoot, { clearVNode, queryVNode, VNodeToIdMap } from '../parser/parseVNode'; +import { packagePayload, checkMessage } from '../utils/transferUtils'; +import { + RequestAllVNodeTreeInfos, + AllVNodeTreeInfos, + RequestComponentAttrs, + ComponentAttrs, + DevToolHook, + DevToolContentScript, + ModifyAttrs, + ModifyHooks, + ModifyState, + ModifyProps, + InspectDom, + LogComponentData, + Highlight, + RemoveHighlight, + ViewSource, + PickElement, + StopPickElement, + CopyToConsole, + StorageValue, +} from '../utils/constants'; +import { VNode } from '../../../inula/src/renderer/vnode/VNode'; +import { parseVNodeAttrs } from '../parser/parseAttr'; +import { showHighlight, hideHighlight } from '../highlight'; +import { + FunctionComponent, + ClassComponent, + IncompleteClassComponent, + ForwardRef, + MemoComponent +} from '../../../inula/src/renderer/vnode/VNodeTags'; +import { pickElement } from './pickElement'; + +const roots = []; +let storeDataCount = 0; + +function addIfNotInclude(treeRoot: VNode) { + if (!roots.includes(treeRoot)) { + roots.push(treeRoot); + } +} + +function send() { + const result = roots.reduce((pre, current) => { + const info = parseTreeRoot(helper.travelVNodeTree, current); + pre.push(info); + return pre; + }, []); + postMessage(AllVNodeTreeInfos, result); +} + +function deleteVNode(vNode: VNode) { + // 开发工具中保存了 vNode 的引用,在清理 vNode 的时候需要一并删除 + clearVNode(vNode); + const index = roots.indexOf(vNode); + if (index !== -1) { + roots.splice(index, 1); + } +} + +export function postMessage(type: string, data) { + window.postMessage( + packagePayload( + { + type: type, + data: data, + }, + DevToolHook + ), + '*' + ); +} + +function parseCompAttrs(id: number) { + const vNode = queryVNode(id); + if (!vNode) { + console.error('Do not find match vNode, this is a bug, please report us.'); + return; + } + const parsedAttrs = parseVNodeAttrs(vNode, helper.getHookInfo); + postMessage(ComponentAttrs, parsedAttrs); +} + +function calculateNextValue(editValue, value, attrPath) { + let nextState; + const editValueType = typeof editValue; + if ( + editValueType === 'string' || + editValueType === 'undefined' || + editValueType === 'boolean' + ) { + nextState = value; + } else if (editValueType === 'number') { + const numValue = Number(value); + nextState = isNaN(numValue) ? value : numValue; // 如果能转为数字,转数字,不能转数字旧用原值 + } else if (editValueType === 'object') { + if (editValue === null) { + nextState = value; + } else { + const newValue = Array.isArray(editValue) ? [...editValue] : { ...editValue }; + // 遍历读取到直接指向需要修改值的对象 + let attr = newValue; + for (let i = 0; i < attrPath.length - 1; i++) { + attr = attr[attrPath[i]]; + } + // 修改对象上的值 + attr[attrPath[attrPath.length - 1]] = value; + nextState = newValue; + } + } else { + console.error( + 'The dev tools tried to edit a non-editable value, this is a bug, please report.', + editValue + ); + } + return nextState; +} + +function modifyVNodeAttrs(data) { + const { type, id, value, path } = data; + const vNode = queryVNode(id); + if (!vNode) { + console.error('Do not find match vNode, this is a bug, please report us.'); + return; + } + if (type === ModifyProps) { + const nextProps = calculateNextValue(vNode.props, value, path); + helper.updateProps(vNode, nextProps); + } else if (type === ModifyHooks) { + const hooks = vNode.hooks; + const editHook = hooks[path[0]]; + const hookInfo = helper.getHookInfo(editHook); + if (hookInfo) { + const editValue = hookInfo.value; + // path 的第一个指向 hIndex,从第二个值才开始指向具体属性访问路径 + const nextState = calculateNextValue(editValue, value, path.slice(1)); + helper.updateHooks(vNode, path[0], nextState); + } else { + console.error( + 'The dev tools tried to edit a non-editable hook, this is a bug, please report.', + hooks + ); + } + } else if (type === ModifyState) { + const oldState = vNode.state || {}; + const nextState = { ...oldState }; + let accessRef = nextState; + for (let i = 0; i < path.length - 1; i++) { + accessRef = accessRef[path[i]]; + } + accessRef[path[path.length - 1]] = value; + helper.updateState(vNode, nextState); + } +} + +function logComponentData(id: number) { + const vNode = queryVNode(id); + if (vNode == null) { + console.warn(`Could not find vNode with id "${id}"`); + return null; + } + if (vNode) { + const info = helper.getComponentInfo(vNode); + console.log('vNode: ', vNode); + console.log('Component Info: ', info); + } +} + +/** + * 通过 path 在 vNode 拿到对应的值 + * + * @param {VNode} vNode dom 节点 + * @param {Array} path 路径 + * @param {string} attrsName 值的类型(props 或者 hooks) + */ +const getValueByPath = ( + vNode: VNode, + path: Array, + attrsName: string +) => { + if (attrsName === 'Props') { + return path.reduce((previousValue, currentValue) => { + return previousValue[currentValue]; + }, vNode.props); + } else { + // attrsName 为 Hooks + if (path.length > 1) { + return path.reduce((previousValue, currentValue) => { + return previousValue[currentValue]; + }, vNode.hooks); + } + return vNode.hooks[path[0]]; + } +}; + +/** + * 通过 path 在 vNode 拿到对应的值,并且在控制台打印出来 + * + * @param {number} id idToVNodeMap 的 key 值,通过 id 拿到 VNode + * @param {string} itemName 打印出来值的名称 + * @param {Array} path 值的路径 + * @param {string} attrsName 值的类型 + */ +function logDataWithPath( + id: number, + itemName: string, + path: Array, + attrsName: string +) { + const vNode = queryVNode(id); + if (vNode === null) { + console.warn(`Could not find vNode with id "${id}"`); + return null; + } + if (vNode) { + const value = getValueByPath(vNode, path, attrsName); + if (attrsName === 'Hooks') { + console.log(itemName, value); + } else { + console.log(`${path[path.length - 1]}`, value); + } + } +} + +/** + * 通过 path 在 vNode 拿到对应的值,并且存为全局变量 + * + * @param {number} id idToVNodeMap 的 key 值,通过 id 拿到 VNode + * @param {Array} path 值的路径 + * @param {string} attrsName 值的类型 + */ +function storeDataWithPath( + id: number, + path: Array, + attrsName: string +) { + const vNode = queryVNode(id); + if (vNode === null) { + console.warn(`Could not find vNode with id "${id}"`); + return null; + } + if (vNode) { + const value = getValueByPath(vNode, path, attrsName); + const key = `$InulaTemp${storeDataCount++}`; + + window[key] = value; + console.log(key); + console.log(value); + } +} + +export let helper; + +function init(inulaHelper) { + helper = inulaHelper; + (window as any).__INULA_DEV_HOOK__.isInit = true; +} + +export function getElement(travelVNodeTree, treeRoot: VNode) { + const result: any[] = []; + travelVNodeTree( + treeRoot, + (node: VNode) => { + if (node.realNode) { + if (Object.keys(node.realNode).length > 0 || node.realNode.size > 0) { + result.push(node); + } + } + }, + (node: VNode) => + node.realNode != null && + (Object.keys(node.realNode).length > 0 || node.realNode.size > 0) + ); + return result; +} + +// dev tools 点击眼睛图标功能 +const inspectDom = data => { + const { id } = data; + const vNode = queryVNode(id); + if (vNode == null) { + console.warn(`Could not find vNode with id "${id}"`); + return null; + } + const info = getElement(helper.travelVNodeTree, vNode); + if (info) { + showHighlight(info); + (window as any).__INULA_DEV_HOOK__.$0 = info[0]; + } +}; + +const picker = pickElement(window); + +const actions = new Map([ + // 请求左树所有数据 + [ + RequestAllVNodeTreeInfos, + () => { + send(); + }, + ], + // 请求某个节点的 props,hooks + [ + RequestComponentAttrs, + data => { + parseCompAttrs(data); + }, + ], + // 修改 props,hooks + [ + ModifyAttrs, + data => { + modifyVNodeAttrs(data); + }, + ], + // 找到节点对应 element + [ + InspectDom, + data => { + inspectDom(data); + }, + ], + // 打印节点数据 + [ + LogComponentData, + data => { + logComponentData(data); + }, + ], + // 高亮 + [ + Highlight, + data => { + const node = queryVNode(data.id); + if (node == null) { + console.warn(`Could not find vNode with id "${data.id}"`); + return null; + } + const info = getElement(helper.travelVNodeTree, node); + showHighlight(info); + }, + ], + // 移出高亮 + [ + RemoveHighlight, + () => { + hideHighlight(); + }, + ], + // 查看节点源代码位置 + [ + ViewSource, + data => { + const node = queryVNode(data.id); + if (node == null) { + console.warn(`Could not find vNode with id "${data.id}"`); + return null; + } + showSource(node); + }, + ], + // 选中页面元素对应 dev tools 节点 + [ + PickElement, + () => { + picker.startPick(); + }, + ], + [ + StopPickElement, + () => { + picker.stopPick(); + }, + ], + // 在控制台打印 Props Hooks State 值 + [ + CopyToConsole, + data => { + const node = queryVNode(data.id); + if (node == null) { + console.warn(`Could not find vNode with id "${data.id}"`); + return null; + } + logDataWithPath(data.id, data.itemName, data.path, data.attrsName); + }, + ], + // 把 Props Hooks State 值存为全局变量 + [ + StorageValue, + data => { + const node = queryVNode(data.id); + if (node == null) { + console.warn(`Could not find vNode with id "${data.id}"`); + return null; + } + storeDataWithPath(data.id, data.path, data.attrsName); + }, + ], +]); + +const showSource = (node: VNode) => { + switch (node.tag) { + case ClassComponent: + case IncompleteClassComponent: + case FunctionComponent: + global.$type = node.type; + break; + case ForwardRef: + global.$type = node.type.render; + break; + case MemoComponent: + global.$type = node.type.type; + break; + default: + global.$type = null; + break; + } +}; + +const handleRequest = (type: string, data) => { + const action = actions.get(type); + if (action) { + action.call(this, data); + return null; + } + console.warn('unknown command', type); +}; + +function injectHook() { + if ((window as any).__INULA_DEV_HOOK__) { + return; + } + Object.defineProperty(window, '__INULA_DEV_HOOK__', { + enumerable: false, + value: { + $0: null, + init, + isInit: false, + addIfNotInclude, + send, + deleteVNode, + // inulaX 使用 + getVNodeId: vNode => { + return VNodeToIdMap.get(vNode); + }, + }, + }); + + window.addEventListener('message', function (event) { + // 只接收我们自己的消息 + if (event.source !== window) { + return; + } + const request = event.data; + if (checkMessage(request, DevToolContentScript)) { + const { payload } = request; + const { type, data } = payload; + + // 忽略 inulaX 的 actions + if (type.startsWith('inulax')) { + return; + } + handleRequest(type, data); + } + }); +} + +injectHook(); diff --git a/packages/inula-dev-tools/src/injector/pickElement.ts b/packages/inula-dev-tools/src/injector/pickElement.ts new file mode 100644 index 00000000..b1116faf --- /dev/null +++ b/packages/inula-dev-tools/src/injector/pickElement.ts @@ -0,0 +1,96 @@ +/* + * Copyright (c) 2023 Huawei Technologies Co.,Ltd. + * + * openInula is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * + * http://license.coscl.org.cn/MulanPSL2 + * + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +import { PickElement, StopPickElement } from '../utils/constants'; +import { getElement, helper, postMessage } from './index'; +import { queryVNode, VNodeToIdMap } from '../parser/parseVNode'; +import { isUserComponent } from '../parser/parseVNode'; +import { throttle } from 'lodash'; +import { hideHighlight, showHighlight } from '../highlight'; + +// 判断鼠标移入节点是否为 dev tools 上的节点,如果不是则找父节点 +function getUserComponent(target) { + if (target.tag && isUserComponent(target.tag)) { + return target; + } + while (target.tag && !isUserComponent(target.tag)) { + if (target.parent) { + target = target.parent; + } + } + return target; +} + +function onMouseEvent(event: MouseEvent) { + event.preventDefault(); + event.stopPropagation(); +} + +function onMouseMove(event: MouseEvent) { + event.preventDefault(); + event.stopPropagation(); + + const target = (event.target as any)._inula_VNode; + if (target) { + const id = VNodeToIdMap.get(getUserComponent(target)); + const vNode = queryVNode(id); + if (vNode == null) { + console.warn(`Could not find vNode with id "${id}"`); + return null; + } + const info = getElement(helper.travelVNodeTree, vNode); + if (info) { + showHighlight(info); + } + + // 0.5 秒内在节流结束后只触发一次 + throttle( + () => { + postMessage(PickElement, id); + }, + 500, + { leading: false, trailing: true } + )(); + } +} + +export function pickElement(window: Window) { + function onClick(event: MouseEvent) { + event.preventDefault(); + event.stopPropagation(); + + stopPick(); + postMessage(StopPickElement, null); + } + + const startPick = () => { + if (window && typeof window.addEventListener === 'function') { + window.addEventListener('click', onClick, true); + window.addEventListener('mousedown', onMouseEvent, true); + window.addEventListener('mousemove', onMouseMove, true); + window.addEventListener('mouseup', onMouseEvent, true); + } + }; + + const stopPick = () => { + hideHighlight(); + window.removeEventListener('click', onClick, true); + window.removeEventListener('mousedown', onMouseEvent, true); + window.removeEventListener('mousemove', onMouseMove, true); + window.removeEventListener('mouseup', onMouseEvent, true); + }; + + return { startPick, stopPick }; +} diff --git a/packages/inula-dev-tools/src/main/index.ts b/packages/inula-dev-tools/src/main/index.ts new file mode 100644 index 00000000..2de25903 --- /dev/null +++ b/packages/inula-dev-tools/src/main/index.ts @@ -0,0 +1,101 @@ +/* + * Copyright (c) 2023 Huawei Technologies Co.,Ltd. + * + * openInula is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * + * http://license.coscl.org.cn/MulanPSL2 + * + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +import { render, createElement } from 'openinula'; +import Panel from '../panel/Panel'; +import PanelX from '../panelX/PanelX'; + +let panelCreated = false; + +const viewSource = () => { + setTimeout(() => { + chrome.devtools.inspectedWindow.eval(` + if (window.$type != null) { + if ( + window.$type && + window.$type.prototype && + window.$type.prototype.render + ) { + // 类组件 + inspect(window.$type.prototype.render); + } else { + // 函数组件 + inspect(window.$type); + } + } + `); + }, 100); +}; + +const inspectVNode = () => { + chrome.devtools.inspectedWindow.eval( + ` + window.__INULA_DEV_HOOK__ && window.__INULA_DEV_HOOK__.$0 !== $0 + ? (inspect(window.__INULA_DEV_HOOK__.$0.realNode), true) + : false + `, + (_, error) => { + if (error) { + console.error(error); + } + } + ); +}; + +let currentPanel = null; + +chrome.devtools.inspectedWindow.eval( + 'window.__INULA_DEV_HOOK__', + function (isInula, error) { + if (!isInula || panelCreated) { + return; + } + + panelCreated = true; + chrome.devtools.panels.create( + 'Inula', + '', + 'panel.html', + (extensionPanel) => { + extensionPanel.onShown.addListener((panel) => { + if (currentPanel === panel) { + return; + } + currentPanel = panel; + const container = panel.document.getElementById('root'); + const element = createElement(Panel, { viewSource, inspectVNode }); + render(element, container); + }); + } + ); + + chrome.devtools.panels.create( + 'InulaX', + '', + 'panelX.html', + (extensionPanel) => { + extensionPanel.onShown.addListener((panel) => { + if (currentPanel === panel) { + return; + } + currentPanel = panel; + const container = panel.document.getElementById('root'); + const element = createElement(PanelX, {}); + render(element, container); + }); + } + ); + } +); diff --git a/packages/inula-dev-tools/src/main/main.html b/packages/inula-dev-tools/src/main/main.html new file mode 100644 index 00000000..210cec23 --- /dev/null +++ b/packages/inula-dev-tools/src/main/main.html @@ -0,0 +1,30 @@ + + + + + + + + + + +
+

Inula dev tools!

+
+ + + diff --git a/packages/inula-dev-tools/src/manifest.json b/packages/inula-dev-tools/src/manifest.json new file mode 100644 index 00000000..34c0f2b6 --- /dev/null +++ b/packages/inula-dev-tools/src/manifest.json @@ -0,0 +1,35 @@ +{ + "name": "Inula dev tools", + "description": "Inula chrome dev extension", + "version": "1.0", + "minimum_chrome_version": "10.0", + "manifest_version": 3, + "content_security_policy": "script-src 'self' 'unsafe-eval'; object-src 'self'", + "background": { + "script": [ + "background.js" + ], + "persistent": true + }, + "permissions": [ + "file:///*", + "http://*/*", + "https://*/*" + ], + "devtools_page": "main.html", + "content_scripts": [ + { + "matches": [ + "" + ], + "js": [ + "contentScript.js" + ], + "run_at": "document_start" + } + ], + "web_accessible_resources": [ + "injector.js", + "background.js" + ] +} diff --git a/packages/inula-dev-tools/src/panel/Panel.less b/packages/inula-dev-tools/src/panel/Panel.less new file mode 100644 index 00000000..c68274a1 --- /dev/null +++ b/packages/inula-dev-tools/src/panel/Panel.less @@ -0,0 +1,117 @@ +/* + * Copyright (c) 2023 Huawei Technologies Co.,Ltd. + * + * openInula is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * + * http://license.coscl.org.cn/MulanPSL2 + * + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +@import '../components/assets.less'; + +.app { + display: flex; + flex-direction: row; + height: 100%; + font-size: @common-font-size; +} + +button { + border: none; + background: none; + padding: 0; +} + +.left { + flex: 0 0 var(--horizontal-percentage); + display: flex; + flex-direction: column; + + .left-top { + border-bottom: @divider-style; + flex: 0 0 @top-height; + display: flex; + align-items: center; + padding-right: 0.4rem; + + .select { + padding: 0 0.5rem 0 0.8rem; + flex: 0 0; + + .Picking { + color: #0088fa; + } + + .StopPicking { + color: #5f6673; + } + + .StopPicking :hover { + color: #23272f; + } + } + + .divider { + flex: 0 0 1px; + margin: 0 0.25rem 0 0.25rem; + border-left: @divider-style; + height: calc(100% - 1rem); + } + + .search { + display: flex; + flex: 1 1 0; + } + + .searchResult { + flex: 0 0; + padding: 0 0.4rem; + } + + .searchAction { + flex: 0 0 1rem; + height: 1rem; + color: @arrow-color; + &:hover { + color: @hover-color; + } + } + } + + .left-bottom { + flex: 1; + height: 0; + } +} + +.resizeBar { + flex: 0 0 0; + position: relative; + resize: horizontal; + .resizeLine { + position: absolute; + left: -2px; + width: 5px; + height: 100%; + cursor: ew-resize; + } +} + +.right { + flex: 3; + overflow-x: hidden; + overflow-y: auto; + border-left: @divider-style; +} + +input { + outline: none; + border-width: 0; + padding: 0; +} diff --git a/packages/inula-dev-tools/src/panel/Panel.tsx b/packages/inula-dev-tools/src/panel/Panel.tsx new file mode 100644 index 00000000..66a34896 --- /dev/null +++ b/packages/inula-dev-tools/src/panel/Panel.tsx @@ -0,0 +1,448 @@ +/* + * Copyright (c) 2023 Huawei Technologies Co.,Ltd. + * + * openInula is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * + * http://license.coscl.org.cn/MulanPSL2 + * + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +import { + useState, + useEffect, + useRef, + memo, + useMemo, + useCallback, + useReducer, +} from 'openinula'; +import VTree, { IData } from '../components/VTree'; +import Search from '../components/Search'; +import ComponentInfo from '../components/ComponentInfo'; +import styles from './Panel.less'; +import Select from '../svgs/Select'; +import { FilterTree } from '../hooks/FilterTree'; +import Close from '../svgs/Close'; +import Arrow from '../svgs/Arrow'; +import { + AllVNodeTreeInfos, + RequestComponentAttrs, + ComponentAttrs, + PickElement, + StopPickElement, +} from '../utils/constants'; +import { + addBackgroundMessageListener, + initBackgroundConnection, + postMessageToBackground, + removeBackgroundMessageListener, +} from '../panelConnection'; +import { IAttr } from '../parser/parseAttr'; +import { NameObj } from '../parser/parseVNode'; +import { createLogger } from '../utils/logUtil'; +import type { Source } from '../../../inula/src/renderer/Types'; +import ViewSourceContext from '../utils/ViewSource'; +import PickElementContext from '../utils/PickElement'; +import Discover from '../svgs/Discover'; + +type ResizeActionType = 'START_RESIZE' | 'SET_HORIZONTAL_PERCENTAGE'; + +type ResizeAction = { + type: ResizeActionType; + payload: any; +}; + +type ResizeState = { + horizontalPercentage: number; + isResizing: boolean; +}; + +const logger = createLogger('panelApp'); +let maxDeep = 0; +const parseVNodeData = (rawData, idToTreeNodeMap, nextIdToTreeNodeMap) => { + const indentationMap: { + [id: string]: number; + } = {}; + const data: IData[] = []; + let i = 0; + while (i < rawData.length) { + const id = rawData[i] as number; + i++; + const name = rawData[i] as NameObj; + i++; + const parentId = rawData[i] as string; + i++; + const userKey = rawData[i] as string; + i++; + const indentation = parentId === '' ? 0 : indentationMap[parentId] + 1; + maxDeep = maxDeep >= indentation ? maxDeep : indentation; + indentationMap[id] = indentation; + const lastItem = idToTreeNodeMap[id]; + if (lastItem) { + // 由于 diff 算法限制,一个 vNode 的 name,userKey,indentation 属性不会发生变化 + // 但是在跳转到新页面时, id 值重置,此时原有 id 对应的节点都发生了变化,需要更新 + // 为了让架构尽可能简单,不区分是否是页面挑战,所以每次都需要重新赋值 + nextIdToTreeNodeMap[id] = lastItem; + lastItem.name = name; + lastItem.indentation = indentation; + lastItem.userKey = userKey; + data.push(lastItem); + } else { + const item = { + id, + name, + indentation, + userKey, + }; + nextIdToTreeNodeMap[id] = item; + data.push(item); + } + } + return data; +}; + +const getParents = (item: IData | null, parsedVNodeData: IData[]) => { + const parents: IData[] = []; + if (item) { + const index = parsedVNodeData.indexOf(item); + let indentation = item.indentation; + for (let i = index; i >= 0; i--) { + const last = parsedVNodeData[i]; + const lastIndentation = last.indentation; + if (lastIndentation < indentation) { + parents.push(last); + indentation = lastIndentation; + } + } + } + return parents; +}; + +interface IIdToNodeMap { + [id: number]: IData; +} + +/** + * 设置 dev tools 页面左树占比 + * + * @param {null | HTMLElement} resizeElement 要改变宽度的页面元素 + * @param {number} percentage 宽度占比 + */ +const setResizePCTForElement = ( + resizeElement: null | HTMLElement, + percentage: number +): void => { + if (resizeElement !== null) { + resizeElement.style.setProperty( + '--horizontal-percentage', + `${percentage}` + ); + } +}; + +function resizeReducer(state: ResizeState, action: ResizeAction): ResizeState { + switch (action.type) { + case "START_RESIZE": + return { + ...state, + isResizing: action.payload, + }; + case "SET_HORIZONTAL_PERCENTAGE": + return { + ...state, + horizontalPercentage: action.payload, + }; + default: + return state; + } +} + +function initResizeState(): ResizeState { + const horizontalPercentage = 0.62; + + return { + horizontalPercentage, + isResizing: false, + }; +} + +function Panel({ viewSource, inspectVNode }) { + const [parsedVNodeData, setParsedVNodeData] = useState([]); + const [componentAttrs, setComponentAttrs] = useState<{ + parsedProps?: IAttr[]; + parsedState?: IAttr[]; + parsedHooks?: IAttr[]; + }>({}); + const [selectComp, setSelectComp] = useState(null); + const [isPicking, setPicking] = useState(false); + const [source, setSource] = useState(null); + const idToTreeNodeMapref = useRef({}); + const [state, dispatch] = useReducer( + resizeReducer, + null, + initResizeState + ); + const pageRef = useRef(null); + const treeRef = useRef(null); + + const { horizontalPercentage } = state; + const { + filterValue, + onChangeSearchValue: setFilterValue, + onClear, + currentItem, + matchItems, + onSelectNext, + onSelectLast, + setShowItems, + collapsedNodes, + setCollapsedNodes, + } = FilterTree({ data: parsedVNodeData }); + + useEffect(() => { + if (isDev) { + // const nextIdToTreeNodeMap: IIdToNodeMap = {}; + } else { + const handleBackgroundMessage = message => { + const { payload } = message; + // 对象数据只是记录了引用,内容可能在后续被修改,打印字符串可以获取当前真正内容,不被后续修改影响 + logger.info(JSON.stringify(payload)); + if (payload) { + const {type, data} = payload; + if (type === AllVNodeTreeInfos) { + const idToTreeNodeMap = idToTreeNodeMapref.current; + const nextIdToTreeNodeMap: IIdToNodeMap = {}; + const allTreeData = data.reduce((pre, current) => { + const parsedTreeData = parseVNodeData( + current, + idToTreeNodeMap, + nextIdToTreeNodeMap + ); + return pre.concat(parsedTreeData); + }, []); + idToTreeNodeMapref.current = nextIdToTreeNodeMap; + setParsedVNodeData(allTreeData); + if (selectComp) { + postMessageToBackground(RequestComponentAttrs, selectComp.id); + } + } else if (type === ComponentAttrs) { + const { parsedProps, parsedState, parsedHooks, src } = data; + setComponentAttrs({ + parsedProps, + parsedState, + parsedHooks, + }); + setSource(src); + } else if (type === StopPickElement) { + setPicking(false); + } else if (type === PickElement) { + const target = Object.values(idToTreeNodeMapref.current).find(({ id }) => id == data); + setSelectComp(target); + } + } + }; + // 在页面渲染后初始化连接 + initBackgroundConnection('panel'); + // 监听 background 消息 + addBackgroundMessageListener(handleBackgroundMessage); + return () => { + removeBackgroundMessageListener(handleBackgroundMessage); + }; + } + }, [selectComp]); + + useEffect(() => { + const treeElement = treeRef.current; + + setResizePCTForElement(treeElement, horizontalPercentage * 100); + }, []); + + const handleSearchChange = (str: string) => { + setFilterValue(str); + }; + + const handleSelectComp = (item: IData) => { + setSelectComp(item); + if (isDev) { + // setComponentAttrs({}); + } else { + postMessageToBackground(RequestComponentAttrs, item.id); + } + }; + + const handleClickParent = useCallback((item: IData) => { + setSelectComp(item); + }, []); + + const onRendered = info => { + setShowItems(info.visibleItems); + }; + + const parents = useMemo( + () => getParents(selectComp, parsedVNodeData), + [selectComp, parsedVNodeData] + ); + + const viewSourceFunction = useMemo( + () => ({ + viewSource: viewSource || null, + }), + [viewSource] + ); + + // 选择页面元素对应到 dev tools + const pickElementFunction = useMemo( + () => ({ + inspectVNode: inspectVNode || null, + }), + [inspectVNode] + ); + + // 选择页面元素图标样式 + let pickClassName; + if (isPicking) { + pickClassName = styles.Picking; + } else { + pickClassName = styles.StopPicking; + } + + const MINIMUM_SIZE = 50; + const { isResizing } = state; + const doResize = () => dispatch({ type: 'START_RESIZE', payload: true }); + let onResize; + let stopResize; + if (isResizing) { + stopResize = () => dispatch({ type: 'START_RESIZE', payload: false }); + + onResize = event => { + // 设置横向 resize 百分比区域(左树部分) + const treeElement = treeRef.current; + // 整个页面(左树部分加节点详情部分),要拿到页面宽度,防止 resize 时移出页面 + const pageElement = pageRef.current; + + if (isResizing || pageElement === null || treeElement === null) { + return; + } + + // 左移时防止左树移出页面 + event.preventDefault(); + + const { width, left } = pageElement.getBoundingClientRect(); + + const mouseAbscissa = event.clientX - left; + + const pageSizeMin = MINIMUM_SIZE; + const pageSizeMax = width-MINIMUM_SIZE; + + const isMouseInPage = mouseAbscissa > pageSizeMin && mouseAbscissa < pageSizeMax; + + if (isMouseInPage) { + const resizedElementWidth = width; + const actionType = 'SET_HORIZONTAL_PERCENTAGE'; + const percentage = (mouseAbscissa / resizedElementWidth) * 100; + + setResizePCTForElement(treeElement, percentage); + + dispatch({ + type: actionType, + payload: mouseAbscissa / resizedElementWidth, + }); + } + }; + } + + return ( + + +
+
+
+
+ + {' | '} + { + e.stopPropagation(); + + chrome.runtime.sendMessage({ + type: 'INULA_DEV_TOOLS', + payload: { + type: otherTypes.SET_PERSISTENT, + tabId: chrome.devtools.inspectedWindow.tabId, + persistent: !persistent, + }, + from: DevToolPanel, + }); + + setPersistent(!persistent); + }} + > + Persistent events + + {' | '} + + {eventFilter['message.data.store.id'] ? ( + + {' | '} + { + setNextStore(eventFilter['message.data.store.id']); + }} + >{` Displaying: [${eventFilter['message.data.store.id']}] `} + + + ) : null} +
+
+ + {Object.values(eventTypes).map(eventType => { + return ( + + ); + })} +
+ { + const message = data.event.message; + return { + type: data.type, + store: { + actions: Object.fromEntries( + Object.entries(message.data.store.$config.actions).map( + ([id, action]) => { + return [ + id, + (action as string).replace(/\{.*}/gms, '{...}').replace('function ', ''), + ]; + } + ) + ), + computed: Object.fromEntries( + Object.keys(message.data.store.$c).map(key => [ + key, + message.data.store.expanded[key], + ]) + ), + state: message.data.store.$s, + id: message.data.store.id, + }, + // data: omit(data, 'storeClick', 'additionalData'), + }; + }} + search={eventFilter.fulltext ? eventFilter.fulltext : ''} + /> + + ); +} diff --git a/packages/inula-dev-tools/src/panelX/Modal.tsx b/packages/inula-dev-tools/src/panelX/Modal.tsx new file mode 100644 index 00000000..267d5493 --- /dev/null +++ b/packages/inula-dev-tools/src/panelX/Modal.tsx @@ -0,0 +1,103 @@ +/* + * Copyright (c) 2023 Huawei Technologies Co.,Ltd. + * + * openInula is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * + * http://license.coscl.org.cn/MulanPSL2 + * + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +import Inula, { useRef, useState } from 'openinula'; + +export function Modal({ + closeModal, + then, + children, +}: { + closeModal: () => void; + then: (value: any) => void; + children?: any[]; +}) { + const inputRef = useRef(null); + const [error, setError] = useState(null); + + setTimeout(() => { + inputRef.current.focus(); + inputRef.current.value = ''; + }, 10); + + const tryGatherData = () => { + let data; + try { + data = eval(inputRef.current.value); + } catch (err) { + setError(err); + return; + } + + if (then) { + then(data); + } + }; + + return ( +
+
+

{children}

+

+ { + if (key === 'Enter') { + tryGatherData(); + } + }} + /> +

+ {error ?

Variable parsing error

: null} +

+ + +

+
+
+ ); +} diff --git a/packages/inula-dev-tools/src/panelX/PanelX.less b/packages/inula-dev-tools/src/panelX/PanelX.less new file mode 100644 index 00000000..04656c3c --- /dev/null +++ b/packages/inula-dev-tools/src/panelX/PanelX.less @@ -0,0 +1,197 @@ +/* + * Copyright (c) 2023 Huawei Technologies Co.,Ltd. + * + * openInula is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * + * http://license.coscl.org.cn/MulanPSL2 + * + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +@import '../components/assets.less'; + +.displayData { + background-color: rgb(241, 243, 244); +} + +.app { + display: flex; + flex-direction: row; + height: 100%; + font-size: @common-font-size; +} + +div.wrapper { + margin: 15px; + position: relative; + width: calc(100% - 30px); + display: block; +} + +div.table { + display: table; + vertical-align: top; + width: calc(100%); + background-color: white; + position: relative; +} + +div.row { + display: table-row; + + &:nth-child(2n + 1) { + background-color: rgb(241, 243, 244); + .default { + background-color: rgb(241, 243, 244); + } + } +} + +div.cell { + display: table-cell; + cursor: pointer; + padding: 5px; +} + +div.half { + width: calc(50% - 8px); + float: left; +} + +div.header { + background-color: rgb(241, 243, 244); + font-weight: bold; +} + +div.row.active { + background-color: #00a; + color: white; +} + +button.tab { + border: 1px solid grey; + border-top-left-radius: 5px; + border-top-right-radius: 5px; + &.active { + border-bottom: none; + color: black; + font-weight: bold; + background-color: white; + } +} + +span.highlighted { + background-color: #ff0; +} + +.grey { + color: grey; +} + +.red { + color: #a00; +} + +.blue { + color: #00a; +} + +.purple { + color: #909; +} + +.bold { + font-weight: bold; +} + +.link { + font-weight: bold; + text-decoration: underline; + cursor: pointer; + color: #00a; +} + +.compositeInput { + background-color: white; + border: 1px solid grey; + display: inline-block; + border-radius: 0; + padding: 5px; + &.left { + border-right: 0; + margin-right: 0; + padding-right: 0; + } + + &.right { + border-left: 0; + margin-left: 0; + } + + &:focus-visible { + outline: none; + } +} + +.filterButton { + background-color: transparent; + padding: 5px; + border-radius: 5px; + border: 0; + &.active { + background-color: #ddd; + } +} + +.added { + background-color: #afa; + &::before { + font-weight: bold; + color: #0a0; + } +} + +.deleted { + background-color: #faa; + text-decoration-line: line-through; + &::before { + font-weight: bold; + color: #a00; + } +} + +.changed { + background-color: #ffa; + &::before { + font-weight: bold; + color: #ca0; + } +} + +.default { + background-color: white; +} + +.floatingButton { + right: 5px; + position: absolute; + height: 17px; + width: 17px; + font-size: 10px; + padding: 0; + cursor: pointer; +} + +.scrollable { + max-height: calc(100vh - 65px); + overflow: auto; + + div.row { + display: block; + } +} diff --git a/packages/inula-dev-tools/src/panelX/PanelX.tsx b/packages/inula-dev-tools/src/panelX/PanelX.tsx new file mode 100644 index 00000000..b3038c86 --- /dev/null +++ b/packages/inula-dev-tools/src/panelX/PanelX.tsx @@ -0,0 +1,84 @@ +/* + * Copyright (c) 2023 Huawei Technologies Co.,Ltd. + * + * openInula is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * + * http://license.coscl.org.cn/MulanPSL2 + * + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +import { useState } from 'openinula'; +import EventLog from './EventLog'; +import Stores from './Stores'; +import styles from './PanelX.less'; + +export default function PanelX() { + const [active, setActive] = useState('stores'); + const [nextStoreId, setNextStoreId] = useState(null); + const [eventFilter, setEventFilter] = useState({}); + + function showFilterEvents(filter) { + setActive('events'); + setEventFilter(filter); + } + + const tabs = [ + { + id: 'stores', + title: 'Stores', + getComponent: () => ( + + ), + }, + { + id: 'events', + title: 'Events', + getComponents: () => ( + { + setNextStoreId(id); + setActive('stores'); + }} + setEventFilter={setEventFilter} + eventFilter={eventFilter} + /> + ), + }, + ]; + + return ( +
+
+ {tabs.map(tab => + tab.id === active ? ( + + ) : ( + + ) + )} +
+ {tabs.find(item => item.id === active).getComponent()} +
+ ); +} diff --git a/packages/inula-dev-tools/src/panelX/Stores.tsx b/packages/inula-dev-tools/src/panelX/Stores.tsx new file mode 100644 index 00000000..aa7c60e5 --- /dev/null +++ b/packages/inula-dev-tools/src/panelX/Stores.tsx @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2023 Huawei Technologies Co.,Ltd. + * + * openInula is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * + * http://license.coscl.org.cn/MulanPSL2 + * + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +import { useState, useEffect } from 'openinula'; +import { + initBackgroundConnection, + addBackgroundMessageListener, + removeBackgroundMessageListener, + postMessageToBackground, +} from '../panelConnection'; +import { Table } from './Table'; +import { omit, sendMessage } from './utils'; +import styles from './PanelX.less'; +import { Highlight, RemoveHighlight } from '../utils/constants'; +import { ActionRunner } from './ActionRunner'; +import { Tree } from './Tree'; + +export default function Stores({ nextStoreId, showFilteredEvents }) { + const [stores, setStores] = useState([]); + const [initialized, setInitialized] = useState(false); + + if (!initialized) { + setTimeout(() => { + sendMessage({ + type: 'inulax getStores', + tabId: chrome.devtools.inspectedWindow.tabId, + }); + }, 100); + } + + useEffect(() => { + const listener = message => { + if (message.payload.type.startsWith('inulax')) { + // 过滤 inula 消息 + if (message.payload.type === 'inulax stores') { + // Stores 更新 + setStores(message.payload.stores); + setInitialized(true); + } else if (message.payload.type === 'inulax flush stores') { + // Flush store + sendMessage({ + type: 'inulax getStores', + tabId: chrome.devtools.inspectedWindow.tabId, + }); + } else if (message.payload.type === 'inulax observed components') { + // observed components 更新 + setStores( + stores.map(store => { + store.observedComponents = message.payload.data[store.id] || []; + return store; + }) + ); + } + } + }; + initBackgroundConnection('panel'); + addBackgroundMessageListener(listener); + return () => { + removeBackgroundMessageListener(listener); + }; + }); +} diff --git a/packages/inula-dev-tools/src/panelX/Table.tsx b/packages/inula-dev-tools/src/panelX/Table.tsx new file mode 100644 index 00000000..81ab7248 --- /dev/null +++ b/packages/inula-dev-tools/src/panelX/Table.tsx @@ -0,0 +1,164 @@ +/* + * Copyright (c) 2023 Huawei Technologies Co.,Ltd. + * + * openInula is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * + * http://license.coscl.org.cn/MulanPSL2 + * + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +import { useState } from 'openinula'; +import {Tree} from './Tree'; +import styles from './PanelX.less'; + +type displayKeysType = [string, string][]; + +export function Table({ + data, + dataKey = 'id', + displayKeys, + activate, + displayDataProcessor, + search = '', +}: { + data; + dataKey: string; + displayKeys: displayKeysType; + activate?: { + [key: string]: any; + }; + displayDataProcessor: (data: { [key: string]: any }) => { + [key: string]: any; + }; + search: string; +}) { + const [keyToDisplay, setKeyToDisplay] = useState(false); + const [manualOverride, setManualOverride] = useState(false); + let displayRow = null; + + if (!manualOverride && activate) { + data.forEach(item => { + if (displayRow) { + return; + } + let notMatch = false; + Object.entries(activate).forEach(([key, value]) => { + if (notMatch) { + return; + } + if (item[key] !== value) { + notMatch = true; + } + }); + if (notMatch) { + return; + } + displayRow = item; + }); + } else if (manualOverride && keyToDisplay) { + data.forEach(item => { + if (displayRow) { + return; + } + if (item[dataKey] === keyToDisplay) { + displayRow = item; + } + }); + } + + if (displayRow) { + const [attr, title] = displayKeys[0]; + return ( +
+
+
+
{title}
+
+
+ + {data.map(row => ( +
{ + setManualOverride(true); + setKeyToDisplay( + keyToDisplay === row[dataKey] ? null : row[dataKey] + ); + }} + > +
{row?.[attr] || ''}
+
+ ))} +
+
+ +
+
+
+ Data: + +
+
+
+ +
+
+ +
+
+
+
+
+ ); + } else { + return ( +
+
+
+ {displayKeys.map(([key, title]) => ( +
{title}
+ ))} +
+ {data.map(item => ( +
{ + setManualOverride(true); + setKeyToDisplay(item[dataKey]); + }} + className={styles.row} + > + {displayKeys.map(([key, title]) => ( +
{item[key]}
+ ))} +
+ ))} +
+
+ ); + } +} diff --git a/packages/inula-dev-tools/src/panelX/Tree.tsx b/packages/inula-dev-tools/src/panelX/Tree.tsx new file mode 100644 index 00000000..3091c1a4 --- /dev/null +++ b/packages/inula-dev-tools/src/panelX/Tree.tsx @@ -0,0 +1,230 @@ +/* + * Copyright (c) 2023 Huawei Technologies Co.,Ltd. + * + * openInula is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * + * http://license.coscl.org.cn/MulanPSL2 + * + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +import { useState } from 'openinula'; +import styles from './PanelX.less'; +import {Modal} from './Modal'; +import { displayValue, omit } from './utils'; + +export function Tree({ + data, + indent = 0, + index = '', + expand = false, + search = '', + forcedExpand = false, + onEdit = null, + omitAttrs = [], + className, + forcedLabel = null, +}: { + data: any; + indent?: number; + index?: string | number; + expand?: boolean; + search?: string; + forcedExpand?: boolean; + className?: string | undefined + omitAttrs?: string[]; + onEdit?: (path: any[], value: any) => void | null; + forcedLabel?: string | number | null; +}) { + const [expanded, setExpanded] = useState(expand); + const [modal, setModal] = useState(false); + const isArray = Array.isArray(data); + const isObject = data && !isArray && typeof data === 'object'; + const isSet = isObject && data?._type === 'Set'; + const isWeakSet = isObject && data?._type === 'WeakSet'; + const isMap = isObject && data?._type === 'Map'; + const isWeakMap = isObject && data?._type === 'WeakMap'; + const isVNode = isObject && data.vtype; + const canBeExpanded = isArray || (isObject && !isWeakSet && !isWeakMap); + + if (isObject && omitAttrs?.length) { + data = omit(data, ...omitAttrs); + } + + return canBeExpanded ? ( +
{ + e.stopPropagation(); + }} + > + { + setExpanded(!expanded); + }} + > + {new Array(Math.max(indent, 0)).fill( )} + {forcedExpand || isVNode ? null : expanded ? ( + + ) : ( + + )} + {index === 0 || index ? ( + <> + {displayValue(index, search)}: + + ) : ( + '' + )} + {forcedLabel + ? forcedLabel + : expanded + ? isVNode + ? null + : Array.isArray(data) + ? `Array(${data.length})` + : isMap + ? `Map(${data.entries.length})` + : isSet + ? `Set(${data.values.length})` + : '{ ... }' + : isWeakMap + ? 'WeakMap()' + : isWeakSet + ? 'WeakSet()' + : isMap + ? `Map(${data.entries.length})` + : isSet + ? `Set(${data.values.length})` + : Array.isArray(data) + ? `Array(${data.length})` + : '{ ... }'} + + {expanded || isVNode ? ( + isArray ? ( + <> + {data.map((value, index) => { + return ( +
+ { + onEdit(path.concat([index]), val); + } + : null + } + /> +
+ ); + })} + + ) : isVNode ? ( + data + ) : isMap ? ( +
+ {data.entries.map(([key, value]) => { + return ( + + ); + })} +
+ ) : isSet ? ( + data.values.map(item => { + return ( +
+ +
+ ); + }) + ) : ( + Object.entries(data).map(([key, value]) => { + return ( +
+ { + onEdit(path.concat([key]), val); + } + : null + } + /> +
+ ); + }) + ) + ) : ( + '' + )} +
+ ) : ( +
+ {new Array(indent).fill( )} + + {typeof index !== 'undefined' ? ( + <> + {displayValue(index, search)}: + + ) : ( + '' + )} + {displayValue(data, search)} + {onEdit && !isWeakSet && !isWeakMap ? ( // TODO: editable weak set and map + <> + { + setModal(true); + }} + > + ☼ + + {onEdit && modal ? ( + { + setModal(false); + }} + then={data => { + onEdit([], data); + setModal(false); + }} + > +

Edit value:

{index} +
+ ) : null} + + ) : null} +
+
+ ); +} diff --git a/packages/inula-dev-tools/src/panelX/index.tsx b/packages/inula-dev-tools/src/panelX/index.tsx new file mode 100644 index 00000000..7e46144c --- /dev/null +++ b/packages/inula-dev-tools/src/panelX/index.tsx @@ -0,0 +1,18 @@ +/* + * Copyright (c) 2023 Huawei Technologies Co.,Ltd. + * + * openInula is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * + * http://license.coscl.org.cn/MulanPSL2 + * + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +import PanelX from './PanelX'; + +export default PanelX; diff --git a/packages/inula-dev-tools/src/panelX/panelX.html b/packages/inula-dev-tools/src/panelX/panelX.html new file mode 100644 index 00000000..4a211dcf --- /dev/null +++ b/packages/inula-dev-tools/src/panelX/panelX.html @@ -0,0 +1,49 @@ + + + + + + + + + + + + + +
+ + + + diff --git a/packages/inula-dev-tools/src/panelX/utils.tsx b/packages/inula-dev-tools/src/panelX/utils.tsx new file mode 100644 index 00000000..1f0d807f --- /dev/null +++ b/packages/inula-dev-tools/src/panelX/utils.tsx @@ -0,0 +1,228 @@ +/* + * Copyright (c) 2023 Huawei Technologies Co.,Ltd. + * + * openInula is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * + * http://license.coscl.org.cn/MulanPSL2 + * + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +import * as Inula from 'openinula'; +import styles from './PanelX.less'; +import { DevToolPanel } from '../utils/constants'; + +export function highlight(source, search) { + if (!search || !source?.split) { + return source; + } + + const parts = source.split(search); + const result = []; + + for (let i= 0; i < parts.length * 2 - 1; i++) { + if (i % 2) { + result.push({search}); + } else { + result.push(parts[i / 2]); + } + } + + return result; +} + +export function displayValue(val: any, search = '') { + if (typeof val === 'boolean') { + return ( + + {highlight(val ? 'true' : 'false', search)} + + ); + } + + if (val === '') { + return {'""'}; + } + + if (typeof val === 'undefined') { + return {highlight('undefined', search)}; + } + + if (val === 'null') { + return {highlight('null', search)}; + } + + if (typeof val === 'string') { + if (val.match(/^function\s?\(/)) { + return ( + + ƒ + {highlight( + val.match(/^function\s?\([\w,]*\)/g)[0].replace(/^function\s?/, ''), + search + )} + + ); + } + return "{highlight(val, search)}"; + } + if (typeof val === 'number') { + return {highlight('' + val, search)}; + } + if (typeof val === 'function') { + const args = val.toString().match(/^function\s?\([\w,]*\)/g)[0].replace(/^function\s?/, ''); + return ( + + ƒ + {highlight(args, search)} + + ); + } + if (typeof val === 'object') { + if (val?._type === 'WeakSet') { + return WeakSet(); + } + + if (val?._type === 'WeakMap') { + return WeakMap(); + } + } +} + +export function fullTextSearch(value, search) { + if (!value) { + return false; + } + + if (Array.isArray(value)) { + return value.some(val => fullTextSearch(val, search)); + } + + if (typeof value === 'object') { + if (value?._type === 'Set') { + return value.values.some(val => fullTextSearch(val, search)); + } + if (value?._type === 'Map') { + return value.entries.some( + (key, val) => fullTextSearch(key, search) || fullTextSearch(val, search) + ); + } + return Object.values(value).some(val => fullTextSearch(val, search)); + } + + return value.toString().includes(search); +} + +export function omit(obj, ...attrs) { + const res = { ...obj }; + attrs.forEach(attr => delete res[attr]); + return res; +} + +export function stringify(data) { + if (typeof data === 'string' && data.startsWith('function(')) { + return ( + + ƒ + {data.match(/^function\([\w,]*\)/g)[0].substring(8)} + + ); + } + + if (!data) { + return displayValue(data); + } + + if (Array.isArray(data)) { + return `Array(${data.length})`; + } + + if (typeof data === 'object') { + return `{${Object.entries(data).map(([key, value]) => { + if (typeof value === 'string' && value.startsWith('function(')) { + return ( + + {key} + + ƒ + {value.match(/^function\([\w,]*\)/g)[0].substring(8)} + + + ); + } + if (!value) { + return ( + + {key}:{displayValue(value)} + + ); + } + if (Array.isArray(value)) { + return ( + + {key}:{' '} + {`Array(${value.length})`} + + ); + } + if (typeof value === 'object') { + if ((value as any)?._type === 'WeakSet') { + return ( + + {key}: {'WeakSet()'} + + ); + } + if ((value as any)?._type === 'WeakMap') { + return ( + + {key}: {'WeakMap'} + + ); + } + if ((value as any)?._type === 'Set') { + return ( + + {key}:{' '} + {`Set(${(value as Set).size})`} + + ); + } + if ((value as any)?._type === 'Map') { + return ( + + {key}:{' '} + {`Map(${(value as Map).size})`} + + ); + } + + // object + return ( + + {key}: {'{...}'} + + ); + } + return ( + + {key}: {displayValue(value)} + + ); + })}}`; + } + return data; +} + +export function sendMessage(payload) { + chrome.runtime.sendMessage({ + type: 'INULA_DEV_TOOLS', + payload, + from: DevToolPanel, + }); +} diff --git a/packages/inula-dev-tools/src/parser/parseAttr.ts b/packages/inula-dev-tools/src/parser/parseAttr.ts new file mode 100644 index 00000000..59b47073 --- /dev/null +++ b/packages/inula-dev-tools/src/parser/parseAttr.ts @@ -0,0 +1,416 @@ +/* + * Copyright (c) 2023 Huawei Technologies Co.,Ltd. + * + * openInula is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * + * http://license.coscl.org.cn/MulanPSL2 + * + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +import { Hook } from '../../../inula/src/renderer/hooks/HookType'; +import { ModifyHooks, ModifyProps, ModifyState } from '../utils/constants'; +import { VNode } from '../../../inula/src/renderer/vnode/VNode'; +import { + ClassComponent, + FunctionComponent, + ContextConsumer, + ContextProvider, + ForwardRef, + SuspenseComponent, + MemoComponent, +} from '../../../inula/src/renderer/vnode/VNodeTags'; +import { helper } from '../injector'; +import { JSXElement, ContextType } from '../../../inula/src/renderer/Types'; +import { decycle } from 'json-decycle'; + +// 展示值为 string 的可编辑模型 +type EditableStringType = 'string' | 'number' | 'undefined' | 'null'; +// 展示值为 string 的不可编辑类型 +type UnEditableStringType = + | 'function' + | 'symbol' + | 'object' + | 'map' + | 'set' + | 'array' + | 'dom' // 值为 dom 元素的 ref 类型 + | 'ref'; // 值为其他数据的 ref 类型 + +type ShowAsStringType = EditableStringType | UnEditableStringType; + +export type IAttr = { + name: string | number; + indentation: number; + hIndex?: number; // 用于记录 hook 的 hIndex 值 +} & ( + | { + type: ShowAsStringType; + value: string; +} + | { + type: 'boolean'; + value: boolean; +} + ); + +type ShowType = ShowAsStringType | 'boolean'; + +const propsAndStateTag = [ClassComponent]; +const propsAndHooksTag = [FunctionComponent, ForwardRef]; +const propsTag = [ContextConsumer, ContextProvider, SuspenseComponent, MemoComponent]; +const MAX_TITLE_LENGTH = 50; + +function isJSXElement(obj: any): obj is JSXElement { + return !!(obj?.type && obj.vtype); +} + +const isCycle = (obj: any): boolean => { + return obj?.Consumer === obj; +}; + +const getObjectKeys = (attr: Record): Array => { + const keys: (string | symbol)[] = []; + let current = attr; + try { + while (current != null) { + const currentKeys = [ + ...Object.keys(current), + ...Object.getOwnPropertySymbols(current) + ]; + const descriptors = Object.getOwnPropertyDescriptors(current); + currentKeys.forEach(key => { + // @ts-ignore key 可以为 symbol 类型 + if (descriptors[key].enumerable) { + keys.push(key); + } + }); + current = Object.getPrototypeOf(current); + } + } catch (e) { + console.log(attr); + } + return keys; +}; + +// 用于比较两个 key 值的顺序 +export function sortKeys( + firstKey: string | number | symbol, + secondKey: string | number | symbol +): number { + if (firstKey.toString() > secondKey.toString()) { + return 1; + } else if (secondKey.toString() > firstKey.toString()) { + return -1; + } else { + return 0; + } +} + +const parseSubTitle = (attr: T) => { + const AttrType = typeof attr; + + if (Array.isArray(attr)) { + let title = ''; + // 当 i > 0 时多加一个逗号和空格,例如:Person: { name: 'XXX', age: xxx } + for (let i = 0; i < attr.length; i++) { + if (i > 0) { + title = `${title}, `; + } + title = `${title}${parseSubTitle(attr[i])}`; + if (title.length > MAX_TITLE_LENGTH) { + break; + } + } + + if (title.length > MAX_TITLE_LENGTH) { + title = `${title.substr(0, MAX_TITLE_LENGTH)}…`; + } + return `[${title}]`; + } else if (AttrType === 'string') { + return `"${attr}"`; + } else if (AttrType === 'function') { + const funcName = attr['name']; + return `ƒ ${funcName}() {}`; + } else if ( + AttrType === 'boolean' || + AttrType === 'number' || + AttrType === 'undefined' + ) { + return `${attr}`; + } else if (AttrType === 'object') { + if (attr === null) { + return 'null'; + } + + if (isCycle(attr)) { + attr = JSON.parse(JSON.stringify(attr, decycle())); + } + const keys = getObjectKeys(attr).sort(sortKeys); + let title = ''; + for (let i = 0; i < keys.length; i++) { + const key = keys[i]; + // 当 i > 0 时多加一个逗号和空格,例如:Person: { name: "xxx", age: xxx } + if (i > 0) { + title = `${title}, `; + } + title = `${title}${key.toString()}: ${parseSubTitle(attr[key])}`; + if (title.length > MAX_TITLE_LENGTH) { + break; + } + } + if (title.length > MAX_TITLE_LENGTH) { + title = `${title.substr(0, MAX_TITLE_LENGTH)}…`; + } + return `{${title}}`; + } else if (isJSXElement(attr)) { + let title = ''; + if (typeof attr.type === 'string') { + title = attr.type; + } else { + title = attr.type?.name ? attr.type.name : helper.getElementTag(attr); + } + return `${title} />`; + } +}; + +const parseSubAttr = ( + attr: any, + parentIndentation: number, + attrName: string, + result: IAttr[], + hIndex?: number +) => { + const AttrType = typeof attr; + let value: any; + let showType: any; + let addSubState; + + if ( + AttrType === 'boolean' || + AttrType === 'number' || + AttrType === 'undefined' || + AttrType === 'string' + ) { + value = attr; + showType = AttrType; + } else if (AttrType === 'function') { + const funcName = attr.name; + value = `ƒ ${funcName}() {}`; + } else if (AttrType === 'symbol') { + value = attr.description; + } else if (AttrType === 'object') { + if (attr === null) { + value = 'null'; + } else if (attr instanceof Map) { + showType = 'map'; + const size = attr.size; + value = `Map(${size})`; + addSubState = () => { + attr.forEach((value, key) => { + parseSubAttr(value, parentIndentation + 2, key, result); + }); + }; + } else if (attr instanceof Set) { + showType = 'set'; + const size = attr.size; + value = `Set(${size})`; + addSubState = () => { + let i = 0; + attr.forEach(value => { + parseSubAttr(value, parentIndentation + 2, String(i), result); + }); + i++; + }; + } else if (Array.isArray(attr)) { + showType = 'array'; + value = parseSubTitle(attr); + addSubState = () => { + attr.forEach((attrValue, index) => { + if (isJSXElement(attrValue)) { + if (typeof attrValue.type === 'string') { + value = attrValue.type + ' />'; + } else { + value = attrValue.type?.name ? attrValue.type.name + ' />' : helper.getElementTag(attrValue) + ' />'; + } + showType = 'string'; + const arrayItem: IAttr = { + name: index, + type: showType, + value, + indentation: parentIndentation + 2, + }; + result.push(arrayItem); + } else { + parseSubAttr(attrValue, parentIndentation + 2, String(index), result); + } + }); + }; + } else if (attr instanceof Element) { + showType = 'dom'; + value = '<' + attr.tagName.toLowerCase() + ' />'; + } else { + if (isJSXElement(attr)) { + if (typeof attr.type === 'string') { + value = attr.type + ' />'; + } else { + value = attr.type?.name ? attr.type.name + ' />' : helper.getElementTag(attr) + ' />'; + } + showType = 'string'; + } else { + showType = AttrType; + value = Object.keys(attr).length === 0 ? '{}' : parseSubTitle(attr); + addSubState = () => { + // 判断是否为 Context 循环引用 + if (isCycle(attr)) { + attr = JSON.parse(JSON.stringify(attr, decycle())); + } + Object.entries(attr).map(([key, val]) => { + if (key === '_vNode') { + val = JSON.parse(JSON.stringify(val, decycle())); + } + parseSubAttr(val, parentIndentation + 2, key, result); + }); + }; + } + } + } + + const item: IAttr = { + name: attrName, + type: showType, + value, + indentation: parentIndentation + 1, + }; + if (hIndex !== undefined) { + item.hIndex = hIndex; + } + result.push(item); + if (addSubState) { + addSubState(); + } +}; + +// 将属性的值解析成固定格式, props 和类组件的 state 必须是一个对象 +export function parseAttr(rootAttr: any) { + const result: IAttr[] = []; + const indentation = 0; + if (typeof rootAttr === 'object' && rootAttr !== null) { + Object.keys(rootAttr).forEach(key => { + parseSubAttr(rootAttr[key], indentation, key, result); + }); + } + return result; +} + +export function parseHooks( + hooks: Hook[] | null, + depContexts: Array> | null, + getHookInfo +) { + const result: IAttr[] = []; + const indentation = 0; + if (depContexts !== null && depContexts?.length > 0) { + depContexts.forEach(context => { + parseSubAttr(context.value, indentation, 'Context', result); + }); + } + hooks?.forEach(hook => { + const hookInfo = getHookInfo(hook); + if (hookInfo) { + const { name, hIndex, value } = hookInfo; + parseSubAttr(value, indentation, name, result, hIndex); + } + }); + return result; +} + +export function parseVNodeAttrs(vNode: VNode, getHookInfo) { + const tag = vNode.tag; + + if (propsAndStateTag.includes(tag)) { + const { props, state, src } = vNode; + const parsedProps = parseAttr(props); + const parsedState = parseAttr(state); + return { + parsedProps, + parsedState, + src, + }; + } else if (propsAndHooksTag.includes(tag)) { + const { props, hooks, depContexts, src } = vNode; + const parsedProps = parseAttr(props); + const parsedHooks = parseHooks(hooks, depContexts, getHookInfo); + return { + parsedProps, + parsedHooks, + src, + }; + } else if (propsTag.includes(tag)) { + const { props, src } = vNode; + const parsedProps = parseAttr(props); + return { + parsedProps, + src, + }; + } +} + +// 计算属性的访问顺序 +function calculateAttrAccessPath(item: IAttr, index: number, attrs: IAttr[], isHook: boolean) { + let currentIndentation = item.indentation; + const path: (string | number | undefined)[] = [item.name]; + let hookRootItem: IAttr = item; + for (let i = index - 1; i >= 0; i--) { + const lastItem = attrs[i]; + const lastIndentation = lastItem.indentation; + if (lastIndentation < currentIndentation) { + hookRootItem = lastItem; + path.push(lastItem.name); + currentIndentation = lastIndentation; + } + } + path.reverse(); + if (isHook) { + if (hookRootItem) { + path[0] = hookRootItem.hIndex; + } else { + console.error('There is a bug, please report'); + } + } + return path; +} + +export function buildAttrModifyData( + parsedAttrsType: string, + attrs: IAttr[], + value, + item: IAttr, + index: number, + id: number +) { + let type; + if (parsedAttrsType === 'parsedProps') { + type = ModifyProps; + } else if (parsedAttrsType === 'parsedState') { + type = ModifyState; + } else if (parsedAttrsType === 'parsedHooks') { + type = ModifyHooks; + } else { + return null; + } + + const path = calculateAttrAccessPath(item, index, attrs, parsedAttrsType === 'parsedHooks'); + + return { + id: id, + type: type, + value: value, + path: path, + }; +} diff --git a/packages/inula-dev-tools/src/parser/parseVNode.ts b/packages/inula-dev-tools/src/parser/parseVNode.ts new file mode 100644 index 00000000..0b01799f --- /dev/null +++ b/packages/inula-dev-tools/src/parser/parseVNode.ts @@ -0,0 +1,229 @@ +/* + * Copyright (c) 2023 Huawei Technologies Co.,Ltd. + * + * openInula is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * + * http://license.coscl.org.cn/MulanPSL2 + * + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +import { VNode } from '../../../inula/src/renderer/vnode/VNode'; +import { + ClassComponent, + ContextConsumer, + ContextProvider, + ForwardRef, + FunctionComponent, + MemoComponent, + SuspenseComponent +} from '../../../inula/src/renderer/vnode/VNodeTags'; + +export type NameObj = { + itemName: string; + badge: Array; +}; + +// 建立双向映射关系,当用户在修改属性值后,可以找到对应的 VNode +export let VNodeToIdMap: Map; +export let IdToVNodeMap: Map; + +if (!VNodeToIdMap) { + VNodeToIdMap = new Map(); +} + +if (!IdToVNodeMap) { + IdToVNodeMap = new Map(); +} + +let uid = 0; +function generateUid(vNode: VNode) { + const id = VNodeToIdMap.get(vNode); + if (id !== undefined) { + return id; + } + uid++; + return uid; +} + +const componentType = [ + ClassComponent, + FunctionComponent, + ContextProvider, + ContextConsumer, + ForwardRef, + SuspenseComponent, + MemoComponent, +]; + +const badgeNameArr: Array = [ + 'withRouter(', + 'SideEffect(', + 'Connect(', + 'injectIntl(', + 'Pure(', +]; + +export function isUserComponent(tag: string) { + return componentType.includes(tag); +} + +function getParentUserComponent(node: VNode) { + let parent = node.parent; + while (parent) { + if (isUserComponent(parent.tag)) { + break; + } + parent = parent.parent; + } + return parent; +} + +function getContextName(node: VNode, type: string) { + const contextType = type; + if (!node.type.displayName) { + if (node.type.value) { + if (typeof node.type.value === 'object') { + return `Context.${contextType}`; + } else { + return `${node.type.value}.${contextType}`; + } + } else { + if (node.type._context?.displayName) { + return `${node.type._context.displayName}.${contextType}`; + } + return `Context.${contextType}`; + } + } + return `${node.type.displayName}.${contextType}`; +} + +const getForwardRefName = (node: VNode): NameObj => { + const forwardRefName: NameObj = { + itemName: '', + badge: ['ForwardRef'], + }; + if (!node.type.render?.name) { + if (node.type.render?.name !== '') { + forwardRefName.itemName = node.type?.displayName ? node.type?.displayName : 'Anonymous'; + } else { + forwardRefName.itemName = 'Anonymous'; + } + } else { + forwardRefName.itemName = node.type.render?.name; + } + return forwardRefName; +}; + +// 用于结构组件名,例如: Pure(Memo(xxx)) => xxx 并且把 Pure Memo 加到 NameObj.badge 里 +const parseComponentName = (name: NameObj): NameObj => { + badgeNameArr.forEach(badgeName => { + if (name.itemName.startsWith(badgeName)) { + // 截断开头的高阶组件名,并把最后一个 ) 替换为 ''。例如: Pure(Memo(xxx)) => Memo(xxx)) => Memo(xxx) + name.itemName = name.itemName.substring(badgeName.length).replace(/(\))(?!.*\1)/, ''); + name.badge.push(badgeName.substring(0, badgeName.length - 1)); + } + }); + return name; +}; + +// 取字符串括号里的值 +const getValuesInParentheses = (name: string) => { + let result = name; + const regex = /\((.+?)\)/g; + const results = name.match(regex); + if (results) { + const option = results[0]; + if (option) { + result = option.substring(1, option.length - 1); + } + } + return result; +}; + +function isNullOrUndefined(prop) { + return !prop || typeof prop === 'undefined' || prop === 0; +} + +function parseTreeRoot(travelVNodeTree, treeRoot: VNode) { + const result: any[] = []; + travelVNodeTree(treeRoot, (node: VNode) => { + const tag = node.tag; + + if (isUserComponent(tag)) { + // 添加 ID + const id = generateUid(node); + result.push(id); + let nameObj: NameObj = { + itemName: '', + badge: [], + }; + // 拿到不同类型的展示名字 + if (tag === ContextProvider) { + nameObj.itemName = getContextName(node, 'Provider'); + result.push(nameObj); + } else if (tag === ContextConsumer) { + nameObj.itemName = getContextName(node, 'Consumer'); + result.push(nameObj); + } else if (tag === ForwardRef) { + const name = getForwardRefName(node); + result.push(name); + } else if (tag === SuspenseComponent) { + nameObj.itemName = 'Suspense'; + result.push(nameObj); + } else if (tag === MemoComponent) { + const name = node.type?.displayName || node.type?.name || node.type.render?.name; + nameObj.itemName = !isNullOrUndefined(name) ? name : 'Anonymous'; + nameObj.badge.push('Memo'); + nameObj = parseComponentName(nameObj); + result.push(nameObj); + } else { + const name = node.type.displayName || node.type?.name; + nameObj.itemName = !isNullOrUndefined(name) ? name : 'Anonymous'; + nameObj = parseComponentName(nameObj); + result.push(nameObj); + } + + // 添加父节点 ID + const parent = getParentUserComponent(node); + if (parent) { + const parentId = VNodeToIdMap.get(parent); + result.push(parentId); + } else { + result.push(''); + } + + // 添加节点 key 值 + const key = node.key; + if (key !== null) { + result.push(key); + } else { + result.push(''); + } + + VNodeToIdMap.set(node, id); + IdToVNodeMap.set(id, node); + } + }); + + return result; +} + +export function queryVNode(id: number): VNode | undefined { + return IdToVNodeMap.get(id); +} + +export function clearVNode(vNode: VNode) { + if (VNodeToIdMap.has(vNode)) { + const id = VNodeToIdMap.get(vNode); + VNodeToIdMap.delete(vNode); + IdToVNodeMap.delete(id); + } +} + +export default parseTreeRoot; diff --git a/packages/inula-dev-tools/src/svgs/Arrow.tsx b/packages/inula-dev-tools/src/svgs/Arrow.tsx new file mode 100644 index 00000000..0e6dd813 --- /dev/null +++ b/packages/inula-dev-tools/src/svgs/Arrow.tsx @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2023 Huawei Technologies Co.,Ltd. + * + * openInula is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * + * http://license.coscl.org.cn/MulanPSL2 + * + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +interface IArrow { + direction: 'up' | 'down' +} + +export default function Arrow({ direction: director }: IArrow) { + let d = ''; + if (director === 'up') { + d = 'M4 9.5 L5 10.5 L8 7.5 L11 10.5 L12 9.5 L8 5.5 z'; + } else if (director === 'down') { + d = 'M5 5.5 L4 6.5 L8 10.5 L12 6.5 L11 5.5 L8 8.5z'; + } + + return ( + + + + ); +} diff --git a/packages/inula-dev-tools/src/svgs/Close.tsx b/packages/inula-dev-tools/src/svgs/Close.tsx new file mode 100644 index 00000000..4d8cfd81 --- /dev/null +++ b/packages/inula-dev-tools/src/svgs/Close.tsx @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2023 Huawei Technologies Co.,Ltd. + * + * openInula is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * + * http://license.coscl.org.cn/MulanPSL2 + * + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +export default function Close() { + return ( + + + + ); +} diff --git a/packages/inula-dev-tools/src/svgs/Debug.tsx b/packages/inula-dev-tools/src/svgs/Debug.tsx new file mode 100644 index 00000000..f50f8ddb --- /dev/null +++ b/packages/inula-dev-tools/src/svgs/Debug.tsx @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2023 Huawei Technologies Co.,Ltd. + * + * openInula is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * + * http://license.coscl.org.cn/MulanPSL2 + * + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +export default function Debug() { + return ( + + + + + ); +} diff --git a/packages/inula-dev-tools/src/svgs/Discover.tsx b/packages/inula-dev-tools/src/svgs/Discover.tsx new file mode 100644 index 00000000..defd1702 --- /dev/null +++ b/packages/inula-dev-tools/src/svgs/Discover.tsx @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2023 Huawei Technologies Co.,Ltd. + * + * openInula is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * + * http://license.coscl.org.cn/MulanPSL2 + * + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +export default function Discover() { + return ( + + + + + ); +} diff --git a/packages/inula-dev-tools/src/svgs/Eye.tsx b/packages/inula-dev-tools/src/svgs/Eye.tsx new file mode 100644 index 00000000..c3fc2582 --- /dev/null +++ b/packages/inula-dev-tools/src/svgs/Eye.tsx @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2023 Huawei Technologies Co.,Ltd. + * + * openInula is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * + * http://license.coscl.org.cn/MulanPSL2 + * + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +export default function Eye() { + return ( + + + + + ); +} diff --git a/packages/inula-dev-tools/src/svgs/Location.tsx b/packages/inula-dev-tools/src/svgs/Location.tsx new file mode 100644 index 00000000..40e6c713 --- /dev/null +++ b/packages/inula-dev-tools/src/svgs/Location.tsx @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2023 Huawei Technologies Co.,Ltd. + * + * openInula is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * + * http://license.coscl.org.cn/MulanPSL2 + * + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +export default function Location() { + return ( + + + + + ); +} diff --git a/packages/inula-dev-tools/src/svgs/Operation.tsx b/packages/inula-dev-tools/src/svgs/Operation.tsx new file mode 100644 index 00000000..13831192 --- /dev/null +++ b/packages/inula-dev-tools/src/svgs/Operation.tsx @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2023 Huawei Technologies Co.,Ltd. + * + * openInula is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * + * http://license.coscl.org.cn/MulanPSL2 + * + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +export default function Operation() { + return ( + + + + ); +} diff --git a/packages/inula-dev-tools/src/svgs/Select.tsx b/packages/inula-dev-tools/src/svgs/Select.tsx new file mode 100644 index 00000000..86c6c1ea --- /dev/null +++ b/packages/inula-dev-tools/src/svgs/Select.tsx @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2023 Huawei Technologies Co.,Ltd. + * + * openInula is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * + * http://license.coscl.org.cn/MulanPSL2 + * + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +export default function Select() { + return ( + + + + + + + + + + ); +} diff --git a/packages/inula-dev-tools/src/svgs/Triangle.tsx b/packages/inula-dev-tools/src/svgs/Triangle.tsx new file mode 100644 index 00000000..49ed3afd --- /dev/null +++ b/packages/inula-dev-tools/src/svgs/Triangle.tsx @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2023 Huawei Technologies Co.,Ltd. + * + * openInula is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * + * http://license.coscl.org.cn/MulanPSL2 + * + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +interface IArrow { + director: 'right' | 'down' +} + +export default function Triangle({ director }: IArrow) { + let d = ''; + if (director === 'right') { + d = 'm2 0l12 8l-12 8 z'; + } else if (director === 'down') { + d = 'm0 2h16 l-8 12 z'; + } + return ( + + + + ); +} diff --git a/packages/inula-dev-tools/src/svgs/copy.svg b/packages/inula-dev-tools/src/svgs/copy.svg new file mode 100644 index 00000000..48431e0e --- /dev/null +++ b/packages/inula-dev-tools/src/svgs/copy.svg @@ -0,0 +1,20 @@ + + + + + + + diff --git a/packages/inula-dev-tools/src/svgs/storage.svg b/packages/inula-dev-tools/src/svgs/storage.svg new file mode 100644 index 00000000..470821fc --- /dev/null +++ b/packages/inula-dev-tools/src/svgs/storage.svg @@ -0,0 +1,19 @@ + + + + + + diff --git a/packages/inula-dev-tools/src/utils/Checkbox.tsx b/packages/inula-dev-tools/src/utils/Checkbox.tsx new file mode 100644 index 00000000..e28782fa --- /dev/null +++ b/packages/inula-dev-tools/src/utils/Checkbox.tsx @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2023 Huawei Technologies Co.,Ltd. + * + * openInula is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * + * http://license.coscl.org.cn/MulanPSL2 + * + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +export function Checkbox({ value }) { + return ( +
+
+
+ ); +} diff --git a/packages/inula-dev-tools/src/utils/PickElement.tsx b/packages/inula-dev-tools/src/utils/PickElement.tsx new file mode 100644 index 00000000..80ed91d5 --- /dev/null +++ b/packages/inula-dev-tools/src/utils/PickElement.tsx @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2023 Huawei Technologies Co.,Ltd. + * + * openInula is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * + * http://license.coscl.org.cn/MulanPSL2 + * + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +import { createContext } from "openinula"; + +const PickElementContext = createContext(null); +PickElementContext.displayName = 'PickElementContext'; + +export default PickElementContext; diff --git a/packages/inula-dev-tools/src/utils/ViewSource.tsx b/packages/inula-dev-tools/src/utils/ViewSource.tsx new file mode 100644 index 00000000..0b07584f --- /dev/null +++ b/packages/inula-dev-tools/src/utils/ViewSource.tsx @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2023 Huawei Technologies Co.,Ltd. + * + * openInula is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * + * http://license.coscl.org.cn/MulanPSL2 + * + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +import {createContext} from 'openinula'; + +const ViewSourceContext = createContext(null); +ViewSourceContext.displayName = 'ViewSourceContext'; + +export default ViewSourceContext; diff --git a/packages/inula-dev-tools/src/utils/constants.ts b/packages/inula-dev-tools/src/utils/constants.ts new file mode 100644 index 00000000..99f36fb8 --- /dev/null +++ b/packages/inula-dev-tools/src/utils/constants.ts @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2023 Huawei Technologies Co.,Ltd. + * + * openInula is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * + * http://license.coscl.org.cn/MulanPSL2 + * + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +// panel 页面打开后初始化连接标志 +export const InitDevToolPageConnection = 'init dev tool page connection'; +// background 解析全部 root VNodes 标志 +export const RequestAllVNodeTreeInfos = 'request all vNodes tree infos'; +// vNodes 全部树解析结果标志 +export const AllVNodeTreeInfos = 'vNode trees infos'; +// 一棵树的解析 +export const OneVNodeTreeInfos = 'one vNode tree'; +// 获取组件属性 +export const RequestComponentAttrs = 'get component attrs'; +// 返回组件属性 +export const ComponentAttrs = 'component attrs'; + +export const ModifyAttrs = 'modify attrs'; + +export const ModifyProps = 'modify props'; + +export const ModifyState = 'modify state'; + +export const ModifyHooks = 'modify hooks'; + +export const InspectDom = 'inspect component dom'; + +export const LogComponentData = 'log component data'; + +export const CopyComponentAttr = 'copy component attr'; +// 传递消息来源标志 +export const DevToolPanel = 'dev tool panel'; + +export const DevToolBackground = 'dev tool background'; + +export const DevToolContentScript = 'dev tool content script'; + +export const DevToolHook = 'dev tool hook'; + +export const GetStores = 'get stores'; + +// 高亮显示与消除 +export const Highlight = 'highlight'; +export const RemoveHighlight = 'remove highlight'; + +// 跳转元素代码位置 +export const ViewSource = 'view source'; + +// 选择页面元素 +export const PickElement = 'pick element'; +export const StopPickElement = 'stop pick element'; + +// 复制和存为全局变量 +export const CopyToConsole = 'copy to console'; +export const StorageValue = 'storage value'; diff --git a/packages/inula-dev-tools/src/utils/injectUtils.ts b/packages/inula-dev-tools/src/utils/injectUtils.ts new file mode 100644 index 00000000..e5614388 --- /dev/null +++ b/packages/inula-dev-tools/src/utils/injectUtils.ts @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2023 Huawei Technologies Co.,Ltd. + * + * openInula is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * + * http://license.coscl.org.cn/MulanPSL2 + * + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +function ifNullThrows(value) { + if (value === null) { + throw new Error('receive a null'); + } + + return value; +} + +export function injectSrc(src) { + const script = document.createElement('script'); + script.src = src; + script.type = 'text/javascript'; + script.async = false; + script.onload = function () { + // 加载完毕后需要移除 + script.remove(); + }; + + ifNullThrows( + document.head + || document.getElementsByName('head')[0] + || document.documentElement + ).appendChild(script); +} + +export function injectCode(code) { + const script = document.createElement('script'); + script.textContent = code; + + ifNullThrows(document.documentElement).appendChild(script); + ifNullThrows(script.parentNode).removeChild(script); +} diff --git a/packages/inula-dev-tools/src/utils/logUtil.ts b/packages/inula-dev-tools/src/utils/logUtil.ts new file mode 100644 index 00000000..00c1370c --- /dev/null +++ b/packages/inula-dev-tools/src/utils/logUtil.ts @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2023 Huawei Technologies Co.,Ltd. + * + * openInula is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * + * http://license.coscl.org.cn/MulanPSL2 + * + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +/** + * chrome 通过 iframe 的方式将 panel 页面嵌入到开发者工具中,如果报错无法感知 + * 同时也无法在运行时打断点,需要适当的日志辅助开发和定位问题 + */ +interface LoggerType { + error: typeof console.error, + info: typeof console.info, + log: typeof console.log, + warn: typeof console.warn +} + +export function createLogger(id: string): LoggerType { + return ['error', 'info', 'log', 'warn'].reduce((pre, current) => { + const prefix = `[inula_dev_tools][${id}] `; + pre[current] = (...data) => { + console[current](prefix, ...data); + }; + return pre; + }, {} as LoggerType); +} diff --git a/packages/inula-dev-tools/src/utils/publicUtil.ts b/packages/inula-dev-tools/src/utils/publicUtil.ts new file mode 100644 index 00000000..9608c766 --- /dev/null +++ b/packages/inula-dev-tools/src/utils/publicUtil.ts @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2023 Huawei Technologies Co.,Ltd. + * + * openInula is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * + * http://license.coscl.org.cn/MulanPSL2 + * + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +import { debounce } from 'lodash'; + +export const debounceFunc = debounce(callback => { + callback(); +}, 100); diff --git a/packages/inula-dev-tools/src/utils/regExpUtil.ts b/packages/inula-dev-tools/src/utils/regExpUtil.ts new file mode 100644 index 00000000..07a54a8d --- /dev/null +++ b/packages/inula-dev-tools/src/utils/regExpUtil.ts @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2023 Huawei Technologies Co.,Ltd. + * + * openInula is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * + * http://license.coscl.org.cn/MulanPSL2 + * + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +export function createRegExp(expression: string) { + let str = expression; + if (str[0] === '/') { + str = str.slice(1); + } + if (str[str.length - 1] === '/') { + str = str.slice(0, str.length - 1); + } + try { + return new RegExp(str, 'i'); + } catch (err) { + return null; + } +} diff --git a/packages/inula-dev-tools/src/utils/transferUtils.ts b/packages/inula-dev-tools/src/utils/transferUtils.ts new file mode 100644 index 00000000..ddf03739 --- /dev/null +++ b/packages/inula-dev-tools/src/utils/transferUtils.ts @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2023 Huawei Technologies Co.,Ltd. + * + * openInula is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * + * http://license.coscl.org.cn/MulanPSL2 + * + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +const devTools = 'INULA_DEV_TOOLS'; + +interface PayloadType { + type: string; + data?: any; + inulaX?: boolean; +} + +interface Message { + type: typeof devTools; + payload: PayloadType; + from: string; +} + +export function packagePayload(payload: PayloadType, from: string, inulaX?: boolean): Message { + if (inulaX) { + payload.inulaX = true; + } + return { + type: devTools, + payload, + from + }; +} + +export function checkMessage(data: any, from: string) { + return data?.type === devTools && data?.from === from; +} + +export function changeSource(message: Message, from: string) { + message.from = from; +} diff --git a/packages/inula-dev-tools/tsconfig.json b/packages/inula-dev-tools/tsconfig.json new file mode 100644 index 00000000..d07996f8 --- /dev/null +++ b/packages/inula-dev-tools/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "outDir": "./dist", + "allowJs": true, + "noImplicitAny": false, + "module": "es6", + "moduleResolution": "node", + "target": "es5", + "jsx": "preserve", + "allowSyntheticDefaultImports": true, + "lib": ["dom", "esnext", "ES2015", "ES2016", "ES2017", "ES2018", "ES2019", "ES2020"], + "baseUrl": "./", + "paths": { + "*": ["types/*"] + } + }, + "include": [ + "./src/**/*.ts", "./src/index.d.ts", "./src/**/*.tsx", "./externals.d.ts", "./global.d.ts" + ] +} diff --git a/packages/inula-dev-tools/webpack.config.js b/packages/inula-dev-tools/webpack.config.js new file mode 100644 index 00000000..0e23a1b0 --- /dev/null +++ b/packages/inula-dev-tools/webpack.config.js @@ -0,0 +1,114 @@ +/* + * Copyright (c) 2023 Huawei Technologies Co.,Ltd. + * + * openInula is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * + * http://license.coscl.org.cn/MulanPSL2 + * + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +import path from 'path'; +import webpack from 'webpack'; +import fs from 'fs'; + +function handleBuildDir() { + const staticDir = path.join(__dirname, 'build'); + console.log('staticDir: ', staticDir); + const isBuildExist = fs.existsSync(staticDir); + console.log('isBuildExist: ', isBuildExist); + + if (!isBuildExist) { + fs.mkdirSync(staticDir); + } + fs.copyFileSync( + path.join(__dirname, 'src', 'panel', 'panel.html'), + path.join(staticDir, 'panel.html') + ); + fs.copyFileSync( + path.join(__dirname, 'src', 'panelX', 'panel.html'), + path.join(staticDir, 'panelX.html') + ); + fs.copyFileSync( + path.join(__dirname, 'src', 'main', 'main.html'), + path.join(staticDir, 'main.html') + ); + fs.copyFileSync( + path.join(__dirname, 'src', 'manifest.json'), + path.join(staticDir, 'manifest.json') + ); + fs.copyFileSync( + path.join( + __dirname, + '../inula/build/umd', + 'inula.development.js' + ), + path.join(staticDir, 'inula.development.js') + ); +} + +handleBuildDir(); + +const config = { + entry: { + background: './src/background/index.ts', + main: './src/main/index.ts', + injector: './src/injector/index.ts', + contentScript: './sec/contentScript/index.ts', + panel: './src/panel/index.ts', + panelX: './src/panelX/index.ts', + }, + output: { + path: path.resolve(__dirname, './build'), + filename: '[name].js' + }, + mode: 'development', + devtool: 'inline-source-map', + module: { + rules: [ + { + test: /(\.ts)|(\.tsx)$/, + exclude: function (path) { + return /node_modules/.test(path) && !/inula/.test(path); + }, + use: [ + { + loader: 'babel-loader', + }, + ], + }, + { + test: /\.less/i, + use: [ + 'style-loader', + { + loader: 'css-loader', + options: { + modules: true, + }, + }, + 'less-loader', + ], + }, + ], + }, + resolve: { + extensions: ['.js', '.ts', 'tsx'], + }, + externals: { + openinula: 'Inula', + }, + plugins: [ + new webpack.DefinePlugin({ + 'process.env.NODE_ENV': '"development"', + isDev: 'false', + }), + ], +}; + +module.exports = config; diff --git a/packages/inula-dev-tools/webpack.dev.js b/packages/inula-dev-tools/webpack.dev.js new file mode 100644 index 00000000..99cc835a --- /dev/null +++ b/packages/inula-dev-tools/webpack.dev.js @@ -0,0 +1,77 @@ +/* + * Copyright (c) 2023 Huawei Technologies Co.,Ltd. + * + * openInula is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * + * http://license.coscl.org.cn/MulanPSL2 + * + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +import path from 'path'; +import webpack from 'webpack'; + +// 用于 panel 页面开发 +module.exports = { + entry: { + panel: './src/panel/index.tsx', + mockPage: './src/devtools/mockPage/index.tsx', + }, + output: { + path: path.resolve(__dirname, './dist'), + filename: '[name].js' + }, + mode: 'development', + devtool: 'source-map', + module: { + rules: [ + { + test: /\.tsx?$/, + exclude: /node_modules/, + use: [ + { + loader: 'babel-loader', + }, + ], + }, + { + test: /\.less/i, + use: [ + 'style-loader', + { + loader: 'css-loader', + options: { + modules: true, + }, + }, + 'less-loader', + ], + }, + ], + }, + resolve: { + extensions: ['.js', '.ts', 'tsx'], + }, + externals: { + openinula: 'Inula', + }, + plugins: [ + new webpack.DefinePlugin({ + 'process.env.NODE_ENV': '"development"', + isDev: 'true', + }), + ], + devServer: { + static: { + directory: path.join(__dirname, 'build'), + }, + open: 'panel.html', + port: 9000, + magicHtml: true, + } +}; diff --git a/packages/inula-intl/babel.config.js b/packages/inula-intl/babel.config.js index 68d69905..7f94d715 100644 --- a/packages/inula-intl/babel.config.js +++ b/packages/inula-intl/babel.config.js @@ -13,22 +13,20 @@ * See the Mulan PSL v2 for more details. */ -const {preset} = require("./jest.config"); +const { preset } = require('./jest.config'); module.exports = { presets: [ [ '@babel/preset-env', { targets: { node: 'current' } }, ], + ['@babel/preset-typescript'], [ - '@babel/preset-typescript', - ], - [ - "@babel/preset-react", + '@babel/preset-react', { - "runtime": "automatic", - "importSource": "openinula" - } - ] + runtime: 'automatic', + importSource: 'openinula', + }, + ], ], }; diff --git a/packages/inula-intl/build-type.js b/packages/inula-intl/build-type.js new file mode 100644 index 00000000..0be14358 --- /dev/null +++ b/packages/inula-intl/build-type.js @@ -0,0 +1,50 @@ +import fs from 'fs'; +import path from 'path'; +import dts from 'rollup-plugin-dts'; + +function deleteFolder(filePath) { + if (fs.existsSync(filePath)) { + if (fs.lstatSync(filePath).isDirectory()) { + const files = fs.readdirSync(filePath); + files.forEach(file => { + const nectFilePath = path.join(filePath, file); + const states = fs.lstatSync(nectFilePath); + if (states.isDirectory()) { + deleteFolder(nectFilePath); + } else { + fs.unlinkSync(nectFilePath); + } + }); + fs.rmdirSync(filePath); + } else if (fs.lstatSync(filePath).isFile()) { + fs.unlinkSync(filePath); + } + } +} + +/** + * + * @param folders {string[]} + * @returns {{buildEnd(): void, name: string}} + */ +export function cleanUp(folders) { + return { + name: 'clean-up', + buildEnd() { + folders.forEach(folder => deleteFolder(folder)); + }, + }; +} + +function builderTypeConfig() { + return { + input: './build/@types/index.d.ts', + output: { + file: './build/@types/index.d.ts', + format: 'es', + }, + plugins: [dts(), cleanUp(['./build/@types/example', './build/@types/src'])], + }; +} + +export default [builderTypeConfig()]; diff --git a/packages/inula-intl/example/App.tsx b/packages/inula-intl/example/App.tsx index be1cf84c..ba6166c7 100644 --- a/packages/inula-intl/example/App.tsx +++ b/packages/inula-intl/example/App.tsx @@ -32,14 +32,14 @@ const App = () => { const message = locale === 'zh' ? zh : en - return ( + return (
Inula-Intl API Test Demo
- +
diff --git a/packages/inula-intl/example/index.tsx b/packages/inula-intl/example/index.tsx index f4185e04..0678d203 100644 --- a/packages/inula-intl/example/index.tsx +++ b/packages/inula-intl/example/index.tsx @@ -17,9 +17,7 @@ import App from './App' function render() { Inula.render( - <> - - , + , document.querySelector('#root') as any ) } diff --git a/packages/inula-intl/index.ts b/packages/inula-intl/index.ts index da43b69b..776255b8 100644 --- a/packages/inula-intl/index.ts +++ b/packages/inula-intl/index.ts @@ -54,7 +54,7 @@ export default { IntlProvider: I18nProvider, injectIntl: injectIntl, RawIntlProvider: InjectProvider, -} +}; // 用于定义文本 export function defineMessages>(msgs: U): U { diff --git a/packages/inula-intl/package.json b/packages/inula-intl/package.json index 00cca8ea..f15665e3 100644 --- a/packages/inula-intl/package.json +++ b/packages/inula-intl/package.json @@ -1,13 +1,14 @@ { "name": "inula-intl", - "version": "0.0.2", + "version": "0.0.5", "description": "", "main": "build/intl.umd.js", "type": "commonjs", - "types": "build/index.d.ts", + "types": "build/@types/index.d.ts", "scripts": { "demo-serve": "webpack serve --mode=development", - "build": "rollup --config rollup.config.js", + "rollup-build": "rollup --config rollup.config.js && npm run build-types", + "build-types": "tsc -p tsconfig.json && rollup -c build-type.js", "test": "jest --config jest.config.js", "test-c": "jest --coverage" }, @@ -23,7 +24,7 @@ "author": "", "license": "MulanPSL2", "peerDependencies": { - "openinula": "^0.0.1" + "openinula": ">=0.1.1" }, "devDependencies": { "@babel/core": "7.21.3", @@ -33,6 +34,7 @@ "@rollup/plugin-babel": "^6.0.3", "@rollup/plugin-node-resolve": "^7.1.3", "@rollup/plugin-typescript": "^11.0.0", + "rollup-plugin-dts": "^6.1.0", "@testing-library/jest-dom": "^5.16.5", "@testing-library/react": "^14.0.0", "@types/node": "^16.18.27", @@ -40,7 +42,6 @@ "babel": "^6.23.0", "babel-jest": "^29.5.0", "babel-loader": "^9.1.2", - "core-js": "3.31.0", "html-webpack-plugin": "^5.5.1", "jest": "29.3.1", "jest-environment-jsdom": "^29.5.0", @@ -56,8 +57,6 @@ "typescript": "4.9.3", "webpack": "^5.81.0", "webpack-cli": "^5.1.4", - "webpack-dev-server": "^4.13.3", - "react": "18.2.0", - "react-dom": "18.2.0" + "webpack-dev-server": "^4.13.3" } } diff --git a/packages/inula-intl/src/format/Translation.ts b/packages/inula-intl/src/format/Translation.ts index b1f57f79..84b35baf 100644 --- a/packages/inula-intl/src/format/Translation.ts +++ b/packages/inula-intl/src/format/Translation.ts @@ -13,7 +13,6 @@ * See the Mulan PSL v2 for more details. */ -import { UNICODE_REG } from '../constants'; import { CompiledMessage, Locale, LocaleConfig, Locales } from '../types/types'; import generateFormatters from './generateFormatters'; import {FormatOptions, I18nCache} from '../types/interfaces'; diff --git a/packages/inula-intl/src/parser/Lexer.ts b/packages/inula-intl/src/parser/Lexer.ts index c233861e..f4a602b1 100644 --- a/packages/inula-intl/src/parser/Lexer.ts +++ b/packages/inula-intl/src/parser/Lexer.ts @@ -14,51 +14,45 @@ */ import ruleUtils from '../utils/parseRuleUtils'; -import { LexerInterface } from "../types/interfaces"; - -const getMatch = ruleUtils.checkSticky() - ? // 正则表达式具有 sticky 标志 - (regexp, buffer) => regexp.exec(buffer) - : // 正则表达式具有 global 标志,匹配的字符串长度为 0,则表示匹配失败 - (regexp, buffer) => (regexp.exec(buffer)[0].length === 0 ? null : regexp.exec(buffer)); +import { LexerInterface } from '../types/interfaces'; class Lexer implements LexerInterface { readonly startState: string; readonly states: Record; private buffer: string = ''; private stack: string[] = []; - private index; - private line; - private col; - private queuedText; - private state; - private groups; - private error; + private index: number = 0; + private line: number = 1; + private col: number = 1; + private queuedText: string = ''; + private state: string = ''; + private groups: string[] = []; + private error: Record | undefined; private regexp; - private fast; - private queuedGroup; - private value; + private fast: object = {}; + private queuedGroup: string | null = ''; + private value: string = ''; - constructor(states, state) { - this.startState = state; - this.states = states; + constructor(unionReg: Record, startState: string) { + this.startState = startState; + this.states = unionReg; this.buffer = ''; this.stack = []; this.reset(); } - public reset(data?, info?) { + public reset(data?: string) { this.buffer = data || ''; this.index = 0; - this.line = info ? info.line : 1; - this.col = info ? info.col : 1; - this.queuedText = info ? info.queuedText : ''; - this.setState(info ? info.state : this.startState); - this.stack = info && info.stack ? info.stack.slice() : []; + this.line = 1; + this.col = 1; + this.queuedText = ''; + this.setState(this.startState); + this.stack = []; return this; } - private setState(state) { + private setState(state: string) { if (!state || this.state === state) { return; } @@ -71,15 +65,15 @@ class Lexer implements LexerInterface { } private popState() { - this.setState(this.stack.pop()); + this.setState(this.stack.pop()); } - private pushState(state) { + private pushState(state: string) { this.stack.push(this.state); this.setState(state); } - private getGroup(match) { + private getGroup(match: Record) { const groupCount = this.groups.length; for (let i = 0; i < groupCount; i++) { if (match[i + 1] !== undefined) { @@ -127,7 +121,7 @@ class Lexer implements LexerInterface { const group = this.getGroup(match); const text = match[0]; - if (error.fallback && match.index !== index) { + if (error?.fallback && match.index !== index) { this.queuedGroup = group; this.queuedText = text; return this.getToken(error, buffer.slice(index, match.index), index); @@ -136,7 +130,14 @@ class Lexer implements LexerInterface { return this.getToken(group, text, index); } - private getToken(group, text, offset) { + /** + * 獲取Token + * @param group 解析模板后獲得的屬性值 + * @param text 文本屬性的信息 + * @param offset 偏移量 + * @private + */ + private getToken(group: any, text: string, offset: number) { let lineNum = 0; let last = 1; // 最后一个换行符的索引位置 if (group.lineBreaks) { @@ -192,9 +193,14 @@ class Lexer implements LexerInterface { next: (): IteratorResult => { const token = this.next(); return { value: token, done: !token } as IteratorResult; - } - } + }, + }; } } +const getMatch = ruleUtils.checkSticky() + ? // 正则表达式具有 sticky 标志 + (regexp, buffer) => regexp.exec(buffer) + : // 正则表达式具有 global 标志,匹配的字符串长度为 0,则表示匹配失败 + (regexp, buffer) => (regexp.exec(buffer)[0].length === 0 ? null : regexp.exec(buffer)); export default Lexer; diff --git a/packages/inula-intl/src/parser/mappingRule.ts b/packages/inula-intl/src/parser/mappingRule.ts index 962816ca..54bee482 100644 --- a/packages/inula-intl/src/parser/mappingRule.ts +++ b/packages/inula-intl/src/parser/mappingRule.ts @@ -70,5 +70,5 @@ const select: Record = { export const mappingRule: Record = { body, arg, - select + select, }; diff --git a/packages/inula-intl/src/parser/parseMappingRule.ts b/packages/inula-intl/src/parser/parseMappingRule.ts index 55e9af13..9323378c 100644 --- a/packages/inula-intl/src/parser/parseMappingRule.ts +++ b/packages/inula-intl/src/parser/parseMappingRule.ts @@ -16,16 +16,16 @@ import Lexer from './Lexer'; import { mappingRule } from './mappingRule'; import ruleUtils from '../utils/parseRuleUtils'; -import { RawToken } from "../types/types"; +import { RawToken } from '../types/types'; const defaultErrorRule = ruleUtils.getRuleOptions('error', { lineBreaks: true, shouldThrow: true }); // 解析规则并生成词法分析器所需的数据结构,以便进行词法分析操作 function parseRules(rules: Record, hasStates: boolean): Record { let errorRule: Record | null = null; - const fast = {}; - let enableFast = true; - let unicodeFlag = null; + const fast: object = {}; + let enableFast: boolean = true; + let unicodeFlag: boolean | null = null; const groups: Record[] = []; const parts: string[] = []; @@ -108,11 +108,11 @@ export function checkStateGroup(group: Record, name: string, map: R } // 将国际化解析规则注入分词器中 -function parseMappingRule(mappingRule: Record, start?: string): Lexer { +function parseMappingRule(mappingRule: Record, startState?: string): Lexer { const keys = Object.getOwnPropertyNames(mappingRule); - if (!start) { - start = keys[0]; + if (!startState) { + startState = keys[0]; } // 将每个状态的规则解析为规则数组,并存储在 ruleMap 对象中 @@ -153,27 +153,27 @@ function parseMappingRule(mappingRule: Record, start?: string): Lex } } - const map = {}; + const mappingAllRules = {}; - // 将规则映射为词法分析器数据结构,并存储在 map 对象中 + // 将规则映射为词法分析器数据结构,并存储在 mappingAllRules 对象中 keys.forEach(key => { - map[key] = parseRules(ruleMap[key], true); + mappingAllRules[key] = parseRules(ruleMap[key], true); }); // 检查状态组中的规则是否正确引用了其他状态 keys.forEach(name => { - const state = map[name]; + const state = mappingAllRules[name]; const groups = state.groups; groups.forEach(group => { - checkStateGroup(group, name, map); + checkStateGroup(group, name, mappingAllRules); }); const fastKeys = Object.getOwnPropertyNames(state.fast); fastKeys.forEach(fastKey => { - checkStateGroup(state.fast[fastKey], name, map); + checkStateGroup(state.fast[fastKey], name, mappingAllRules); }); }); - return new Lexer(map, start); + return new Lexer(mappingAllRules, startState); } function processFast(match, fast: {}, options) { diff --git a/packages/inula-intl/src/parser/parser.ts b/packages/inula-intl/src/parser/parser.ts index 76a6f9fd..cf885f67 100644 --- a/packages/inula-intl/src/parser/parser.ts +++ b/packages/inula-intl/src/parser/parser.ts @@ -17,7 +17,7 @@ import { lexer } from './parseMappingRule'; import { RawToken, Token } from '../types/types'; import { DEFAULT_PLURAL_KEYS } from '../constants'; import { Content, FunctionArg, PlainArg, Select, TokenContext } from '../types/interfaces'; -import Lexer from "./Lexer"; +import Lexer from './Lexer'; const getContext = (lt: Record): TokenContext => ({ offset: lt.offset, @@ -29,15 +29,14 @@ const getContext = (lt: Record): TokenContext => ({ export const checkSelectType = (value: string): boolean => { return value === 'plural' || value === 'select' || value === 'selectordinal'; -} +}; class Parser { - lexer: Lexer; cardinalKeys: string[] = DEFAULT_PLURAL_KEYS; ordinalKeys: string[] = DEFAULT_PLURAL_KEYS; constructor(message: string) { - this.lexer = lexer.reset(message); + lexer.reset(message); } isSelectKeyValid(token: RawToken, type: Select['type'], value: string) { @@ -60,7 +59,7 @@ class Parser { isPlural = true; } - for (const token of this.lexer) { + for (const token of lexer) { switch (token.type) { case 'offset': { if (type === 'select') { @@ -97,7 +96,7 @@ class Parser { parseToken(token: RawToken, isPlural: boolean): PlainArg | FunctionArg | Select { const context = getContext(token); - const nextToken = this.lexer.next(); + const nextToken = lexer.next(); if (!nextToken) { throw new Error('The message end position is invalid.'); @@ -111,7 +110,7 @@ class Parser { return { type: 'argument', arg: token.value, ctx: context }; } case 'func-simple': { - const end = this.lexer.next(); + const end = lexer.next(); if (!end) { throw new Error('The message end position is invalid.'); } @@ -159,7 +158,7 @@ class Parser { const tokens: any[] = []; let content: string | Content | null = null; - for (const token of this.lexer) { + for (const token of lexer) { if (token.type === 'argument') { if (content) { content = null; @@ -175,7 +174,7 @@ class Parser { } else if (token.type === 'doubleapos') { tokens.push(token.value); } else if (token.type === 'quoted') { - tokens.push(token.value) + tokens.push(token.value); } else if (token.type === 'content') { tokens.push(token.value); } else { diff --git a/packages/inula-intl/src/types/types.ts b/packages/inula-intl/src/types/types.ts index 2a7e463b..d5607ab6 100644 --- a/packages/inula-intl/src/types/types.ts +++ b/packages/inula-intl/src/types/types.ts @@ -21,9 +21,10 @@ import { Select, FunctionArg, I18nContextProps, - configProps + configProps, + InjectedIntl, } from './interfaces'; -import I18n from "../core/I18n"; +import I18n from '../core/I18n'; export type Error = string | ((message, id, context) => string); @@ -71,11 +72,15 @@ export type RawToken = { col: number; }; -export type I18nProviderProps = I18nContextProps & configProps +export type I18nProviderProps = I18nContextProps & configProps; export type IntlType = { i18n: I18n; - formatMessage: Function, - formatNumber: Function, - formatDate: Function, + formatMessage: Function; + formatNumber: Function; + formatDate: Function; +}; + +export interface InjectedIntlProps { + intl: InjectedIntl; } diff --git a/packages/inula-intl/tsconfig.json b/packages/inula-intl/tsconfig.json index 2ea73c31..f3ff266e 100644 --- a/packages/inula-intl/tsconfig.json +++ b/packages/inula-intl/tsconfig.json @@ -31,6 +31,7 @@ "declaration": true, "experimentalDecorators": true, "downlevelIteration": true, + "declarationDir": "./build/@types", // 赋值为空数组使@types/node不会起作用 "lib": [ "dom", @@ -53,19 +54,22 @@ } }, "include": [ - "./src/**/*", - "./src/format/**/*.ts", - "./example/**/*" + "./index.ts" ], "exclude": [ "node_modules", "lib", "**/*.spec.ts", - "dev" + "dev", + "./example/**/*", + "./tsconfig.json" ], "types": [ "node", "jest", "@testing-library/jest-dom" + ], + "files": [ + "./index.ts" ] } diff --git a/packages/inula-request/examples/cancelRequest/cancelRequestTest.html b/packages/inula-request/examples/cancelRequest/cancelRequestTest.html index 9b17da87..f9a4ebcd 100644 --- a/packages/inula-request/examples/cancelRequest/cancelRequestTest.html +++ b/packages/inula-request/examples/cancelRequest/cancelRequestTest.html @@ -16,42 +16,33 @@
diff --git a/packages/inula-request/index.ts b/packages/inula-request/index.ts index 464cdd8b..dc2d0309 100644 --- a/packages/inula-request/index.ts +++ b/packages/inula-request/index.ts @@ -68,7 +68,7 @@ export { isAxiosError, }; -export type { IrRequestConfig, IrResponse, IrInstance, CancelTokenSource } from './src/types/interfaces'; -export type { Method, ResponseType } from './src/types/types'; +export { IrRequestConfig, IrResponse, IrInstance, CancelTokenSource, IrProgressEvent } from './src/types/interfaces'; +export { Method, ResponseType } from './src/types/types'; export default inulaRequest; diff --git a/packages/inula-request/package.json b/packages/inula-request/package.json index 1a8fbe97..beac309b 100644 --- a/packages/inula-request/package.json +++ b/packages/inula-request/package.json @@ -1,6 +1,6 @@ { "name": "inula-request", - "version": "0.0.5", + "version": "0.0.9", "description": "Inula-request brings you a convenient request experience!", "main": "./dist/inulaRequest.js", "scripts": { @@ -62,7 +62,7 @@ "webpack-dev-server": "^4.13.3" }, "peerDependencies": { - "openinula": "^0.0.1" + "openinula": ">=0.1.1" }, "exclude": [ "node_modules" diff --git a/packages/inula-request/src/cancel/CancelError.ts b/packages/inula-request/src/cancel/CancelError.ts index fbc0d32d..85717094 100644 --- a/packages/inula-request/src/cancel/CancelError.ts +++ b/packages/inula-request/src/cancel/CancelError.ts @@ -17,7 +17,7 @@ import IrError from '../core/IrError'; import { IrRequestConfig } from '../types/interfaces'; class CancelError extends IrError { - constructor(message: string | undefined, config: IrRequestConfig, request?: any) { + constructor(message: string | undefined | null, config: IrRequestConfig, request?: any) { const errorMessage = message || 'canceled'; super(errorMessage, (IrError as any).ERR_CANCELED, config, request); this.name = 'CanceledError'; diff --git a/packages/inula-request/src/cancel/checkCancel.ts b/packages/inula-request/src/cancel/checkCancel.ts index 2fb875fb..8c2bafe7 100644 --- a/packages/inula-request/src/cancel/checkCancel.ts +++ b/packages/inula-request/src/cancel/checkCancel.ts @@ -15,7 +15,7 @@ // 检查是否为用户主动请求取消场景 function checkCancel(input: any): boolean { - return input.cancelFlag || false; + return input.name === 'CanceledError' || input.cancelFlag || false; } export default checkCancel; diff --git a/packages/inula-request/src/core/IrError.ts b/packages/inula-request/src/core/IrError.ts index 4f4f9605..fe9a2fb7 100644 --- a/packages/inula-request/src/core/IrError.ts +++ b/packages/inula-request/src/core/IrError.ts @@ -98,6 +98,7 @@ const errorTypes = [ 'ERR_CANCELED', 'ERR_NOT_SUPPORT', 'ERR_INVALID_URL', + 'ERR_FETCH_FAILED', ]; const descriptors: PropertyDescriptorMap = errorTypes.reduce((acc, code) => { diff --git a/packages/inula-request/src/request/fetchRequest.ts b/packages/inula-request/src/request/fetchRequest.ts index d1b6e9d5..b2f598ee 100644 --- a/packages/inula-request/src/request/fetchRequest.ts +++ b/packages/inula-request/src/request/fetchRequest.ts @@ -19,6 +19,7 @@ import { IrRequestConfig, IrResponse, Cancel } from '../types/interfaces'; import { Method, ResponseType } from '../types/types'; import processUploadProgress from './processUploadProgress'; import processDownloadProgress from './processDownloadProgress'; +import CancelError from '../cancel/CancelError'; export const fetchRequest = (config: IrRequestConfig): Promise => { return new Promise((resolve, reject) => { @@ -47,8 +48,9 @@ export const fetchRequest = (config: IrRequestConfig): Promise => { // 处理请求取消 if (cancelToken) { cancelToken.promise.then((reason: Cancel) => { + const cancelError = new CancelError(reason.message, config); controller.abort(); - reject(reason); + reject(cancelError); }); } @@ -166,18 +168,23 @@ export const fetchRequest = (config: IrRequestConfig): Promise => { } }) .catch((error: IrError) => { - if (error.name === 'AbortError') { - reject(error.message); + // fetch 在取消请求的极限场景会抛出 Failed to fetch 的 error,此时将其转为取消 error + if (signal?.aborted) { + const irError = new CancelError('request canceled', config); + reject(irError); } else { - reject(error); + const irError = new IrError(error.message, 'ERR_FETCH_FAILED', responseData.config, responseData.request, responseData); + reject(irError); } }); }) .catch((error: IrError) => { if (error.name === 'AbortError') { - reject(error.message); + const cancelError = new CancelError('request canceled', config); + reject(cancelError); } else { - reject(error); + const irError = new IrError(error.message, 'ERR_FETCH_FAILED'); + reject(irError); } }); } diff --git a/packages/inula-request/src/request/processDownloadProgress.ts b/packages/inula-request/src/request/processDownloadProgress.ts index 5e2ea808..58b211a3 100644 --- a/packages/inula-request/src/request/processDownloadProgress.ts +++ b/packages/inula-request/src/request/processDownloadProgress.ts @@ -13,7 +13,13 @@ * See the Mulan PSL v2 for more details. */ -function processDownloadProgress(stream: ReadableStream | null, response: Response, onProgress: Function | null) { +import { IrProgressEvent } from '../types/interfaces'; + +function processDownloadProgress( + stream: ReadableStream | null, + response: Response, + onProgress: (progressEvent: IrProgressEvent) => void | null +) { // 文件下载过程中更新进度 if (onProgress) { const reader = stream?.getReader(); diff --git a/packages/inula-request/src/request/processRequest.ts b/packages/inula-request/src/request/processRequest.ts index f904cdd1..ceeaa5e6 100644 --- a/packages/inula-request/src/request/processRequest.ts +++ b/packages/inula-request/src/request/processRequest.ts @@ -29,10 +29,6 @@ export default function processRequest(config: IrRequestConfig): Promise); @@ -53,6 +49,16 @@ export default function processRequest(config: IrRequestConfig): Promise { + if (config.signal?.aborted) { + error.response = { + data: null, + headers: config.headers, + status: 200, + statusText: 'ok', + config, + }; + } + if (!checkCancel(error)) { if (config.cancelToken) { config.cancelToken.throwIfRequested(); diff --git a/packages/inula-request/src/request/processUploadProgress.ts b/packages/inula-request/src/request/processUploadProgress.ts index 4f5299a9..4e1586a4 100644 --- a/packages/inula-request/src/request/processUploadProgress.ts +++ b/packages/inula-request/src/request/processUploadProgress.ts @@ -13,11 +13,12 @@ * See the Mulan PSL v2 for more details. */ -import { IrRequestConfig, IrResponse } from '../types/interfaces'; -import IrError from "../core/IrError"; +import { IrProgressEvent, IrRequestConfig, IrResponse } from '../types/interfaces'; +import IrError from '../core/IrError'; +import CancelError from '../cancel/CancelError'; function processUploadProgress( - onUploadProgress: Function | null, + onUploadProgress: (progressEvent: IrProgressEvent) => void | null, data: FormData, reject: (reason?: any) => void, resolve: (value: PromiseLike> | IrResponse) => void, @@ -95,7 +96,7 @@ function processUploadProgress( xhr.abort(); const errorMsg = config.timeoutErrorMessage ?? `timeout of ${config.timeout}ms exceeded`; throw new IrError(errorMsg, '', config, xhr, undefined); - } + }; } for (const header in config.headers) { @@ -106,6 +107,21 @@ function processUploadProgress( xhr.setRequestHeader(header, config.headers[header]); } } + + if (config.signal) { + const onCanceled = () => { + const irError = new CancelError('request canceled', config); + reject(irError); + xhr.abort(); + }; + + if (config.signal.aborted) { + onCanceled(); + } else { + config.signal.addEventListener('abort', onCanceled); + } + } + xhr.send(data); }; diff --git a/packages/inula-request/src/types/interfaces.ts b/packages/inula-request/src/types/interfaces.ts index 146cc773..ce4d2dc6 100644 --- a/packages/inula-request/src/types/interfaces.ts +++ b/packages/inula-request/src/types/interfaces.ts @@ -125,6 +125,9 @@ export interface IrInterface { // Ir 实例接口类型 export interface IrInstance extends IrInterface { + >(config: IrRequestConfig): Promise; + >(url: string, config?: IrRequestConfig): Promise; + // Ir 类 InulaRequest: IrInterface; @@ -177,6 +180,18 @@ export interface IrInstance extends IrInterface { AxiosHeaders: any; } +export interface IrProgressEvent { + loaded: string | number; + total?: string | number | null; + progress?: number; + bytes?: number; + rate?: number; + estimated?: number; + upload?: boolean; + download?: boolean; + event?: any; +} + export interface Interceptors { request: IrInterceptorManager; response: IrInterceptorManager; diff --git a/packages/inula-request/src/utils/commonUtils/utils.ts b/packages/inula-request/src/utils/commonUtils/utils.ts index 59ac6354..0096c66a 100644 --- a/packages/inula-request/src/utils/commonUtils/utils.ts +++ b/packages/inula-request/src/utils/commonUtils/utils.ts @@ -386,7 +386,18 @@ const convertToCamelCase = (str: string) => { function objectToQueryString(obj: Record) { return Object.keys(obj) - .map(key => encodeURIComponent(key) + '=' + encodeURIComponent(obj[key])) + .map(key => { + // params 中 value 为数组时需特殊处理,如:{ key: [1, 2, 3] } -> key[]=1&key[]=2&key[]=3 + if (Array.isArray(obj[key])) { + let urlPart = ''; + obj[key].forEach((value: string) => { + urlPart = `${urlPart}${key}[]=${value}&`; + return urlPart; + }); + return urlPart.slice(0, -1); + } + return encodeURIComponent(key) + '=' + encodeURIComponent(obj[key]); + }) .join('&'); } diff --git a/packages/inula-request/tests/unitTest/utils/commonUtils/objectToQueryString.test.ts b/packages/inula-request/tests/unitTest/utils/commonUtils/objectToQueryString.test.ts index ea1c89ec..db98f676 100644 --- a/packages/inula-request/tests/unitTest/utils/commonUtils/objectToQueryString.test.ts +++ b/packages/inula-request/tests/unitTest/utils/commonUtils/objectToQueryString.test.ts @@ -53,7 +53,7 @@ describe('objectToQueryString function', () => { key5: { a: 'b' }, }; const expectedResult = - 'key1=string&key2=42&key3=true&key4=1%2C2%2C3&key5=%5Bobject%20Object%5D'; + 'key1=string&key2=42&key3=true&key4[]=1&key4[]=2&key4[]=3&key5=%5Bobject%20Object%5D'; const result = utils.objectToQueryString(input); expect(result).toBe(expectedResult); }); diff --git a/packages/inula-router/package.json b/packages/inula-router/package.json index 1c3356c6..b2476ad7 100644 --- a/packages/inula-router/package.json +++ b/packages/inula-router/package.json @@ -1,6 +1,6 @@ { "name": "inula-router", - "version": "0.0.1", + "version": "0.0.4", "description": "router for inula framework, a part of inula-ecosystem", "main": "./router/cjs/router.js", "module": "./router/esm/router.js", @@ -67,7 +67,7 @@ "typescript": "4.9.3" }, "peerDependencies": { - "openinula": ">=0.0.10" + "openinula": ">=0.1.1" }, "browserslist": { "production": [ diff --git a/packages/inula-router/src/connect-router/connectedRouter.tsx b/packages/inula-router/src/connect-router/connectedRouter.tsx index a526663a..8ce5a383 100644 --- a/packages/inula-router/src/connect-router/connectedRouter.tsx +++ b/packages/inula-router/src/connect-router/connectedRouter.tsx @@ -16,7 +16,7 @@ import Inula from 'openinula'; import { useLayoutEffect, useRef, reduxAdapter, InulaNode } from 'openinula'; import { connect, ReactReduxContext } from 'react-redux'; -import { Store } from 'redux'; +import type { Store } from 'redux'; import { History, Location, Router } from '../router'; import { Action, DefaultStateType, Navigation } from '../history/types'; import { ActionMessage, onLocationChanged } from './actions'; @@ -130,9 +130,14 @@ function getConnectedRouter(type: StoreType) { ); }; + const ConnectedHRouterWithContext = (props: any) => { + const { store, ...rest } = props; + return ; + }; + // 针对不同的Store类型,使用对应的connect函数 if (type === 'InulaXCompat') { - return hConnect(null as any, mapDispatchToProps)(ConnectedRouterWithContext as any); + return hConnect(null as any, mapDispatchToProps)(ConnectedHRouterWithContext as any); } if (type === 'Redux') { return connect(null, mapDispatchToProps)(ConnectedRouterWithContext); diff --git a/packages/inula-router/src/history/hashHistory.ts b/packages/inula-router/src/history/hashHistory.ts index c44a85bb..f1cf421d 100644 --- a/packages/inula-router/src/history/hashHistory.ts +++ b/packages/inula-router/src/history/hashHistory.ts @@ -107,7 +107,7 @@ export function createHashHistory(option: HashHistoryOptio warning(state !== undefined, 'Hash history does not support state, it will be ignored'); const action = Action.push; - const location = createLocation(history.location, to, undefined, ''); + const location = createLocation(history.location, to, state, ''); transitionManager.confirmJumpTo(location, action, getUserConfirmation, isJump => { if (!isJump) { @@ -132,7 +132,7 @@ export function createHashHistory(option: HashHistoryOptio function replace(to: To, state?: S) { warning(state !== undefined, 'Hash history does not support state, it will be ignored'); const action = Action.replace; - const location = createLocation(history.location, to, undefined, ''); + const location = createLocation(history.location, to, state, ''); transitionManager.confirmJumpTo(location, action, getUserConfirmation, isJump => { if (!isJump) { diff --git a/packages/inula-router/src/history/utils.ts b/packages/inula-router/src/history/utils.ts index 0fe235a2..8a671372 100644 --- a/packages/inula-router/src/history/utils.ts +++ b/packages/inula-router/src/history/utils.ts @@ -28,31 +28,32 @@ export function createPath(path: Partial): string { } export function parsePath(url: string): Partial { - if (!url) { - return {}; - } - let parsedPath: Partial = {}; + let pathname = url || '/'; + const parsedPath: Partial = { + search: '', + hash: '', + }; - let hashIdx = url.indexOf('#'); + const hashIdx = url.indexOf('#'); if (hashIdx > -1) { - parsedPath.hash = url.substring(hashIdx); - url = url.substring(0, hashIdx); + const hash = url.substring(hashIdx); + parsedPath.hash = hash === '#' ? '' : hash; + pathname = pathname.substring(0, hashIdx); } - let searchIdx = url.indexOf('?'); + const searchIdx = url.indexOf('?'); if (searchIdx > -1) { - parsedPath.search = url.substring(searchIdx); - url = url.substring(0, searchIdx); - } - if (url) { - parsedPath.pathname = url; + const search = url.substring(searchIdx); + parsedPath.search = search === '?' ? '' : search; + pathname = pathname.substring(0, searchIdx); } + parsedPath.pathname = pathname; return parsedPath; } export function createLocation(current: string | Location, to: To, state?: S, key?: string): Readonly> { - let pathname = typeof current === 'string' ? current : current.pathname; - let urlObj = typeof to === 'string' ? parsePath(to) : to; + const pathname = typeof current === 'string' ? current : current.pathname; + const urlObj = typeof to === 'string' ? parsePath(to) : to; // 随机key长度取6 const getRandKey = genRandomKey(6); const location = { @@ -64,7 +65,13 @@ export function createLocation(current: string | Location, to: To, state?: S, ...urlObj, }; if (!location.pathname) { - location.pathname = '/'; + location.pathname = pathname ? pathname : '/'; + } + if (location.search && location.search[0] !== '?') { + location.search = '?' + location.search; + } + if (location.hash && location.hash[0] !== '#') { + location.hash = '#' + location.hash; } return location; } @@ -95,7 +102,7 @@ export function normalizeSlash(path: string): string { return tempPath; } -export function hasBasename(path: string, prefix: string): Boolean { +export function hasBasename(path: string, prefix: string): boolean { return ( path.toLowerCase().indexOf(prefix.toLowerCase()) === 0 && ['/', '?', '#', ''].includes(path.charAt(prefix.length)) ); @@ -109,12 +116,12 @@ export function stripBasename(path: string, prefix: string): string { export function createMemoryRecord(initVal: S, fn: (arg: S) => T) { let visitedRecord: T[] = [fn(initVal)]; - function getDelta(to: S, form: S): number { - let toIdx = visitedRecord.lastIndexOf(fn(to)); + function getDelta(toKey: S, formKey: S): number { + let toIdx = visitedRecord.lastIndexOf(fn(toKey)); if (toIdx === -1) { toIdx = 0; } - let fromIdx = visitedRecord.lastIndexOf(fn(form)); + let fromIdx = visitedRecord.lastIndexOf(fn(formKey)); if (fromIdx === -1) { fromIdx = 0; } diff --git a/packages/inula-router/src/router/NavLink.tsx b/packages/inula-router/src/router/NavLink.tsx index 37cbbeca..9a836e71 100644 --- a/packages/inula-router/src/router/NavLink.tsx +++ b/packages/inula-router/src/router/NavLink.tsx @@ -24,27 +24,42 @@ import { parsePath } from '../history/utils'; type NavLinkProps = { to: Partial | string | ((location: Location) => string | Partial); - isActive?: (match: Matched | null, location: Location) => boolean; + isActive?

(match: Matched

| null, location: Location): boolean; + exact?: boolean; + strict?: boolean; + sensitive?: boolean; + className?: string | ((isActive: boolean) => string); + activeClassName?: string; [key: string]: any; -} & LinkProps; +} & Omit; type Page = 'page'; function NavLink

(props: P) { - const { to, isActive, ...rest } = props; + const { to, isActive, exact, strict, sensitive, className, activeClassName, ...rest } = props; const context = useContext(Context); const toLocation = typeof to === 'function' ? to(context.location) : to; const { pathname } = typeof toLocation === 'string' ? parsePath(toLocation) : toLocation; - const match = pathname ? matchPath(context.location.pathname, pathname) : null; + const match = pathname ? matchPath(context.location.pathname, pathname, { + exact: exact, + strictMode: strict, + caseSensitive: sensitive, + }) : null; - const isLinkActive = match && isActive ? isActive(match, context.location) : false; + const isLinkActive = !!(isActive ? isActive(match, context.location) : match); + + let classNames = typeof className === 'function' ? className(isLinkActive) : className; + if (isLinkActive) { + classNames = [activeClassName, classNames].filter(Boolean).join(''); + } const page: Page = 'page'; const otherProps = { - 'aria-current': isLinkActive ? page : false, + className: classNames, + 'aria-current': isLinkActive ? page : undefined, ...rest, }; diff --git a/packages/inula-router/src/router/Route.tsx b/packages/inula-router/src/router/Route.tsx index fdf2db7f..ddd7c195 100644 --- a/packages/inula-router/src/router/Route.tsx +++ b/packages/inula-router/src/router/Route.tsx @@ -43,15 +43,19 @@ export type RouteProps

= {}, Path extends string = function Route = GetURLParams>(props: RouteProps) { const context = useContext(RouterContext); - const { computed, location, path } = props; - let { children, component, render } = props; + const { computed, location, path, component, render, strict, sensitive, exact } = props; + let { children } = props; let match: Matched

| null; const routeLocation = location || context.location; if (computed) { match = computed; } else if (path) { - match = matchPath

(routeLocation.pathname, path); + match = matchPath

(routeLocation.pathname, path, { + strictMode: strict, + caseSensitive: sensitive, + exact: exact, + }); } else { match = context.match; } diff --git a/packages/inula-router/src/router/Router.tsx b/packages/inula-router/src/router/Router.tsx index dd9791a1..1dcdf7e6 100644 --- a/packages/inula-router/src/router/Router.tsx +++ b/packages/inula-router/src/router/Router.tsx @@ -29,22 +29,27 @@ function Router

(props: P) { const { history, children = null } = props; const [location, setLocation] = useState(props.history.location); const pendingLocation = useRef(null); + const unListen = useRef void)>(null); + const isMount = useRef(false); // 在Router加载时就监听history地址变化,以保证在始渲染时重定向能正确触发 - const unListen = useRef void)>( - history.listen(arg => { + if (unListen.current === null) { + unListen.current = history.listen(arg => { pendingLocation.current = arg.location; - }), - ); + }); + } // 模拟componentDidMount和componentWillUnmount useLayoutEffect(() => { + isMount.current = true; if (unListen.current) { unListen.current(); } // 监听history中的位置变化 unListen.current = history.listen(arg => { - setLocation(arg.location); + if (isMount.current) { + setLocation(arg.location); + } }); if (pendingLocation.current) { @@ -53,6 +58,7 @@ function Router

(props: P) { return () => { if (unListen.current) { + isMount.current = false; unListen.current(); unListen.current = null; pendingLocation.current = null; diff --git a/packages/inula-router/src/router/matcher/parser.ts b/packages/inula-router/src/router/matcher/parser.ts index 0598066c..ea75e4e5 100644 --- a/packages/inula-router/src/router/matcher/parser.ts +++ b/packages/inula-router/src/router/matcher/parser.ts @@ -40,7 +40,7 @@ export type Matched

= { const defaultOption: Required = { // url匹配时是否大小写敏感 - caseSensitive: true, + caseSensitive: false, // 是否严格匹配url结尾的/ strictMode: false, // 是否完全精确匹配 diff --git a/packages/inula-router/src/router/withRouter.tsx b/packages/inula-router/src/router/withRouter.tsx index 3d7f58bd..04c588c8 100644 --- a/packages/inula-router/src/router/withRouter.tsx +++ b/packages/inula-router/src/router/withRouter.tsx @@ -20,10 +20,11 @@ import RouterContext from './context'; function withRouter(Component: C) { function ComponentWithRouterProp(props: any) { + const { wrappedComponentRef, ...rest } = props; const { history, location, match } = useContext(RouterContext); const routeProps = { history: history, location: location, match: match }; - return ; + return ; } return ComponentWithRouterProp; diff --git a/packages/inula/README.md b/packages/inula/README.md index 7a1082aa..d362820a 100644 --- a/packages/inula/README.md +++ b/packages/inula/README.md @@ -157,10 +157,6 @@ openinula团队会关注所有Pull Request,我们会review以及合入你的 1. `npm run build` 同时构建openinula UMD的prod版本和dev版本 2. `build-types` 单独构建openinula的类型提示@types目录 -#### 配套开发工具 - -- [openinula-devtool](https://www.XXXX.com): 可视化openinula项目页面的vDom树 - ## 开源许可协议 请查阅 License 获取开源许可协议的更多信息. diff --git a/packages/inula/package.json b/packages/inula/package.json index 3ec98d8b..54ae4908 100644 --- a/packages/inula/package.json +++ b/packages/inula/package.json @@ -4,7 +4,7 @@ "keywords": [ "openinula" ], - "version": "0.0.1", + "version": "0.0.4", "homepage": "", "bugs": "", "license": "MulanPSL2", @@ -23,6 +23,14 @@ "test": "jest --config=jest.config.js", "watch-test": "yarn test --watch --dev" }, - "files": ["build/@types", "build/cjs", "build/umd", "build/index.js", "build/jsx-dev-runtime.js", "build/jsx-runtime.js", "README.md"], - "types": "./build/@types/index.d.ts" + "files": ["build/**/*", "README.md"], + "types": "./build/@types/index.d.ts", + "exports": { + ".": { + "default": "./index.js" + }, + "./package.json":"./package.json", + "./jsx-runtime": "./jsx-runtime.js", + "./jsx-dev-runtime": "./jsx-dev-runtime.js" + } } diff --git a/packages/inula/scripts/__tests__/ComponentTest/FragmentComponent.test.js b/packages/inula/scripts/__tests__/ComponentTest/FragmentComponent.test.js index c91c5dac..6ac08b89 100644 --- a/packages/inula/scripts/__tests__/ComponentTest/FragmentComponent.test.js +++ b/packages/inula/scripts/__tests__/ComponentTest/FragmentComponent.test.js @@ -473,4 +473,61 @@ describe('Fragment', () => { expect(LogUtils.getNotClear()).toEqual([]); expect(container.textContent).toBe('1'); }); + + it('Fragment 设置相同的key不会重新渲染组件', () => { + const { useState, useRef, act, Fragment } = Inula; + let setFn; + const didMount = jest.fn(); + const didUpdate = jest.fn(); + + const App = () => { + const [list, setList] = useState([ + { text: 'Apple', id: 1 }, + { text: 'Banana', id: 2 }, + ]); + + setFn = setList; + + return ( + <> + {list.map(item => { + return ( + + + + ); + })} + + ); + }; + + const Child = ({ val }) => { + const mount = useRef(false); + useEffect(() => { + if (!mount.current) { + didMount(); + mount.current = true; + } else { + didUpdate(); + } + }); + + return

{val}
; + }; + + act(() => Inula.render(, container)); + + expect(didMount).toHaveBeenCalledTimes(2); + act(() => { + setFn([ + { text: 'Apple', id: 1 }, + { text: 'Banana', id: 2 }, + { text: 'Grape', id: 3 }, + ]); + }); + + // 数组前两项Key不变子组件更新,第三个子组件会挂载 + expect(didMount).toHaveBeenCalledTimes(3); + expect(didUpdate).toHaveBeenCalledTimes(2); + }); }); diff --git a/packages/inula/scripts/__tests__/ComponentTest/PortalComponent.test.js b/packages/inula/scripts/__tests__/ComponentTest/PortalComponent.test.js index 4c6924b9..98e55340 100644 --- a/packages/inula/scripts/__tests__/ComponentTest/PortalComponent.test.js +++ b/packages/inula/scripts/__tests__/ComponentTest/PortalComponent.test.js @@ -286,4 +286,98 @@ describe('PortalComponent Test', () => { dispatchChangeEvent(inputRef.current, 'test'); expect(fn).toHaveBeenCalledTimes(1); }); + + + it('portal场景下,portal下元素点击事件冒泡到父元素', () => { + class Dialog extends Inula.Component { + node; + + constructor(props) { + super(props); + this.node = window.document.createElement('div'); + window.document.body.appendChild(this.node); + } + + render() { + return Inula.createPortal(this.props.children, this.node); + } + } + + const fn = jest.fn(); + const subRef = Inula.createRef(); + + function App() { + return ( +
+ +
+
+
+ ); + } + + Inula.render(, container); + Inula.act(() => { + subRef.current.dispatchEvent(new Event('click', { bubbles: true })); + }); + expect(fn).toHaveBeenCalledTimes(1); + }); + + it('portal嵌套场景下事件委托', () => { + class Dialog extends Inula.Component { + node; + + constructor(props) { + super(props); + this.node = window.document.createElement('div'); + window.document.body.appendChild(this.node); + } + + render() { + return Inula.createPortal(this.props.children, this.node); + } + } + + const fn = jest.fn(); + const inputRef = Inula.createRef(); + let value = ''; + const onChange = (evt) => { + value = evt.target.value; + } + + let showSubPortal = () => {}; + + function App() { + return ( +
+ + + + +
+ ); + } + + function Sub() { + const [show, setShow] = Inula.useState(false); + showSubPortal = setShow; + return ( +
+ { + show && + + + + } +
+ ) + } + + Inula.render(, container); + Inula.act(() => { + showSubPortal(true); + }); + dispatchChangeEvent(inputRef.current, 'test'); + expect(value).toEqual('test'); + }); }); diff --git a/packages/inula/scripts/__tests__/ComponentTest/StrictMode.test.js b/packages/inula/scripts/__tests__/ComponentTest/StrictMode.test.js new file mode 100644 index 00000000..a12fbed4 --- /dev/null +++ b/packages/inula/scripts/__tests__/ComponentTest/StrictMode.test.js @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2023 Huawei Technologies Co.,Ltd. + * + * openInula is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * + * http://license.coscl.org.cn/MulanPSL2 + * + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +import * as Inula from '../../../src/index'; +import { getLogUtils } from '../jest/testUtils'; + +describe('StrictMode Component test', () => { + const LogUtils = getLogUtils(); + const { useState, useEffect, useRef, render, act } = Inula; + it('StrictMode is same to Fragment', () => { + const Parent = () => { + const [, setS] = useState('1'); + return ( + + + + + ); + }; + + const Child = () => { + const isMount = useRef(false); + + useEffect(() => { + if (!isMount.current) { + LogUtils.log('didMount'); + isMount.current = true; + } else { + LogUtils.log('didUpdate'); + } + }); + + return null; + }; + + act(() => render(, container)); + // 子组件初始化,会挂载一次 + expect(LogUtils.getAndClear()).toStrictEqual(['didMount']); + const button = container.querySelector('#btn'); + // 父组件State更新,子组件也会更新一次 + act(() => button.click()); + expect(LogUtils.getAndClear()).toStrictEqual(['didUpdate']); + }); +}); diff --git a/packages/inula/scripts/__tests__/HorizonXTest/adapters/ReduxAdapter.test.tsx b/packages/inula/scripts/__tests__/HorizonXTest/adapters/ReduxAdapter.test.tsx index 109ccbc2..499be389 100644 --- a/packages/inula/scripts/__tests__/HorizonXTest/adapters/ReduxAdapter.test.tsx +++ b/packages/inula/scripts/__tests__/HorizonXTest/adapters/ReduxAdapter.test.tsx @@ -13,7 +13,7 @@ * See the Mulan PSL v2 for more details. */ -//@ts-ignore +// @ts-ignore import * as Inula from '../../../../src/index'; import { createStore, @@ -187,7 +187,7 @@ describe('Redux adapter', () => { reduxStore.dispatch({ type: 'toggle' }); reduxStore.dispatch({ type: 'toggle' }); - expect(counter).toBe(3); // NOTE: first action is always store initialization + expect(counter).toBe(2); // execute dispatch two times, applyMiddleware was called same times }); it('Should apply multiple enhancers', async () => { @@ -226,7 +226,7 @@ describe('Redux adapter', () => { reduxStore.dispatch({ type: 'toggle' }); - expect(counter).toBe(2); // NOTE: first action is always store initialization + expect(counter).toBe(1); // execute dispatch two times, applyMiddleware was called same times expect(lastAction).toBe('toggle'); expect(middlewareCallList[0]).toBe('callCounter'); expect(middlewareCallList[1]).toBe('lastFunctionStorage'); diff --git a/packages/inula/src/dom/DOMExternal.ts b/packages/inula/src/dom/DOMExternal.ts index 001d18f5..12dea7b1 100644 --- a/packages/inula/src/dom/DOMExternal.ts +++ b/packages/inula/src/dom/DOMExternal.ts @@ -21,6 +21,7 @@ import { findDOMByClassInst } from '../renderer/vnode/VNodeUtils'; import { listenSimulatedDelegatedEvents } from '../event/EventBinding'; import { Callback } from '../renderer/Types'; import { InulaNode } from '../types'; +import { EVENT_KEY } from './DOMInternalKeys'; function createRoot(children: any, container: Container, callback?: Callback) { // 清空容器 @@ -89,7 +90,7 @@ function findDOMNode(domOrEle?: Element): null | Element | Text { // 情况根节点监听器 function removeRootEventLister(container: Container) { - const events = (container._treeRoot as any).$EV; + const events = (container as any)[EVENT_KEY]; if (events) { Object.keys(events).forEach(event => { const listener = events[event]; diff --git a/packages/inula/src/dom/DOMInternalKeys.ts b/packages/inula/src/dom/DOMInternalKeys.ts index a8727261..c4f1824f 100644 --- a/packages/inula/src/dom/DOMInternalKeys.ts +++ b/packages/inula/src/dom/DOMInternalKeys.ts @@ -22,9 +22,12 @@ import type { Container, Props } from './DOMOperator'; import { DomComponent, DomText, TreeRoot } from '../renderer/vnode/VNodeTags'; -const INTERNAL_VNODE = '_inula_VNode'; -const INTERNAL_PROPS = '_inula_Props'; -const INTERNAL_NONDELEGATEEVENTS = '_inula_NonDelegatedEvents'; +const randomKey = Math.random().toString(16).slice(2); +const INTERNAL_VNODE = `_inula_VNode_${randomKey}`; +const INTERNAL_PROPS = `_inula_Props_${randomKey}`; +const INTERNAL_NONDELEGATEEVENTS = `_inula_nonDelegatedEvents_${randomKey}`; +export const HANDLER_KEY = `_inula_valueChangeHandler_${randomKey}`; +export const EVENT_KEY = `_inula_ev_${randomKey}`; // 通过 VNode 实例获取 DOM 节点 export function getDom(vNode: VNode): Element | Text | null { diff --git a/packages/inula/src/dom/valueHandler/ValueChangeHandler.ts b/packages/inula/src/dom/valueHandler/ValueChangeHandler.ts index cd07c35c..262dc837 100644 --- a/packages/inula/src/dom/valueHandler/ValueChangeHandler.ts +++ b/packages/inula/src/dom/valueHandler/ValueChangeHandler.ts @@ -18,7 +18,7 @@ * 只有值发生变化时才会触发change事件。 */ -const HANDLER_KEY = '_valueChangeHandler'; +import { HANDLER_KEY } from '../DOMInternalKeys'; // 判断是否是 check 类型 function isCheckType(dom: HTMLInputElement): boolean { diff --git a/packages/inula/src/event/EventBinding.ts b/packages/inula/src/event/EventBinding.ts index 01b3ea54..52bfffcd 100644 --- a/packages/inula/src/event/EventBinding.ts +++ b/packages/inula/src/event/EventBinding.ts @@ -16,9 +16,9 @@ /** * 事件绑定实现,分为绑定委托事件和非委托事件 */ -import { allDelegatedInulaEvents, simulatedDelegatedEvents } from './EventHub'; +import { allDelegatedInulaEvents, portalDefaultDelegatedEvents, simulatedDelegatedEvents } from './EventHub'; import { isDocument } from '../dom/utils/Common'; -import { getNearestVNode, getNonDelegatedListenerMap } from '../dom/DOMInternalKeys'; +import { EVENT_KEY, getNearestVNode, getNonDelegatedListenerMap } from '../dom/DOMInternalKeys'; import { asyncUpdates, runDiscreteUpdates } from '../renderer/TreeBuilder'; import { handleEventMain } from './InulaEventMain'; import { decorateNativeEvent } from './EventWrapper'; @@ -73,8 +73,8 @@ export function lazyDelegateOnRoot(currentRoot: VNode, eventName: string) { const nativeFullName = isCapture ? nativeEvent + 'capture' : nativeEvent; // 事件存储在DOM节点属性,避免多个VNode(root和portal)对应同一个DOM, 造成事件重复监听 - currentRoot.realNode.$EV = currentRoot.realNode.$EV ?? {}; - const events = currentRoot.realNode.$EV; + currentRoot.realNode[EVENT_KEY] = currentRoot.realNode[EVENT_KEY] ?? {}; + const events = currentRoot.realNode[EVENT_KEY]; if (!events[nativeFullName]) { events[nativeFullName] = listenToNativeEvent(nativeEvent, currentRoot.realNode, isCapture); @@ -89,6 +89,13 @@ export function listenSimulatedDelegatedEvents(root: VNode) { } } +// portal绑定默认事件 +export function listenPortalEvents(root: VNode) { + for (let i = 0; i < portalDefaultDelegatedEvents.length; i++) { + lazyDelegateOnRoot(root, portalDefaultDelegatedEvents[i]); + } +} + // 通过inula事件名获取到native事件名 function getNativeEvtName(inulaEventName, capture) { let nativeName; diff --git a/packages/inula/src/event/EventHub.ts b/packages/inula/src/event/EventHub.ts index 2f8e8a1f..0dc01c50 100644 --- a/packages/inula/src/event/EventHub.ts +++ b/packages/inula/src/event/EventHub.ts @@ -16,6 +16,13 @@ // 需要委托的inula事件和原生事件对应关系 export const allDelegatedInulaEvents = new Map(); +/** + * Portal根节点默认绑定事件,解决常见事件无法冒泡到parent vnode的问题 + * 例如:parent vNode节点绑定了mousedown事件,子节点为portal节点,子节点下元素未绑定mousedown事件 + * 此时,点击portal下子元素,mousedown事件无法冒泡到parentNode + */ +export const portalDefaultDelegatedEvents = ['onMouseDown', 'onMouseUp', 'onKeyDown', 'onKeyUp', 'onFocus', 'onBlur', 'onClick']; + // 模拟委托事件,不冒泡事件需要利用其他事件来触发冒泡过程 export const simulatedDelegatedEvents = ['onMouseEnter', 'onMouseLeave']; // 所有委托的原生事件集合 diff --git a/packages/inula/src/event/InulaEventMain.ts b/packages/inula/src/event/InulaEventMain.ts index a281acf1..817618f4 100644 --- a/packages/inula/src/event/InulaEventMain.ts +++ b/packages/inula/src/event/InulaEventMain.ts @@ -142,11 +142,17 @@ function triggerInulaEvents( const target = nativeEvent.target || nativeEvent.srcElement!; // 触发普通委托事件 - const listenerList: ListenerUnitList = getCommonListeners(nativeEvtName, vNode, nativeEvent, target, isCapture); + const listenerList: ListenerUnitList = getCommonListeners( + nativeEvtName, + vNode, + nativeEvent as MouseEvent, + target, + isCapture + ); let mouseEnterListeners: ListenerUnitList = []; if (inulaEventToNativeMap.get('onMouseEnter')!.includes(nativeEvtName)) { - mouseEnterListeners = getMouseEnterListeners(nativeEvtName, vNode, nativeEvent, target); + mouseEnterListeners = getMouseEnterListeners(nativeEvtName, vNode, nativeEvent as MouseEvent, target); } let changeEvents: ListenerUnitList = []; diff --git a/packages/inula/src/event/MouseEvent.ts b/packages/inula/src/event/MouseEvent.ts index b817db36..7f514b83 100644 --- a/packages/inula/src/event/MouseEvent.ts +++ b/packages/inula/src/event/MouseEvent.ts @@ -13,7 +13,7 @@ * See the Mulan PSL v2 for more details. */ -import { getNearestVNode } from '../dom/DOMInternalKeys'; +import { getNearestVNode, getVNode } from '../dom/DOMInternalKeys'; import { WrappedEvent } from './EventWrapper'; import { VNode } from '../renderer/vnode/VNode'; import { AnyNativeEvent, ListenerUnitList } from './Types'; @@ -87,12 +87,29 @@ function getEndpointVNode( return [fromVNode, toVNode]; } +function checkIsInulaNode(related: HTMLElement): boolean { + if (getVNode(related) || getNearestVNode(related)) { + return true; + } + return false; +} + export function getMouseEnterListeners( domEventName: string, targetInst: null | VNode, - nativeEvent: AnyNativeEvent, + nativeEvent: MouseEvent, nativeEventTarget: null | EventTarget ): ListenerUnitList { + + if (domEventName === 'mouseover') { + // 如果 related 节点是 openInula 框架管理的,那么在 out 事件节点已经触发过 mouseEnter 或者 mouseLeave 事件了,不需要 over 事件再次触发 + // IE 通过 fromElement 属性获取失去焦点的 DOM 节点 + const related = nativeEvent.relatedTarget || (nativeEvent as any).fromElemnt; + if (related && checkIsInulaNode(related)) { + return []; + } + } + // 获取起点和终点的VNode const [fromVNode, toVNode] = getEndpointVNode(domEventName, targetInst, nativeEvent); if (fromVNode === toVNode) { diff --git a/packages/inula/src/inulax/CommonUtils.ts b/packages/inula/src/inulax/CommonUtils.ts index a6c47181..5e309ca4 100644 --- a/packages/inula/src/inulax/CommonUtils.ts +++ b/packages/inula/src/inulax/CommonUtils.ts @@ -67,18 +67,51 @@ export function isPromise(obj: any): boolean { return isObject(obj) && typeof obj.then === 'function'; } -export function isSame(x, y) { - if (!(typeof Object.is === 'function')) { - if (x === y) { - // +0 != -0 - return x !== 0 || 1 / x === 1 / y; - } else { - // NaN == NaN - return x !== x && y !== y; - } - } else { - return Object.is(x, y); +export function isSame(x: unknown, y: unknown): boolean { + // 如果两个对象是同一个引用,直接返回true + if (x === y) { + return true; } + // 如果两个对象类型不同,直接返回false + if (typeof x !== typeof y) { + return false; + } + // 此时x和y类型一致,若都为undefined或null返回true,但也有可能此时为一个对象和null,这种情况直接比较 + // typeof null === 'object' + if (x == null || y == null) { + return x === y; + } + // 如果两个对象都是基本类型,比较他们的值是否相等 + if (typeof x !== 'object') { + return x === y; + } + // 如果两个对象都是数组,比较他们的长度是否相等,然后递归比较每个元素是否相等 + if (Array.isArray(x) && Array.isArray(y)) { + if (x.length !== y.length) { + return false; + } + for (let i = 0; i < x.length; i++) { + if (!isSame(x[i], y[i])) { + return false; + } + } + return true; + } + // 两个对象都是普通对象,首先比较他们的属性数量是否相等,然后递归比较每个属性的值是否相等 + if (typeof x === 'object' && typeof y === 'object') { + const keys1 = Object.keys(x!).sort(); + const keys2 = Object.keys(y!).sort(); + if (keys1.length !== keys2.length) { + return false; + } + for (let i = 0; i < keys1.length; i++) { + if (!isSame(x![keys1[i]], y![keys2[i]])) { + return false; + } + } + return true; + } + return false; } export function getDetailedType(val: any) { diff --git a/packages/inula/src/inulax/adapters/redux.ts b/packages/inula/src/inulax/adapters/redux.ts index b996abed..99e57748 100644 --- a/packages/inula/src/inulax/adapters/redux.ts +++ b/packages/inula/src/inulax/adapters/redux.ts @@ -27,12 +27,12 @@ export { createDispatchHook, } from './reduxReact'; -export type ReduxStoreHandler = { - reducer: (state: any, action: { type: string }) => any; - dispatch: (action: { type: string }) => void; - getState: () => any; - subscribe: (listener: () => void) => () => void; - replaceReducer: (reducer: (state: any, action: { type: string }) => any) => void; +export type ReduxStoreHandler = { + reducer(state: T, action: { type: string }): any; + dispatch(action: { type: string }): void; + getState(): T; + subscribe(listener: () => void): () => void; + replaceReducer(reducer: (state: T, action: { type: string }) => any): void; }; export type ReduxAction = { @@ -53,6 +53,9 @@ export type ReduxMiddleware = ( type Reducer = (state: any, action: ReduxAction) => any; +type StoreCreator = (reducer: Reducer, preloadedState?: any) => ReduxStoreHandler; +type StoreEnhancer = (next: StoreCreator) => StoreCreator; + function mergeData(state, data) { if (!data) { state.stateWrapper = data; @@ -87,7 +90,11 @@ function mergeData(state, data) { state.stateWrapper = data; } -export function createStore(reducer: Reducer, preloadedState?: any, enhancers?): ReduxStoreHandler { +export function createStore(reducer: Reducer, preloadedState?: any, enhancers?: StoreEnhancer): ReduxStoreHandler { + if (typeof preloadedState === 'function' && typeof enhancers === 'undefined') { + enhancers = preloadedState; + preloadedState = undefined; + } const store = createStoreX({ id: 'defaultStore', state: { stateWrapper: preloadedState }, @@ -104,6 +111,7 @@ export function createStore(reducer: Reducer, preloadedState?: any, enhancers?): return; } // NOTE: reducer should never return undefined, in this case, do not change state state.stateWrapper = result; + return action; }, }, options: { @@ -130,12 +138,14 @@ export function createStore(reducer: Reducer, preloadedState?: any, enhancers?): dispatch: store.$a.dispatch, }; - enhancers && enhancers(result); - result.dispatch({ type: 'InulaX' }); store.reduxHandler = result; + if (typeof enhancers === 'function') { + return enhancers(createStore)(reducer, preloadedState); + } + return result as ReduxStoreHandler; } @@ -150,19 +160,23 @@ export function combineReducers(reducers: { [key: string]: Reducer }): Reducer { }; } -function applyMiddlewares(store: ReduxStoreHandler, middlewares: ReduxMiddleware[]): void { - middlewares = middlewares.slice(); - middlewares.reverse(); - let dispatch = store.dispatch; - middlewares.forEach(middleware => { - dispatch = middleware(store)(dispatch); - }); - store.dispatch = dispatch; +function applyMiddlewares(createStore: StoreCreator, middlewares: ReduxMiddleware[]): StoreCreator { + return (reducer, preloadedState) => { + middlewares = middlewares.slice(); + middlewares.reverse(); + const storeObj = createStore(reducer, preloadedState); + let dispatch = storeObj.dispatch; + middlewares.forEach(middleware => { + dispatch = middleware(storeObj)(dispatch); + }); + storeObj.dispatch = dispatch; + return storeObj; + }; } -export function applyMiddleware(...middlewares: ReduxMiddleware[]): (store: ReduxStoreHandler) => void { - return store => { - return applyMiddlewares(store, middlewares); +export function applyMiddleware(...middlewares: ReduxMiddleware[]): (createStore: StoreCreator) => StoreCreator { + return createStore => { + return applyMiddlewares(createStore, middlewares); }; } @@ -170,7 +184,7 @@ type ActionCreator = (...params: any[]) => ReduxAction; type ActionCreators = { [key: string]: ActionCreator }; export type BoundActionCreator = (...params: any[]) => void; type BoundActionCreators = { [key: string]: BoundActionCreator }; -type Dispatch = (action) => any; +type Dispatch = (action: ReduxAction) => any; export function bindActionCreators(actionCreators: ActionCreators, dispatch: Dispatch): BoundActionCreators { const boundActionCreators = {}; @@ -183,12 +197,12 @@ export function bindActionCreators(actionCreators: ActionCreators, dispatch: Dis return boundActionCreators; } -export function compose(...middlewares: ReduxMiddleware[]) { - return (store: ReduxStoreHandler, extraArgument: any) => { - let val; - middlewares.reverse().forEach((middleware: ReduxMiddleware, index) => { +export function compose(...middlewares: ((...args: any[]) => any)[]): (...args: any[]) => T { + return (...args) => { + let val: any; + middlewares.reverse().forEach((middleware, index) => { if (!index) { - val = middleware(store, extraArgument); + val = middleware(...args); return; } val = middleware(val); diff --git a/packages/inula/src/inulax/adapters/reduxReact.ts b/packages/inula/src/inulax/adapters/reduxReact.ts index 51650f98..2b5a9dae 100644 --- a/packages/inula/src/inulax/adapters/reduxReact.ts +++ b/packages/inula/src/inulax/adapters/reduxReact.ts @@ -17,6 +17,7 @@ import { useState, useContext, useEffect, useRef } from '../../renderer/hooks/Ho import { createContext } from '../../renderer/components/context/CreateContext'; import { createElement } from '../../external/JSXElement'; import type { ReduxStoreHandler, ReduxAction, BoundActionCreator } from './redux'; +import { forwardRef } from '../../renderer/components/ForwardRef'; const DefaultContext = createContext(null); type Context = typeof DefaultContext; @@ -40,29 +41,27 @@ export function createStoreHook(context: Context): () => ReduxStoreHandler { }; } -export function createSelectorHook(context: Context): (selector?: (any) => any) => any { - const store = createStoreHook(context)() as unknown as ReduxStoreHandler; - return function (selector = state => state) { - const [b, fr] = useState(false); +export function createSelectorHook(context: Context): (selector?: ((state: unknown) => any) | undefined) => any { + const store = createStoreHook(context)(); + return function useSelector(selector = state => state) { + const [state, setState] = useState(() => store.getState()); useEffect(() => { - const unsubscribe = store.subscribe(() => fr(!b)); - return () => { - unsubscribe(); - }; - }); + const unsubscribe = store.subscribe(() => { + setState(store.getState()); + }); + return () => unsubscribe(); + }, []); - return selector(store.getState()); + return selector(state); }; } export function createDispatchHook(context: Context): () => BoundActionCreator { - const store = createStoreHook(context)() as unknown as ReduxStoreHandler; - return function () { - return action => { - store.dispatch(action); - }; - }.bind(store); + const store = createStoreHook(context)(); + return function useDispatch() { + return store.dispatch; + }; } export const useSelector = selector => { @@ -90,6 +89,11 @@ type MergePropsP = ( type WrappedComponent = (props: OwnProps) => ReturnType; type OriginalComponent = (props: MergedProps) => ReturnType; type Connector = (Component: OriginalComponent) => WrappedComponent; +type ConnectOption = { + areStatesEqual?: (oldState: State, newState: State) => boolean; + context?: Context; + forwardRef?: boolean; +} export function connect( mapStateToProps: MapStateToPropsP = () => ({} as StateProps), @@ -99,52 +103,39 @@ export function connect( dispatchProps, ownProps ): MergedProps => ({ ...stateProps, ...dispatchProps, ...ownProps } as unknown as MergedProps), - options?: { - areStatesEqual?: (oldState: any, newState: any) => boolean; - context?: Context; - } + options: ConnectOption = {}, ): Connector { - if (!options) { - options = {}; - } - //this component should bear the type returned from mapping functions return (Component: OriginalComponent): WrappedComponent => { - const useStore = createStoreHook(options?.context || DefaultContext); + const useStore = createStoreHook(options.context || DefaultContext); //this component should mimic original type of component used const Wrapper: WrappedComponent = (props: OwnProps) => { - const [f, forceReload] = useState(true); - - const store = useStore() as unknown as ReduxStoreHandler; + const store = useStore() as ReduxStoreHandler; + const [state, setState] = useState(() => store.getState()); useEffect(() => { - const unsubscribe = store.subscribe(() => forceReload(!f)); - return () => { - unsubscribe(); - }; + const unsubscribe = store.subscribe(() => { + setState(store.getState()); + }); + return () => unsubscribe(); + }, []); + + const previous = useRef<{ state: { [key: string]: any }; mappedState: StateProps }>({ + state: {}, + mappedState: {} as StateProps, }); - const previous = useRef({ - state: {}, - mappedState: {}, - }) as { - current: { - state: { [key: string]: any }; - mappedState: StateProps; - }; - }; - let mappedState: StateProps; - if (options?.areStatesEqual) { - if (options.areStatesEqual(previous.current.state, store.getState())) { + if (options.areStatesEqual) { + if (options.areStatesEqual(previous.current.state, state)) { mappedState = previous.current.mappedState as StateProps; } else { - mappedState = mapStateToProps ? mapStateToProps(store.getState(), props) : ({} as StateProps); + mappedState = mapStateToProps ? mapStateToProps(state, props) : ({} as StateProps); previous.current.mappedState = mappedState; } } else { - mappedState = mapStateToProps ? mapStateToProps(store.getState(), props) : ({} as StateProps); + mappedState = mapStateToProps ? mapStateToProps(state, props) : ({} as StateProps); previous.current.mappedState = mappedState; } let mappedDispatch: DispatchProps = {} as DispatchProps; @@ -153,12 +144,14 @@ export function connect( Object.entries(mapDispatchToProps).forEach(([key, value]) => { mappedDispatch[key] = (...args: ReduxAction[]) => { store.dispatch(value(...args)); + setState(store.getState()); }; }); } else { mappedDispatch = mapDispatchToProps(store.dispatch, props); } } + mappedDispatch = Object.assign({}, mappedDispatch, { dispatch: store.dispatch }); const mergedProps = ( mergeProps || ((state, dispatch, originalProps) => { @@ -166,12 +159,18 @@ export function connect( }) )(mappedState, mappedDispatch, props); - previous.current.state = store.getState(); + previous.current.state = state; - const node = createElement(Component, mergedProps); - return node; + return createElement(Component, mergedProps); }; + if (options.forwardRef) { + const forwarded = forwardRef((props, ref) => { + return Wrapper({ ...props, ref: ref }); + }); + return forwarded as WrappedComponent; + } + return Wrapper; }; } diff --git a/packages/inula/src/inulax/adapters/reduxThunk.ts b/packages/inula/src/inulax/adapters/reduxThunk.ts index cd14fee6..7eaaf227 100644 --- a/packages/inula/src/inulax/adapters/reduxThunk.ts +++ b/packages/inula/src/inulax/adapters/reduxThunk.ts @@ -35,5 +35,5 @@ function createThunkMiddleware(extraArgument?: any): ReduxMiddleware { } export const thunk = createThunkMiddleware(); -// @ts-ignore -thunk.withExtraArgument = createThunkMiddleware; + +export const withExtraArgument = createThunkMiddleware; diff --git a/packages/inula/src/inulax/devtools/index.ts b/packages/inula/src/inulax/devtools/index.ts index 10c71a9e..01dc5c37 100644 --- a/packages/inula/src/inulax/devtools/index.ts +++ b/packages/inula/src/inulax/devtools/index.ts @@ -16,7 +16,7 @@ import { isMap, isSet, isWeakMap, isWeakSet } from '../CommonUtils'; import { getStore, getAllStores } from '../store/StoreHandler'; import { OBSERVED_COMPONENTS } from './constants'; -import { VNode } from "../../renderer/vnode/VNode"; +import { VNode } from '../../renderer/vnode/VNode'; const sessionId = Date.now(); diff --git a/packages/inula/src/renderer/TreeBuilder.ts b/packages/inula/src/renderer/TreeBuilder.ts index dff56e07..c29126fb 100644 --- a/packages/inula/src/renderer/TreeBuilder.ts +++ b/packages/inula/src/renderer/TreeBuilder.ts @@ -55,6 +55,9 @@ import { getPathArr } from './utils/vNodePath'; import { injectUpdater } from '../external/devtools'; import { popCurrentRoot, pushCurrentRoot } from './RootStack'; +// 使用 push 扩展语法合并数组场景下被合并数组元素的上限(经验值) +const MAX_NUM_PUSH_MERGE_ARRAY = 1000; + // 不可恢复错误 let unrecoverableErrorDuringBuild: any = null; @@ -81,7 +84,12 @@ function collectDirtyNodes(vNode: VNode, parent: VNode): void { if (parent.dirtyNodes === null) { parent.dirtyNodes = dirtyNodes; } else { - parent.dirtyNodes.push(...vNode.dirtyNodes!); + // 超过上限继续使用 push 方法合并数组将导致性能劣化/调用栈溢出 + if (dirtyNodes.length > MAX_NUM_PUSH_MERGE_ARRAY) { + parent.dirtyNodes = parent.dirtyNodes.concat(dirtyNodes); + } else { + parent.dirtyNodes.push(...dirtyNodes); + } dirtyNodes.length = 0; } vNode.dirtyNodes = null; @@ -236,17 +244,22 @@ export function calcStartUpdateVNode(treeRoot: VNode) { // 在局部更新时,从上到下恢复父节点的context和PortalStack function recoverTreeContext(vNode: VNode) { const contextProviders: VNode[] = []; + const portalRoots: VNode[] = []; let parent = vNode.parent; while (parent !== null) { if (parent.tag === ContextProvider) { contextProviders.unshift(parent); + } else if (parent.tag === DomPortal) { + portalRoots.unshift(parent); } if (parent.tag === DomPortal) { pushCurrentRoot(parent); } parent = parent.parent; } - + portalRoots.forEach(node => { + pushCurrentRoot(node); + }); contextProviders.forEach(node => { setContext(node, node.props.value); }); diff --git a/packages/inula/src/renderer/diff/nodeDiffComparator.ts b/packages/inula/src/renderer/diff/nodeDiffComparator.ts index 8de6f392..e7161174 100644 --- a/packages/inula/src/renderer/diff/nodeDiffComparator.ts +++ b/packages/inula/src/renderer/diff/nodeDiffComparator.ts @@ -15,7 +15,7 @@ import type { VNode } from '../Types'; import { FlagUtils } from '../vnode/VNodeFlags'; -import { TYPE_COMMON_ELEMENT, TYPE_FRAGMENT, TYPE_PORTAL } from '../../external/JSXElementType'; +import { TYPE_COMMON_ELEMENT, TYPE_FRAGMENT, TYPE_PORTAL, TYPE_STRICT_MODE } from '../../external/JSXElementType'; import { DomText, DomPortal, Fragment, DomComponent } from '../vnode/VNodeTags'; import { updateVNode, @@ -35,9 +35,9 @@ enum DiffCategory { ARR_NODE = 'ARR_NODE', } -// 检查是不是被 FRAGMENT 包裹 -function isNoKeyFragment(child: any) { - return child != null && child.type === TYPE_FRAGMENT && child.key === null; +// 检查是不是被 FRAGMENT 或 StrictMode 包裹 +function isNoKeyFragmentOrStrictMode(child: any) { + return child != null && (child.type === TYPE_FRAGMENT || child.type === TYPE_STRICT_MODE) && child.key === null; } // 清除单个节点 @@ -159,7 +159,7 @@ function getNewNode(parentNode: VNode, newChild: any, oldNode: VNode | null) { const key = oldNode !== null ? oldNode.key : newChild.key; resultNode = createFragmentVNode(key, newChild.props.children); } else { - resultNode = updateVNode(oldNode, newChild); + resultNode = updateVNode(oldNode, newChild.props.children); } break; } @@ -631,7 +631,7 @@ export function createChildrenByDiff( newChild: any, isComparing: boolean ): VNode | null { - const isFragment = isNoKeyFragment(newChild); + const isFragment = isNoKeyFragmentOrStrictMode(newChild); newChild = isFragment ? newChild.props.children : newChild; // 1. 没有新节点,直接把vNode标记为删除 diff --git a/packages/inula/src/renderer/hooks/UseReducerHook.ts b/packages/inula/src/renderer/hooks/UseReducerHook.ts index 1278e65e..190d185f 100644 --- a/packages/inula/src/renderer/hooks/UseReducerHook.ts +++ b/packages/inula/src/renderer/hooks/UseReducerHook.ts @@ -64,8 +64,13 @@ export function TriggerAction(vNode: VNode, hook: Hook, isUseState: } } - // 执行vNode节点渲染 - launchUpdateFromVNode(vNode); + if (vNode === getProcessingVNode()) { + // 绑定的VNode就是当前渲染的VNode时,就是在函数组件体内触发setState + markUpdatedInRender(); + } else { + // 执行vNode节点渲染 + launchUpdateFromVNode(vNode); + } } export function useReducerForInit(reducer, initArg, init, isUseState?: boolean): [S, Trigger] { diff --git a/packages/inula/src/renderer/render/DomPortal.ts b/packages/inula/src/renderer/render/DomPortal.ts index 27bad601..24628605 100644 --- a/packages/inula/src/renderer/render/DomPortal.ts +++ b/packages/inula/src/renderer/render/DomPortal.ts @@ -17,11 +17,12 @@ import type { VNode } from '../Types'; import { resetNamespaceCtx, setNamespaceCtx } from '../ContextSaver'; import { createChildrenByDiff } from '../diff/nodeDiffComparator'; import { popCurrentRoot, pushCurrentRoot } from '../RootStack'; -import { listenSimulatedDelegatedEvents } from '../../event/EventBinding'; +import { listenPortalEvents, listenSimulatedDelegatedEvents } from '../../event/EventBinding'; export function bubbleRender(processing: VNode) { resetNamespaceCtx(processing); listenSimulatedDelegatedEvents(processing); + listenPortalEvents(processing); popCurrentRoot(); } diff --git a/packages/inula/src/renderer/utils/compare.ts b/packages/inula/src/renderer/utils/compare.ts index 95e0f3a5..de5d58ca 100644 --- a/packages/inula/src/renderer/utils/compare.ts +++ b/packages/inula/src/renderer/utils/compare.ts @@ -17,7 +17,7 @@ * 兼容IE浏览器没有Object.is */ export function isSame(x: any, y: any) { - if (!(typeof Object.is === 'function')) { + if (typeof Object.is !== 'function') { if (x === y) { // +0 != -0 return x !== 0 || 1 / x === 1 / y; diff --git a/packages/inula/src/renderer/vnode/VNodeCreator.ts b/packages/inula/src/renderer/vnode/VNodeCreator.ts index 142bac33..55ee5247 100644 --- a/packages/inula/src/renderer/vnode/VNodeCreator.ts +++ b/packages/inula/src/renderer/vnode/VNodeCreator.ts @@ -74,7 +74,7 @@ export function getLazyVNodeTag(lazyComp: any): string { } else if (lazyComp !== undefined && lazyComp !== null && typeLazyMap[lazyComp.vtype]) { return typeLazyMap[lazyComp.vtype]; } - throw Error("Inula can't resolve the content of lazy"); + throw Error('Inula can\'t resolve the content of lazy'); } // 创建processing