├── .eslintignore ├── .eslintrc.json ├── .gitignore ├── LICENSE ├── README.md ├── gulpfile.js ├── package.json ├── screenshots ├── donate_wechat.png ├── screenshot_basic.jpg ├── screenshot_canvas.jpg ├── screenshot_component.jpg ├── screenshot_font.jpg ├── screenshot_iconfont.jpg ├── screenshot_multiple.jpg ├── screenshot_overflow.jpg ├── screenshot_package.jpg ├── screenshot_snapshot.jpg ├── screenshot_snapshot2.jpg ├── screenshot_snapshot3.jpg └── screenshot_video.jpg ├── src ├── canvas.js ├── constants.js ├── element.js ├── gradient.js ├── index.js ├── index.json ├── index.wxml └── index.wxss └── tools ├── build.js ├── config.js └── demo ├── app.js ├── app.json ├── app.wxss ├── images ├── U3e6ny.jpg └── qrcode.png ├── pages ├── basic │ ├── index.js │ ├── index.json │ └── index.wxml ├── canvas │ ├── index.js │ ├── index.json │ └── index.wxml ├── component │ ├── comp.js │ ├── comp.json │ ├── comp.wxml │ ├── comp.wxss │ ├── index.js │ ├── index.json │ └── index.wxml ├── font │ ├── index.js │ ├── index.json │ └── index.wxml ├── iconfont │ ├── index.js │ ├── index.json │ ├── index.wxml │ └── index.wxss ├── index │ ├── index.js │ ├── index.json │ └── index.wxml ├── multiple │ ├── index.js │ ├── index.json │ └── index.wxml ├── overflow │ ├── index.js │ ├── index.json │ └── index.wxml ├── package │ ├── comp.js │ ├── comp.json │ ├── comp.wxml │ ├── comp.wxss │ ├── index.js │ ├── index.json │ └── index.wxml ├── snapshot-2 │ ├── index.js │ ├── index.json │ ├── index.wxml │ └── index.wxss ├── snapshot-3 │ ├── index.js │ ├── index.json │ ├── index.wxml │ └── index.wxss ├── snapshot │ ├── index.js │ ├── index.json │ ├── index.wxml │ └── index.wxss └── video │ ├── index.js │ ├── index.json │ └── index.wxml ├── project.config.json ├── sitemap.json └── utils └── defer.js /.eslintignore: -------------------------------------------------------------------------------- 1 | miniprogram_dev 2 | miniprogram_dist 3 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es2021": true, 5 | "node": true 6 | }, 7 | "extends": [ 8 | "airbnb-base" 9 | ], 10 | "parserOptions": { 11 | "ecmaVersion": 12, 12 | "sourceType": "module" 13 | }, 14 | "globals": { 15 | "App": true, 16 | "Page": true, 17 | "Component": true, 18 | "Behavior": true, 19 | "wx": true, 20 | "getApp": true, 21 | "getCurrentPages": true 22 | }, 23 | "rules": { 24 | "import/no-extraneous-dependencies": "off", 25 | "no-plusplus": "off", 26 | "no-continue": "off", 27 | "linebreak-style": "off", 28 | "no-multi-assign": "off", 29 | "no-underscore-dangle": "off", 30 | "no-param-reassign": "off", 31 | "no-await-in-loop": "off", 32 | "no-fallthrough": "off", 33 | "no-nested-ternary": "off" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | miniprogram_dev 2 | miniprogram_dist 3 | node_modules 4 | package-lock.json 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 ChrisChan 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # wxml2canvas-2d 2 | 3 | 基于微信小程序 2D Canvas 的画布组件,根据给定 WXML 结构以及 CSS 样式快速转换成 Canvas 元素,以用于生成海报图片分享等操作。所见即所得(bushi 4 | 5 | ## 安装 6 | 7 | ### npm 8 | 9 | 使用 npm 构建前,请先阅读微信官方的 [npm 支持](https://developers.weixin.qq.com/miniprogram/dev/devtools/npm.html)。 10 | 11 | ```bash 12 | # 通过 npm 安装 13 | npm i wxml2canvas-2d -S --production 14 | ``` 15 | 16 | ### 构建 npm 包 17 | 18 | 打开微信开发者工具,点击 **工具** -> **构建 npm**,并勾选 **使用 npm 模块** 选项,构建完成后,即可引入组件。 19 | 20 | ## 使用 21 | 22 | 1. 在页面配置中引入 `wxml2canvas-2d` ; 23 | ```json 24 | { 25 | "usingComponents": { 26 | "wxml2canvas": "wxml2canvas-2d" 27 | } 28 | } 29 | ``` 30 | 2. 在页面中编写 wxml 结构,将要生成画布内容的**根节点**用名为 `wxml2canvas-container` 的样式类名称标记,将该根节点内部**需要生成画布内容的节点**用名为 `wxml2canvas-item` 的样式类名称标记(文字类节点需在对应节点声明 `data-text` 属性,并传入文字内容)。上述两个样式类名称可以自定义,只需将对应名称传入 `wxml2canvas-2d` 组件的对应属性参数即可; 31 | ```html 32 | 33 | 34 | 测试标题 35 | 36 | 测试内容,长文本。。 37 | 38 | 39 | 40 | ``` 41 | 3. 补充各个节点样式; 42 | ```css 43 | /* pages/index/index.wxss */ 44 | .box { /* 根节点(容器)的样式 */ background: white; } 45 | .title { /* 标题的样式 */ } 46 | .image { /* 图片的样式 */ } 47 | .content { /* 内容的样式 */ } 48 | ``` 49 | 4. 依据 wxml 结构以及 css 样式,生成画布内容,并将生成结果导出; 50 | ```javascript 51 | // pages/index/index.js 52 | Page({ 53 | async generateSharingCard() { 54 | const canvas = this.selectComponent('#wxml2canvas'); 55 | await canvas.draw(); 56 | const filePath = await canvas.toTempFilePath(); 57 | wx.previewImage({ 58 | urls: [filePath], 59 | }); 60 | }, 61 | }); 62 | ``` 63 | 5. 更多使用方式以及注意事项参考 [API](#api) 以及 [其他](#其他),或克隆此仓库查看更多示例; 64 | 65 | > **PS**:使用字体时,请注意在**生成画布内容前** [**加载对应的字体文件**](https://developers.weixin.qq.com/miniprogram/dev/api/ui/font/wx.loadFontFace.html);部分平台如 Windows 可能不支持画布使用自定义字体(小程序基础库 [v3.6.6](https://developers.weixin.qq.com/miniprogram/dev/framework/release/#v3-6-6-2024-11-12) 及以上已修复);离屏画布模式下,大部分设备均不支持画布使用自定义字体(小程序基础库 [v3.8.7](https://developers.weixin.qq.com/miniprogram/dev/framework/release/#v3-8-7-2025-05-27) 及以上已修复)。 66 | 67 | ## API 68 | 69 | ### 组件参数 70 | 71 | ||类型|说明|默认值| 72 | |:-|:-|:-|:-| 73 | |container-class|String|根节点(容器)样式类名称|wxml2canvas-container| 74 | |item-class|String|内部节点样式类名称|wxml2canvas-item| 75 | |scale|Number|画布缩放比例|1| 76 | |offscreen|Boolean|是否使用离屏画布|false| 77 | 78 | ### 外部样式类 79 | 80 | ||说明| 81 | |:-|:-| 82 | |box-class|Canvas 节点样式类名称| 83 | 84 | ### 组件方法 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 |
说明参数
属性默认值说明
draw(page?: PageObject, component?: ComponentObject)绘制画布内容page当前页面实例组件所在页面实例
component-组件所在组件实例
toTempFilePath(original?: Boolean)导出画布至临时路径originaltrue是否使用实机渲染尺寸
true:各设备像素比不同,导出结果尺寸不同
false:以 750px 为标准,与 WXSS 一致
toDataURL()导出画布至 Data URI-
getImageData()提取画布的像素数据-
127 | 128 | > **PS**:iOS、Mac 与 Windows 平台在**离屏画布模式**(offscreen 为 true)下使用 `wx.canvasToTempFilePath` 导出时会[报错](https://developers.weixin.qq.com/community/search?query=fail%2520invalid%2520viewId)(小程序基础库 [v3.7.1](https://developers.weixin.qq.com/miniprogram/dev/framework/release/#v3-7-1-2024-11-26) 及以上已修复),可以使用 `Canvas.toDataURL` 搭配 `FileSystemManager.saveFile` 保存导出的图片 129 | 130 | ### 其他 131 | 132 |
133 | WXML 组件支持情况 134 |
135 | 136 | > 仅能获取组件自身的样式内容,无法获取组件的伪元素等样式内容 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 |
名称说明
view视图容器,支持
text文本,支持
button按钮,支持
image图片,支持
video视频,支持
canvas画布,支持
169 |
170 |
171 | CSS 属性支持情况 172 |
173 | 174 | > 基础定位布局相关属性 left、width、padding、margin 等均支持 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | 210 | 211 | 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | 228 | 229 | 230 | 231 | 232 | 233 | 234 | 235 | 236 | 237 | 238 | 239 | 240 | 241 | 242 | 243 | 244 | 245 | 246 | 247 | 248 | 249 | 250 | 251 | 252 | 253 | 254 | 255 | 256 | 257 | 258 | 259 | 260 | 261 | 262 | 263 | 264 | 265 | 266 | 267 | 268 | 269 | 270 | 271 | 272 | 273 | 274 | 275 | 276 | 277 | 278 | 279 | 280 | 281 | 282 | 283 | 284 | 285 | 286 | 287 | 288 | 289 | 290 | 291 | 292 | 293 | 294 | 295 | 296 | 297 | 298 | 299 | 300 | 301 | 302 | 303 | 304 | 305 | 306 | 307 | 308 | 309 | 310 | 311 | 312 |
属性说明
background背景,支持渐变图案
background-color背景颜色,支持
background-image背景图像,支持
background-positionbackground-position-x背景图像水平方向的位置,支持
background-position-y背景图像垂直方向的位置,支持
background-size背景图像的大小,支持
background-repeat背景图像的重复方式,暂不支持 space 和 round
background-clip背景图像的延伸方式,支持
borderborder-width边框宽度,支持
border-style边框样式,暂仅支持 solid、dashed 和 double
border-color边框颜色,支持
opacity透明度,支持
box-shadow阴影,暂仅支持单一阴影
border-radius圆角,支持
font-family字体,支持
font-size字体大小,支持
font-weight字重,支持
text-align文本对齐,支持
line-height行高,支持
text-overflow文字溢出处理,支持
color文字颜色,支持
text-indent首行缩进,支持
text-shadow文字阴影,支持
direction文本方向,支持
letter-spacing字符间距,部分平台支持:Windows
word-spacing单词间距,部分平台支持:Windows
filter滤镜效果,部分平台支持:Windows
transform二维变换,支持
transform-origin变形原点,支持
text-decorationtext-decoration-line文本装饰类型,支持
text-decoration-style文本装饰样式,暂仅支持 solid、dashed 和 double
text-decoration-color文本装饰颜色,支持
313 |
314 |
315 | TODOs 316 |
317 | 318 | - [x] 支持 `background-image` 等背景图片样式 319 | - [x] 支持 `background-image` 基础属性设置 320 | - [x] 支持 `background-clip` 延伸范围 321 | - [ ] 支持渐变类 `Gradients` 322 | - [x] 支持 `linear-gradient` 线性渐变 323 | - [x] 支持 `radial-gradient` 径向渐变 324 | - [x] 支持 `conic-gradient` 锥形渐变 325 | - [ ] 支持多重 `Gradients` 渐变 326 | - [ ] 支持渐变类 `Gradients` 插值提示(*大脑烧烤中...*) 327 | - [ ] 支持多重 `background`,多重 `box-shadow` 328 | - [x] 支持多重 `background-image` 329 | - [ ] 支持多重 `box-shadow` 330 | - [x] 支持 `CSS Transforms` 相关属性 331 | - [ ] 支持 `CSS Writing Modes` 相关属性(*大脑烧烤中...*) 332 | - [x] 支持 `text-indent`、`text-shadow` 等文字样式 333 | - [x] 支持 `filter` 滤镜效果 334 | - [x] 支持 `video` 标签 335 | - [x] 支持 `canvas` 标签 336 | - [x] 支持渲染自定义组件 337 | - [x] 支持渲染 iconfont 矢量图标 338 |
339 |
340 | 使用注意 341 |
342 | 343 | - 微信新版 Canvas 2D 的画布有宽高分别最大不能超过 4096px 的限制,此 repo 绘制画布时会将画布大小根据设备像素比(dpr)进行放大,使用时请注意避免容器的宽高大于 4096px / dpr 344 | - 尽管微信新版 Canvas 2D 接口采用同步的方式绘制 Canvas 元素,但在部分机型或平台上调用 wx.canvasToTempFilePath 时,也可能绘制过程尚未完成,所以使用过程中尽可能延迟或分步骤调用 wx.canvasToTempFilePath 进行导出图片的操作 345 | - 绘制文字元素时,各机型和各平台对于 font-size、font-weight、line-height 的实际表现与 CSS 中的表现有细微不同,取决于元素的 font-family,建议为文字设置固定的 line-height 346 | - Image 元素的 src 支持:绝对路径、网络地址、临时路径、本地路径以及 base64 Data URI,暂不支持相对路径,无法根据相对路径定位图片资源地址 347 | - 组件方法中的 draw 方法,允许传入 page 与 component 两个参数。当未传入 page 时,默认使用 getCurrentPages 中的最后一个页面实例,即当前页面实例。若此组件位于另一组件内,需传入 component 参数,支持仅传入 component 参数,即:draw(page, component) 与 draw(component) 两种传参方式 348 | - 绘制元素的阴影时,阴影的透明度将随着背景色的透明度等比改变,未设置背景色时,元素的阴影将会不可见,所以绘制元素的阴影时,请尽量设置该元素的背景色为不透明的实色,若无设置,此 repo 在绘制该元素的阴影前会自动设置为纯黑色背景 349 | - 绘制文字的阴影时,阴影的透明度将随着文字颜色的透明度等比改变,所以绘制文字的阴影时,请尽量设置该元素的文字颜色为不透明的实色 350 | - 绘制渐变图案时,请尽量在 CSS 中将渐变的色标按位置正序顺序依次书写,支持使用负值(径向渐变除外),暂未处理色标位置错乱情况下的表现形式,暂不支持控制渐变进程的插值提示 351 | - 设置渐变背景图案时,请尽量避免使用 black、white 等名词形式描述颜色,部分 iOS 设备不会自动转换颜色内容,难以匹配并识别颜色(目前发现部分 iOS 设备中,红色不管以任何形式描述,结果均显示为 red,暂时已处理,且仅处理颜色为 red 的情况) 352 | - 开启离屏画布模式时,部分平台在绘制图片时,由 Canvas.createImage 创建的图片元素,相同的 src 只触发一次 onload 回调,目前只能避免对同一图片重复绘制 353 |
354 |
355 | 开发注意 356 |
357 | 358 | - 微信新版 Canvas 2D 接口基本与 Web Canvas API 对齐,但仍有部分 API 存在差异,随着微信版本或基础库更新,或许会提高相应 API 的支持度 359 | - iOS 平台对于 Path2D 的支持度不足,此 repo 已去除 Path2D 的相关应用,转而使用普通路径,相对应的路径生成次数会增多,绘制时长有所增加,但不多 360 | - 部分 iOS 平台使用 CanvasContext.ellipse 以及 Path2D.ellipse 时,其中的参数 rotation 旋转角度所使用的角度单位不同:iOS 使用角度值,macOS 平台未知,其余使用弧度值 361 | - 绘制文字元素时,各机型和各平台对于 font-size、font-weight、line-height 的实际表现与 CSS 中的表现有细微不同,此 repo 暂时使用常量比例进行换算对齐,未彻底解决 362 | - 绘制元素的边框暂时只支持 solid、dashed 和 double 三种样式,其中 dashed 样式的边框使用 CanvasContext.setLineDash 实现,各机型和各平台的边框虚线间距表现均有差异,此 repo 暂时使用与边框宽度等比的间距表现虚线边框 363 | - 微信新版 Canvas API 目前不支持绘制椭圆形径向渐变图案,此 repo 使用 CanvasContext.scale 对圆形径向渐变图案进行放大或缩小,以实现椭圆形径向渐变图案,而在 closest-corner 与 farthest-corner 模式下的椭圆形径向渐变中,目前还未找出 CSS 在绘制椭圆形径向渐变图案时的长轴与短轴的大小的计算规则,暂时使用常量比例进行换算对齐,未彻底解决 364 | - 锥形渐变图案目前仅微信开发者工具以及 Windows 平台支持,开发工具上锥形渐变角度的 0° 基准与 CSS 表现一致(即 12 点钟方向),起始角度参数的角度单位为弧度,Windows 平台上的 0° 基准为 3 点钟方向,起始角度参数的角度单位为角度,iOS 和 Android 均不支持 CanvasContext.createConicGradient API,macOS 平台未知 365 |
366 |
367 | 更新日志 368 |
369 | 370 | - **v1.3.8 (2025-08-09)** 371 | 1. `F` 修复 transform 表现错误 372 | - **v1.3.7 (2025-08-08)** 373 | 1. `A` 新增 支持绘制 iconfont 矢量图标 374 | - **v1.3.6 (2025-08-06)** 375 | 1. `F` 修复 部分情况下文字缺失 376 | - **v1.3.5 (2025-07-30)** 377 | 1. `F` 修复 部分情况下文字错乱 378 | - **v1.3.4 (2025-07-08)** 379 | 1. `A` 新增 支持绘制自定义组件 380 | - **v1.3.3 (2025-07-08)** 381 | 1. `A` 新增 支持绘制元素 canvas 382 | - **v1.3.2 (2025-07-07)** 383 | 1. `A` 新增 支持绘制样式 text-decoration、text-decoration-color、text-decoration-line、text-decoration-style (solid、dashed、double) 384 | - **v1.3.1 (2025-05-27)** 385 | 1. `U` 更新 兼容部分情况圆角表现差异 386 | - **v1.3.0 (2025-04-28)** 387 | 1. `A` 新增 支持绘制样式 border-left、border-right、border-top、border-bottom 388 | 2. `A` 新增 支持绘制样式 border-style (double) 389 | - **v1.2.5 (2025-04-26)** 390 | 1. `U` 更新 兼容部分设备字体表现差异 391 | - **v1.2.4 (2025-04-21)** 392 | 1. `U` 更新 优化绘制流程 393 | 2. `A` 新增 支持绘制元素 video [详情](https://github.com/ChrisChan13/wxml2canvas-2d/issues/20) 394 | - **v1.2.3 (2025-04-01)** 395 | 1. `F` 修复 text-overflow 表现错误 [详情](https://github.com/ChrisChan13/wxml2canvas-2d/issues/17) 396 | - **v1.2.2 (2025-03-18)** 397 | 1. `U` 更新 优化文字绘制流程 398 | 2. `F` 修复 Number 类型文字绘制报错 [详情](https://github.com/ChrisChan13/wxml2canvas-2d/issues/14) 399 | - **v1.2.1 (2025-02-25)** 400 | 1. `A` 新增 支持导出 ImageData (像素点数据) 401 | 2. `U` 更新 优化文字绘制流程 402 | 3. `A` 新增 支持绘制样式 direction [详情](https://github.com/ChrisChan13/wxml2canvas-2d/issues/13) 403 | - **v1.2.0 (2025-02-18)** 404 | 1. `A` 新增 支持绘制样式 transform、transform-origin [详情](https://github.com/ChrisChan13/wxml2canvas-2d/issues/4) 405 | - **v1.1.8 (2025-01-22)** 406 | 1. `F` 修复 line-height 过高时表现错误 [详情](https://juejin.cn/post/7439556363104600079#comment) 407 | - **v1.1.7 (2025-01-21)** 408 | 1. `F` 修复 组件嵌套于组件时绘制报错 [详情](https://developers.weixin.qq.com/community/develop/article/doc/0000eae9008c484fe262362c66b013?jumpto=comment&commentid=00024297c4c28081a9b2672a1654) 409 | - **v1.1.6 (2025-01-14)** 410 | 1. `F` 修复 组件嵌套于组件时绘制报错 [详情](https://developers.weixin.qq.com/community/develop/article/doc/0000eae9008c484fe262362c66b013?jumpto=comment&commentid=00024297c4c28081a9b2672a1654) 411 | - **v1.1.5 (2024-11-27)** 412 | 1. `A` 修复 iOS 平台 border-radius 表现错误 (iOS 角度单位与其他平台对齐) [详情](https://github.com/ChrisChan13/wxml2canvas-2d/issues/11) 413 | - **v1.1.4 (2024-11-18)** 414 | 1. `U` 更新 优化部分常量变量设置 415 | - **v1.1.3 (2024-11-16)** 416 | 1. `A` 新增 支持离屏画布模式 417 | 2. `A` 新增 支持导出 DataURI (Base64 编码) 418 | - **v1.1.2 (2024-11-14)** 419 | 1. `F` 修复 text-align 表现错误 420 | - **v1.1.1 (2024-11-14)** 421 | 1. `A` 新增 支持绘制样式 filter (仅 Windows 支持) 422 | - **v1.1.0 (2024-11-11)** 423 | 1. `U` 更新 优化绘制流程 424 | 2. `A` 新增 支持绘制样式 letter-spacing (仅 Windows 支持)、word-spacing (仅 Windows 支持) 425 | - **v1.0.10 (2024-11-11)** 426 | 1. `A` 新增 支持绘制样式 text-shadow [详情](https://github.com/ChrisChan13/wxml2canvas-2d/issues/10) 427 | - **v1.0.9 (2024-11-01)** 428 | 1. `A` 新增 支持绘制换行符 [详情](https://github.com/ChrisChan13/wxml2canvas-2d/issues/9) 429 | 2. `F` 修复 单行文字 text-overflow 表现错误 430 | 3. `A` 新增 支持绘制样式 text-indent 431 | - **v1.0.8 (2024-07-02)** 432 | 1. `U` 更新 优化节点信息查询逻辑 433 | 2. `U` 更新 兼容部分设备字体表现差异 [详情](https://github.com/ChrisChan13/wxml2canvas-2d/issues/7) 434 | - **v1.0.7 (2024-04-22)** 435 | 1. `A` 新增 支持绘制样式 background-clip 436 | - **v1.0.6 (2024-04-19)** 437 | 1. `F` 修复 Windows 平台画布缩放错误 [详情](https://github.com/ChrisChan13/wxml2canvas-2d/issues/5) 438 | 2. `A` 新增 支持导出时统一尺寸 439 | - **v1.0.5 (2024-04-16)** 440 | 1. `A` 新增 支持绘制样式 radial-gradient 441 | 2. `A` 新增 支持绘制样式 conic-gradient (仅 Windows 支持) 442 | - **v1.0.4 (2024-04-11)** 443 | 1. `U` 更新 修改元素的盒子模型绘制逻辑 444 | - **v1.0.3 (2024-04-11)** 445 | 1. `F` 修复 绘制背景图报错 446 | - **v1.0.2 (2024-04-10)** 447 | 1. `A` 新增 支持绘制样式 background-image、background-size、background-repeat、background-position [详情](https://github.com/ChrisChan13/wxml2canvas-2d/issues/1) 448 | 2. `U` 更新 优化 Gradient 对象创建逻辑 449 | - **v1.0.1 (2024-03-16)** 450 | 1. `F` 修复 iOS 平台表现错误 (iOS 不支持 Path2D) [详情](https://github.com/ChrisChan13/wxml2canvas-2d/issues/3) 451 | 2. `A` 新增 支持绘制样式 linear-gradient 452 | - **v1.0.0 (2023-12-19)** 453 | 1. `A` 新增 支持绘制元素 image、view、text、button 454 | 2. `A` 新增 支持绘制样式 定位相关属性、padding、background-color、opacity、border-radius 455 | 3. `A` 新增 支持绘制样式 font-weight、font-size、font-family、text-align、line-height、text-overflow、color 456 | 4. `A` 新增 支持绘制样式 box-shadow (单个阴影) 457 | 5. `A` 新增 支持绘制样式 border (四边一致)、border-width、border-color 458 | 6. `A` 新增 支持绘制样式 border-style (dashed 和 solid) 459 | 7. `A` 新增 支持绘制内容缩放 460 | 8. `A` 新增 支持导出 tempFile 临时文件 461 |
462 | 463 | ## FAQ 464 | 465 |
466 | 如何绘制自定义组件? 467 |
468 | 469 | `wxml2canvas-2d` 支持绘制自定义组件,自定义组件内也可以使用其他自定义组件。 470 | 1. 自定义组件的元素节点需要声明 `id` 以及 `data-component` 属性,当然 **样式类** `wxml2canvas-item` 也不可缺少。请确保 `id` 在文档中不重复,`data-component` 为 `Boolean` 类型,只需声明即为 `true` 值。 471 | 2. 自定义组件内的元素节点与页面内的元素节点无异,为需要渲染的元素节点用样式类 `wxml2canvas-item` 标记即可。 472 | 3. 自定义组件 `slot` 插槽内的元素节点与页面内的元素节点无异,同上。 473 | 4. 支持渲染自定义组件内的子自定义组件,为子自定义组件进行如上同样的设置即可。 474 | 475 | 参考如下: 476 | ```html 477 | 478 | 479 | 测试标题 480 | 481 | 482 | 483 | 484 | 485 | 486 | 487 | 测试内容,长文本。。 488 | 489 | 490 | ``` 491 |
492 |
493 | 如何绘制 iconfont 矢量图标? 494 |
495 | 496 | `wxml2canvas-2d` 支持绘制 iconfont 矢量图标。与自定义字体类似,生成画布内容前需加载对应的矢量图标字体文件。此外,还需搭配 `data-icon` 属性传入对应图标的十六进制 Unicode 码,该码与 CSS 中对应的矢量图标字符码相同。 497 | 498 | 参考如下: 499 | ```html 500 | 501 | 502 | 503 | 504 | 测试标题 505 | 506 | 507 | ``` 508 | ```css 509 | @font-face { 510 | font-family: 'iconfont'; 511 | src: url('data:font/ttf;charset=utf-8;base64,XXXXXXXXXXXXX') format('truetype'); 512 | /* 其他样式 */ 513 | } 514 | [class^="icon-"], [class*=" icon-"] { 515 | font-family: 'iconfont' !important; 516 | /* 其他样式 */ 517 | } 518 | .icon-title::before { 519 | content: '\e996'; 520 | } 521 | ``` 522 | ```javascript 523 | Page({ 524 | async generateSharingCard() { 525 | await wx.loadFontFace({ 526 | family: 'iconfont', 527 | // 可以为 https 链接或者 Data URL 528 | source: 'data:font/ttf;charset=utf-8;base64,XXXXXXXXXXXXX', 529 | scopes: ['native'], 530 | }); 531 | // 导出画布 532 | // ... 533 | }, 534 | }); 535 | ``` 536 |
537 |
538 | 如何同时截取多个不同节点的图片? 539 |
540 | 541 | 当需要同时截取页面上不同节点多张不同图片的时候,可以用多个 `wxml2canvas-2d` 组件,各自为 `container-class` 以及 `item-class` 自定义不同的样式类名,并在对应节点的 `class` 中体现,如: 542 | ```html 543 | 544 | 545 | 测试标题 546 | 547 | 测试内容,长文本。。 548 | 549 | 550 | 551 | 552 | 测试标题 553 | 554 | 测试内容,长文本。。 555 | 556 | 557 | 558 | 559 | 560 | 561 | ``` 562 | ```javascript 563 | Page({ 564 | // 同时截取节点一与节点二的图片 565 | async captureAllNodes() { 566 | const filePaths = await Promise.all( 567 | this.captureNodeScreenshot('#canvas_1'), 568 | this.captureNodeScreenshot('#canvas_2'), 569 | ); 570 | }, 571 | async captureNodeScreenshot(id) { 572 | const canvas = this.selectComponent(id); 573 | await canvas.draw(); 574 | const filePath = await canvas.toTempFilePath(); 575 | return filePath; 576 | }, 577 | }); 578 | ``` 579 |
580 |
581 | Error: set height/width out of range: xxxx > xxxx 582 |
583 | 584 | 此问题为微信对 2D Canvas 的高度/宽度限制,在不同设备中具体的限制大小有所不同,根据设备的像素比,以 4096 为基数的倍数作为限制大小。 585 | 若触发了此类限制,且由 `wxml2canvas-2d` 组件渲染的内容相对固定,即宽高变化不大,可以传入组件参数 `scale` 对画布进行缩小。比较好的方案为根据设备的像素比按比例缩放,但需注意缩小后导出图片的质量有所降低。 586 | 若需要渲染的内容过多,且宽高无法确定,推荐将内容分段渲染,使用多个 `wxml2canvas-2d` 组件渲染不同段落的内容,最后将所有导出的图片,使用第三方库合成图片,或将所有图片按序拼接在一个旧版 Canvas 画布中并导出。 587 |
588 |
589 | Error: The height/width xxxx has exceeded the limit 4096 590 |
591 | 592 | 此问题与上一个问题相同,均为微信对 2D Canvas 的高度/宽度限制,区别在于此类报错信息存在于旧基础库版本中,该限制为固定高度限制。参考上一问题。 593 |
594 |
595 | TypeError: Cannot read property 'draw' of null 596 |
597 | 598 | 此问题一般是由于调用 `draw` 方法时,`wxml2canvas-2d` 组件实例不存在于当前页面中。请检查页面 JSON 配置文件,是否配置了 `wxml2canvas-2d` 组件,以及页面中是否编写了 `` 节点。 599 |
600 |
601 | TypeError: Cannot read property 'width' of undefined 602 |
603 | 604 | 此问题一般是由于将 `wxml2canvas-2d` 组件封装于另一组件内,而调用 `draw` 方法时,没有将组件实例传入,导致查询不到 `wxml2canvas-2d` 节点。请参考“如何在自定义组件中使用 wxml2canvas-2d 组件”。 605 |
606 |
607 | 如何在自定义组件中使用 wxml2canvas-2d 组件? 608 |
609 | 610 | 将 `wxml2canvas-2d` 组件封装于自定义组件中时,由于小程序的节点查询方法需要传入对应的组件实例,所以 `draw` 方法支持传入页面或组件的实例。传参方式有: 611 | ```javascript 612 | // 一、默认使用当前页面实例,即不传参数 613 | Page({ 614 | captureNodeScreenshot() { 615 | const canvas = this.selectComponent('#wxml2canvas'); 616 | await canvas.draw(); 617 | }, 618 | }); 619 | 620 | // 二、传入页面实例,调用另一个页面的方法 621 | Page({ 622 | captureNodeScreenshot() { 623 | /** 上一个页面的页面实例 */ 624 | const page = getCurrentPages().slice(-2)[0] 625 | const canvas = page.selectComponent('#wxml2canvas'); 626 | await canvas.draw(page); 627 | }, 628 | }); 629 | 630 | // 三、传入组件实例,位于自定义组件内时必传 631 | Component({ 632 | methods: { 633 | captureNodeScreenshot() { 634 | const canvas = this.selectComponent('#wxml2canvas'); 635 | await canvas.draw(this); 636 | }, 637 | }, 638 | }); 639 | 640 | // 四、待绘制节点位于组件内,传入组件实例 641 | Page({ 642 | captureNodeScreenshot() { 643 | const component = this.selectComponent('#yourComponent'); 644 | const canvas = this.selectComponent('#wxml2canvas'); 645 | await canvas.draw(this, component); 646 | }, 647 | }); 648 | ``` 649 |
650 |
651 | 为什么文本内容截图出来不一样? 652 |
653 | 654 | 关于文本内容,不同设备有不同的默认字体、行高、字重等影响文字在界面中表现的因素,而在将文字绘制于画布中时,这些差异也会被放大。因此,若画布渲染与界面渲染之间有细微的差异,属于正常现象,适当设置文字的字体、行高、字重等样式可以减少此类差异。 655 |
656 |
657 | 文本内容被截取、溢出缩略不正确、换行结果不正确? 658 |
659 | 660 | 上一个问题“为什么文本内容截图出来不一样?”中提到了不同设备之间文字的表现差异,这是其中一个对于此问题很大的影响因素,具体分为以下几种情况: 661 | 662 | 1. 文字未能渲染完整,末端发生了截取:`wxml2canvas-2d` 组件会获取元素在界面中渲染的宽高,并将渲染范围限制在该宽高范围内,超出的部分将不会渲染。由于界面与画布的文字表现存在差异,有可能出现界面上文字所占宽高小于画布上文字所占宽高,导致溢出部分被截取。 663 | 2. 文字缩略位置不一致或没有正常缩略:与情况 1 相似,界面与画布的表现差异影响了文字所占空间的大小,从而使缩略位置产生偏差。而没有正常缩略的情况与情况 3 相似,参考情况 3。 664 | 3. 多行文字没有换行或单行文字产生换行:不同语言的文字存在不同的分词规则,从而决定其文字在界面上的表现,如英文单词会在行内空间不足时提前换行以确保单词完整显示等等。`wxml2canvas-2d` 组件使用 `Intl.Segmenter` 处理分词,但该 API 支持范围有限。在不支持 `Intl.Segmenter` 的设备上将会调用简单的 polyfill 来模拟分词,该 polyfill 分词规则简单,因此误判率高,从而对换行结果产生了影响。 665 | 666 | 上述情况 1 的问题虽已经过计算优化,但仍无法覆盖所有语言文字字符组合的情况。情况 3 中 polyfill 的分词规则与 空格符(/x20)以及一部分英文标点字符相关,若分词规则有误,很大可能是由于文本中有大量的中英文数字或空格等字符的混合内容。若文本中空格较多,画布绘制与界面表现差距太大,可以尝试将 空格(/x20)替换为 空格(/xa0),此举将绕过部分 polyfill 的分词过滤。 667 |
668 |
669 | 为什么部分设备圆角绘制不正确? 670 |
671 | 672 | 这个问题目前仅在部分 iOS 设备中发现过,由于圆角使用了 `CanvasContext.ellipse` API 来绘制,而部分 iOS 设备的 `CanvasContext.ellipse` 方法实现不同,其中一个角度参数的描述单位不同,iOS 使用了角度为单位,而其他设备是正常的弧度单位。出现该问题的 iOS 设备范围暂时无法准确界定,无法得到有效的修复,实际过程中可以减少椭圆形圆角的使用,采用圆形圆角代替,避免出现该问题。 673 |
674 |
675 | 为什么单词间距和字符间距不生效? 676 |
677 | 678 | 单词间距(word-spacing)和字符间距(letter-spacing)目前发现仅在开发工具和 Windows 设备上有效,其他设备设置了对应的 Canvas 样式后没有起到任何效果。实际过程中尽量避免单词间距和字符间距的设置,否则可能会导致文字占用空间变小,绘制时产生截取。若必须控制间距,可将文字内容拆分为单词/字符,为每个单词/字符设置 margin 样式。 679 |
680 |
681 | 如何在 uni-app 与 Taro 中使用? 682 |
683 | 684 | `wxml2canvas-2d` 组件可以在 uni-app 与 Taro 中使用,但跨平台的支持度有限,目前只支持微信小程序平台。 685 | 1. 在 uni-app 中使用:参考 [小程序自定义组件支持](https://uniapp.dcloud.net.cn/tutorial/miniprogram-subject.html)。 686 | 2. 在 Taro 中使用:参考 [Taro 使用原生模块](https://docs.taro.zone/docs/hybrid)。 687 | 688 | 需要注意的是,Taro 对于小程序 dataset 的模拟是在小程序的逻辑层实现的,并没有真正在模板设置这个属性。`wxml2canvas-2d` 组件渲染文本内容时需要对应的节点设置 `data-text` 属性,而 Taro 会忽略该属性,导致 `wxml2canvas-2d` 组件读取不到文本内容。Taro 提供了属性注入的方案,参考 [模板属性 dataset](https://docs.taro.zone/docs/vue-overall/#dataset)。 689 |
690 |
691 | 如何在 skyline 渲染引擎中使用? 692 |
693 | 694 | 非常抱歉,`wxml2canvas-2d` 目前无法在小程序 skyline 引擎中使用,因 skyline 引擎无法获取 `computedStyle`,导致无法在画布中绘制对应的样式。 695 |
696 | 697 | ## Demo 698 | 699 | 克隆本仓库,运行 `npm i & npm run dev`,将 miniprogram_dev 文件夹导入微信开发者工具 700 | 701 | ## 效果预览 702 | 703 | 704 | 705 | 708 | 711 | 714 | 715 | 716 | 719 | 722 | 725 | 726 |
706 | 基础示例 707 | 709 | 绘制视频节点示例 710 | 712 | 绘制 Canvas 节点示例 713 |
717 | 718 | 720 | 721 | 723 | 724 |
727 | 728 | 729 | 732 | 735 | 738 | 739 | 740 | 743 | 746 | 749 | 750 |
730 | 自定义字体示例 731 | 733 | IconFont 图标示例 734 | 736 | 并发绘制示例 737 |
741 | 742 | 744 | 745 | 747 | 748 |
751 | 752 | 753 | 756 | 759 | 762 | 763 | 764 | 767 | 770 | 773 | 774 |
754 | 自定义组件内示例 755 | 757 | 绘制自定义组件示例 758 | 760 | 绘制超长节点示例 761 |
765 | 766 | 768 | 769 | 771 | 772 |
775 | 776 | 777 | 780 | 783 | 786 | 787 | 788 | 791 | 794 | 797 | 798 |
778 | 完整页面截图示例 779 | 781 | 完整页面截图示例-2 782 | 784 | 完整页面截图示例-3 785 |
789 | 790 | 792 | 793 | 795 | 796 |
799 | 800 | ## 支持 801 | 802 | 如果这个项目对您有所帮助,或者您希望支持我的持续开发,欢迎通过以下方式进行赞赏。 803 | 804 | 您的支持就是我保持更新的最大动力! 805 | 806 | |微信赞赏码| 807 | |:---:| 808 | || 809 | |感谢您的慷慨赞赏!| 810 | 811 | > 请在赞赏时留言备注您的 GitHub ID 或昵称,我会铭记于心! 812 | 813 | ## Star History 814 | 815 | [![Star History Chart](https://api.star-history.com/svg?repos=ChrisChan13/wxml2canvas-2d&type=Date)](https://star-history.com/#ChrisChan13/wxml2canvas-2d&Date) -------------------------------------------------------------------------------- /gulpfile.js: -------------------------------------------------------------------------------- 1 | const gulp = require('gulp'); 2 | const clean = require('gulp-clean'); 3 | 4 | const config = require('./tools/config'); 5 | require('./tools/build'); 6 | 7 | const cleanPath = (path) => gulp.src(path, { 8 | read: false, allowEmpty: true, 9 | }).pipe(clean()); 10 | 11 | gulp.task('clean', gulp.series( 12 | () => cleanPath(config.distPath), 13 | (done) => { 14 | if (!config.isDev) return done(); 15 | return cleanPath(config.demoDist); 16 | }, 17 | )); 18 | 19 | gulp.task('default', gulp.series('build')); 20 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "wxml2canvas-2d", 3 | "version": "1.3.8", 4 | "description": "基于微信小程序 2D Canvas 的画布组件,根据给定 WXML 结构以及 CSS 样式快速转换成 Canvas 元素", 5 | "main": "miniprogram_dist/index.js", 6 | "miniprogram": "miniprogram_dist", 7 | "scripts": { 8 | "dev": "gulp dev --dev", 9 | "clean-dev": "gulp clean --dev", 10 | "clean": "gulp clean", 11 | "build": "gulp" 12 | }, 13 | "keywords": [ 14 | "wxml2canvas", 15 | "canvas", 16 | "miniprogram" 17 | ], 18 | "files": ["miniprogram_dist"], 19 | "author": "chrischan", 20 | "license": "MIT", 21 | "repository": "https://github.com/ChrisChan13/wxml2canvas-2d.git", 22 | "devDependencies": { 23 | "eslint": "^7.32.0", 24 | "eslint-config-airbnb-base": "^14.2.1", 25 | "eslint-plugin-import": "^2.24.2", 26 | "gulp": "^4.0.2", 27 | "gulp-clean": "^0.4.0" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /screenshots/donate_wechat.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ChrisChan13/wxml2canvas-2d/99c432e9791d8a98ceff32aa4490ec8f30e2a0fb/screenshots/donate_wechat.png -------------------------------------------------------------------------------- /screenshots/screenshot_basic.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ChrisChan13/wxml2canvas-2d/99c432e9791d8a98ceff32aa4490ec8f30e2a0fb/screenshots/screenshot_basic.jpg -------------------------------------------------------------------------------- /screenshots/screenshot_canvas.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ChrisChan13/wxml2canvas-2d/99c432e9791d8a98ceff32aa4490ec8f30e2a0fb/screenshots/screenshot_canvas.jpg -------------------------------------------------------------------------------- /screenshots/screenshot_component.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ChrisChan13/wxml2canvas-2d/99c432e9791d8a98ceff32aa4490ec8f30e2a0fb/screenshots/screenshot_component.jpg -------------------------------------------------------------------------------- /screenshots/screenshot_font.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ChrisChan13/wxml2canvas-2d/99c432e9791d8a98ceff32aa4490ec8f30e2a0fb/screenshots/screenshot_font.jpg -------------------------------------------------------------------------------- /screenshots/screenshot_iconfont.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ChrisChan13/wxml2canvas-2d/99c432e9791d8a98ceff32aa4490ec8f30e2a0fb/screenshots/screenshot_iconfont.jpg -------------------------------------------------------------------------------- /screenshots/screenshot_multiple.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ChrisChan13/wxml2canvas-2d/99c432e9791d8a98ceff32aa4490ec8f30e2a0fb/screenshots/screenshot_multiple.jpg -------------------------------------------------------------------------------- /screenshots/screenshot_overflow.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ChrisChan13/wxml2canvas-2d/99c432e9791d8a98ceff32aa4490ec8f30e2a0fb/screenshots/screenshot_overflow.jpg -------------------------------------------------------------------------------- /screenshots/screenshot_package.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ChrisChan13/wxml2canvas-2d/99c432e9791d8a98ceff32aa4490ec8f30e2a0fb/screenshots/screenshot_package.jpg -------------------------------------------------------------------------------- /screenshots/screenshot_snapshot.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ChrisChan13/wxml2canvas-2d/99c432e9791d8a98ceff32aa4490ec8f30e2a0fb/screenshots/screenshot_snapshot.jpg -------------------------------------------------------------------------------- /screenshots/screenshot_snapshot2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ChrisChan13/wxml2canvas-2d/99c432e9791d8a98ceff32aa4490ec8f30e2a0fb/screenshots/screenshot_snapshot2.jpg -------------------------------------------------------------------------------- /screenshots/screenshot_snapshot3.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ChrisChan13/wxml2canvas-2d/99c432e9791d8a98ceff32aa4490ec8f30e2a0fb/screenshots/screenshot_snapshot3.jpg -------------------------------------------------------------------------------- /screenshots/screenshot_video.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ChrisChan13/wxml2canvas-2d/99c432e9791d8a98ceff32aa4490ec8f30e2a0fb/screenshots/screenshot_video.jpg -------------------------------------------------------------------------------- /src/canvas.js: -------------------------------------------------------------------------------- 1 | import { 2 | DEFAULT_LINE_HEIGHT, FONT_SIZE_OFFSET, 3 | SYS_DPR, RPX_RATIO, LINE_BREAK_SYMBOL, 4 | IS_MOBILE, VIDEO_POSTER_MODES, 5 | POSITIONS, DOUBLE_LINE_RATIO, 6 | } from './constants'; 7 | import { drawGradient } from './gradient'; 8 | 9 | /** 10 | * 拆分文本 11 | * @param {String} text 文本内容 12 | * @returns {Array} 文本字符 13 | */ 14 | const segmentText = (text) => { 15 | // 使用内置的 Intl.Segmenter API 进行拆分,安卓设备不支持 16 | if (typeof Intl !== 'undefined' && Intl.Segmenter) { 17 | const segmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' }); 18 | return Array.from(segmenter.segment(text)).map((item) => item.segment); 19 | } 20 | return Array.from(text); 21 | }; 22 | 23 | /** 24 | * 获取单词长度中位数 25 | * @param {Array} segments 单词数组 26 | * @return {Number} 单词长度中位数 27 | */ 28 | const getSegmentLengthMedian = (segments) => { 29 | const words = segments.filter((segment) => segment.isWord); 30 | const size = words.length; 31 | const lengths = words.map((segment) => segment.value.length).sort((a, b) => a - b); 32 | if (size % 2 === 1) { 33 | return lengths[Math.floor(size / 2)]; 34 | } 35 | return (lengths[size / 2 - 1] + lengths[size / 2]) / 2; 36 | }; 37 | 38 | /** 39 | * 拆分文本为单词与符号 40 | * @param {String} text 文本内容 41 | * @returns {Array} 单词与符号数组 42 | */ 43 | const segmentTextIntoWords = (text) => { 44 | /** 分隔符号计数 */ 45 | let delimitersCount = 0; 46 | /** 单词计数 */ 47 | let wordsCount = 0; 48 | /** 是否由单词组成 */ 49 | let isWordBased = false; 50 | /** 单词与符号数组 */ 51 | let segments = []; 52 | // 使用内置的 Intl.Segmenter API 进行拆分,安卓设备不支持 53 | if (typeof Intl !== 'undefined' && Intl.Segmenter) { 54 | const segmenter = new Intl.Segmenter(undefined, { granularity: 'word' }); 55 | segments = Array.from(segmenter.segment(text)).map((item) => { 56 | if (!item.isWordLike) delimitersCount += 1; 57 | else wordsCount += 1; 58 | return { 59 | value: item.segment, 60 | isWord: item.isWordLike, 61 | }; 62 | }); 63 | } else { 64 | if (typeof text !== 'string') text = text.toString(); 65 | /** 分隔符号匹配 */ 66 | const delimiters = text.matchAll(/[,.!?; ]/g); 67 | let delimiter = delimiters.next(); 68 | /** 连续分隔符号计数 */ 69 | let consecutiveNonWord = 0; 70 | let lastIndex = 0; 71 | while (!delimiter.done) { 72 | const word = text.slice(lastIndex, delimiter.value.index); 73 | if (word) { 74 | // 单独处理换行符,不记入分隔符号 75 | if (new RegExp(`${LINE_BREAK_SYMBOL}`).test(word)) { 76 | // eslint-disable-next-line no-loop-func 77 | word.split(LINE_BREAK_SYMBOL).map((item) => { 78 | segments.push({ 79 | value: item, 80 | isWord: true, 81 | }, { 82 | value: LINE_BREAK_SYMBOL, 83 | isWord: false, 84 | }); 85 | wordsCount += 1; 86 | return item; 87 | }); 88 | segments.splice(-1, 1); 89 | } else { 90 | segments.push({ 91 | value: word, 92 | isWord: true, 93 | }); 94 | wordsCount += 1; 95 | } 96 | consecutiveNonWord = 0; 97 | } 98 | segments.push({ 99 | value: delimiter.value[0], 100 | isWord: false, 101 | }); 102 | // 连续的分隔符号只计一次 103 | if (consecutiveNonWord === 0) { 104 | delimitersCount += 1; 105 | } 106 | consecutiveNonWord += 1; 107 | lastIndex = delimiter.value.index + delimiter.value[0].length; 108 | delimiter = delimiters.next(); 109 | } 110 | if (lastIndex < text.length) { 111 | segments.push({ 112 | value: text.slice(lastIndex), 113 | isWord: true, 114 | }); 115 | wordsCount += 1; 116 | } 117 | } 118 | /** 119 | * 判断是否由单词组成 120 | * 121 | * 1. 单词数量超过 1 个 122 | * 2. 单词长度中位数不超过 13 123 | * 3. 分隔符号占比超过 30% 124 | */ 125 | isWordBased = wordsCount > 1 && getSegmentLengthMedian(segments) <= 13 126 | && delimitersCount / (wordsCount + delimitersCount) > 0.3; 127 | if (!isWordBased) { 128 | segments = segmentText(text).map((item) => ({ 129 | value: item, 130 | isWord: true, 131 | })); 132 | } 133 | return segments; 134 | }; 135 | 136 | /** 137 | * 获取画布对象 138 | * @param {ComponentObject} component 组件实例对象 139 | * @param {String} selector 选择器 140 | * @returns {Promise} 画布对象 141 | */ 142 | const getCanvas = (component, selector) => new Promise( 143 | (resolve) => { 144 | if (selector) { 145 | const query = component.createSelectorQuery(); 146 | query.select(selector).fields({ 147 | node: true, 148 | }).exec((res) => { 149 | const [{ node: canvas }] = res; 150 | resolve(canvas); 151 | }); 152 | } else { 153 | const canvas = wx.createOffscreenCanvas({ 154 | type: '2d', 155 | compInst: component, 156 | }); 157 | resolve(canvas); 158 | } 159 | }, 160 | ); 161 | 162 | /** 163 | * 绘制重复背景图案 164 | * @param {CanvasRenderingContext2D} ctx 画布上下文 165 | * @param {Object} clipBox wxml 元素背景延伸的盒子模型 166 | * @param {Image} image 图片元素对象 167 | * @param {Number} x image 的左上角在目标画布上 X 轴坐标 168 | * @param {Number} y image 的左上角在目标画布上 Y 轴坐标 169 | * @param {Number} width image 在目标画布上绘制的宽度 170 | * @param {Number} height image 在目标画布上绘制的高度 171 | * @param {Boolean} repeatX X 轴是否重复绘制 172 | * @param {Boolean} repeatY Y 轴是否重复绘制 173 | * @param {Number} stepX X 轴坐标的步进数 174 | * @param {Number} stepY Y 轴坐标的步进数 175 | */ 176 | const drawImageRepeated = ( 177 | ctx, clipBox, image, 178 | x, y, width, height, 179 | repeatX = false, repeatY = false, 180 | stepX = 0, stepY = 0, 181 | ) => { 182 | ctx.drawImage( 183 | image, 184 | 0, 0, image.width, image.height, 185 | x + stepX * width, y + stepY * height, width, height, 186 | ); 187 | if (repeatX) { 188 | if (stepX > -1 && x + (stepX + 1) * width < clipBox.right) { 189 | drawImageRepeated( 190 | ctx, clipBox, image, 191 | x, y, width, height, 192 | true, false, 193 | stepX + 1, stepY, 194 | ); 195 | } if (stepX < 1 && x + stepX * width > clipBox.left) { 196 | drawImageRepeated( 197 | ctx, clipBox, image, 198 | x, y, width, height, 199 | true, false, 200 | stepX - 1, stepY, 201 | ); 202 | } 203 | } if (repeatY) { 204 | if (stepY > -1 && y + (stepY + 1) * height < clipBox.bottom) { 205 | drawImageRepeated( 206 | ctx, clipBox, image, 207 | x, y, width, height, 208 | repeatX && repeatY, true, 209 | stepX, stepY + 1, 210 | ); 211 | } if (stepY < 1 && y + stepY * height > clipBox.top) { 212 | drawImageRepeated( 213 | ctx, clipBox, image, 214 | x, y, width, height, 215 | repeatX && repeatY, true, 216 | stepX, stepY - 1, 217 | ); 218 | } 219 | } 220 | }; 221 | 222 | /** 223 | * 获取等边三角形顶点坐标 224 | * @param {Number} x 中心点 x 轴坐标 225 | * @param {Number} y 中心点 y 轴坐标 226 | * @param {Number} l 等边三角形边长 227 | * @returns {Array} 顶点坐标数组 228 | */ 229 | const getEquilateralTriangle = (x, y, l) => { 230 | const area = (Math.sqrt(3) / 4) * l ** 2; 231 | const halfLength = l / 2; 232 | const centerToSide = ((area / 6) * 2) / halfLength; 233 | const centerToCorner = (area * 2) / l - centerToSide; 234 | const a = [x - centerToSide, y - halfLength]; 235 | const b = [x + centerToCorner, y]; 236 | const c = [x - centerToSide, y + halfLength]; 237 | return [a, b, c]; 238 | }; 239 | 240 | /** 241 | * 变换矩阵逆运算,获取原始坐标 242 | * @param {Number} m11 矩阵中第一行第一列的单元格 243 | * @param {Number} m12 矩阵中第二行第一列的单元格 244 | * @param {Number} m21 矩阵中第一行第二列的单元格 245 | * @param {Number} m22 矩阵中第二行第二列的单元格 246 | * @param {Number} m41 矩阵中第一行第三列的单元格 247 | * @param {Number} m42 矩阵中第二行第三列的单元格 248 | * @param {Number} xTransformed 变换后的 x 坐标 249 | * @param {Number} yTransformed 变换后的 y 坐标 250 | * @returns 原始坐标 (x, y) 251 | */ 252 | const inverseTransform = (m11, m12, m21, m22, m41, m42, xTransformed, yTransformed) => { 253 | // 计算行列式 254 | const det = m11 * m22 - m12 * m21; 255 | if (det === 0) { 256 | throw new Error('Transform is not invertible (determinant is zero)'); 257 | } 258 | // 计算原始坐标 259 | const x = (m22 * (xTransformed - m41) - m21 * (yTransformed - m42)) / det; 260 | const y = (-m12 * (xTransformed - m41) + m11 * (yTransformed - m42)) / det; 261 | return { x, y }; 262 | }; 263 | 264 | /** 265 | * 画布工具类 266 | * 267 | * 1.实例化:传入组件实例以及画布选择器 268 | * ```javascript 269 | * const canvas = new Canvas(componentInstance, canvasSelector); 270 | * ``` 271 | * 2.初始化:传入容器元素节点信息以及缩放倍率(可选) 272 | * ```javascript 273 | * await canvas.init(containerNodeRef, scale); 274 | * ``` 275 | * 3.执行方法: 276 | * ```javascript 277 | * canvas.xxx(); 278 | * ``` 279 | * 280 | * **注意:** 281 | * 282 | * 切换元素节点进行绘制前,请先执行 `Canvas.setElement` 283 | */ 284 | class Canvas { 285 | /** 286 | * @param {ComponentObject} component 组件实例对象 287 | * @param {String} selector 画布选择器 288 | */ 289 | constructor(component, selector) { 290 | this.component = component; 291 | this.selector = selector; 292 | this.isOffscreen = !selector; 293 | } 294 | 295 | /** 296 | * 初始化 297 | * @param {NodesRef} container 容器元素节点信息 298 | * @param {Number} scale 画布缩放倍数 299 | */ 300 | async init(container, scale = 1) { 301 | const canvas = this.canvas = await getCanvas(this.component, this.selector); 302 | this.scale = scale; 303 | this.container = container; 304 | scale *= SYS_DPR; 305 | canvas.width = container.width * scale; 306 | canvas.height = container.height * scale; 307 | const ctx = this.context = canvas.getContext('2d'); 308 | ctx.scale(scale, scale); 309 | ctx.translate(-container.left, -container.top); 310 | ctx.save(); 311 | } 312 | 313 | /** 314 | * 设置当前绘制的 wxml 元素 315 | * @param {Element} element wxml 元素 316 | */ 317 | setElement(element) { 318 | this.element = element; 319 | this.context.globalAlpha = +element.opacity; 320 | // 仅在开发工具、Windows 及部分真机上生效 321 | this.context.filter = element.filter; 322 | this.context.save(); 323 | } 324 | 325 | /** 326 | * 创建图片对象 327 | * @param {String} src 图片链接 328 | * @returns {Promise} 图片对象 329 | */ 330 | async createImage(src) { 331 | return new Promise((resolve, reject) => { 332 | const image = this.canvas.createImage(); 333 | image.src = src; 334 | image.onload = () => resolve(image); 335 | image.onerror = reject; 336 | }); 337 | } 338 | 339 | /** 重置画布上下文 */ 340 | restoreContext() { 341 | this.context.restore(); 342 | this.context.save(); 343 | } 344 | 345 | /** 346 | * 绘制/裁切 wxml 元素的边框路径 347 | * @param {String} sizing 盒子模型描述 348 | */ 349 | clipElementPath(sizing = 'border') { 350 | const { context: ctx, element } = this; 351 | const content = element.getBoxSize(sizing); 352 | 353 | ctx.beginPath(); 354 | if (element['border-radius'] !== '0px') { 355 | const radius = element.getBorderRadius(); 356 | /** 旋转角度的单位:iOS 角度、Android 弧度 */ 357 | const unitRotateAngle = Math.PI / 180; 358 | 359 | /** 元素左外边距 与 内容左外边距 的差值 */ 360 | const diffLeft = content.left - element.left; 361 | /** 元素右外边距 与 内容右外边距 的差值 */ 362 | const diffRight = element.right - content.right; 363 | /** 元素顶外边距 与 内容顶外边距 的差值 */ 364 | const diffTop = content.top - element.top; 365 | /** 元素底外边距 与 内容底外边距 的差值 */ 366 | const diffBottom = element.bottom - content.bottom; 367 | 368 | /** 元素左顶圆角 */ 369 | const leftTopRadius = radius.leftTop - diffLeft; 370 | /** 元素顶左圆角 */ 371 | const topLeftRadius = radius.topLeft - diffTop; 372 | /** 元素顶右圆角 */ 373 | const topRightRadius = radius.topRight - diffTop; 374 | /** 元素右顶圆角 */ 375 | const rightTopRadius = radius.rightTop - diffRight; 376 | /** 元素右底圆角 */ 377 | const rightBottomRadius = radius.rightBottom - diffRight; 378 | /** 元素底右圆角 */ 379 | const bottomRightRadius = radius.bottomRight - diffBottom; 380 | /** 元素底左圆角 */ 381 | const bottomLeftRadius = radius.bottomLeft - diffBottom; 382 | /** 元素左底圆角 */ 383 | const leftBottomRadius = radius.leftBottom - diffLeft; 384 | 385 | if (leftTopRadius === topLeftRadius) { 386 | ctx.moveTo(content.left, content.top + topLeftRadius); 387 | ctx.arcTo( 388 | content.left, 389 | content.top, 390 | content.left + leftTopRadius, 391 | content.top, 392 | topLeftRadius, 393 | ); 394 | } else { 395 | ctx.ellipse( 396 | content.left + leftTopRadius, 397 | content.top + topLeftRadius, 398 | leftTopRadius, 399 | topLeftRadius, 400 | -180 * unitRotateAngle, 401 | 0, 402 | Math.PI / 2, 403 | ); 404 | } 405 | ctx.lineTo(content.right - rightTopRadius, content.top); 406 | if (rightTopRadius === topRightRadius) { 407 | ctx.arcTo( 408 | content.right, 409 | content.top, 410 | content.right, 411 | content.top + topRightRadius, 412 | topRightRadius, 413 | ); 414 | } else { 415 | ctx.ellipse( 416 | content.right - rightTopRadius, 417 | content.top + topRightRadius, 418 | topRightRadius, 419 | rightTopRadius, 420 | -90 * unitRotateAngle, 421 | 0, 422 | Math.PI / 2, 423 | ); 424 | } 425 | ctx.lineTo(content.right, content.bottom - bottomRightRadius); 426 | if (rightBottomRadius === bottomRightRadius) { 427 | ctx.arcTo( 428 | content.right, 429 | content.bottom, 430 | content.right - rightBottomRadius, 431 | content.bottom, 432 | bottomRightRadius, 433 | ); 434 | } else { 435 | ctx.ellipse( 436 | content.right - rightBottomRadius, 437 | content.bottom - bottomRightRadius, 438 | rightBottomRadius, 439 | bottomRightRadius, 440 | 0, 441 | 0, 442 | Math.PI / 2, 443 | ); 444 | } 445 | ctx.lineTo(content.left + leftBottomRadius, content.bottom); 446 | if (leftBottomRadius === bottomLeftRadius) { 447 | ctx.arcTo( 448 | content.left, 449 | content.bottom, 450 | content.left, 451 | content.bottom - bottomLeftRadius, 452 | bottomLeftRadius, 453 | ); 454 | } else { 455 | ctx.ellipse( 456 | content.left + leftBottomRadius, 457 | content.bottom - bottomLeftRadius, 458 | bottomLeftRadius, 459 | leftBottomRadius, 460 | 90 * unitRotateAngle, 461 | 0, 462 | Math.PI / 2, 463 | ); 464 | } 465 | ctx.lineTo(content.left, content.top + topLeftRadius); 466 | } else { 467 | ctx.rect( 468 | content.left, 469 | content.top, 470 | content.width, 471 | content.height, 472 | ); 473 | } 474 | ctx.closePath(); 475 | } 476 | 477 | /** 478 | * 设置 wxml 元素的边界 479 | * @param {String} sizing 盒子模型描述 480 | */ 481 | setElementBoundary(sizing = 'border') { 482 | this.clipElementPath(sizing); 483 | this.context.clip(); 484 | } 485 | 486 | /** 487 | * 设置 wxml 元素的边框边界 488 | * @param {String} borderSide 边框位置 489 | * @param {String} outerSizing 外框盒子模型描述 490 | * @param {String} innerSizing 内框盒子模型描述 491 | */ 492 | setBorderBoundary(borderSide, outerSizing = 'border', innerSizing = 'padding') { 493 | const { context: ctx, element } = this; 494 | const outerVertex = element.getVertex(outerSizing); 495 | const innerVertex = element.getVertex(innerSizing); 496 | ctx.beginPath(); 497 | const start = POSITIONS.indexOf(borderSide); 498 | const end = start === 0 ? POSITIONS.length - 1 : start - 1; 499 | ctx.moveTo(...outerVertex[start]); 500 | ctx.lineTo(...innerVertex[start]); 501 | ctx.lineTo(...innerVertex[end]); 502 | ctx.lineTo(...outerVertex[end]); 503 | ctx.closePath(); 504 | ctx.clip(); 505 | } 506 | 507 | /** 设置 wxml 元素的变换矩阵 */ 508 | setTransform() { 509 | const { context: ctx, element } = this; 510 | const { transform } = element; 511 | if (!transform || transform === 'none') return; 512 | const [m11, m12, m21, m22, m41, m42] = transform.slice(7).slice(0, -1).split(', '); 513 | // 变换后的中心点 514 | const xTransformed = element.left + element.width / 2; 515 | const yTransformed = element.top + element.height / 2; 516 | // 变换前的中心点 517 | const { x, y } = inverseTransform( 518 | m11, m12, m21, m22, m41, m42, xTransformed, yTransformed, 519 | ); 520 | // 变换前的节点信息 521 | Object.assign(element, { 522 | left: x - element.__computedRect.width / 2, 523 | top: y - element.__computedRect.height / 2, 524 | right: x + element.__computedRect.width / 2, 525 | bottom: y + element.__computedRect.height / 2, 526 | width: element.__computedRect.width, 527 | height: element.__computedRect.height, 528 | }); 529 | ctx.transform(m11, m12, m21, m22, m41, m42); 530 | ctx.save(); 531 | } 532 | 533 | /** 重置 wxml 元素的变换矩阵 */ 534 | resetTransform() { 535 | const { context: ctx, scale, container } = this; 536 | const { transform } = this.element; 537 | if (!transform || transform === 'none') return; 538 | ctx.resetTransform(); 539 | ctx.scale(scale * SYS_DPR, scale * SYS_DPR); 540 | ctx.translate(-container.left, -container.top); 541 | ctx.save(); 542 | } 543 | 544 | /** 545 | * 绘制 wxml 元素的背景色 546 | * @param {String} color 背景色 547 | */ 548 | drawBackgroundColor(color) { 549 | const { context: ctx, element } = this; 550 | const clips = element['background-clip'].split(', '); 551 | const colorClip = clips[clips.length - 1].slice(0, -4); 552 | 553 | this.restoreContext(); 554 | if (colorClip !== 'border') { 555 | this.setElementBoundary(colorClip); 556 | } else { 557 | this.setElementBoundary(); 558 | } 559 | ctx.fillStyle = color ?? element['background-color']; 560 | ctx.fillRect(element.left, element.top, element.width, element.height); 561 | 562 | const gradientClip = clips[0].slice(0, -4); 563 | if (gradientClip !== 'border') { 564 | this.restoreContext(); 565 | this.setElementBoundary(gradientClip); 566 | } 567 | drawGradient(ctx, element); 568 | this.restoreContext(); 569 | } 570 | 571 | /** 绘制 wxml 元素的背景图案 */ 572 | async drawBackgroundImage() { 573 | const { context: ctx, element } = this; 574 | const backgroundImage = element['background-image']; 575 | if (!backgroundImage || backgroundImage === 'none') return; 576 | 577 | const content = element.getBoxSize('padding'); 578 | const images = backgroundImage.split(', ').reverse(); 579 | if (images.length === 0) return; 580 | this.restoreContext(); 581 | this.setElementBoundary(); 582 | 583 | const clips = element['background-clip'].split(', ').reverse(); 584 | const sizes = element['background-size'].split(', ').reverse(); 585 | const positions = element['background-position'].split(', ').reverse(); 586 | const repeats = element['background-repeat'].split(', ').reverse(); 587 | 588 | /** 上个背景元素延伸模式是否为 border-box */ 589 | let isLast1BorderBox = true; 590 | /** 所有背景元素延伸模式是否为 border-box */ 591 | let isAllBorderBox = true; 592 | for (let index = 0; index < images.length; index++) { 593 | if (!/url\(".*"\)/.test(images[index])) continue; 594 | const src = images[index].slice(5, -2); 595 | const image = await this.createImage(src); 596 | let dx; 597 | let dy; 598 | let dWidth; 599 | let dHeight; 600 | 601 | const size = sizes[index]; 602 | if (size === 'auto') { 603 | dWidth = image.width; 604 | dHeight = image.height; 605 | } else if (size === 'contain') { 606 | // 对比宽高,根据长边计算缩放结果数值 607 | if (image.width / image.height >= content.width / content.height) { 608 | dWidth = content.width; 609 | dx = content.left; 610 | dHeight = image.height * (dWidth / image.width); 611 | } else { 612 | dHeight = content.height; 613 | dy = content.top; 614 | dWidth = image.width * (dHeight / image.height); 615 | } 616 | } else if (size === 'cover') { 617 | // 对比宽高,根据短边计算缩放结果数值 618 | if (image.width / image.height <= content.width / content.height) { 619 | dWidth = content.width; 620 | dx = content.left; 621 | dHeight = image.height * (dWidth / image.width); 622 | } else { 623 | dHeight = content.height; 624 | dy = content.top; 625 | dWidth = image.width * (dHeight / image.height); 626 | } 627 | } else { 628 | const [sizeWidth, sizeHeight] = size.split(' '); 629 | dWidth = /%/.test(sizeWidth) 630 | ? content.width * (parseFloat(sizeWidth) / 100) 631 | : parseFloat(sizeWidth); 632 | dHeight = /%/.test(sizeHeight) 633 | ? content.height * (parseFloat(sizeHeight) / 100) 634 | : parseFloat(sizeHeight); 635 | } 636 | 637 | // 关于背景图像位置的百分比偏移量计算方式,参考文档: 638 | // https://developer.mozilla.org/zh-CN/docs/Web/CSS/background-position#%E5%85%B3%E4%BA%8E%E7%99%BE%E5%88%86%E6%AF%94%EF%BC%9A 639 | const position = positions[index]; 640 | const [positionX, positionY] = position.split(' '); 641 | dx = dx ?? ( 642 | content.left + (/%/.test(positionX) 643 | ? (content.width - dWidth) * (parseFloat(positionX) / 100) 644 | : parseFloat(positionX)) 645 | ); 646 | dy = dy ?? ( 647 | content.top + (/%/.test(positionY) 648 | ? (content.height - dHeight) * (parseFloat(positionY) / 100) 649 | : parseFloat(positionY)) 650 | ); 651 | 652 | /** 当前背景元素重复模式 */ 653 | const repeat = repeats[index]; 654 | /** 当前背景元素延伸模式 */ 655 | const boxSizing = clips[index].slice(0, -4); 656 | /** 当前背景元素延伸盒子大小 */ 657 | const clipBox = element.getBoxSize(boxSizing); 658 | // 减少边缘裁剪绘制次数 659 | if (!isLast1BorderBox || boxSizing !== 'border') { 660 | this.restoreContext(); 661 | this.setElementBoundary(boxSizing); 662 | if (isAllBorderBox) isAllBorderBox = false; 663 | } 664 | isLast1BorderBox = boxSizing === 'border'; 665 | drawImageRepeated( 666 | ctx, clipBox, image, 667 | dx, dy, dWidth, dHeight, 668 | repeat === 'repeat' || repeat === 'repeat-x', 669 | repeat === 'repeat' || repeat === 'repeat-y', 670 | ); 671 | } 672 | this.restoreContext(); 673 | } 674 | 675 | /** 676 | * 绘制 wxml 的 image 元素 677 | * @param {String} src 图片链接 678 | * @param {String} mode 图片裁剪、缩放的模式 679 | */ 680 | async drawImage(src, mode) { 681 | const { element } = this; 682 | this.restoreContext(); 683 | this.setElementBoundary(); 684 | const image = await this.createImage(src ?? element.src); 685 | let dx; 686 | let dy; 687 | let dWidth; 688 | let dHeight; 689 | let sx; 690 | let sy; 691 | let sWidth; 692 | let sHeight; 693 | const content = element.getBoxSize('content'); 694 | if ((mode ?? element.mode) === 'aspectFit') { 695 | sx = 0; 696 | sy = 0; 697 | sWidth = image.width; 698 | sHeight = image.height; 699 | // 对比宽高,根据长边计算缩放结果数值 700 | if (image.width / image.height >= content.width / content.height) { 701 | dWidth = content.width; 702 | dHeight = image.height * (dWidth / image.width); 703 | dx = content.left; 704 | dy = content.top + (content.height - dHeight) / 2; 705 | } else { 706 | dHeight = content.height; 707 | dWidth = image.width * (dHeight / image.height); 708 | dx = content.left + (content.width - dWidth) / 2; 709 | dy = content.top; 710 | } 711 | } else if ((mode ?? element.mode) === 'aspectFill') { 712 | dx = content.left; 713 | dy = content.top; 714 | dWidth = content.width; 715 | dHeight = content.height; 716 | // 对比宽高,根据短边计算缩放结果数值 717 | if (image.width / image.height <= content.width / content.height) { 718 | sWidth = image.width; 719 | sHeight = sWidth * (content.height / content.width); 720 | sx = 0; 721 | sy = (image.height - sHeight) / 2; 722 | } else { 723 | sHeight = image.height; 724 | sWidth = sHeight * (content.width / content.height); 725 | sx = (image.width - sWidth) / 2; 726 | sy = 0; 727 | } 728 | } else { 729 | sx = 0; 730 | sy = 0; 731 | sWidth = image.width; 732 | sHeight = image.height; 733 | dx = content.left; 734 | dy = content.top; 735 | dWidth = content.width; 736 | dHeight = content.height; 737 | } 738 | this.context.drawImage(image, sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight); 739 | this.restoreContext(); 740 | } 741 | 742 | /** 绘制 wxml 的 video 元素 */ 743 | async drawVideo() { 744 | const { context: ctx, element } = this; 745 | this.drawBackgroundColor('#000000'); 746 | if (element.poster) { 747 | await this.drawImage(element.poster, VIDEO_POSTER_MODES[element.objectFit]); 748 | } 749 | this.restoreContext(); 750 | this.setElementBoundary(); 751 | /** 播放按钮边长 */ 752 | const LENGTH = 50; 753 | /** 播放按钮顶点坐标 */ 754 | const vertexes = getEquilateralTriangle( 755 | element.left + element.width / 2, element.top + element.height / 2, LENGTH, 756 | ); 757 | /** 播放按钮圆角数据 */ 758 | const RADIUS = [0, 0, 8]; 759 | RADIUS[0] = RADIUS[2] / 2; 760 | RADIUS[1] = Math.sqrt(3) * RADIUS[0]; 761 | ctx.beginPath(); 762 | ctx.moveTo(vertexes[0][0], vertexes[0][1] + RADIUS[2]); 763 | ctx.quadraticCurveTo( 764 | vertexes[0][0], vertexes[0][1], 765 | vertexes[0][0] + RADIUS[1], vertexes[0][1] + RADIUS[0], 766 | ); 767 | ctx.lineTo(vertexes[1][0] - RADIUS[1], vertexes[1][1] - RADIUS[0]); 768 | ctx.quadraticCurveTo( 769 | vertexes[1][0], vertexes[1][1], 770 | vertexes[1][0] - RADIUS[1], vertexes[1][1] + RADIUS[0], 771 | ); 772 | ctx.lineTo(vertexes[2][0] + RADIUS[1], vertexes[2][1] - RADIUS[0]); 773 | ctx.quadraticCurveTo( 774 | vertexes[2][0], vertexes[2][1], 775 | vertexes[2][0], vertexes[2][1] - RADIUS[2], 776 | ); 777 | ctx.closePath(); 778 | ctx.fillStyle = 'rgba(255, 255, 255, 0.5)'; 779 | ctx.fill(); 780 | this.restoreContext(); 781 | } 782 | 783 | /** 784 | * 绘制 wxml 的 canvas 元素 785 | * @param {Object} instance canvas 元素所在页面/组件实例 786 | */ 787 | async drawCanvas(instance) { 788 | const { element } = this; 789 | const payload = { 790 | fileType: 'png', 791 | }; 792 | if (element.type === '2d') { 793 | Object.assign(payload, { 794 | canvas: element.node, 795 | }); 796 | } else { 797 | Object.assign(payload, { 798 | canvasId: element.canvasId, 799 | }); 800 | } 801 | const { tempFilePath } = await wx.canvasToTempFilePath(payload, instance); 802 | await this.drawImage(tempFilePath); 803 | } 804 | 805 | /** 绘制 wxml 的 text 元素 */ 806 | drawText() { 807 | const { context: ctx, element } = this; 808 | const content = element.getBoxSize('content'); 809 | const shadow = element.getTextShadow(); 810 | this.restoreContext(); 811 | if (shadow.color) { 812 | ctx.shadowColor = shadow.color; 813 | ctx.shadowBlur = shadow.blur; 814 | ctx.shadowOffsetX = shadow.offsetX; 815 | ctx.shadowOffsetY = shadow.offsetY; 816 | } 817 | 818 | // 固定格式(不可缺省):font-weight font-size font-family 819 | ctx.font = `${element['font-weight']} ${element['font-size']} ${element['font-family']}`; 820 | ctx.textBaseline = 'top'; 821 | ctx.textAlign = element['text-align']; 822 | ctx.fillStyle = element.color; 823 | 824 | // 仅在 Windows 上生效,真机暂不支持 825 | ctx.textLetterSpacing = parseFloat(element['letter-spacing']) || 0; 826 | ctx.textWordSpacing = parseFloat(element['word-spacing']); 827 | // 小程序画布中无实际表现,暂不支持 828 | ctx.letterSpacing = element['letter-spacing']; 829 | ctx.wordSpacing = element['word-spacing']; 830 | 831 | const fontSize = parseFloat(element['font-size']); 832 | /** 文本方向向右 */ 833 | const isTextRTL = element.direction === 'rtl'; 834 | const isTextCentered = element['text-align'] === 'center'; 835 | const isTextRightAlign = element['text-align'] === 'right' || (isTextRTL && element['text-align'] === 'start'); 836 | ctx.textAlign = isTextRightAlign ? 'right' : isTextCentered ? 'center' : 'left'; 837 | 838 | /** 文字行高 */ 839 | let lineHeight; 840 | if (!Number.isNaN(+element['line-height'])) { 841 | lineHeight = fontSize * +element['line-height']; 842 | } else if (/px/.test(element['line-height'])) { 843 | lineHeight = parseFloat(element['line-height']); 844 | } else { 845 | lineHeight = fontSize * DEFAULT_LINE_HEIGHT; 846 | } 847 | /** 首行缩进 */ 848 | let textIndent; 849 | if (/em/.test(element['text-indent'])) { 850 | textIndent = fontSize * +element['text-indent']; 851 | } else if (/px/.test(element['text-indent'])) { 852 | textIndent = parseFloat(element['text-indent']); 853 | } else if (/%/.test(element['text-indent'])) { 854 | textIndent = (parseFloat(element['text-indent']) / 100) * content.width; 855 | } 856 | 857 | /** 858 | * 计算元素内实际显示最大行数 859 | * 860 | * 向上取整避免行高过大,文字错位 861 | */ 862 | const maxLines = Math.max(Math.ceil( 863 | Number(content.height / lineHeight).toFixed(1), 864 | ), 1); 865 | // 消除行高计算偏差 866 | lineHeight = content.height / maxLines; 867 | /** 单行内容,逐行显示 */ 868 | let lineText = ''; 869 | /** 内容基本单位拆分 */ 870 | const segments = segmentTextIntoWords( 871 | element.dataset.icon 872 | ? String.fromCharCode(parseInt(element.dataset.icon, 16)) 873 | : element.dataset.text, 874 | ); 875 | 876 | let lines = 0; 877 | let lastIndex = 0; 878 | let segment = segments[lastIndex]; 879 | let lastSegment; 880 | for (; lines < maxLines; lines += 1) { 881 | /** 882 | * 计算最大限制行宽 883 | * 884 | * 判断首行缩进,取整避免行宽过小,导致文字变形 885 | */ 886 | const lineWidth = Math.ceil(content.width - (lines === 0 ? textIndent : 0)); 887 | while (segment && ctx.measureText(lineText + segment.value).width <= lineWidth) { 888 | const isForcedLineBreak = segment.value === LINE_BREAK_SYMBOL; 889 | lineText += segment.value; 890 | lastSegment = segment; 891 | lastIndex += 1; 892 | segment = segments[lastIndex]; 893 | // 判断换行符强制换行 894 | if (isForcedLineBreak) break; 895 | } 896 | 897 | /** 是否内容最后一行 */ 898 | const isLastLine = (lines + 1) === maxLines; 899 | if (isLastLine && lastIndex < segments.length - 1 && element['text-overflow'] === 'ellipsis') { 900 | let ellipsisLineText = isTextRTL && !IS_MOBILE ? `...${lineText}` : `${lineText}...`; 901 | while (ctx.measureText(ellipsisLineText).width > lineWidth) { 902 | lineText = lineText.slice(0, -1); 903 | ellipsisLineText = isTextRTL && !IS_MOBILE ? `...${lineText}` : `${lineText}...`; 904 | } 905 | lineText = ellipsisLineText; 906 | } else if (isLastLine && segment) { 907 | // 因画布与页面文字表现不一致,溢出内容放置于末行 908 | lineText += segment.value; 909 | lastSegment = segment; 910 | } 911 | if (isTextRTL && !IS_MOBILE && lastSegment && !lastSegment.isWord) { 912 | lineText = lineText.slice(0, -lastSegment.value.length); 913 | lineText = `${lastSegment.value}${lineText}`; 914 | } 915 | lineText = lineText.trim(); 916 | if (isTextRTL && IS_MOBILE) { 917 | lineText = segmentText(lineText).reverse().join(''); 918 | } 919 | 920 | const lineLeft = ( 921 | isTextRightAlign ? content.right : content.left 922 | ) + ( // 首行缩进位置偏移 923 | (isTextRightAlign ? -1 : 1) * (lines === 0 ? textIndent : 0) 924 | ) + ( // 文字居中位置偏移 925 | (isTextRightAlign ? -1 : 1) * (isTextCentered ? lineWidth / 2 : 0) 926 | ); 927 | const lineTop = content.top + lines * lineHeight; 928 | const lineTopOffset = ( 929 | lineHeight - fontSize * FONT_SIZE_OFFSET 930 | ) / 2; 931 | ctx.fillText( 932 | lineText, 933 | lineLeft, 934 | lineTop + lineTopOffset, 935 | lineWidth, 936 | ); 937 | 938 | /** 文字实际宽度 */ 939 | const textWidth = Math.min(ctx.measureText(lineText).width, lineWidth); 940 | let decorLines = element['text-decoration-line']; 941 | if (decorLines && decorLines !== 'none') { 942 | const decorStyle = element['text-decoration-style']; 943 | const decorColor = element['text-decoration-color']; 944 | ctx.strokeStyle = decorColor; 945 | ctx.lineWidth = 2 / RPX_RATIO; 946 | if (decorStyle === 'dashed') { 947 | ctx.setLineDash([4, 4]); 948 | } 949 | 950 | decorLines = decorLines.split(' ').map((decor) => { 951 | let decorLineLeft = lineLeft; 952 | let decorLineTop = lineTop; 953 | if (isTextCentered) { 954 | decorLineLeft -= textWidth / 2; 955 | } else if (isTextRightAlign) { 956 | decorLineLeft -= textWidth; 957 | } 958 | if (decor === 'line-through') { 959 | decorLineTop += lineTopOffset + fontSize / 2; 960 | } else if (decor === 'underline') { 961 | decorLineTop += lineTopOffset + fontSize; 962 | } 963 | ctx.beginPath(); 964 | ctx.moveTo(decorLineLeft, decorLineTop); 965 | ctx.lineTo(decorLineLeft + textWidth, decorLineTop); 966 | ctx.closePath(); 967 | ctx.stroke(); 968 | if (decorStyle === 'double') { 969 | decorLineTop += 2 * ctx.lineWidth; 970 | ctx.beginPath(); 971 | ctx.moveTo(decorLineLeft, decorLineTop); 972 | ctx.lineTo(decorLineLeft + textWidth, decorLineTop); 973 | ctx.closePath(); 974 | ctx.stroke(); 975 | } 976 | return decor; 977 | }); 978 | } 979 | 980 | lineText = ''; 981 | } 982 | this.restoreContext(); 983 | } 984 | 985 | /** 绘制 wxml 元素边框 */ 986 | drawBorder() { 987 | const { context: ctx, element } = this; 988 | const border = element.getBorder(); 989 | if (border.width > 0) { 990 | this.restoreContext(); 991 | this.setElementBoundary(); 992 | ctx.strokeStyle = border.color; 993 | ctx.lineWidth = border.width * 2; 994 | if (border.style === 'dashed') { 995 | ctx.lineDashOffset = -border.width * 2; 996 | ctx.setLineDash([2 * border.width, border.width]); 997 | } 998 | this.clipElementPath(); 999 | ctx.stroke(); 1000 | this.restoreContext(); 1001 | } else { 1002 | const vertex = element.getVertex(); 1003 | POSITIONS.map((key, index) => { 1004 | if (border[key].width === 0) return key; 1005 | this.restoreContext(); 1006 | this.setBorderBoundary(key); 1007 | ctx.strokeStyle = border[key].color; 1008 | if (border[key].style === 'double') { 1009 | ctx.lineWidth = border[key].width * DOUBLE_LINE_RATIO * 2; 1010 | const innerVertex = element.getVertex('padding'); 1011 | const point = []; 1012 | // 双实线边框的宽高,加长避免露出矩形其他边 1013 | const width = ['left', 'right'].indexOf(key) > -1 ? border[key].width : (element.width + 2 * ctx.lineWidth); 1014 | const height = ['top', 'bottom'].indexOf(key) > -1 ? border[key].width : (element.height + 2 * ctx.lineWidth); 1015 | if (key === 'right') { 1016 | point.push(innerVertex[1][0], vertex[1][1] - ctx.lineWidth); 1017 | } else if (key === 'bottom') { 1018 | point.push(vertex[3][0] - ctx.lineWidth, innerVertex[3][1]); 1019 | } else { 1020 | point.push( 1021 | vertex[0][0] - (key === 'top' ? ctx.lineWidth : 0), 1022 | vertex[0][1] - (key === 'left' ? ctx.lineWidth : 0), 1023 | ); 1024 | } 1025 | ctx.beginPath(); 1026 | ctx.rect(...point, width, height); 1027 | ctx.closePath(); 1028 | ctx.stroke(); 1029 | } else { 1030 | ctx.lineWidth = border[key].width * 2; 1031 | if (border[key].style === 'dashed') { 1032 | ctx.lineDashOffset = -border[key].width * 2; 1033 | ctx.setLineDash([2 * border[key].width, 2 * border[key].width]); 1034 | } 1035 | const line = [vertex[index], vertex[index === 0 ? POSITIONS.length - 1 : index - 1]]; 1036 | ctx.beginPath(); 1037 | ctx.moveTo(...line[0]); 1038 | ctx.lineTo(...line[1]); 1039 | ctx.closePath(); 1040 | ctx.stroke(); 1041 | } 1042 | this.restoreContext(); 1043 | return key; 1044 | }); 1045 | } 1046 | } 1047 | 1048 | /** 绘制 wxml 元素阴影 */ 1049 | drawBoxShadow() { 1050 | const { context: ctx, element } = this; 1051 | const shadow = element.getBoxShadow(); 1052 | if (!shadow.color) return; 1053 | this.restoreContext(); 1054 | ctx.shadowColor = shadow.color; 1055 | ctx.shadowBlur = shadow.blur; 1056 | ctx.shadowOffsetX = shadow.offsetX; 1057 | ctx.shadowOffsetY = shadow.offsetY; 1058 | const background = element.getBackgroundColor(); 1059 | // 必须填充背景色,否则阴影不可见 1060 | ctx.fillStyle = `rgba(${background.rColor}, ${background.gColor}, ${background.bColor}, 1)`; 1061 | this.clipElementPath(); 1062 | ctx.fill(); 1063 | this.restoreContext(); 1064 | } 1065 | 1066 | /** 1067 | * 导出画布至临时图片 1068 | * @param {Boolean} original 是否使用实机表现作为导出图片的尺寸; 1069 | * 1070 | * `true` 则导出当前实机设备渲染的尺寸,各设备的设备像素比不同,导出图片尺寸将有所不同; 1071 | * 1072 | * `false` 则导出以 750px 设计图为基准的尺寸,即与 WXSS 中设置的 rpx 大小一致,全设备导出图片尺寸一致; 1073 | * @returns {Promise} 图片临时路径 1074 | */ 1075 | async toTempFilePath(original = true) { 1076 | const payload = { 1077 | canvas: this.canvas, 1078 | fileType: 'png', 1079 | }; 1080 | if (!original) { 1081 | Object.assign(payload, { 1082 | destWidth: this.container.width * RPX_RATIO * this.scale, 1083 | destHeight: this.container.height * RPX_RATIO * this.scale, 1084 | }); 1085 | } 1086 | const { tempFilePath } = await wx.canvasToTempFilePath(payload, this.component); 1087 | return tempFilePath; 1088 | } 1089 | 1090 | /** 1091 | * 导出画布至 Data URI(base64 编码) 1092 | * 1093 | * iOS、Mac 与 Windows 平台在离屏 Canvas 模式下使用 `wx.canvasToTempFilePath` 导出时会报错 1094 | * 1095 | * 可以使用 `Canvas.toDataURL` 搭配 `FileSystemManager.saveFile` 保存导出的图片 1096 | * @returns {String} URI 1097 | */ 1098 | toDataURL() { 1099 | return this.canvas.toDataURL(); 1100 | } 1101 | 1102 | /** 1103 | * 获取画布的像素数据 1104 | */ 1105 | getImageData() { 1106 | return this.context.getImageData( 1107 | 0, 0, this.canvas.width, this.canvas.height, 1108 | ); 1109 | } 1110 | } 1111 | 1112 | export default Canvas; 1113 | -------------------------------------------------------------------------------- /src/constants.js: -------------------------------------------------------------------------------- 1 | /** CSS 默认行高 */ 2 | export const DEFAULT_LINE_HEIGHT = 1.4; 3 | /** 字体大小位置校准 */ 4 | // CSS 与 画布 的 font-size 存在数值偏差,暂时以常数换算实现近似结果 5 | export const FONT_SIZE_OFFSET = 0.88; 6 | /** 换行符 */ 7 | export const LINE_BREAK_SYMBOL = '\n'; 8 | /** 系统信息 */ 9 | export const { 10 | platform: SYS_PLATFORM, 11 | pixelRatio: SYS_DPR, 12 | windowWidth: SYS_WIDTH, 13 | } = wx.getSystemInfoSync(); 14 | /** 是否为 iOS 平台 */ 15 | export const IS_IOS = SYS_PLATFORM === 'ios'; 16 | /** 是否为 macOS 平台 */ 17 | export const IS_MAC = SYS_PLATFORM === 'mac'; 18 | /** 是否为 Android 平台 */ 19 | export const IS_ANDROID = SYS_PLATFORM === 'android'; 20 | /** 是否为微信开发者工具 */ 21 | export const IS_DEVTOOL = SYS_PLATFORM === 'devtools'; 22 | /** 是否为 Windows 平台 */ 23 | export const IS_WINDOWS = SYS_PLATFORM === 'windows'; 24 | /** 是否为移动设备 */ 25 | export const IS_MOBILE = IS_ANDROID || IS_IOS; 26 | /** 设备像素与 750px 设计图比例 */ 27 | export const RPX_RATIO = 750 / SYS_WIDTH; 28 | /** 三角函数值 转换 弧度 换算比例 */ 29 | export const TRI2RAD_RATIO = Math.PI / 180; 30 | /** 椭圆形径向渐变的长短轴比例 */ 31 | // 暂未找到 CSS 中椭圆形径向渐变的长短轴生成规律,以常数代替实现近似结果 32 | export const SIDE2CORNER_RATIO = 1.4141; 33 | /** 视频海报的裁剪、缩放模式 */ 34 | export const VIDEO_POSTER_MODES = { 35 | contain: 'aspectFit', 36 | cover: 'aspectFill', 37 | fill: 'scaleToFill', 38 | }; 39 | /** 位置列表 */ 40 | export const POSITIONS = ['left', 'top', 'right', 'bottom']; 41 | /** 双实线宽度与单实线宽度比例 */ 42 | export const DOUBLE_LINE_RATIO = 2 / 7; 43 | -------------------------------------------------------------------------------- /src/element.js: -------------------------------------------------------------------------------- 1 | import { POSITIONS } from './constants'; 2 | 3 | /** 4 | * 获取部分指定字段(与布局位置字段重名) 5 | * @param {Object} nodesRef WXML 节点信息对象 6 | * @returns {Object} 指定字段对象 7 | */ 8 | const getComputedRect = (nodesRef) => { 9 | let { 10 | left, right, 11 | bottom, top, 12 | width, height, 13 | } = nodesRef; 14 | if (left !== 'auto') left = parseFloat(left); 15 | if (right !== 'auto') right = parseFloat(right); 16 | if (top !== 'auto') top = parseFloat(top); 17 | if (bottom !== 'auto') bottom = parseFloat(bottom); 18 | if (width !== 'auto') width = parseFloat(width); 19 | if (height !== 'auto') height = parseFloat(height); 20 | return { 21 | left, right, bottom, top, width, height, 22 | }; 23 | }; 24 | 25 | /** 26 | * wxml 元素工具类 27 | * 28 | * 实例化: 29 | * ```javascript 30 | * const element = new Element(nodesRef); 31 | * ``` 32 | */ 33 | class Element { 34 | constructor(nodesRef) { 35 | Object.assign(this, nodesRef); 36 | } 37 | 38 | /** 39 | * 获取 wxml 元素的顶点坐标:左上、右上、右下、左下 40 | * @param {String} sizing 盒子模型描述 41 | * @return {Array} 顶点坐标 42 | */ 43 | getVertex(sizing = 'border') { 44 | const key = `__${sizing}Vertex`; 45 | if (this[key]) return this[key]; 46 | const content = this.getBoxSize(sizing); 47 | const leftTop = [content.left, content.top]; 48 | const rightTop = [content.left + content.width, content.top]; 49 | const leftBottom = [content.left, content.top + content.height]; 50 | const rightBottom = [content.left + content.width, content.top + content.height]; 51 | /** 顶点坐标:左上、右上、右下、左下 */ 52 | const vertex = [leftTop, rightTop, rightBottom, leftBottom]; 53 | Object.assign(this, { [key]: vertex }); 54 | return vertex; 55 | } 56 | 57 | /** 58 | * 获取 wxml 元素的内容盒子大小数据 59 | * @param {String} sizing 盒子模型描述 60 | * @returns {Object} 盒子大小数据 61 | */ 62 | getBoxSize(sizing = 'border') { 63 | const key = `__${sizing}Box`; 64 | if (this[key]) return this[key]; 65 | 66 | let offsetLeft = this.left; 67 | let offsetTop = this.top; 68 | let offsetRight = this.right; 69 | let offsetBottom = this.bottom; 70 | let offsetWidth = this.width; 71 | let offsetHeight = this.height; 72 | 73 | switch (sizing) { 74 | case 'content': { 75 | const padLeft = parseFloat(this['padding-left']); 76 | const padTop = parseFloat(this['padding-top']); 77 | const padRight = parseFloat(this['padding-right']); 78 | const padBottom = parseFloat(this['padding-bottom']); 79 | offsetLeft += padLeft; 80 | offsetTop += padTop; 81 | offsetRight -= padRight; 82 | offsetBottom -= padBottom; 83 | offsetWidth -= (padLeft + padRight); 84 | offsetHeight -= (padTop + padBottom); 85 | } 86 | case 'padding': { 87 | const border = this.getBorder(); 88 | offsetLeft += border.left.width; 89 | offsetTop += border.top.width; 90 | offsetRight -= border.right.width; 91 | offsetBottom -= border.bottom.width; 92 | offsetWidth -= border.left.width + border.right.width; 93 | offsetHeight -= border.top.width + border.bottom.width; 94 | break; 95 | } 96 | default: 97 | } 98 | Object.assign(this, { 99 | [key]: { 100 | left: offsetLeft, 101 | top: offsetTop, 102 | right: offsetRight, 103 | bottom: offsetBottom, 104 | width: offsetWidth, 105 | height: offsetHeight, 106 | }, 107 | }); 108 | return this[key]; 109 | } 110 | 111 | /** 112 | * 获取 wxml 元素的边框数据 113 | * @returns {Object} 边框数据 114 | */ 115 | getBorder() { 116 | if (this.__border) return this.__border; 117 | let borderWidth = 0; 118 | let borderStyle = ''; 119 | let borderColor = ''; 120 | Object.assign(this, { __border: {} }); 121 | if (this.border) { 122 | [borderWidth, borderStyle, ...borderColor] = this.border.split(' '); 123 | if (borderStyle === 'none') { 124 | borderWidth = 0; 125 | } else { 126 | borderWidth = parseFloat(borderWidth); 127 | } 128 | borderColor = borderColor.join(' '); 129 | Object.assign(this.__border, { 130 | width: borderWidth, 131 | style: borderStyle, 132 | color: borderColor, 133 | }); 134 | } 135 | POSITIONS.map((key) => { 136 | [borderWidth, borderStyle, ...borderColor] = this[`border-${key}`].split(' '); 137 | if (borderStyle === 'none') { 138 | borderWidth = 0; 139 | } else { 140 | borderWidth = parseFloat(borderWidth); 141 | } 142 | borderColor = borderColor.join(' '); 143 | Object.assign(this.__border, { 144 | [`${key}`]: { 145 | width: borderWidth, 146 | style: borderStyle, 147 | color: borderColor, 148 | }, 149 | }); 150 | return key; 151 | }); 152 | return this.__border; 153 | } 154 | 155 | /** 156 | * 获取 wxml 元素的背景色数据 157 | * @returns {Object} 背景色数据 158 | */ 159 | getBackgroundColor() { 160 | if (this.__backgroundColor) return this.__backgroundColor; 161 | let rColor = 0; 162 | let gColor = 0; 163 | let bColor = 0; 164 | let alpha = 0; 165 | 166 | [rColor, gColor, bColor, alpha] = this['background-color'].split(', '); 167 | rColor = +rColor.slice(rColor.indexOf('(') + 1); 168 | gColor = +gColor; 169 | if (!alpha) { 170 | alpha = 1; 171 | bColor = +bColor.slice(0, -1); 172 | } else { 173 | bColor = +bColor; 174 | alpha = +alpha.slice(0, -1); 175 | } 176 | Object.assign(this, { 177 | __backgroundColor: { 178 | rColor, 179 | gColor, 180 | bColor, 181 | alpha, 182 | }, 183 | }); 184 | return this.__backgroundColor; 185 | } 186 | 187 | /** 188 | * 获取 wxml 元素的阴影数据 189 | * @returns {Object} 阴影数据 190 | */ 191 | getBoxShadow() { 192 | if (this.__boxShadow) return this.__boxShadow; 193 | let color = ''; 194 | let blur = 0; 195 | let offsetX = 0; 196 | let offsetY = 0; 197 | if (this['box-shadow'] !== 'none') { 198 | let tempStr; 199 | [tempStr, offsetY, blur] = this['box-shadow'].split('px '); 200 | const tempIdx = tempStr.lastIndexOf(' '); 201 | color = tempStr.slice(0, tempIdx); 202 | offsetX = tempStr.slice(tempIdx + 1); 203 | } 204 | Object.assign(this, { 205 | __boxShadow: { 206 | blur, 207 | color, 208 | offsetX, 209 | offsetY, 210 | }, 211 | }); 212 | return this.__boxShadow; 213 | } 214 | 215 | /** 216 | * 获取 wxml 文本的阴影数据 217 | * @returns {Object} 阴影数据 218 | */ 219 | getTextShadow() { 220 | if (this.__textShadow) return this.__textShadow; 221 | let color = ''; 222 | let blur = 0; 223 | let offsetX = 0; 224 | let offsetY = 0; 225 | if (this['text-shadow'] !== 'none') { 226 | let tempStr; 227 | [tempStr, offsetY, blur] = this['text-shadow'].split('px '); 228 | const tempIdx = tempStr.lastIndexOf(' '); 229 | color = tempStr.slice(0, tempIdx); 230 | offsetX = tempStr.slice(tempIdx + 1); 231 | blur = parseFloat(blur); 232 | } 233 | Object.assign(this, { 234 | __textShadow: { 235 | blur, 236 | color, 237 | offsetX, 238 | offsetY, 239 | }, 240 | }); 241 | return this.__textShadow; 242 | } 243 | 244 | /** 245 | * 获取 wxml 元素的边缘圆角数据 246 | * @returns {Object} 圆角数据 247 | */ 248 | getBorderRadius() { 249 | if (this.__borderRadius) return this.__borderRadius; 250 | let [leftTop, topLeft] = this['border-top-left-radius'].split(' '); 251 | let [rightTop, topRight] = this['border-top-right-radius'].split(' '); 252 | let [leftBottom, bottomLeft] = this['border-bottom-left-radius'].split(' '); 253 | let [rightBottom, bottomRight] = this['border-bottom-right-radius'].split(' '); 254 | 255 | if (/%/.test(topLeft ?? leftTop)) { 256 | topLeft = this.height * (parseFloat(topLeft ?? leftTop) / 100); 257 | } else { 258 | topLeft = parseFloat(topLeft ?? leftTop); 259 | } 260 | if (/%/.test(leftTop)) { 261 | leftTop = this.width * (parseFloat(leftTop) / 100); 262 | } else { 263 | leftTop = parseFloat(leftTop); 264 | } 265 | 266 | if (/%/.test(topRight ?? rightTop)) { 267 | topRight = this.height * (parseFloat(topRight ?? rightTop) / 100); 268 | } else { 269 | topRight = parseFloat(topRight ?? rightTop); 270 | } 271 | if (/%/.test(rightTop)) { 272 | rightTop = this.width * (parseFloat(rightTop) / 100); 273 | } else { 274 | rightTop = parseFloat(rightTop); 275 | } 276 | 277 | if (/%/.test(bottomLeft ?? leftBottom)) { 278 | bottomLeft = this.height * (parseFloat(bottomLeft ?? leftBottom) / 100); 279 | } else { 280 | bottomLeft = parseFloat(bottomLeft ?? leftBottom); 281 | } 282 | if (/%/.test(leftBottom)) { 283 | leftBottom = this.width * (parseFloat(leftBottom) / 100); 284 | } else { 285 | leftBottom = parseFloat(leftBottom); 286 | } 287 | 288 | if (/%/.test(bottomRight ?? rightBottom)) { 289 | bottomRight = this.height * (parseFloat(bottomRight ?? rightBottom) / 100); 290 | } else { 291 | bottomRight = parseFloat(bottomRight ?? rightBottom); 292 | } 293 | if (/%/.test(rightBottom)) { 294 | rightBottom = this.width * (parseFloat(rightBottom) / 100); 295 | } else { 296 | rightBottom = parseFloat(rightBottom); 297 | } 298 | 299 | /** 各个圆角的缩放比例 */ 300 | let rScale; 301 | if ( 302 | (leftTop + rightTop) > this.width 303 | || (leftBottom + rightBottom) > this.width 304 | || (topLeft + bottomLeft) > this.height 305 | || (topRight + bottomRight) > this.height 306 | ) { 307 | // 由各边长度以及对应的圆角半径决定 308 | rScale = Math.min( 309 | this.height / (topLeft + bottomLeft), 310 | this.height / (topRight + bottomRight), 311 | this.width / (leftTop + rightTop), 312 | this.width / (leftBottom + rightBottom), 313 | ); 314 | leftTop *= rScale; 315 | rightTop *= rScale; 316 | leftBottom *= rScale; 317 | rightBottom *= rScale; 318 | topLeft *= rScale; 319 | topRight *= rScale; 320 | bottomLeft *= rScale; 321 | bottomRight *= rScale; 322 | } 323 | Object.assign(this, { 324 | __borderRadius: { 325 | leftTop, 326 | rightTop, 327 | leftBottom, 328 | rightBottom, 329 | topLeft, 330 | topRight, 331 | bottomLeft, 332 | bottomRight, 333 | }, 334 | }); 335 | return this.__borderRadius; 336 | } 337 | } 338 | 339 | /** 节点通用属性名 */ 340 | Element.COMMON_PROPERTIES = []; 341 | /** 节点固定样式名(可能与节点其他字段重名) */ 342 | Element.CONSTANT_COMPUTED_STYLE = [ 343 | 'width', 'height', 'left', 'top', 'right', 'bottom', 344 | ]; 345 | /** 节点通用样式名 */ 346 | Element.COMMON_COMPUTED_STYLE = [ 347 | 'background-color', 'border-radius', 'background-image', 348 | 'background-position', 'background-size', 'background-repeat', 349 | 'padding-top', 'padding-left', 'padding-right', 'padding-bottom', 350 | 'border', 'box-shadow', 'opacity', 'background-clip', 351 | 'border-top-left-radius', 'border-top-right-radius', 352 | 'border-bottom-right-radius', 'border-bottom-left-radius', 353 | 'overflow', 'filter', 'transform', 'transform-origin', 354 | 'border-top', 'border-right', 'border-bottom', 'border-left', 355 | ]; 356 | /** 文字节点特殊属性名 */ 357 | Element.TEXT_PROPERTIES = []; 358 | /** 文字节点特殊样式名 */ 359 | Element.TEXT_COMPUTED_STYLE = [ 360 | 'font-family', 'font-size', 'font-weight', 'text-align', 361 | 'line-height', 'text-overflow', 'color', 'text-indent', 362 | 'text-shadow', 'letter-spacing', 'word-spacing', 'direction', 363 | 'text-decoration-style', 'text-decoration-line', 'text-decoration-color', 364 | ]; 365 | /** 图片节点特殊属性名 */ 366 | Element.IMAGE_PROPERTIES = [ 367 | 'src', 'mode', 368 | ]; 369 | /** 图片节点特殊样式名 */ 370 | Element.IMAGE_COMPUTED_STYLE = []; 371 | /** 视频节点特殊属性名 */ 372 | Element.VIDEO_PROPERTIES = [ 373 | 'src', 'object-fit', 'poster', 374 | ]; 375 | /** 视频节点特殊样式名 */ 376 | Element.VIDEO_COMPUTED_STYLE = []; 377 | /** 视频节点特殊属性名 */ 378 | Element.CANVAS_PROPERTIES = [ 379 | 'type', 'canvas-id', 380 | ]; 381 | /** 画布节点特殊属性名 */ 382 | Element.CANVAS_COMPUTED_STYLE = []; 383 | 384 | /** 385 | * 获取 WXML 节点信息对象 386 | * @param {String} selector 选择器 387 | * @param {Object} fields 节点信息字段 388 | * @param {PageObject} page 页面实例对象 389 | * @param {ComponentObject} component 组件实例对象 390 | * @returns {Promise} 节点信息 391 | */ 392 | Element.getNodesRef = (selector, fields, page, component) => new Promise((resolve) => { 393 | const query = page.createSelectorQuery(); 394 | if (component) { query.in(component); } 395 | const refs = query.selectAll(selector); 396 | refs.fields(fields); 397 | refs.fields({ 398 | computedStyle: Element.CONSTANT_COMPUTED_STYLE, 399 | }); 400 | refs.node(); 401 | query.exec((res) => { 402 | if (res && res.length > 0) { 403 | res[0].map((item, index) => { 404 | Object.assign(item, { 405 | __computedRect: getComputedRect(res[1][index]), 406 | ...res[2][index], 407 | }); 408 | return item; 409 | }); 410 | } 411 | resolve(res[0]); 412 | }); 413 | }); 414 | 415 | export default Element; 416 | -------------------------------------------------------------------------------- /src/gradient.js: -------------------------------------------------------------------------------- 1 | import { 2 | TRI2RAD_RATIO, SIDE2CORNER_RATIO, IS_WINDOWS, 3 | } from './constants'; 4 | 5 | /** 6 | * 获取 wxml 元素背景渐变类型 7 | * @param {Element} element wxml 元素 8 | * @returns {String} 渐变类型 9 | */ 10 | const getGradientType = (element) => { 11 | const backgroundImage = element['background-image']; 12 | if (!backgroundImage || backgroundImage === 'none') return ''; 13 | if (backgroundImage.startsWith('linear-gradient')) return 'linear'; 14 | if (backgroundImage.startsWith('radial-gradient')) return 'radial'; 15 | if (backgroundImage.startsWith('conic-gradient')) return 'conic'; 16 | return ''; 17 | }; 18 | 19 | /** 20 | * 获取 wxml 元素背景渐变内容 21 | * @param {String} gradientType 渐变类型 22 | * @param {String} backgroundImage wxml 元素背景 23 | * @returns {String} 渐变内容 24 | */ 25 | const getGradientContent = (gradientType, backgroundImage) => { 26 | let gradientContent; 27 | if (gradientType === 'linear') { 28 | [gradientContent] = backgroundImage.match( 29 | // 线性渐变语法参考:https://developer.mozilla.org/zh-CN/docs/Web/CSS/gradient/linear-gradient#%E5%BD%A2%E5%BC%8F%E8%AF%AD%E6%B3%95 30 | /linear-gradient\(((-?\d+(\.\d+)?(turn|deg|grad|rad))|(to( (left|top|bottom|right))+))?((, )?(((rgba?\(((, )?\d+(\.\d+)?)+\)|red)( -?\d+(\.\d+)?(px|%))*)|(-?\d+(\.\d+)?(px|%))))+\)/, 31 | ) ?? []; 32 | } else if (gradientType === 'radial') { 33 | [gradientContent] = backgroundImage.match( 34 | // 径向渐变语法参考:https://developer.mozilla.org/zh-CN/docs/Web/CSS/gradient/radial-gradient#%E5%BD%A2%E5%BC%8F%E8%AF%AD%E6%B3%95 35 | /radial-gradient\((circle|ellipse)?(( ?(closest-corner|closest-side|farthest-corner|farthest-side))|( ?\d+(\.\d+)?(px|%))+)?( ?at( (left|top|bottom|right|center|(-?\d+(\.\d+)?(px|%))))+)?((, )?(((rgba?\(((, )?\d+(\.\d+)?)+\)|red)( -?\d+(\.\d+)?(px|%))*)|(-?\d+(\.\d+)?(px|%))))+\)/, 36 | ) ?? []; 37 | } else if (gradientType === 'conic') { 38 | [gradientContent] = backgroundImage.match( 39 | // 锥形渐变语法参考:https://developer.mozilla.org/zh-CN/docs/Web/CSS/gradient/conic-gradient#%E5%BD%A2%E5%BC%8F%E8%AF%AD%E6%B3%95 40 | /conic-gradient\((from -?\d+(\.\d+)?(turn|deg|grad|rad))?( ?at( (left|top|bottom|right|center|(-?\d+(\.\d+)?(px|%))))+)?((, )?(((rgba?\(((, )?\d+(\.\d+)?)+\)|red)( -?\d+(\.\d+)?(turn|deg|grad|rad|%))*)|(-?\d+(\.\d+)?(turn|deg|grad|rad|%))))+\)/, 41 | ); 42 | } 43 | return gradientContent; 44 | }; 45 | 46 | /** 47 | * 生成线性渐变对象 48 | * @param {CanvasRenderingContext2D} ctx 画布上下文 49 | * @param {Element} element wxml 元素 50 | * @returns {CanvasGradient} 渐变对象 51 | */ 52 | export const createLinearGradient = (ctx, element) => { 53 | const gradientContent = getGradientContent('linear', element['background-image']); 54 | if (!gradientContent) return undefined; 55 | const content = element.getBoxSize('padding'); 56 | 57 | /** 矩形斜边长度 */ 58 | const rectDiagonal = Math.sqrt(content.width ** 2 + content.height ** 2); 59 | 60 | /** 渐变角度描述内容 */ 61 | const [angleContent] = gradientContent.match(/(-?\d+(\.\d+)?(turn|deg|grad|rad))|(to( (left|top|bottom|right))+)/) ?? []; 62 | /** 渐变角度,默认 180 度角,即从上往下 */ 63 | let gradientAngle = 180; 64 | if (/-?\d+(\.\d+)?(turn|deg|grad|rad)/.test(angleContent)) { 65 | /** 当前单位 的一个完整圆的数值,默认单位:度 */ 66 | let roundAngle = 360; 67 | /** 角度单位 与 当前单位 的单位换算比例,默认单位:度 */ 68 | let angleRatio = 1; 69 | if (/turn/.test(angleContent)) { // 圈数 70 | roundAngle = 1; 71 | angleRatio = 360 / roundAngle; 72 | } else if (/grad/.test(angleContent)) { // 百分度数 73 | roundAngle = 400; 74 | angleRatio = 360 / roundAngle; 75 | } else if (/rad/.test(angleContent)) { // 弧度数 76 | roundAngle = 2 * Math.PI; 77 | angleRatio = 180 / Math.PI; 78 | } 79 | gradientAngle = angleRatio * (parseFloat(angleContent) % roundAngle); 80 | // 超过 180 度转换为 逆时针角度 81 | if (gradientAngle > 180) gradientAngle = -(360 - gradientAngle); 82 | } else if (angleContent === 'to left') { // 从右往左 83 | gradientAngle = -90; 84 | } else if (angleContent === 'to right') { // 从左往右 85 | gradientAngle = 90; 86 | } else if (angleContent === 'to top') { // 从下往上 87 | gradientAngle = 0; 88 | } else if (/( (left|top|bottom|right)){2}/.test(angleContent)) { // 斜上 或 斜下,对角方向 89 | gradientAngle = ( 90 | /left/.test(angleContent) ? -1 : 1 // 左方向,转换为 逆时针角度 91 | ) * (90 + ( 92 | /top/.test(angleContent) ? -1 : 1 // 上方向,锐角角度 93 | ) * (Math.atan(content.width / content.height) / TRI2RAD_RATIO)); 94 | } 95 | 96 | // 关于线性渐变各点位置以及各个角度的计算 97 | // 参考官方图例: 98 | // https://developer.mozilla.org/zh-CN/docs/Web/CSS/gradient/linear-gradient#%E7%BA%BF%E6%80%A7%E6%B8%90%E5%8F%98%E7%9A%84%E5%90%88%E6%88%90 99 | 100 | /** 渐变角度是否顺时针 */ 101 | const isClockwise = gradientAngle >= 0; 102 | /** 渐变角度是否为钝角 */ 103 | const isObtuse = Math.abs(gradientAngle) > 90; 104 | // 渐变角度 与 水平线 的夹角(锐角) 105 | const gradientAngleDiffHorizon = 90 - ( 106 | isObtuse 107 | ? (180 - Math.abs(gradientAngle)) 108 | : Math.abs(gradientAngle) 109 | ); 110 | /** 渐变角度 与 矩形斜边 的夹角(锐角) */ 111 | const gradientAngleDiffDiagonal = Math.abs(gradientAngle) - Math.abs( 112 | isObtuse 113 | ? 90 + Math.atan(content.height / content.width) / TRI2RAD_RATIO 114 | : Math.atan(content.width / content.height) / TRI2RAD_RATIO, 115 | ); 116 | /** 渐变射线长度(标准) */ 117 | const gradientDiagonal = Math.cos(gradientAngleDiffDiagonal * TRI2RAD_RATIO) * rectDiagonal; 118 | /** 实际渐变射线长度(拓展) */ 119 | let realGradientDiagonal = gradientDiagonal; 120 | 121 | /** 渐变射线上代表起始颜色值的点 */ 122 | const startingPoint = []; 123 | /** 渐变射线上代表最终颜色值的点 */ 124 | const endingPoint = []; 125 | /** 渐变起始点的斜边长度 */ 126 | const startingDiagonal = Math.sin(gradientAngleDiffDiagonal * TRI2RAD_RATIO) * (rectDiagonal / 2); 127 | /** 渐变起始点的 x 坐标 */ 128 | const startingPointX = Math.sin(gradientAngleDiffHorizon * TRI2RAD_RATIO) * startingDiagonal; 129 | /** 渐变起始点的 y 坐标 */ 130 | const startingPointY = Math.cos(gradientAngleDiffHorizon * TRI2RAD_RATIO) * startingDiagonal; 131 | 132 | /** 渐变起始点的斜边偏移长度 */ 133 | let startingPrefixDiagonal = 0; 134 | /** 渐变起始点的 x 坐标偏移 */ 135 | let startingPrefixX = 0; 136 | /** 渐变起始点的 y 坐标偏移 */ 137 | let startingPrefixY = 0; 138 | /** 渐变最终点的斜边偏移长度 */ 139 | let endingAffixDiagonal = 0; 140 | /** 渐变最终点的 x 坐标偏移 */ 141 | let endingAffixX = 0; 142 | /** 渐变最终点的 y 坐标偏移 */ 143 | let endingAffixY = 0; 144 | 145 | /** 渐变颜色描述内容 */ 146 | const colorsContent = gradientContent.match(/((rgba?\(((, )?\d+(\.\d+)?)+\)|red)( -?\d+(\.\d+)?(px|%))*)|(-?\d+(\.\d+)?(px|%))/g) ?? []; 147 | /** 渐变色标位置集合 */ 148 | const colorStops = colorsContent.map((item, index) => { 149 | /** 渐变色标颜色 */ 150 | const [color] = item.match(/rgba?\(((, )?\d+(\.\d+)?)+\)|red/) ?? []; 151 | /** 渐变色标位置 */ 152 | const stops = ( 153 | item.match(/-?\d+(\.\d+)?(px|%)/g) ?? [] 154 | ).map((stopItem) => ( 155 | parseFloat(stopItem) * (/%/.test(stopItem) ? gradientDiagonal / 100 : 1) 156 | )); 157 | /** 渐变色标信息 */ 158 | const colorStop = { stops }; 159 | if (color) { Object.assign(colorStop, { color }); } 160 | if (index === 0 && color && stops.length > 0) { 161 | const length = -stops[0]; 162 | if (length > 0) { // 渐变起始点是否位于渐变射线上 163 | startingPrefixDiagonal = length; 164 | realGradientDiagonal += startingPrefixDiagonal; 165 | startingPrefixX = Math.sin(gradientAngle * TRI2RAD_RATIO) * startingPrefixDiagonal; 166 | startingPrefixY = Math.cos(gradientAngle * TRI2RAD_RATIO) * startingPrefixDiagonal; 167 | } 168 | } else if (index === colorsContent.length - 1 && color && stops.length > 0) { 169 | const length = stops[stops.length - 1]; 170 | if (length > gradientDiagonal) { // 渐变终止点是否位于渐变射线上 171 | endingAffixDiagonal = length; 172 | realGradientDiagonal += endingAffixDiagonal; 173 | endingAffixX = Math.sin(gradientAngle * TRI2RAD_RATIO) * endingAffixDiagonal; 174 | endingAffixY = Math.cos(gradientAngle * TRI2RAD_RATIO) * endingAffixDiagonal; 175 | } 176 | } 177 | return colorStop; 178 | }); 179 | 180 | startingPoint.push( 181 | (isClockwise ? content.left : content.right) 182 | + (isObtuse ? 1 : -1) * (isClockwise ? 1 : -1) * startingPointX - startingPrefixX, 183 | (isObtuse ? content.top : content.bottom) - startingPointY + startingPrefixY, 184 | ); 185 | endingPoint.push( 186 | (isClockwise ? content.right : content.left) 187 | + (isObtuse ? -1 : 1) * (isClockwise ? 1 : -1) * startingPointX + endingAffixX, 188 | (isObtuse ? content.bottom : content.top) + startingPointY - endingAffixY, 189 | ); 190 | /** 线性渐变对象 */ 191 | const gradient = ctx.createLinearGradient(...startingPoint, ...endingPoint); 192 | 193 | for (let index = 0; index < colorStops.length; index++) { 194 | const item = colorStops[index]; 195 | // 暂不支持控制渐变进程(插值提示) 196 | if (!item.color) continue; 197 | if (item.stops.length === 0) { 198 | if (index === 0) { 199 | item.stops.push(0); // 渐变起始点默认位置:0 200 | } else if (index === colorStops.length - 1) { 201 | item.stops.push(gradientDiagonal); // 渐变终止点默认位置:100% 202 | } else { 203 | /** 两个已声明位置信息的色标间,未声明位置信息的色标数量 */ 204 | let stopInter = 1; 205 | let stopIndex = index; 206 | /** 上一个渐变色标位置 */ 207 | const prevStop = colorStops[stopIndex - 1].stops.slice(-1)[0]; 208 | /** 下一个渐变色标位置 */ 209 | let nextStop; 210 | while (++stopIndex < colorStops.length) { 211 | stopInter += 1; 212 | if (colorStops[stopIndex].stops.length > 0) { 213 | [nextStop] = colorStops[stopIndex].stops; 214 | break; 215 | } 216 | } 217 | /** 当前渐变色标位置 */ 218 | const currentStop = prevStop + ((nextStop ?? 1) - prevStop) / stopInter; 219 | item.stops.push(currentStop); 220 | } 221 | } 222 | let stopIndex = 0; 223 | for (; stopIndex < item.stops.length; stopIndex++) { 224 | const stopItem = item.stops[stopIndex]; 225 | /** 色标位置偏移值 */ 226 | const stopOffset = +Number( 227 | (startingPrefixDiagonal + stopItem) / realGradientDiagonal, 228 | ).toFixed(4); 229 | if (stopOffset > 1 || stopOffset < 0) break; 230 | gradient.addColorStop(stopOffset, item.color); 231 | } 232 | } 233 | return gradient; 234 | }; 235 | 236 | /** 237 | * 生成径向渐变对象 238 | * @param {CanvasRenderingContext2D} ctx 画布上下文 239 | * @param {Element} element wxml 元素 240 | * @returns {CanvasGradient} 渐变对象 241 | */ 242 | export const createRadialGradient = (ctx, element) => { 243 | const gradientContent = getGradientContent('radial', element['background-image']); 244 | if (!gradientContent) return undefined; 245 | const content = element.getBoxSize('padding'); 246 | 247 | /** 渐变的位置 */ 248 | const [position] = gradientContent.match(/at( (left|top|bottom|right|center|(-?\d+(\.\d+)?(px|%))))+/) ?? []; 249 | /** 渐变的形状 */ 250 | let [endingShape] = gradientContent.match(/ellipse|circle/) ?? ['ellipse']; 251 | /** 渐变结束形状的大小描述 */ 252 | const [sizeExtent] = gradientContent.match(/closest-corner|closest-side|farthest-corner|farthest-side/) ?? ['farthest-corner']; 253 | /** 渐变结束形状的大小数值 */ 254 | const [radialSize] = gradientContent.match(/\(( ?\d+(\.\d+)?(px|%))+/) ?? []; 255 | 256 | /** 渐变起始位置的 x 坐标 */ 257 | let positionX; 258 | /** 渐变起始位置的 y 坐标 */ 259 | let positionY; 260 | if (position) { 261 | [, positionX, positionY] = position.split(' '); 262 | if (positionX === 'left') { 263 | positionX = 0; 264 | } else if (positionX === 'right') { 265 | positionX = content.width; 266 | } else if (positionX === 'center') { 267 | positionX = content.width / 2; 268 | } else if (/%/.test(positionX)) { 269 | positionX = content.width * (parseFloat(positionX) / 100); 270 | } else { 271 | positionX = parseFloat(positionX); 272 | } 273 | if (positionY === 'top') { 274 | positionY = 0; 275 | } else if (positionY === 'bottom') { 276 | positionY = content.height; 277 | } else if (positionY === 'center') { 278 | positionY = content.height / 2; 279 | } else if (/%/.test(positionY)) { 280 | positionY = content.height * (parseFloat(positionY) / 100); 281 | } else { 282 | positionY = parseFloat(positionY); 283 | } 284 | } else { 285 | positionX = content.width / 2; 286 | positionY = content.height / 2; 287 | } 288 | 289 | /** 渐变形状的 x 轴长度 */ 290 | let radiusX; 291 | /** 渐变形状的 y 轴长度 */ 292 | let radiusY; 293 | if (radialSize) { 294 | [radiusX, radiusY] = radialSize.split(' '); 295 | radiusX = radiusX.slice(1); 296 | if (/%/.test(radiusX)) { 297 | radiusX = content.width * (parseFloat(radiusX) / 100); 298 | } else { 299 | radiusX = parseFloat(radiusX); 300 | } 301 | if (!radiusY) { 302 | radiusY = radiusX; 303 | } else if (/%/.test(radiusY)) { 304 | radiusY = content.height * (parseFloat(radiusY) / 100); 305 | } else { 306 | radiusY = parseFloat(radiusY); 307 | } 308 | if (radiusX === radiusY) endingShape = 'circle'; 309 | } else if (sizeExtent === 'closest-side') { 310 | radiusX = Math.min(Math.abs(positionX), Math.abs(positionX - content.width)); 311 | radiusY = Math.min(Math.abs(positionY), Math.abs(positionY - content.height)); 312 | if (endingShape === 'circle') { 313 | radiusX = Math.min(radiusX, radiusY); 314 | radiusY = radiusX; 315 | } 316 | } else if (sizeExtent === 'farthest-side') { 317 | radiusX = Math.max(Math.abs(positionX), Math.abs(positionX - content.width)); 318 | radiusY = Math.max(Math.abs(positionY), Math.abs(positionY - content.height)); 319 | if (endingShape === 'circle') { 320 | radiusX = Math.max(radiusX, radiusY); 321 | radiusY = radiusX; 322 | } 323 | } else if (sizeExtent === 'closest-corner') { 324 | radiusX = Math.min(Math.abs(positionX), Math.abs(positionX - content.width)); 325 | radiusY = Math.min(Math.abs(positionY), Math.abs(positionY - content.height)); 326 | if (endingShape === 'circle') { 327 | radiusX = Math.sqrt(radiusX ** 2 + radiusY ** 2); 328 | radiusY = radiusX; 329 | } else { 330 | radiusX *= SIDE2CORNER_RATIO; 331 | radiusY *= SIDE2CORNER_RATIO; 332 | } 333 | } else if (sizeExtent === 'farthest-corner') { 334 | radiusX = Math.max(Math.abs(positionX), Math.abs(positionX - content.width)); 335 | radiusY = Math.max(Math.abs(positionY), Math.abs(positionY - content.height)); 336 | if (endingShape === 'circle') { 337 | radiusX = Math.sqrt(radiusX ** 2 + radiusY ** 2); 338 | radiusY = radiusX; 339 | } else { 340 | radiusX *= SIDE2CORNER_RATIO; 341 | radiusY *= SIDE2CORNER_RATIO; 342 | } 343 | } 344 | 345 | /** 渐变形状的短轴长度 */ 346 | const radius = Math.min(radiusX, radiusY); 347 | /** 实际径向渐变射线长度 */ 348 | let realRadius = radius; 349 | 350 | /** 渐变颜色描述内容 */ 351 | // 正则还存在问题,会把前面位置描述匹配到,但暂不影响结果 352 | const colorsContent = gradientContent.match(/((rgba?\(((, )?\d+(\.\d+)?)+\)|red)( -?\d+(\.\d+)?(px|%))*)|(-?\d+(\.\d+)?(px|%))/g) ?? []; 353 | /** 渐变色标位置集合 */ 354 | const colorStops = colorsContent.map((item) => { 355 | /** 渐变色标颜色 */ 356 | const [color] = item.match(/rgba?\(((, )?\d+(\.\d+)?)+\)|red/) ?? []; 357 | /** 渐变色标位置 */ 358 | const stops = ( 359 | item.match(/-?\d+(\.\d+)?(px|%)/g) ?? [] 360 | ).map((stopItem) => ( 361 | parseFloat(stopItem) * (/%/.test(stopItem) ? radius / 100 : 1) 362 | )); 363 | /** 渐变色标信息 */ 364 | const colorStop = { stops }; 365 | if (color) { Object.assign(colorStop, { color }); } 366 | if (colorStop.stops[colorStop.stops.length - 1] > radius) { 367 | realRadius = colorStop.stops[colorStop.stops.length - 1]; 368 | } 369 | return colorStop; 370 | }); 371 | 372 | // 由于 Canvas 径向渐变只允许生成圆形径向渐变 373 | // 所以设置对应轴缩放比例,生成椭圆形径向渐变 374 | /** x 轴缩放比例 */ 375 | const scaleX = radiusX / radius; 376 | /** y 轴缩放比例 */ 377 | const scaleY = radiusY / radius; 378 | /** 缩放后渐变起始位置的 x 坐标 */ 379 | const scaledLeft = (content.left + positionX) / scaleX; 380 | /** 缩放后渐变起始位置的 y 坐标 */ 381 | const scaledTop = (content.top + positionY) / scaleY; 382 | /** 径向渐变对象 */ 383 | const gradient = ctx.createRadialGradient( 384 | scaledLeft, scaledTop, 0, 385 | scaledLeft, scaledTop, realRadius, 386 | ); 387 | 388 | for (let index = 0; index < colorStops.length; index++) { 389 | const item = colorStops[index]; 390 | // 暂不支持控制渐变进程(插值提示) 391 | if (!item.color) continue; 392 | if (item.stops.length === 0) { 393 | if (index === 0) { 394 | item.stops.push(0); // 渐变起始点默认位置:0 395 | } else if (index === colorStops.length - 1) { 396 | item.stops.push(realRadius); // 渐变终止点默认位置:100% 397 | } else { 398 | /** 两个已声明位置信息的色标间,未声明位置信息的色标数量 */ 399 | let stopInter = 1; 400 | let stopIndex = index; 401 | /** 上一个渐变色标位置 */ 402 | const prevStop = colorStops[stopIndex - 1].stops.slice(-1)[0]; 403 | /** 下一个渐变色标位置 */ 404 | let nextStop; 405 | while (++stopIndex < colorStops.length) { 406 | stopInter += 1; 407 | if (colorStops[stopIndex].stops.length > 0) { 408 | [nextStop] = colorStops[stopIndex].stops; 409 | break; 410 | } 411 | } 412 | /** 当前渐变色标位置 */ 413 | const currentStop = prevStop + ((nextStop ?? 1) - prevStop) / stopInter; 414 | item.stops.push(currentStop); 415 | } 416 | } 417 | let stopIndex = 0; 418 | for (; stopIndex < item.stops.length; stopIndex++) { 419 | const stopItem = item.stops[stopIndex]; 420 | /** 色标位置偏移值 */ 421 | const stopOffset = +Number( 422 | stopItem / realRadius, 423 | ).toFixed(4); 424 | if (stopOffset > 1 || stopOffset < 0) break; 425 | gradient.addColorStop(stopOffset, item.color); 426 | } 427 | } 428 | return { 429 | gradient, 430 | scale: { 431 | x: scaleX, 432 | y: scaleY, 433 | }, 434 | }; 435 | }; 436 | 437 | /** 438 | * 生成锥形渐变对象 439 | * @param {CanvasRenderingContext2D} ctx 画布上下文 440 | * @param {Element} element wxml 元素 441 | * @returns {CanvasGradient} 渐变对象 442 | */ 443 | export const createConicGradient = (ctx, element) => { 444 | const gradientContent = getGradientContent('conic', element['background-image']); 445 | if (!gradientContent) return undefined; 446 | const content = element.getBoxSize('padding'); 447 | 448 | /** 渐变的角度 */ 449 | const [angle] = gradientContent.match(/from -?\d+(\.\d+)?(turn|deg|grad|rad)/) ?? []; 450 | /** 渐变的位置 */ 451 | const [position] = gradientContent.match(/at( (left|top|bottom|right|center|(-?\d+(\.\d+)?(px|%))))+/) ?? []; 452 | 453 | /** 渐变的起始角度 */ 454 | let startAngle = 0; 455 | if (angle) { 456 | [, startAngle] = angle.split(' '); 457 | if (/deg/.test(startAngle)) { 458 | startAngle = parseFloat(startAngle) * TRI2RAD_RATIO; 459 | } else if (/turn/.test(startAngle)) { 460 | startAngle = parseFloat(startAngle) * 360 * TRI2RAD_RATIO; 461 | } else if (/grad/.test(startAngle)) { 462 | startAngle = parseFloat(startAngle) * 0.9 * TRI2RAD_RATIO; 463 | } else { 464 | startAngle = parseFloat(startAngle); 465 | } 466 | } 467 | 468 | // Windows 渐变起始角度校准 469 | if (IS_WINDOWS) { 470 | startAngle -= 0.5 * Math.PI; 471 | } 472 | 473 | /** 渐变起始位置的 x 坐标 */ 474 | let positionX; 475 | /** 渐变起始位置的 y 坐标 */ 476 | let positionY; 477 | if (position) { 478 | [, positionX, positionY] = position.split(' '); 479 | if (positionX === 'left') { 480 | positionX = 0; 481 | } else if (positionX === 'right') { 482 | positionX = content.width; 483 | } else if (positionX === 'center') { 484 | positionX = content.width / 2; 485 | } else if (/%/.test(positionX)) { 486 | positionX = content.width * (parseFloat(positionX) / 100); 487 | } else { 488 | positionX = parseFloat(positionX); 489 | } 490 | if (positionY === 'top') { 491 | positionY = 0; 492 | } else if (positionY === 'bottom') { 493 | positionY = content.height; 494 | } else if (positionY === 'center') { 495 | positionY = content.height / 2; 496 | } else if (/%/.test(positionY)) { 497 | positionY = content.height * (parseFloat(positionY) / 100); 498 | } else { 499 | positionY = parseFloat(positionY); 500 | } 501 | } else { 502 | positionX = content.width / 2; 503 | positionY = content.height / 2; 504 | } 505 | 506 | /** 渐变颜色描述内容 */ 507 | // 正则还存在问题,会把前面位置描述匹配到,但暂不影响结果 508 | const colorsContent = gradientContent.match(/((rgba?\(((, )?\d+(\.\d+)?)+\)|red)( -?\d+(\.\d+)?(turn|deg|grad|rad|%))*)|(-?\d+(\.\d+)?(turn|deg|grad|rad|%))/g) ?? []; 509 | /** 渐变起始角度校准 */ 510 | let angleOffset = 0; 511 | /** 渐变色标位置集合 */ 512 | const colorStops = colorsContent.map((item) => { 513 | /** 渐变色标颜色 */ 514 | const [color] = item.match(/rgba?\(((, )?\d+(\.\d+)?)+\)|red/) ?? []; 515 | /** 渐变色标位置 */ 516 | const stops = ( 517 | item.match(/-?\d+(\.\d+)?(turn|deg|grad|rad|%)/g) ?? [] 518 | ).map((stopItem) => { 519 | let stopAngle; 520 | if (/deg/.test(stopItem)) { 521 | stopAngle = parseFloat(stopItem) * TRI2RAD_RATIO; 522 | } else if (/turn/.test(stopItem)) { 523 | stopAngle = parseFloat(stopItem) * 360 * TRI2RAD_RATIO; 524 | } else if (/grad/.test(stopItem)) { 525 | stopAngle = parseFloat(stopItem) * 0.9 * TRI2RAD_RATIO; 526 | } else if (/%/.test(stopItem)) { 527 | stopAngle = (parseFloat(stopItem) / 100) * 2 * Math.PI; 528 | } else { 529 | stopAngle = parseFloat(stopItem); 530 | } 531 | if (angleOffset === 0 && color && stopAngle < 0) { 532 | angleOffset = -stopAngle; 533 | } 534 | return stopAngle + angleOffset; 535 | }); 536 | /** 渐变色标信息 */ 537 | const colorStop = { stops }; 538 | if (color) { Object.assign(colorStop, { color }); } 539 | return colorStop; 540 | }); 541 | 542 | /** 锥形渐变对象 */ 543 | // 起始角度的单位:Windows 角度、开发工具 弧度 544 | const gradient = ctx.createConicGradient( 545 | IS_WINDOWS ? startAngle : startAngle / TRI2RAD_RATIO, 546 | content.left + positionX, content.top + positionY, 547 | ); 548 | 549 | for (let index = 0; index < colorStops.length; index++) { 550 | const item = colorStops[index]; 551 | // 暂不支持控制渐变进程(插值提示) 552 | if (!item.color) continue; 553 | if (item.stops.length === 0) { 554 | if (index === 0) { 555 | item.stops.push(0); // 渐变起始点默认位置:0 556 | } else if (index === colorStops.length - 1) { 557 | item.stops.push(2 * Math.PI); // 渐变终止点默认位置:100% 558 | } else { 559 | /** 两个已声明位置信息的色标间,未声明位置信息的色标数量 */ 560 | let stopInter = 1; 561 | let stopIndex = index; 562 | /** 上一个渐变色标位置 */ 563 | const prevStop = colorStops[stopIndex - 1].stops.slice(-1)[0]; 564 | /** 下一个渐变色标位置 */ 565 | let nextStop; 566 | while (++stopIndex < colorStops.length) { 567 | stopInter += 1; 568 | if (colorStops[stopIndex].stops.length > 0) { 569 | [nextStop] = colorStops[stopIndex].stops; 570 | break; 571 | } 572 | } 573 | /** 当前渐变色标位置 */ 574 | const currentStop = prevStop + ((nextStop ?? 1) - prevStop) / stopInter; 575 | item.stops.push(currentStop); 576 | } 577 | } 578 | let stopIndex = 0; 579 | for (; stopIndex < item.stops.length; stopIndex++) { 580 | const stopItem = item.stops[stopIndex]; 581 | /** 色标位置偏移值 */ 582 | const stopOffset = +Number( 583 | stopItem / (2 * Math.PI), 584 | ).toFixed(4); 585 | if (stopOffset > 1 || stopOffset < 0) break; 586 | gradient.addColorStop(stopOffset, item.color); 587 | } 588 | } 589 | return gradient; 590 | }; 591 | 592 | /** 593 | * 生成并绘制渐变对象 594 | * @param {CanvasRenderingContext2D} ctx 画布上下文 595 | * @param {Element} element wxml 元素 596 | * @returns {CanvasGradient} 渐变对象 597 | */ 598 | export const drawGradient = (ctx, element) => { 599 | const gradientType = getGradientType(element); 600 | let gradient; 601 | let scaleX = 1; 602 | let scaleY = 1; 603 | 604 | if (gradientType === 'linear') { 605 | if (!ctx.createLinearGradient) return; 606 | gradient = createLinearGradient(ctx, element); 607 | } else if (gradientType === 'radial') { 608 | if (!ctx.createRadialGradient) return; 609 | const radial = createRadialGradient(ctx, element); 610 | if (!radial) return; 611 | gradient = radial.gradient; 612 | scaleX = radial.scale.x; 613 | scaleY = radial.scale.y; 614 | } else if (gradientType === 'conic') { 615 | if (!ctx.createConicGradient) return; 616 | gradient = createConicGradient(ctx, element); 617 | } 618 | 619 | if (!gradient) return; 620 | ctx.fillStyle = gradient; 621 | ctx.scale(scaleX, scaleY); 622 | ctx.fillRect( 623 | scaleX > 1 624 | ? element.left / scaleX - element.width / 2 + element.width / scaleX / 2 625 | : element.left, 626 | scaleY > 1 627 | ? element.top / scaleY - element.height / 2 + element.height / scaleY / 2 628 | : element.top, 629 | element.width, 630 | element.height, 631 | ); 632 | // 恢复画布比例 633 | ctx.scale(1 / scaleX, 1 / scaleY); 634 | }; 635 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import Element from './element'; 2 | import Canvas from './canvas'; 3 | 4 | /** 5 | * 绘制 wxml 元素 6 | * @param {Canvas} canvas 画布对象 7 | * @param {Element} element wxml 元素 8 | * @param {PageObject} page 页面实例对象 9 | * @param {ComponentObject} component 组件实例对象 10 | */ 11 | const drawElement = async (canvas, element, page, component) => { 12 | // 设置画布的当前 wxml 元素上下文(必要) 13 | canvas.setElement(element); 14 | 15 | canvas.setTransform(); 16 | canvas.drawBoxShadow(); 17 | canvas.drawBackgroundColor(); 18 | await canvas.drawBackgroundImage(); 19 | if ('src' in element) { 20 | if ('objectFit' in element) { 21 | await canvas.drawVideo(); 22 | } else { 23 | await canvas.drawImage(); 24 | } 25 | } else if ('text' in element.dataset || 'icon' in element.dataset) { 26 | canvas.drawText(); 27 | } else if ('canvasId' in element) { 28 | await canvas.drawCanvas(component ?? page); 29 | } 30 | canvas.drawBorder(); 31 | canvas.resetTransform(); 32 | canvas.restoreContext(); 33 | }; 34 | 35 | Component({ 36 | externalClasses: ['box-class'], 37 | properties: { 38 | // 根节点(容器)样式类名称 39 | containerClass: { 40 | type: String, 41 | value: 'wxml2canvas-container', 42 | }, 43 | // 内部节点样式类名称 44 | itemClass: { 45 | type: String, 46 | value: 'wxml2canvas-item', 47 | }, 48 | // 画布缩放比例 49 | scale: { 50 | type: Number, 51 | value: 1, 52 | }, 53 | // 是否使用离屏画布 54 | offscreen: Boolean, 55 | }, 56 | data: { 57 | // 画布宽度 58 | canvasWidth: 300, 59 | // 画布高度 60 | canvasHeight: 150, 61 | }, 62 | methods: { 63 | /** 64 | * setData 同步版 65 | * @param {Object} data 66 | * @returns {Promise} 67 | */ 68 | setDataSync(data) { 69 | return new Promise((resolve) => { 70 | this.setData(data, resolve); 71 | }); 72 | }, 73 | /** 74 | * 绘制画布内容 75 | * @param {PageObject} page 页面实例对象,默认当前页面实例 76 | * @param {ComponentObject} component 组件实例对象 77 | */ 78 | async draw(page, component) { 79 | // 获取当前页面实例、组件实例 80 | if (page && !page.route && !component) { 81 | component = page; 82 | } if (!page || !page.route) { 83 | [page] = getCurrentPages().slice(-1); 84 | } 85 | 86 | const { 87 | containerClass, itemClass, scale, offscreen, 88 | } = this.data; 89 | const fields = { 90 | id: true, 91 | size: true, 92 | rect: true, 93 | dataset: true, 94 | properties: [ 95 | ...Element.COMMON_PROPERTIES, 96 | ...Element.TEXT_PROPERTIES, 97 | ...Element.IMAGE_PROPERTIES, 98 | ...Element.VIDEO_PROPERTIES, 99 | ...Element.CANVAS_PROPERTIES, 100 | ], 101 | computedStyle: [ 102 | ...Element.COMMON_COMPUTED_STYLE, 103 | ...Element.TEXT_COMPUTED_STYLE, 104 | ...Element.IMAGE_COMPUTED_STYLE, 105 | ...Element.VIDEO_COMPUTED_STYLE, 106 | ...Element.CANVAS_COMPUTED_STYLE, 107 | ], 108 | }; 109 | const [container] = await Element.getNodesRef(`.${containerClass}`, fields, page, component); 110 | await this.setDataSync({ 111 | canvasWidth: container.width * scale, 112 | canvasHeight: container.height * scale, 113 | }); 114 | const nodes = await Element.getNodesRef(`.${containerClass} .${itemClass}`, fields, page, component); 115 | 116 | const canvas = this.canvas = new Canvas(...(offscreen ? [this] : [this, '#wxml2canvas'])); 117 | await canvas.init(container, scale); 118 | 119 | nodes.unshift(container); 120 | await this.drawElements(nodes, fields, component ?? page); 121 | }, 122 | /** 123 | * 绘制元素节点合集 124 | * @param {Array} elements 元素节点合集 125 | * @param {Object} fields 元素节点相关信息 126 | * @param {Object} parent 父节点实例 127 | */ 128 | async drawElements(elements, fields, parent) { 129 | const { itemClass } = this.data; 130 | // 绘制内层各 wxml 元素 131 | // eslint-disable-next-line no-restricted-syntax 132 | for (const item of elements) { 133 | const itemElement = new Element(item); 134 | if (item.dataset.component) { 135 | const child = parent.selectComponent(`#${item.id}`); 136 | const childElements = await Element.getNodesRef(`.${itemClass}`, fields, child); 137 | await this.drawElements(childElements, fields, child); 138 | } else { 139 | await drawElement(this.canvas, itemElement, parent); 140 | } 141 | } 142 | }, 143 | /** 144 | * 把画布内容导出生成图片 145 | * @param {Boolean} original 是否使用实机表现作为导出图片的尺寸 146 | * @returns {Promise} 图片临时路径 147 | */ 148 | async toTempFilePath(original = true) { 149 | const tempFilePath = await this.canvas.toTempFilePath(original); 150 | return tempFilePath; 151 | }, 152 | /** 153 | * 导出画布至 Data URI 154 | * @returns {String} Data URI 155 | */ 156 | toDataURL() { 157 | return this.canvas.toDataURL(); 158 | }, 159 | /** 160 | * 获取画布的像素数据 161 | * @returns {ImageData} imageData 162 | */ 163 | getImageData() { 164 | return this.canvas.getImageData(); 165 | }, 166 | }, 167 | }); 168 | -------------------------------------------------------------------------------- /src/index.json: -------------------------------------------------------------------------------- 1 | { 2 | "component": true, 3 | "usingComponents": {} 4 | } -------------------------------------------------------------------------------- /src/index.wxml: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/index.wxss: -------------------------------------------------------------------------------- 1 | .wxml2canvas { 2 | position: fixed; 3 | left: 9999px; 4 | top: -9999px; 5 | } -------------------------------------------------------------------------------- /tools/build.js: -------------------------------------------------------------------------------- 1 | const gulp = require('gulp'); 2 | 3 | const config = require('./config'); 4 | 5 | const copyFile = (filePattern, cwd, distPath) => gulp.src(filePattern, { cwd }) 6 | .pipe(gulp.dest(distPath)); 7 | 8 | gulp.task('build-component', () => copyFile('**/*', config.srcPath, config.distPath)); 9 | gulp.task('build-demo', () => copyFile('**/*', config.demoSrc, config.demoDist)); 10 | 11 | gulp.task('watch-component', () => { 12 | const watchCallback = (filePattern) => copyFile(filePattern, config.srcPath, config.distPath); 13 | return gulp.watch('**/*', { cwd: config.srcPath }) 14 | .on('change', watchCallback) 15 | .on('add', watchCallback) 16 | .on('unlink', watchCallback); 17 | }); 18 | gulp.task('watch-demo', () => { 19 | const watchCallback = (filePattern) => copyFile(filePattern, config.demoSrc, config.demoDist); 20 | return gulp.watch('**/*', { cwd: config.demoSrc }) 21 | .on('change', watchCallback) 22 | .on('add', watchCallback) 23 | .on('unlink', watchCallback); 24 | }); 25 | 26 | (() => { 27 | gulp.task('build', gulp.series('build-component')); 28 | gulp.task('dev', gulp.series( 29 | gulp.parallel('build-component', 'build-demo'), 30 | gulp.parallel('watch-component', 'watch-demo'), 31 | )); 32 | })(); 33 | -------------------------------------------------------------------------------- /tools/config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | const isDev = process.argv.indexOf('--dev') >= 0; 4 | const isWatch = process.argv.indexOf('--watch') >= 0; 5 | const demoSrc = path.resolve(__dirname, './demo'); 6 | const demoDist = path.resolve(__dirname, '../miniprogram_dev'); 7 | const src = path.resolve(__dirname, '../src'); 8 | const dev = path.join(demoDist, 'components/wxml2canvas-2d'); 9 | const dist = path.resolve(__dirname, '../miniprogram_dist'); 10 | 11 | module.exports = { 12 | isDev, 13 | isWatch, 14 | demoSrc, 15 | demoDist, 16 | distPath: isDev ? dev : dist, 17 | srcPath: src, 18 | }; 19 | -------------------------------------------------------------------------------- /tools/demo/app.js: -------------------------------------------------------------------------------- 1 | App({}); 2 | -------------------------------------------------------------------------------- /tools/demo/app.json: -------------------------------------------------------------------------------- 1 | { 2 | "pages":[ 3 | "pages/index/index", 4 | "pages/basic/index", 5 | "pages/font/index", 6 | "pages/video/index", 7 | "pages/canvas/index", 8 | "pages/iconfont/index", 9 | "pages/component/index", 10 | "pages/package/index", 11 | "pages/multiple/index", 12 | "pages/overflow/index", 13 | "pages/snapshot/index", 14 | "pages/snapshot-2/index", 15 | "pages/snapshot-3/index" 16 | ], 17 | "window":{ 18 | "backgroundTextStyle":"light", 19 | "navigationBarBackgroundColor": "#fff", 20 | "navigationBarTitleText": "wxml2canvas-2d", 21 | "navigationBarTextStyle":"black" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /tools/demo/app.wxss: -------------------------------------------------------------------------------- 1 | .container { 2 | width: 100vw; 3 | height: 100vh; 4 | display: flex; 5 | flex-direction: column; 6 | align-items: center; 7 | } 8 | .custom-font { 9 | font-family: 'QianTuXianMoTi'; 10 | } 11 | .example { 12 | width: 100%; 13 | flex-grow: 1; 14 | display: flex; 15 | flex-direction: column; 16 | justify-content: center; 17 | align-items: center; 18 | background: #F6F6F6; 19 | } 20 | .example-hint { 21 | margin: 24rpx 0; 22 | font-size: 26rpx; 23 | color: #808080; 24 | } 25 | .swiper { 26 | width: 100vw; 27 | height: 700rpx; 28 | } 29 | .swiper-item { 30 | display: flex; 31 | justify-content: center; 32 | } 33 | .scroll { 34 | width: 600rpx; 35 | height: 70vh; 36 | } 37 | .canvas { 38 | background: #FFF; 39 | overflow: hidden; 40 | width: 600rpx; 41 | height: fit-content; 42 | padding: 30rpx; 43 | box-sizing: border-box; 44 | position: relative; 45 | } 46 | .canvas.small { 47 | width: 550rpx; 48 | } 49 | .wxml2canvas { 50 | position: absolute; 51 | left: 0; 52 | top: 0; 53 | width: 100%; 54 | height: 0; 55 | z-index: -1; 56 | } 57 | .wxml2canvas.compared { 58 | z-index: 1; 59 | } 60 | .canvas-title { 61 | font-size: 36rpx; 62 | font-weight: 800; 63 | text-align: center; 64 | color: #008AFE; 65 | margin-bottom: 20rpx; 66 | display: flex; 67 | align-items: center; 68 | justify-content: center; 69 | } 70 | .canvas-image, 71 | .canvas-picture { 72 | width: 100%; 73 | height: 340rpx; 74 | display: block; 75 | border-radius: 20rpx; 76 | margin-bottom: 20rpx; 77 | overflow: hidden; 78 | } 79 | .canvas-row { 80 | display: flex; 81 | justify-content: space-between; 82 | } 83 | .canvas-qrcode { 84 | display: flex; 85 | flex-direction: column; 86 | align-items: center; 87 | margin-right: 20rpx; 88 | } 89 | .qrcode-image { 90 | box-sizing: border-box; 91 | width: 150rpx; 92 | height: 150rpx; 93 | border-radius: 20rpx; 94 | display: block; 95 | padding: 10rpx; 96 | border: 4rpx dashed #008AFE; 97 | } 98 | .canvas-content { 99 | flex-grow: 1; 100 | min-height: 150rpx; 101 | display: flex; 102 | flex-direction: column; 103 | justify-content: space-between; 104 | } 105 | .canvas-subtitle { 106 | display: flex; 107 | align-items: center; 108 | font-size: 30rpx; 109 | color: #808080; 110 | font-weight: 700; 111 | } 112 | .canvas-descr { 113 | font-size: 28rpx; 114 | color: #333; 115 | width: 100%; 116 | overflow: hidden; 117 | text-overflow: ellipsis; 118 | display: -webkit-box; 119 | -webkit-box-orient: vertical; 120 | -webkit-line-clamp: 2; 121 | } 122 | .canvas-hint { 123 | margin-top: 8rpx; 124 | font-size: 20rpx; 125 | color: #AFAFAF; 126 | } 127 | 128 | .buttons button { 129 | width: 80vw; 130 | font-size: 30rpx; 131 | margin: 20rpx 0; 132 | } 133 | .output { 134 | position: fixed; 135 | left: -99999px; 136 | top: -99999px; 137 | } -------------------------------------------------------------------------------- /tools/demo/images/U3e6ny.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ChrisChan13/wxml2canvas-2d/99c432e9791d8a98ceff32aa4490ec8f30e2a0fb/tools/demo/images/U3e6ny.jpg -------------------------------------------------------------------------------- /tools/demo/images/qrcode.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ChrisChan13/wxml2canvas-2d/99c432e9791d8a98ceff32aa4490ec8f30e2a0fb/tools/demo/images/qrcode.png -------------------------------------------------------------------------------- /tools/demo/pages/basic/index.js: -------------------------------------------------------------------------------- 1 | Page({ 2 | data: { 3 | isCompare: false, 4 | outputImage: '', 5 | }, 6 | async onDrawCanvas() { 7 | try { 8 | const canvas = this.selectComponent('#wxml2canvas'); 9 | wx.showLoading({ 10 | title: '生成中..', 11 | }); 12 | console.time('生成耗时'); 13 | await canvas.draw(); 14 | console.timeEnd('生成耗时'); 15 | let timer = setTimeout(async () => { 16 | try { 17 | const url = await canvas.toTempFilePath(); 18 | this.setData({ outputImage: url }); 19 | } catch (err) { 20 | console.error(err); 21 | } 22 | wx.hideLoading(); 23 | clearTimeout(timer); 24 | timer = null; 25 | }, 300); 26 | } catch (err) { 27 | console.error(err); 28 | } 29 | }, 30 | onPreviewCanvas() { 31 | const { outputImage } = this.data; 32 | wx.previewImage({ 33 | urls: [outputImage], 34 | }); 35 | }, 36 | onCanvasTouch() { 37 | this.setData({ isCompare: true }); 38 | }, 39 | onCanvasLeave() { 40 | this.setData({ isCompare: false }); 41 | }, 42 | }); 43 | -------------------------------------------------------------------------------- /tools/demo/pages/basic/index.json: -------------------------------------------------------------------------------- 1 | { 2 | "navigationBarTitleText": "基础示例", 3 | "usingComponents": { 4 | "wxml2canvas": "/components/wxml2canvas-2d/index" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /tools/demo/pages/basic/index.wxml: -------------------------------------------------------------------------------- 1 | 2 | 3 | {{isCompare ? '生成' : '原'}}内容 4 | 5 | 6 | 这是大标题 7 | 8 | 9 | 10 | 11 | 12 | 13 | 这是小标题 14 | 15 | 填充测试内容填充测试内容填充测试内容填充测试内容填充测试内容 16 | 17 | 扫描左侧二维码 18 | 19 | 20 | 21 | ↑↑ 按住卡片实时对比 ↑↑ 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /tools/demo/pages/canvas/index.js: -------------------------------------------------------------------------------- 1 | const createImage = (canvas, src) => new Promise((resolve, reject) => { 2 | const image = canvas.createImage(); 3 | image.src = src; 4 | image.onload = () => resolve(image); 5 | image.onerror = reject; 6 | }); 7 | 8 | Page({ 9 | data: { 10 | isCompare: false, 11 | outputImage: '', 12 | }, 13 | onReady() { 14 | this.onLoadCanvas(); 15 | }, 16 | onLoadCanvas() { 17 | const query = this.createSelectorQuery(); 18 | query.select('#canvas').fields({ 19 | node: true, 20 | }).exec(async (res) => { 21 | const [{ node: canvas }] = res; 22 | const ctx = canvas.getContext('2d'); 23 | const img = await createImage(canvas, '/images/U3e6ny.jpg'); 24 | ctx.drawImage(img, 0, 0, canvas.width, canvas.height); 25 | ctx.fillStyle = 'white'; 26 | ctx.font = '700 14px sans-serif'; 27 | ctx.fillText('这是一个小水印', 180, 140); 28 | }); 29 | }, 30 | async onDrawCanvas() { 31 | try { 32 | const canvas = this.selectComponent('#wxml2canvas'); 33 | wx.showLoading({ 34 | title: '生成中..', 35 | }); 36 | console.time('生成耗时'); 37 | await canvas.draw(); 38 | console.timeEnd('生成耗时'); 39 | let timer = setTimeout(async () => { 40 | try { 41 | const url = await canvas.toTempFilePath(); 42 | this.setData({ outputImage: url }); 43 | } catch (err) { 44 | console.error(err); 45 | } 46 | wx.hideLoading(); 47 | clearTimeout(timer); 48 | timer = null; 49 | }, 300); 50 | } catch (err) { 51 | console.error(err); 52 | } 53 | }, 54 | onPreviewCanvas() { 55 | const { outputImage } = this.data; 56 | wx.previewImage({ 57 | urls: [outputImage], 58 | }); 59 | }, 60 | onCanvasTouch() { 61 | this.setData({ isCompare: true }); 62 | }, 63 | onCanvasLeave() { 64 | this.setData({ isCompare: false }); 65 | }, 66 | }); 67 | -------------------------------------------------------------------------------- /tools/demo/pages/canvas/index.json: -------------------------------------------------------------------------------- 1 | { 2 | "navigationBarTitleText": "绘制 Canvas 节点示例", 3 | "usingComponents": { 4 | "wxml2canvas": "/components/wxml2canvas-2d/index" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /tools/demo/pages/canvas/index.wxml: -------------------------------------------------------------------------------- 1 | 2 | 3 | {{isCompare ? '生成' : '原'}}内容 4 | 5 | 6 | 这是大标题 7 | 8 | 9 | 10 | 11 | 12 | 13 | 这是小标题 14 | 15 | 填充测试内容填充测试内容填充测试内容填充测试内容填充测试内容 16 | 17 | 扫描左侧二维码 18 | 19 | 20 | 21 | ↑↑ 按住卡片实时对比 ↑↑ 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /tools/demo/pages/component/comp.js: -------------------------------------------------------------------------------- 1 | Component({ 2 | properties: { 3 | title: String, 4 | content: String, 5 | }, 6 | }); 7 | -------------------------------------------------------------------------------- /tools/demo/pages/component/comp.json: -------------------------------------------------------------------------------- 1 | { 2 | "component": true 3 | } 4 | -------------------------------------------------------------------------------- /tools/demo/pages/component/comp.wxml: -------------------------------------------------------------------------------- 1 | 2 | {{title}} 3 | 4 | {{content}} 5 | 6 | 7 | -------------------------------------------------------------------------------- /tools/demo/pages/component/comp.wxss: -------------------------------------------------------------------------------- 1 | @import '../../app.wxss'; 2 | -------------------------------------------------------------------------------- /tools/demo/pages/component/index.js: -------------------------------------------------------------------------------- 1 | Page({ 2 | data: { 3 | isCompare: false, 4 | outputImage: '', 5 | }, 6 | async onDrawCanvas() { 7 | try { 8 | const canvas = this.selectComponent('#wxml2canvas'); 9 | wx.showLoading({ 10 | title: '生成中..', 11 | }); 12 | console.time('生成耗时'); 13 | await canvas.draw(); 14 | console.timeEnd('生成耗时'); 15 | let timer = setTimeout(async () => { 16 | try { 17 | const url = await canvas.toTempFilePath(); 18 | this.setData({ outputImage: url }); 19 | } catch (err) { 20 | console.error(err); 21 | } 22 | wx.hideLoading(); 23 | clearTimeout(timer); 24 | timer = null; 25 | }, 300); 26 | } catch (err) { 27 | console.error(err); 28 | } 29 | }, 30 | onPreviewCanvas() { 31 | const { outputImage } = this.data; 32 | wx.previewImage({ 33 | urls: [outputImage], 34 | }); 35 | }, 36 | onCanvasTouch() { 37 | this.setData({ isCompare: true }); 38 | }, 39 | onCanvasLeave() { 40 | this.setData({ isCompare: false }); 41 | }, 42 | }); 43 | -------------------------------------------------------------------------------- /tools/demo/pages/component/index.json: -------------------------------------------------------------------------------- 1 | { 2 | "navigationBarTitleText": "绘制自定义组件示例", 3 | "usingComponents": { 4 | "comp": "./comp", 5 | "wxml2canvas": "/components/wxml2canvas-2d/index" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /tools/demo/pages/component/index.wxml: -------------------------------------------------------------------------------- 1 | 2 | 3 | {{isCompare ? '生成' : '原'}}内容 4 | 5 | 6 | 这是大标题 7 | 8 | 9 | 10 | 11 | 12 | 19 | 扫描左侧二维码 20 | 21 | 22 | 23 | ↑↑ 按住卡片实时对比 ↑↑ 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /tools/demo/pages/font/index.js: -------------------------------------------------------------------------------- 1 | Page({ 2 | data: { 3 | isCompare: false, 4 | outputImage: '', 5 | }, 6 | async onLoad() { 7 | try { 8 | await this.onLoadFontFace(); 9 | } catch (err) { 10 | console.log(err); 11 | } 12 | }, 13 | async onLoadFontFace() { 14 | wx.showLoading({ 15 | title: '加载字体..', 16 | }); 17 | await wx.loadFontFace({ 18 | family: 'QianTuXianMoTi', 19 | source: 'https://bsvote.yolewa.com/QianTuXianMoTi-2.ttf', 20 | global: true, 21 | }); 22 | wx.hideLoading(); 23 | }, 24 | async onDrawCanvas() { 25 | try { 26 | const canvas = this.selectComponent('#wxml2canvas'); 27 | wx.showLoading({ 28 | title: '生成中..', 29 | }); 30 | console.time('生成耗时'); 31 | await canvas.draw(); 32 | console.timeEnd('生成耗时'); 33 | let timer = setTimeout(async () => { 34 | try { 35 | const url = await canvas.toTempFilePath(); 36 | this.setData({ outputImage: url }); 37 | } catch (err) { 38 | console.error(err); 39 | } 40 | wx.hideLoading(); 41 | clearTimeout(timer); 42 | timer = null; 43 | }, 300); 44 | } catch (err) { 45 | console.error(err); 46 | } 47 | }, 48 | onPreviewCanvas() { 49 | const { outputImage } = this.data; 50 | wx.previewImage({ 51 | urls: [outputImage], 52 | }); 53 | }, 54 | onCanvasTouch() { 55 | this.setData({ isCompare: true }); 56 | }, 57 | onCanvasLeave() { 58 | this.setData({ isCompare: false }); 59 | }, 60 | }); 61 | -------------------------------------------------------------------------------- /tools/demo/pages/font/index.json: -------------------------------------------------------------------------------- 1 | { 2 | "navigationBarTitleText": "自定义字体示例", 3 | "usingComponents": { 4 | "wxml2canvas": "/components/wxml2canvas-2d/index" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /tools/demo/pages/font/index.wxml: -------------------------------------------------------------------------------- 1 | 2 | 3 | {{isCompare ? '生成' : '原'}}内容 4 | 5 | 6 | 这是大标题 7 | 8 | 9 | 10 | 11 | 12 | 13 | 这是小标题 14 | 15 | 填充测试内容填充测试内容填充测试内容填充测试内容填充测试内容 16 | 17 | 扫描左侧二维码 18 | 19 | 20 | 21 | ↑↑ 按住卡片实时对比 ↑↑ 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /tools/demo/pages/iconfont/index.js: -------------------------------------------------------------------------------- 1 | Page({ 2 | data: { 3 | isCompare: false, 4 | outputImage: '', 5 | }, 6 | async onLoad() { 7 | try { 8 | await this.onLoadFontFace(); 9 | } catch (err) { 10 | console.log(err); 11 | } 12 | }, 13 | async onLoadFontFace() { 14 | wx.showLoading({ 15 | title: '加载字体..', 16 | }); 17 | await wx.loadFontFace({ 18 | family: 'iconfont', 19 | source: 'https://bsvote.yolewa.com/1754663635396524.ttf', 20 | scopes: ['native'], 21 | }); 22 | wx.hideLoading(); 23 | }, 24 | async onDrawCanvas() { 25 | try { 26 | const canvas = this.selectComponent('#wxml2canvas'); 27 | wx.showLoading({ 28 | title: '生成中..', 29 | }); 30 | console.time('生成耗时'); 31 | await canvas.draw(); 32 | console.timeEnd('生成耗时'); 33 | let timer = setTimeout(async () => { 34 | try { 35 | const url = await canvas.toTempFilePath(); 36 | this.setData({ outputImage: url }); 37 | } catch (err) { 38 | console.error(err); 39 | } 40 | wx.hideLoading(); 41 | clearTimeout(timer); 42 | timer = null; 43 | }, 300); 44 | } catch (err) { 45 | console.error(err); 46 | } 47 | }, 48 | onPreviewCanvas() { 49 | const { outputImage } = this.data; 50 | wx.previewImage({ 51 | urls: [outputImage], 52 | }); 53 | }, 54 | onCanvasTouch() { 55 | this.setData({ isCompare: true }); 56 | }, 57 | onCanvasLeave() { 58 | this.setData({ isCompare: false }); 59 | }, 60 | }); 61 | -------------------------------------------------------------------------------- /tools/demo/pages/iconfont/index.json: -------------------------------------------------------------------------------- 1 | { 2 | "navigationBarTitleText": "IconFont 图标示例", 3 | "usingComponents": { 4 | "wxml2canvas": "/components/wxml2canvas-2d/index" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /tools/demo/pages/iconfont/index.wxml: -------------------------------------------------------------------------------- 1 | 2 | 3 | {{isCompare ? '生成' : '原'}}内容 4 | 5 | 6 | 7 | 8 | 9 | 这是大标题 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 这是小标题 21 | 22 | 23 | 填充测试内容填充测试内容填充测试内容填充测试内容填充测试内容 24 | 25 | 扫描左侧二维码 26 | 27 | 28 | 29 | ↑↑ 按住卡片实时对比 ↑↑ 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /tools/demo/pages/iconfont/index.wxss: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: 'iconfont'; 3 | src: url('data:font/ttf;charset=utf-8;base64,AAEAAAANAIAAAwBQRkZUTawWnNEAAAhUAAAAHEdERUYAJwANAAAINAAAAB5PUy8yDxMFvAAAAVgAAABgY21hcBdH2gEAAAHUAAABXmdhc3AAAAAQAAAILAAAAAhnbHlmRl31tgAAA0QAAAL8aGVhZCxXteMAAADcAAAANmhoZWEHhQPHAAABFAAAACRobXR4C5UAPQAAAbgAAAAabG9jYQEKAZ4AAAM0AAAAEG1heHAADACTAAABOAAAACBuYW1l+lhN2AAABkAAAAGbcG9zdHy/bFIAAAfcAAAATwABAAAAAAAAD32R1F8PPPUACwQAAAAAAOS7uMUAAAAA5Lu4xQAAAAADwwNcAAAACAACAAAAAAAAAAEAAAPA/8AAAAQAAAAAAAPDAAEAAAAAAAAAAAAAAAAAAAAGAAEAAAAHAJEABAAAAAAAAgAAAAAAAAAAAAAAAAAAAAAAAwOAAZAABQAAApkCzAAAAI8CmQLMAAAB6wAzAQkAAAAAAAAAAAAAAAAAAAABEAAAAAAAAAAAAAAAAAAAAABAAAHpEQPA/8AAQAPAAEAAAAABAAAAAAAAAAAAAAAgAAEEAAAAAAAAAAFVAAAAAAAAAgAAAAQAAD0AQAAAAAAAAwAAAAMAAAAcAAEAAAAAAFgAAwABAAAAHAAEADwAAAAKAAgAAgACAAEAIOkR//3//wAAAAAAIOkQ//3//wAA/+QW9QADAAEACgAAAAAAAAAAAAEAAwAAAQYAAAEDAAAAAAAAAQIAAAACAAAAAAAAAAAAAAAAAAAAAQAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAIABAAGADyAX4AAQAAAAAAAAAAAAIAADkCAAEAAAAAAAAAAAACAAA5AgABAAAAAAAAAAAAAgAAOQIABAA9ACgDwwNcACwAXAB+AJAAACUiJicuATc+ARceATMyNz4BNzY3NjQnLgEnJjY3NhYXHgEXFhQHBgcOAQcGIyUiJicuAScmNDc2Nz4BNzYzMhYXHgEHDgEnLgEjIgcOAQcGBwYUFx4BFx4BBw4BIzciJicuATU0NjMyFhcWBgcGJicuASMiBhUUFhceAQcOASMBIiYnJjQ3ATYyFxYUBwEOASMCABImExIUBAQfEw4cDTg0NFslJBkMDAkTCg0CDg0mDAsVCiAgHCsrbEA/Rv7dCQ8HGTEXICAdKytsPz9GJU0mEQ4IByMRHjocODQ0WiUlGQsLFSwXDgEMBxEJ5wcMBiImXEEqSBUKCRAQJAoJHxIbKBEOEAgKBxQL/uEIEQcNDQLaDSYNDQ39JgYRCY0EBAQgEhIUBAMDFxZDJiceDycODBYLDiUMDQEODBgNJ2coIywsTRoaiQYGFzQcKGcnJCwsTRoaEREHIxERDggNDRcXQyYmHg8nDxovFQwmDQgHQAMEFkYoQVwpIxAkCgoKEA8RJxwRHgkKJRAKCv7SBgcNJg0C2Q4ODSUN/SYHBgAAAAQAQABuA78DFwAiAEUAUgBfAAAlIicuAScmJyY0NzY3PgE3NjMyFx4BFxYXFhQHBgcOAQcGIxEiBw4BBwYHBhQXFhceARcWMzI3PgE3Njc2NCcmJy4BJyYjESImNTQ2MzIWFRQGIzUiBhUUFjMyNjU0JiMB/0U/P2sqKxwgIBwrKms/P0VFPz9rKyodICAdKitrPz5GNzQzWiUkGQsLGSQlWjM0NzgzNFokJRgMDBglJFo0MzhAXFxAQVtbQRsnJxscJyccbhoaTCwrIydmKCMrLEwaGhoaTCwrIyhmJyMrLEwaGgJQFxdCJiUfDicOHyYmQhYXFxZCJiYeDycOHyUmQhcX/mVcQEFbW0FAXN8nHBsnJxscJwAAAA4ArgABAAAAAAABAAcAEAABAAAAAAACAAcAKAABAAAAAAADAAcAQAABAAAAAAAEAAcAWAABAAAAAAAFAAsAeAABAAAAAAAGAAcAlAABAAAAAAAKABoA0gADAAEECQABAA4AAAADAAEECQACAA4AGAADAAEECQADAA4AMAADAAEECQAEAA4ASAADAAEECQAFABYAYAADAAEECQAGAA4AhAADAAEECQAKADQAnABpAGMAbwBtAG8AbwBuAABpY29tb29uAABSAGUAZwB1AGwAYQByAABSZWd1bGFyAABpAGMAbwBtAG8AbwBuAABpY29tb29uAABpAGMAbwBtAG8AbwBuAABpY29tb29uAABWAGUAcgBzAGkAbwBuACAAMQAuADAAAFZlcnNpb24gMS4wAABpAGMAbwBtAG8AbwBuAABpY29tb29uAABGAG8AbgB0ACAAZwBlAG4AZQByAGEAdABlAGQAIABiAHkAIABJAGMAbwBNAG8AbwBuAC4AAEZvbnQgZ2VuZXJhdGVkIGJ5IEljb01vb24uAAAAAgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHAAABAgACAQMAAwEEAQUGZ2x5cGgxB3VuaTAwMDEHdW5pRTkxMAd1bmlFOTExAAABAAH//wAPAAEAAAAMAAAAFgAAAAIAAQABAAYAAQAEAAAAAgAAAAAAAAABAAAAAOKfK0YAAAAA5Lu4xQAAAADku7jF') format('truetype'); 4 | font-weight: normal; 5 | font-style: normal; 6 | font-display: swap; 7 | } 8 | 9 | [class^="icon-"], [class*=" icon-"] { 10 | /* use !important to prevent issues with browser extensions that change fonts */ 11 | font-family: 'iconfont' !important; 12 | speak: never; 13 | font-style: normal; 14 | font-weight: normal; 15 | font-variant: normal; 16 | text-transform: none; 17 | line-height: 1; 18 | 19 | /* Better Font Rendering =========== */ 20 | -webkit-font-smoothing: antialiased; 21 | -moz-osx-font-smoothing: grayscale; 22 | } 23 | 24 | .icon-invisible:before { 25 | content: '\e910'; 26 | } 27 | .icon-visible:before { 28 | content: '\e911'; 29 | } 30 | 31 | .icon-invisible, 32 | .icon-visible { 33 | margin-right: 10rpx; 34 | } -------------------------------------------------------------------------------- /tools/demo/pages/index/index.js: -------------------------------------------------------------------------------- 1 | Page({ 2 | onNavigation(e) { 3 | const { url } = e.currentTarget.dataset; 4 | wx.navigateTo({ url }); 5 | }, 6 | }); 7 | -------------------------------------------------------------------------------- /tools/demo/pages/index/index.json: -------------------------------------------------------------------------------- 1 | {} -------------------------------------------------------------------------------- /tools/demo/pages/index/index.wxml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /tools/demo/pages/multiple/index.js: -------------------------------------------------------------------------------- 1 | import defer from '../../utils/defer'; 2 | 3 | Page({ 4 | data: { 5 | nodes: [{}, {}, {}], 6 | images: [], 7 | current: 0, 8 | generated: false, 9 | }, 10 | onSwiperChange(e) { 11 | this.setData({ 12 | current: e.detail.current, 13 | }); 14 | }, 15 | async onDrawCanvas() { 16 | try { 17 | wx.showLoading({ 18 | title: '生成中..', 19 | }); 20 | const images = await Promise.all(this.data.nodes.map(async (item, index) => { 21 | const deferred = defer(); 22 | const canvas = this.selectComponent(`#wxml2canvas_${index}`); 23 | console.time(`生成第 ${index + 1} 张耗时`); 24 | await canvas.draw(); 25 | console.timeEnd(`生成第 ${index + 1} 张耗时`); 26 | let timer = setTimeout(async () => { 27 | try { 28 | const url = await canvas.toTempFilePath(); 29 | deferred.resolve(url); 30 | } catch (err) { 31 | deferred.reject(err); 32 | } 33 | clearTimeout(timer); 34 | timer = null; 35 | }, 300); 36 | return deferred.promise; 37 | })); 38 | wx.hideLoading(); 39 | this.setData({ 40 | generated: true, 41 | images, 42 | }); 43 | this.onPreviewCanvas(); 44 | } catch (err) { 45 | console.error(err); 46 | } 47 | }, 48 | onPreviewCanvas() { 49 | const { images, current } = this.data; 50 | wx.previewImage({ 51 | urls: images, 52 | current: images[current], 53 | }); 54 | }, 55 | }); 56 | -------------------------------------------------------------------------------- /tools/demo/pages/multiple/index.json: -------------------------------------------------------------------------------- 1 | { 2 | "navigationBarTitleText": "并发绘制示例", 3 | "usingComponents": { 4 | "wxml2canvas": "/components/wxml2canvas-2d/index" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /tools/demo/pages/multiple/index.wxml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 这是大标题 {{index + 1}} 7 | 8 | 9 | 10 | 11 | 12 | 13 | 这是小标题 {{index + 1}} 14 | 15 | 填充测试内容填充测试内容填充测试内容填充测试内容填充测试内容 16 | 17 | 扫描左侧二维码 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /tools/demo/pages/overflow/index.js: -------------------------------------------------------------------------------- 1 | import defer from '../../utils/defer'; 2 | 3 | Page({ 4 | data: { 5 | nodes: Array(10).fill({}), 6 | generated: false, 7 | outputImage: '', 8 | width: 0, 9 | height: 0, 10 | }, 11 | async onDrawCanvas() { 12 | try { 13 | wx.showLoading({ 14 | title: '生成中..', 15 | }); 16 | const images = await Promise.all(this.data.nodes.map(async (item, index) => { 17 | const deferred = defer(); 18 | const canvas = this.selectComponent(`#wxml2canvas_${index}`); 19 | console.time(`生成第 ${index + 1} 张耗时`); 20 | await canvas.draw(); 21 | console.timeEnd(`生成第 ${index + 1} 张耗时`); 22 | let timer = setTimeout(async () => { 23 | try { 24 | const src = await canvas.toTempFilePath(); 25 | const info = await wx.getImageInfo({ src }); 26 | deferred.resolve(info); 27 | } catch (err) { 28 | deferred.reject(err); 29 | } 30 | clearTimeout(timer); 31 | timer = null; 32 | }, 300); 33 | return deferred.promise; 34 | })); 35 | wx.hideLoading(); 36 | wx.showLoading({ 37 | title: '合成中..', 38 | }); 39 | const outputImage = await this.onComposeImages(images); 40 | wx.hideLoading(); 41 | this.setData({ 42 | generated: true, 43 | outputImage, 44 | }); 45 | this.onPreviewCanvas(); 46 | } catch (err) { 47 | console.error(err); 48 | } 49 | }, 50 | async onComposeImages(images) { 51 | const deferred = defer(); 52 | const width = Math.max(...images.map((i) => i.width)); 53 | const height = images.map((i) => i.height).reduce((p, n) => p + n); 54 | this.setData({ width, height }); 55 | const ctx = wx.createCanvasContext('output'); 56 | let offset = 0; 57 | images.map((item) => { 58 | ctx.drawImage(item.path, 0, offset); 59 | offset += item.height; 60 | return item; 61 | }); 62 | ctx.draw(false, () => { 63 | let timer = setTimeout(async () => { 64 | try { 65 | const { tempFilePath } = await wx.canvasToTempFilePath({ 66 | canvasId: 'output', 67 | }); 68 | deferred.resolve(tempFilePath); 69 | } catch (err) { 70 | deferred.reject(err); 71 | } 72 | clearTimeout(timer); 73 | timer = null; 74 | }, 300); 75 | }); 76 | return deferred.promise; 77 | }, 78 | onPreviewCanvas() { 79 | const { outputImage } = this.data; 80 | wx.previewImage({ 81 | urls: [outputImage], 82 | }); 83 | }, 84 | }); 85 | -------------------------------------------------------------------------------- /tools/demo/pages/overflow/index.json: -------------------------------------------------------------------------------- 1 | { 2 | "navigationBarTitleText": "绘制超长节点示例", 3 | "usingComponents": { 4 | "wxml2canvas": "/components/wxml2canvas-2d/index" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /tools/demo/pages/overflow/index.wxml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 这是大标题 {{index + 1}} 7 | 8 | 9 | 10 | 11 | 12 | 13 | 这是小标题 {{index + 1}} 14 | 15 | 填充测试内容填充测试内容填充测试内容填充测试内容填充测试内容 16 | 17 | 扫描左侧二维码 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /tools/demo/pages/package/comp.js: -------------------------------------------------------------------------------- 1 | Component({ 2 | data: { 3 | isCompare: false, 4 | outputImage: '', 5 | }, 6 | methods: { 7 | async onDrawCanvas() { 8 | try { 9 | const canvas = this.selectComponent('#wxml2canvas'); 10 | wx.showLoading({ 11 | title: '生成中..', 12 | }); 13 | console.time('生成耗时'); 14 | await canvas.draw(this); 15 | console.timeEnd('生成耗时'); 16 | let timer = setTimeout(async () => { 17 | try { 18 | const url = await canvas.toTempFilePath(); 19 | this.setData({ outputImage: url }); 20 | } catch (err) { 21 | console.error(err); 22 | } 23 | wx.hideLoading(); 24 | clearTimeout(timer); 25 | timer = null; 26 | }, 300); 27 | } catch (err) { 28 | console.error(err); 29 | } 30 | }, 31 | onPreviewCanvas() { 32 | const { outputImage } = this.data; 33 | wx.previewImage({ 34 | urls: [outputImage], 35 | }); 36 | }, 37 | onCanvasTouch() { 38 | this.setData({ isCompare: true }); 39 | }, 40 | onCanvasLeave() { 41 | this.setData({ isCompare: false }); 42 | }, 43 | }, 44 | }); 45 | -------------------------------------------------------------------------------- /tools/demo/pages/package/comp.json: -------------------------------------------------------------------------------- 1 | { 2 | "component": true, 3 | "usingComponents": { 4 | "wxml2canvas": "/components/wxml2canvas-2d/index" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /tools/demo/pages/package/comp.wxml: -------------------------------------------------------------------------------- 1 | 2 | 3 | {{isCompare ? '生成' : '原'}}内容 4 | 5 | 6 | 这是组件大标题 7 | 8 | 9 | 10 | 11 | 12 | 13 | 这是组件小标题 14 | 15 | 填充测试内容填充测试内容填充测试内容填充测试内容填充测试内容 16 | 17 | 扫描左侧二维码 18 | 19 | 20 | 21 | ↑↑ 按住卡片实时对比 ↑↑ 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /tools/demo/pages/package/comp.wxss: -------------------------------------------------------------------------------- 1 | @import '../../app.wxss'; 2 | -------------------------------------------------------------------------------- /tools/demo/pages/package/index.js: -------------------------------------------------------------------------------- 1 | Page({}); 2 | -------------------------------------------------------------------------------- /tools/demo/pages/package/index.json: -------------------------------------------------------------------------------- 1 | { 2 | "navigationBarTitleText": "自定义组件内示例", 3 | "usingComponents": { 4 | "comp": "./comp" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /tools/demo/pages/package/index.wxml: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tools/demo/pages/snapshot-2/index.js: -------------------------------------------------------------------------------- 1 | // pages/record/record.js 2 | Page({ 3 | data: { 4 | currentTab: 'record', 5 | showFilterModal: false, 6 | activeFilter: 'all', 7 | filterText: '全部', 8 | totalCount: 0, 9 | winCount: 0, 10 | loseCount: 0, 11 | winRate: 0, 12 | records: [ 13 | { 14 | id: 1, 15 | mode: '排位赛', 16 | modeIcon: '/images/U3e6ny.jpg', 17 | time: '2023-05-15 14:30', 18 | result: 'win', 19 | resultText: '胜利', 20 | score: '3:2', 21 | duration: '12分45秒', 22 | }, 23 | { 24 | id: 2, 25 | mode: '娱乐模式', 26 | modeIcon: '/images/U3e6ny.jpg', 27 | time: '2023-05-15 13:15', 28 | result: 'lose', 29 | resultText: '失败', 30 | score: '1:3', 31 | duration: '10分20秒', 32 | }, 33 | { 34 | id: 3, 35 | mode: '排位赛', 36 | modeIcon: '/images/U3e6ny.jpg', 37 | time: '2023-05-14 21:45', 38 | result: 'win', 39 | resultText: '胜利', 40 | score: '2:1', 41 | duration: '15分30秒', 42 | }, 43 | { 44 | id: 4, 45 | mode: '匹配赛', 46 | modeIcon: '/images/U3e6ny.jpg', 47 | time: '2023-05-14 20:10', 48 | result: 'draw', 49 | resultText: '平局', 50 | score: '2:2', 51 | duration: '18分05秒', 52 | }, 53 | { 54 | id: 5, 55 | mode: '排位赛', 56 | modeIcon: '/images/U3e6ny.jpg', 57 | time: '2023-05-13 19:30', 58 | result: 'lose', 59 | resultText: '失败', 60 | score: '0:3', 61 | duration: '8分50秒', 62 | }, 63 | { 64 | id: 6, 65 | mode: '娱乐模式', 66 | modeIcon: '/images/U3e6ny.jpg', 67 | time: '2023-05-13 18:15', 68 | result: 'win', 69 | resultText: '胜利', 70 | score: '3:1', 71 | duration: '14分20秒', 72 | }, 73 | ], 74 | filteredRecords: [], 75 | }, 76 | 77 | onLoad() { 78 | this.calculateStats(); 79 | this.filterRecords(); 80 | }, 81 | 82 | // 计算统计数据 83 | calculateStats() { 84 | const { records } = this.data; 85 | const total = records.length; 86 | const wins = records.filter((r) => r.result === 'win').length; 87 | const loses = records.filter((r) => r.result === 'lose').length; 88 | const rate = total > 0 ? Math.round((wins / total) * 100) : 0; 89 | 90 | this.setData({ 91 | totalCount: total, 92 | winCount: wins, 93 | loseCount: loses, 94 | winRate: rate, 95 | }); 96 | }, 97 | 98 | // 显示筛选弹窗 99 | showFilter() { 100 | this.setData({ 101 | showFilterModal: !this.data.showFilterModal, 102 | }); 103 | }, 104 | 105 | // 改变筛选条件 106 | changeFilter(e) { 107 | const { value } = e.currentTarget.dataset; 108 | let text = '全部'; 109 | 110 | switch (value) { 111 | case 'win': 112 | text = '胜利'; 113 | break; 114 | case 'lose': 115 | text = '失败'; 116 | break; 117 | case 'draw': 118 | text = '平局'; 119 | break; 120 | default: 121 | } 122 | 123 | this.setData({ 124 | activeFilter: value, 125 | filterText: text, 126 | showFilterModal: false, 127 | }, () => { 128 | this.filterRecords(); 129 | }); 130 | }, 131 | 132 | // 筛选记录 133 | filterRecords() { 134 | let { records } = this.data; 135 | 136 | if (this.data.activeFilter !== 'all') { 137 | records = records.filter((r) => r.result === this.data.activeFilter); 138 | } 139 | 140 | this.setData({ 141 | filteredRecords: records, 142 | }); 143 | }, 144 | 145 | // 切换底部导航 146 | switchTab(e) { 147 | const { page } = e.currentTarget.dataset; 148 | 149 | if (page === this.data.currentTab) return; 150 | 151 | if (page === 'home') { 152 | wx.switchTab({ 153 | url: '/pages/home/home', 154 | }); 155 | } else if (page === 'rank') { 156 | wx.switchTab({ 157 | url: '/pages/rank/rank', 158 | }); 159 | } else if (page === 'profile') { 160 | wx.switchTab({ 161 | url: '/pages/profile/profile', 162 | }); 163 | } 164 | }, 165 | 166 | // 分享页面截图 167 | async onPageShare() { 168 | try { 169 | const canvas = this.selectComponent('#wxml2canvas'); 170 | wx.showLoading({ 171 | title: '生成中..', 172 | }); 173 | console.time('生成耗时'); 174 | await canvas.draw(); 175 | console.timeEnd('生成耗时'); 176 | let timer = setTimeout(async () => { 177 | try { 178 | const url = await canvas.toTempFilePath(); 179 | wx.previewImage({ urls: [url] }); 180 | } catch (err) { 181 | console.error(err); 182 | } 183 | wx.hideLoading(); 184 | clearTimeout(timer); 185 | timer = null; 186 | }, 300); 187 | } catch (err) { 188 | console.error(err); 189 | } 190 | }, 191 | }); 192 | -------------------------------------------------------------------------------- /tools/demo/pages/snapshot-2/index.json: -------------------------------------------------------------------------------- 1 | { 2 | "navigationBarTitleText": "完整页面截图示例-2", 3 | "usingComponents": { 4 | "wxml2canvas": "/components/wxml2canvas-2d/index" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /tools/demo/pages/snapshot-2/index.wxml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 对战记录 6 | {{filterText}} 7 | 8 | 9 | 10 | 11 | 12 | 全部 14 | 胜利 16 | 失败 18 | 平局 20 | 21 | 22 | 23 | 24 | 25 | 26 | {{totalCount}} 27 | 总场次 28 | 29 | 30 | {{winCount}} 31 | 胜场 32 | 33 | 34 | {{loseCount}} 35 | 败场 36 | 37 | 38 | {{winRate}}% 39 | 胜率 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | {{item.mode}} 50 | {{item.time}} 51 | 52 | 53 | 54 | {{item.resultText}} 55 | {{item.score}} 56 | {{item.duration}} 57 | 58 | 59 | 60 | 61 | 62 | 暂无对战记录 63 | 64 | 65 | 66 | 67 | 分享 68 | 69 | 70 | -------------------------------------------------------------------------------- /tools/demo/pages/snapshot-2/index.wxss: -------------------------------------------------------------------------------- 1 | /* pages/record/record.wxss */ 2 | .records { 3 | background-color: #f5f7fa; 4 | padding-bottom: 100rpx; 5 | } 6 | 7 | /* 顶部导航 */ 8 | .header { 9 | display: flex; 10 | justify-content: space-between; 11 | align-items: center; 12 | padding: 30rpx 40rpx; 13 | background: linear-gradient(to right, #6e8cfa, #4d6df3); 14 | color: #fff; 15 | } 16 | 17 | .header-title { 18 | font-size: 36rpx; 19 | font-weight: bold; 20 | } 21 | 22 | .header-filter { 23 | display: flex; 24 | align-items: center; 25 | font-size: 28rpx; 26 | padding: 8rpx 24rpx; 27 | background-color: rgba(255, 255, 255, 0.2); 28 | border-radius: 30rpx; 29 | } 30 | 31 | .filter-icon { 32 | width: 24rpx; 33 | height: 24rpx; 34 | margin-left: 8rpx; 35 | } 36 | 37 | /* 筛选弹窗 */ 38 | .filter-modal { 39 | position: fixed; 40 | top: 0; 41 | left: 0; 42 | right: 0; 43 | bottom: 0; 44 | background-color: rgba(0, 0, 0, 0.5); 45 | z-index: 100; 46 | display: flex; 47 | justify-content: flex-end; 48 | padding-top: 120rpx; 49 | } 50 | 51 | .filter-options { 52 | background-color: #fff; 53 | width: 240rpx; 54 | border-radius: 16rpx 0 0 16rpx; 55 | padding: 20rpx 0; 56 | box-shadow: -4rpx 0 20rpx rgba(0, 0, 0, 0.1); 57 | } 58 | 59 | .filter-option { 60 | padding: 24rpx 40rpx; 61 | font-size: 28rpx; 62 | color: #666; 63 | } 64 | 65 | .filter-option.active { 66 | color: #4d6df3; 67 | font-weight: bold; 68 | background-color: #f0f4ff; 69 | } 70 | 71 | /* 战绩概览 */ 72 | .stats-card { 73 | display: flex; 74 | justify-content: space-around; 75 | background-color: #fff; 76 | margin: 20rpx 30rpx; 77 | padding: 30rpx 0; 78 | border-radius: 16rpx; 79 | box-shadow: 0 8rpx 24rpx rgba(110, 140, 250, 0.1); 80 | } 81 | 82 | .stats-item { 83 | display: flex; 84 | flex-direction: column; 85 | align-items: center; 86 | } 87 | 88 | .stats-value { 89 | font-size: 40rpx; 90 | font-weight: bold; 91 | color: #333; 92 | } 93 | 94 | .stats-value.win { 95 | color: #4d6df3; 96 | } 97 | 98 | .stats-value.lose { 99 | color: #ff6b6b; 100 | } 101 | 102 | .stats-label { 103 | font-size: 24rpx; 104 | color: #999; 105 | margin-top: 10rpx; 106 | } 107 | 108 | /* 对局列表 */ 109 | .record-list { 110 | padding: 0 30rpx; 111 | } 112 | 113 | .record-item { 114 | display: flex; 115 | justify-content: space-between; 116 | align-items: center; 117 | background-color: #fff; 118 | padding: 30rpx; 119 | margin-bottom: 20rpx; 120 | border-radius: 0 16rpx 16rpx 0; 121 | box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.05); 122 | } 123 | 124 | .record-item.win { 125 | border-left: 8rpx solid #4d6df3; 126 | } 127 | 128 | .record-item.lose { 129 | border-left: 8rpx solid #ff6b6b; 130 | } 131 | 132 | .record-item.draw { 133 | border-left: 8rpx solid #ffcc00; 134 | } 135 | 136 | .record-left { 137 | display: flex; 138 | align-items: center; 139 | } 140 | 141 | .game-mode-icon { 142 | width: 80rpx; 143 | height: 80rpx; 144 | margin-right: 20rpx; 145 | } 146 | 147 | .game-mode { 148 | font-size: 28rpx; 149 | font-weight: bold; 150 | color: #333; 151 | } 152 | 153 | .game-time { 154 | font-size: 24rpx; 155 | color: #999; 156 | margin-top: 8rpx; 157 | } 158 | 159 | .record-right { 160 | display: flex; 161 | flex-direction: column; 162 | align-items: flex-end; 163 | } 164 | 165 | .result-badge { 166 | font-size: 24rpx; 167 | padding: 6rpx 16rpx; 168 | border-radius: 30rpx; 169 | margin-bottom: 10rpx; 170 | } 171 | 172 | .win .result-badge { 173 | background-color: #e6eeff; 174 | color: #4d6df3; 175 | } 176 | 177 | .lose .result-badge { 178 | background-color: #ffebeb; 179 | color: #ff6b6b; 180 | } 181 | 182 | .draw .result-badge { 183 | background-color: #fff8e6; 184 | color: #ffcc00; 185 | } 186 | 187 | .score { 188 | font-size: 32rpx; 189 | font-weight: bold; 190 | color: #333; 191 | } 192 | 193 | .duration { 194 | font-size: 24rpx; 195 | color: #999; 196 | margin-top: 8rpx; 197 | } 198 | 199 | /* 无数据提示 */ 200 | .no-data { 201 | display: flex; 202 | flex-direction: column; 203 | align-items: center; 204 | justify-content: center; 205 | padding: 100rpx 0; 206 | } 207 | 208 | .no-data-icon { 209 | width: 200rpx; 210 | height: 200rpx; 211 | opacity: 0.6; 212 | margin-bottom: 30rpx; 213 | } 214 | 215 | .no-data-text { 216 | font-size: 28rpx; 217 | color: #999; 218 | } 219 | 220 | /* 底部购物车样式 */ 221 | .footer-share { 222 | position: fixed; 223 | right: 40rpx; 224 | bottom: 80rpx; 225 | width: 100rpx; 226 | height: 100rpx; 227 | background-color: #ff4f94; 228 | color: #fff; 229 | font-size: 30rpx; 230 | font-weight: 700; 231 | border-radius: 50%; 232 | display: flex; 233 | justify-content: center; 234 | align-items: center; 235 | box-shadow: 0 4rpx 20rpx rgba(255, 79, 148, 0.5); 236 | } -------------------------------------------------------------------------------- /tools/demo/pages/snapshot-3/index.js: -------------------------------------------------------------------------------- 1 | // pages/sales/sales.js 2 | function formatDate(date, fmt) { 3 | date = date instanceof Date ? date : new Date(date); 4 | const o = { 5 | 'M+': date.getMonth() + 1, 6 | 'd+': date.getDate(), 7 | 'h+': date.getHours(), 8 | 'm+': date.getMinutes(), 9 | 's+': date.getSeconds(), 10 | 'q+': Math.floor((date.getMonth() + 3) / 3), 11 | S: date.getMilliseconds(), 12 | }; 13 | 14 | if (/(y+)/.test(fmt)) { 15 | fmt = fmt.replace(RegExp.$1, (`${date.getFullYear()}`).substr(4 - RegExp.$1.length)); 16 | } 17 | 18 | // eslint-disable-next-line no-restricted-syntax 19 | for (const k in o) { 20 | if (new RegExp(`(${k})`).test(fmt)) { 21 | fmt = fmt.replace(RegExp.$1, (RegExp.$1.length === 1) ? (o[k]) : ((`00${o[k]}`).substr((`${o[k]}`).length))); 22 | } 23 | } 24 | 25 | return fmt; 26 | } 27 | 28 | Page({ 29 | data: { 30 | currentMonth: formatDate(new Date(), 'yyyy-MM'), 31 | salesData: { 32 | totalAmount: '28,456', 33 | orderCount: '342', 34 | customerCount: '1,245', 35 | avgAmount: '82.3', 36 | }, 37 | timeSlots: [ 38 | { time: '9-12', percent: 25, color: '#667eea' }, 39 | { time: '12-14', percent: 38, color: '#7e6bff' }, 40 | { time: '14-17', percent: 18, color: '#ff6b8b' }, 41 | { time: '17-20', percent: 45, color: '#ff9f4f' }, 42 | { time: '20-22', percent: 12, color: '#6bd2ff' }, 43 | ], 44 | weekData: [4200, 5800, 7200, 8900, 6500, 8200, 9300], 45 | productData: [ 46 | { name: '珍珠奶茶', value: 156 }, 47 | { name: '芝士绿茶', value: 128 }, 48 | { name: '芒果冰沙', value: 98 }, 49 | { name: '焦糖玛奇朵', value: 85 }, 50 | { name: '柠檬红茶', value: 76 }, 51 | ], 52 | }, 53 | 54 | onLoad() { 55 | this.drawWeekChart(); 56 | this.drawProductChart(); 57 | }, 58 | 59 | // 切换月份 60 | changeMonth(e) { 61 | this.setData({ 62 | currentMonth: e.detail.value, 63 | }); 64 | // 这里应该重新获取数据 65 | // this.fetchSalesData(e.detail.value) 66 | }, 67 | 68 | // 绘制周销售趋势图 69 | drawWeekChart() { 70 | const ctx = wx.createCanvasContext('weekChart'); 71 | const data = this.data.weekData; 72 | const maxValue = Math.max(...data); 73 | const colors = ['#667eea', '#7e6bff', '#ff6b8b', '#ff9f4f', '#6bd2ff']; 74 | const labels = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']; 75 | const canvasWidth = 630; 76 | const canvasHeight = 200; 77 | const barWidth = 30; 78 | const gap = 10; 79 | 80 | // 绘制坐标轴 81 | ctx.setStrokeStyle('#e0e0e0'); 82 | ctx.setLineWidth(1); 83 | ctx.moveTo(0, canvasHeight - 30); 84 | ctx.lineTo(canvasWidth, canvasHeight - 30); 85 | ctx.stroke(); 86 | 87 | // 绘制柱状图 88 | data.forEach((item, index) => { 89 | const x = index * (barWidth + gap) + 20; 90 | const height = (item / maxValue) * (canvasHeight - 80); 91 | const y = canvasHeight - 30 - height; 92 | 93 | ctx.setFillStyle(colors[index % colors.length]); 94 | ctx.fillRect(x, y, barWidth, height); 95 | 96 | // 绘制数值 97 | ctx.setFontSize(12); 98 | ctx.setFillStyle('#666'); 99 | ctx.setTextAlign('center'); 100 | ctx.fillText(item, x + barWidth / 2, y - 10); 101 | 102 | // 绘制星期标签 103 | ctx.setFontSize(12); 104 | ctx.fillText(labels[index], x + barWidth / 2, canvasHeight - 5); 105 | }); 106 | 107 | ctx.draw(); 108 | }, 109 | 110 | // 绘制商品销售排行图 111 | drawProductChart() { 112 | const ctx = wx.createCanvasContext('productChart'); 113 | const data = this.data.productData; 114 | const maxValue = Math.max(...data.map((item) => item.value)); 115 | const colors = ['#667eea', '#7e6bff', '#ff6b8b', '#ff9f4f', '#6bd2ff']; 116 | const canvasWidth = 630; 117 | // const canvasHeight = 200; 118 | const barHeight = 24; 119 | const gap = 10; 120 | 121 | // 绘制条形图 122 | data.forEach((item, index) => { 123 | const y = index * (barHeight + gap) + 10; 124 | const width = (item.value / maxValue) * (canvasWidth - 400); 125 | 126 | ctx.setFillStyle(colors[index % colors.length]); 127 | ctx.fillRect(100, y, width, barHeight); 128 | 129 | // 绘制商品名称 130 | ctx.setFontSize(12); 131 | ctx.setFillStyle('#333'); 132 | ctx.setTextAlign('left'); 133 | ctx.fillText(item.name, 10, y + barHeight / 2 + 4); 134 | 135 | // 绘制数值 136 | ctx.setFontSize(12); 137 | ctx.setFillStyle('#666'); 138 | ctx.setTextAlign('right'); 139 | ctx.fillText(item.value, 90, y + barHeight / 2 + 4); 140 | }); 141 | 142 | ctx.draw(); 143 | }, 144 | 145 | // 分享报表 146 | shareReport() { 147 | wx.showActionSheet({ 148 | itemList: ['分享给好友', '生成分享图'], 149 | success: (res) => { 150 | if (res.tapIndex === 0) { 151 | wx.shareAppMessage({ 152 | title: `${this.data.currentMonth}门店销售报表`, 153 | path: '/pages/sales/sales', 154 | imageUrl: '/images/share-poster.jpg', 155 | }); 156 | } else { 157 | this.generateShareImage(); 158 | } 159 | }, 160 | }); 161 | }, 162 | 163 | // 生成分享图片 164 | generateShareImage() { 165 | wx.showLoading({ 166 | title: '生成图片中...', 167 | }); 168 | 169 | // 这里应该使用canvas绘制完整的分享图片 170 | // 简化示例,实际使用需要更复杂的绘制逻辑 171 | setTimeout(() => { 172 | wx.hideLoading(); 173 | wx.previewImage({ 174 | urls: ['/images/share-poster.jpg'], 175 | }); 176 | }, 1500); 177 | }, 178 | 179 | // 保存到相册 180 | saveToAlbum() { 181 | wx.showLoading({ 182 | title: '保存中...', 183 | }); 184 | 185 | // 这里应该使用canvas生成图片并保存 186 | // 简化示例,实际使用需要更复杂的绘制逻辑 187 | setTimeout(() => { 188 | wx.hideLoading(); 189 | wx.showToast({ 190 | title: '保存成功', 191 | icon: 'success', 192 | }); 193 | }, 1500); 194 | }, 195 | 196 | // 分享页面截图 197 | async onPageShare() { 198 | try { 199 | const canvas = this.selectComponent('#wxml2canvas'); 200 | wx.showLoading({ 201 | title: '生成中..', 202 | }); 203 | console.time('生成耗时'); 204 | await canvas.draw(); 205 | console.timeEnd('生成耗时'); 206 | let timer = setTimeout(async () => { 207 | try { 208 | const url = await canvas.toTempFilePath(); 209 | wx.previewImage({ urls: [url] }); 210 | } catch (err) { 211 | console.error(err); 212 | } 213 | wx.hideLoading(); 214 | clearTimeout(timer); 215 | timer = null; 216 | }, 300); 217 | } catch (err) { 218 | console.error(err); 219 | } 220 | }, 221 | }); 222 | -------------------------------------------------------------------------------- /tools/demo/pages/snapshot-3/index.json: -------------------------------------------------------------------------------- 1 | { 2 | "navigationBarTitleText": "完整页面截图示例-3", 3 | "usingComponents": { 4 | "wxml2canvas": "/components/wxml2canvas-2d/index" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /tools/demo/pages/snapshot-3/index.wxml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 门店销售数据 6 | 7 | {{currentMonth}} 8 | 9 | 10 | 11 | 12 | 13 | 14 | {{salesData.totalAmount}} 15 | 总销售额 16 | 17 | 18 | {{salesData.orderCount}} 19 | 订单数 20 | 21 | 22 | {{salesData.customerCount}} 23 | 客流量 24 | 25 | 26 | {{salesData.avgAmount}} 27 | 客单价 28 | 29 | 30 | 31 | 32 | 33 | 周销售趋势 34 | 35 | 36 | 37 | 38 | 39 | 热销商品 TOP5 40 | 41 | 42 | 43 | 44 | 45 | 时段分析 46 | 47 | 48 | {{item.time}} 49 | 50 | 51 | 52 | {{item.percent}}% 53 | 54 | 55 | 56 | 57 | 58 | 分享 59 | 60 | 61 | -------------------------------------------------------------------------------- /tools/demo/pages/snapshot-3/index.wxss: -------------------------------------------------------------------------------- 1 | /* pages/sales/sales.wxss */ 2 | .dashboard { 3 | padding: 20rpx 30rpx 120rpx; 4 | background-color: #f8f9fe; 5 | } 6 | 7 | /* 顶部标题 */ 8 | .header { 9 | display: flex; 10 | justify-content: space-between; 11 | align-items: center; 12 | margin-bottom: 30rpx; 13 | } 14 | 15 | .title { 16 | font-size: 40rpx; 17 | font-weight: bold; 18 | color: #333; 19 | } 20 | 21 | .month-picker { 22 | display: flex; 23 | align-items: center; 24 | padding: 10rpx 24rpx; 25 | background-color: #fff; 26 | border-radius: 30rpx; 27 | box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.05); 28 | font-size: 28rpx; 29 | color: #666; 30 | } 31 | 32 | .arrow-icon { 33 | width: 24rpx; 34 | height: 24rpx; 35 | margin-left: 10rpx; 36 | } 37 | 38 | /* 数据概览卡片 */ 39 | .overview-card { 40 | display: flex; 41 | flex-wrap: wrap; 42 | justify-content: space-between; 43 | background-color: #fff; 44 | border-radius: 20rpx; 45 | padding: 30rpx; 46 | margin-bottom: 30rpx; 47 | box-shadow: 0 8rpx 24rpx rgba(102, 126, 234, 0.1); 48 | } 49 | 50 | .overview-item { 51 | width: 48%; 52 | margin-bottom: 20rpx; 53 | } 54 | 55 | .overview-value { 56 | font-size: 40rpx; 57 | font-weight: bold; 58 | color: #667eea; 59 | margin-bottom: 10rpx; 60 | } 61 | 62 | .overview-label { 63 | font-size: 24rpx; 64 | color: #999; 65 | } 66 | 67 | /* 图表容器 */ 68 | .chart-container { 69 | background-color: #fff; 70 | border-radius: 20rpx; 71 | padding: 30rpx; 72 | margin-bottom: 30rpx; 73 | box-shadow: 0 8rpx 24rpx rgba(102, 126, 234, 0.1); 74 | } 75 | 76 | .chart-title { 77 | font-size: 32rpx; 78 | font-weight: bold; 79 | color: #333; 80 | margin-bottom: 20rpx; 81 | } 82 | 83 | .chart { 84 | width: 100%; 85 | height: 400rpx; 86 | } 87 | 88 | /* 时段分析 */ 89 | .time-analysis { 90 | background-color: #fff; 91 | border-radius: 20rpx; 92 | padding: 30rpx; 93 | margin-bottom: 30rpx; 94 | box-shadow: 0 8rpx 24rpx rgba(102, 126, 234, 0.1); 95 | } 96 | 97 | .section-title { 98 | font-size: 32rpx; 99 | font-weight: bold; 100 | color: #333; 101 | margin-bottom: 20rpx; 102 | } 103 | 104 | .time-slots { 105 | margin-top: 20rpx; 106 | } 107 | 108 | .time-slot { 109 | display: flex; 110 | align-items: center; 111 | margin-bottom: 20rpx; 112 | } 113 | 114 | .time { 115 | width: 100rpx; 116 | font-size: 24rpx; 117 | color: #666; 118 | } 119 | 120 | .progress-bar { 121 | flex: 1; 122 | height: 20rpx; 123 | background-color: #f0f0f0; 124 | border-radius: 10rpx; 125 | margin: 0 20rpx; 126 | overflow: hidden; 127 | } 128 | 129 | .progress { 130 | height: 100%; 131 | border-radius: 10rpx; 132 | } 133 | 134 | .percent { 135 | width: 80rpx; 136 | font-size: 24rpx; 137 | color: #666; 138 | text-align: right; 139 | } 140 | 141 | /* 底部购物车样式 */ 142 | .footer-share { 143 | position: fixed; 144 | right: 40rpx; 145 | bottom: 80rpx; 146 | width: 100rpx; 147 | height: 100rpx; 148 | background-color: #ff4f94; 149 | color: #fff; 150 | font-size: 30rpx; 151 | font-weight: 700; 152 | border-radius: 50%; 153 | display: flex; 154 | justify-content: center; 155 | align-items: center; 156 | box-shadow: 0 4rpx 20rpx rgba(255, 79, 148, 0.5); 157 | } -------------------------------------------------------------------------------- /tools/demo/pages/snapshot/index.js: -------------------------------------------------------------------------------- 1 | // pages/index/index.js 2 | Page({ 3 | data: { 4 | banners: [ 5 | { id: 1, image: '/images/U3e6ny.jpg' }, 6 | { id: 2, image: '/images/U3e6ny.jpg' }, 7 | { id: 3, image: '/images/U3e6ny.jpg' }, 8 | ], 9 | categories: [ 10 | { id: 1, name: '全部' }, 11 | { id: 2, name: '新品' }, 12 | { id: 3, name: '热销' }, 13 | { id: 4, name: '折扣' }, 14 | { id: 5, name: '美妆' }, 15 | { id: 6, name: '服饰' }, 16 | { id: 7, name: '数码' }, 17 | { id: 8, name: '家居' }, 18 | ], 19 | activeBanner: 0, 20 | activeCategory: 0, 21 | products: [ 22 | { 23 | id: 1, 24 | image: '/images/U3e6ny.jpg', 25 | title: '粉色少女心无线耳机', 26 | desc: '高音质蓝牙5.0,超长续航,甜美配色', 27 | price: 199, 28 | originalPrice: 299, 29 | sold: 1254, 30 | }, 31 | { 32 | id: 2, 33 | image: '/images/U3e6ny.jpg', 34 | title: '卡通动物手机支架', 35 | desc: '可爱动物造型,360度旋转,防滑设计', 36 | price: 29.9, 37 | originalPrice: 39.9, 38 | sold: 876, 39 | }, 40 | { 41 | id: 3, 42 | image: '/images/U3e6ny.jpg', 43 | title: 'ins风简约帆布包', 44 | desc: '大容量设计,百搭款式,多色可选', 45 | price: 59, 46 | sold: 543, 47 | }, 48 | { 49 | id: 4, 50 | image: '/images/U3e6ny.jpg', 51 | title: '水果造型小夜灯', 52 | desc: 'USB充电,三档调光,可爱水果造型', 53 | price: 45, 54 | originalPrice: 69, 55 | sold: 321, 56 | }, 57 | { 58 | id: 5, 59 | image: '/images/U3e6ny.jpg', 60 | title: '创意卡通陶瓷杯', 61 | desc: '手工绘制,环保材质,可爱造型', 62 | price: 39.9, 63 | sold: 765, 64 | }, 65 | { 66 | id: 6, 67 | image: '/images/U3e6ny.jpg', 68 | title: '迷你手持小风扇', 69 | desc: '三档风速,USB充电,便携设计', 70 | price: 49, 71 | originalPrice: 79, 72 | sold: 987, 73 | }, 74 | ], 75 | cartCount: 0, 76 | }, 77 | 78 | onLoad(options) { 79 | // 可以在这里请求商品数据 80 | }, 81 | 82 | // 切换 banner 83 | bannerChange(e) { 84 | this.setData({ 85 | activeBanner: e.detail.current, 86 | }); 87 | }, 88 | 89 | // 切换分类 90 | switchCategory(e) { 91 | const { index } = e.currentTarget.dataset; 92 | this.setData({ 93 | activeCategory: index, 94 | }); 95 | // 这里可以根据分类筛选商品 96 | // this.filterProducts(index); 97 | }, 98 | 99 | // 跳转到商品详情 100 | goToDetail(e) { 101 | const { id } = e.currentTarget.dataset; 102 | wx.navigateTo({ 103 | url: `/pages/detail/detail?id=${id}`, 104 | }); 105 | }, 106 | 107 | // 添加到购物车 108 | addToCart(e) { 109 | const { id } = e.currentTarget.dataset; 110 | this.setData({ 111 | cartCount: this.data.cartCount + 1, 112 | }); 113 | 114 | wx.showToast({ 115 | title: '已加入购物车', 116 | icon: 'success', 117 | duration: 1000, 118 | }); 119 | 120 | // 实际开发中这里应该调用购物车API 121 | }, 122 | 123 | // 跳转到购物车 124 | goToCart() { 125 | wx.switchTab({ 126 | url: '/pages/cart/cart', 127 | }); 128 | }, 129 | 130 | // 分享页面截图 131 | async onPageShare() { 132 | try { 133 | const canvas = this.selectComponent('#wxml2canvas'); 134 | wx.showLoading({ 135 | title: '生成中..', 136 | }); 137 | console.time('生成耗时'); 138 | await canvas.draw(); 139 | console.timeEnd('生成耗时'); 140 | let timer = setTimeout(async () => { 141 | try { 142 | const url = await canvas.toTempFilePath(); 143 | wx.previewImage({ urls: [url] }); 144 | } catch (err) { 145 | console.error(err); 146 | } 147 | wx.hideLoading(); 148 | clearTimeout(timer); 149 | timer = null; 150 | }, 300); 151 | } catch (err) { 152 | console.error(err); 153 | } 154 | }, 155 | }); 156 | -------------------------------------------------------------------------------- /tools/demo/pages/snapshot/index.json: -------------------------------------------------------------------------------- 1 | { 2 | "navigationBarTitleText": "完整页面截图示例", 3 | "usingComponents": { 4 | "wxml2canvas": "/components/wxml2canvas-2d/index" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /tools/demo/pages/snapshot/index.wxml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 搜索心仪商品... 6 | 7 | 8 | 9 | 10 | 15 | 16 | 17 | 18 | 20 | {{item.name}} 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | {{item.title}} 30 | {{item.desc}} 31 | 32 | ¥{{item.price}} 33 | ¥{{item.originalPrice}} 34 | 35 | 36 | 37 | 已售{{item.sold}}件 38 | 39 | 40 | 41 | 42 | 43 | 44 | 分享 45 | 46 | 47 | -------------------------------------------------------------------------------- /tools/demo/pages/snapshot/index.wxss: -------------------------------------------------------------------------------- 1 | /* pages/index/index.wxss */ 2 | .products { 3 | padding-bottom: 100rpx; 4 | background-color: #f8f8f8; 5 | } 6 | 7 | /* 搜索栏样式 */ 8 | .search-bar { 9 | position: relative; 10 | padding: 20rpx 30rpx; 11 | background-color: #ff4f94; 12 | } 13 | 14 | .search-input { 15 | height: 70rpx; 16 | line-height: 70rpx; 17 | background-color: #fff; 18 | border-radius: 35rpx; 19 | padding-left: 80rpx; 20 | padding-right: 30rpx; 21 | font-size: 28rpx; 22 | box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.1); 23 | } 24 | 25 | .placeholder-style { 26 | color: #ccc; 27 | } 28 | 29 | .search-icon { 30 | position: absolute; 31 | left: 50rpx; 32 | top: 35rpx; 33 | width: 36rpx; 34 | height: 36rpx; 35 | } 36 | 37 | /* 轮播图样式 */ 38 | .banner { 39 | height: 360rpx; 40 | margin: 20rpx 30rpx; 41 | border-radius: 16rpx; 42 | overflow: hidden; 43 | box-shadow: 0 8rpx 24rpx rgba(255, 79, 148, 0.2); 44 | } 45 | 46 | .banner-image { 47 | width: 100%; 48 | height: 100%; 49 | border-radius: 16rpx; 50 | } 51 | 52 | /* 分类标签样式 */ 53 | .category-scroll { 54 | box-sizing: border-box; 55 | white-space: nowrap; 56 | padding: 20rpx 0; 57 | background-color: #fff; 58 | margin: 20rpx 0; 59 | box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.05); 60 | } 61 | 62 | .category-item { 63 | display: inline-block; 64 | padding: 12rpx 30rpx; 65 | margin-right: 20rpx; 66 | font-size: 28rpx; 67 | color: #666; 68 | border-radius: 30rpx; 69 | background-color: #f5f5f5; 70 | transition: all 0.3s; 71 | } 72 | .category-item:first-of-type { 73 | margin-left: 30rpx; 74 | } 75 | .category-item:last-of-type { 76 | margin-right: 30rpx; 77 | } 78 | 79 | .category-item.active { 80 | background-color: #ff4f94; 81 | color: #fff; 82 | font-weight: bold; 83 | } 84 | 85 | /* 商品列表样式 */ 86 | .product-list { 87 | display: flex; 88 | flex-wrap: wrap; 89 | justify-content: space-between; 90 | padding: 0 20rpx; 91 | } 92 | 93 | .product-item { 94 | width: 345rpx; 95 | background-color: #fff; 96 | border-radius: 16rpx; 97 | margin-bottom: 20rpx; 98 | overflow: hidden; 99 | box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.05); 100 | transition: transform 0.3s; 101 | } 102 | 103 | .product-item:active { 104 | transform: scale(0.98); 105 | } 106 | 107 | .product-image { 108 | width: 100%; 109 | height: 345rpx; 110 | border-radius: 16rpx 16rpx 0 0; 111 | } 112 | 113 | .product-info { 114 | padding: 20rpx; 115 | } 116 | 117 | .product-title { 118 | font-size: 30rpx; 119 | font-weight: bold; 120 | color: #333; 121 | margin-bottom: 10rpx; 122 | overflow: hidden; 123 | text-overflow: ellipsis; 124 | white-space: nowrap; 125 | } 126 | 127 | .product-desc { 128 | font-size: 24rpx; 129 | color: #999; 130 | margin-bottom: 15rpx; 131 | overflow: hidden; 132 | text-overflow: ellipsis; 133 | display: -webkit-box; 134 | -webkit-line-clamp: 2; 135 | -webkit-box-orient: vertical; 136 | } 137 | 138 | .product-price { 139 | margin-bottom: 15rpx; 140 | } 141 | 142 | .current-price { 143 | font-size: 36rpx; 144 | color: #ff4f94; 145 | font-weight: bold; 146 | } 147 | 148 | .original-price { 149 | font-size: 24rpx; 150 | color: #999; 151 | text-decoration: line-through; 152 | margin-left: 10rpx; 153 | } 154 | 155 | .product-actions { 156 | display: flex; 157 | justify-content: space-between; 158 | align-items: center; 159 | } 160 | 161 | .cart-icon { 162 | width: 50rpx; 163 | height: 50rpx; 164 | } 165 | 166 | .sold-count { 167 | font-size: 24rpx; 168 | color: #999; 169 | } 170 | 171 | /* 底部购物车样式 */ 172 | .footer-share { 173 | position: fixed; 174 | right: 40rpx; 175 | bottom: 80rpx; 176 | width: 100rpx; 177 | height: 100rpx; 178 | background-color: #ff4f94; 179 | color: #fff; 180 | font-size: 30rpx; 181 | font-weight: 700; 182 | border-radius: 50%; 183 | display: flex; 184 | justify-content: center; 185 | align-items: center; 186 | box-shadow: 0 4rpx 20rpx rgba(255, 79, 148, 0.5); 187 | } -------------------------------------------------------------------------------- /tools/demo/pages/video/index.js: -------------------------------------------------------------------------------- 1 | Page({ 2 | data: { 3 | isCompare: false, 4 | outputImage: '', 5 | }, 6 | async onDrawCanvas() { 7 | try { 8 | const canvas = this.selectComponent('#wxml2canvas'); 9 | wx.showLoading({ 10 | title: '生成中..', 11 | }); 12 | console.time('生成耗时'); 13 | await canvas.draw(); 14 | console.timeEnd('生成耗时'); 15 | let timer = setTimeout(async () => { 16 | try { 17 | const url = await canvas.toTempFilePath(); 18 | this.setData({ outputImage: url }); 19 | } catch (err) { 20 | console.error(err); 21 | } 22 | wx.hideLoading(); 23 | clearTimeout(timer); 24 | timer = null; 25 | }, 300); 26 | } catch (err) { 27 | console.error(err); 28 | } 29 | }, 30 | onPreviewCanvas() { 31 | const { outputImage } = this.data; 32 | wx.previewImage({ 33 | urls: [outputImage], 34 | }); 35 | }, 36 | onCanvasTouch() { 37 | this.setData({ isCompare: true }); 38 | }, 39 | onCanvasLeave() { 40 | this.setData({ isCompare: false }); 41 | }, 42 | }); 43 | -------------------------------------------------------------------------------- /tools/demo/pages/video/index.json: -------------------------------------------------------------------------------- 1 | { 2 | "navigationBarTitleText": "绘制视频节点示例", 3 | "usingComponents": { 4 | "wxml2canvas": "/components/wxml2canvas-2d/index" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /tools/demo/pages/video/index.wxml: -------------------------------------------------------------------------------- 1 | 2 | 3 | {{isCompare ? '生成' : '原'}}内容 4 | 5 | 6 | 这是大标题 7 | 21 | ↑↑ 按住卡片实时对比 ↑↑ 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /tools/demo/project.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "description": "项目配置文件。", 3 | "packOptions": { 4 | "ignore": [] 5 | }, 6 | "setting": { 7 | "urlCheck": true, 8 | "es6": true, 9 | "postcss": true, 10 | "minified": true, 11 | "newFeature": true, 12 | "nodeModules": true, 13 | "compileHotReLoad": true 14 | }, 15 | "compileType": "miniprogram", 16 | "libVersion": "2.14.0", 17 | "appid": "", 18 | "projectname": "wxml2canvas-2d", 19 | "isGameTourist": false, 20 | "condition": { 21 | "search": { 22 | "current": -1, 23 | "list": [] 24 | }, 25 | "conversation": { 26 | "current": -1, 27 | "list": [] 28 | }, 29 | "game": { 30 | "currentL": -1, 31 | "list": [] 32 | }, 33 | "miniprogram": { 34 | "current": -1, 35 | "list": [] 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /tools/demo/sitemap.json: -------------------------------------------------------------------------------- 1 | { 2 | "desc": "关于本文件的更多信息,请参考文档 https://developers.weixin.qq.com/miniprogram/dev/framework/sitemap.html", 3 | "rules": [{ 4 | "action": "allow", 5 | "page": "*" 6 | }] 7 | } 8 | -------------------------------------------------------------------------------- /tools/demo/utils/defer.js: -------------------------------------------------------------------------------- 1 | const defer = () => { 2 | const deferred = {}; 3 | deferred.promise = new Promise((resolve, reject) => { 4 | deferred.resolve = resolve; 5 | deferred.reject = reject; 6 | }); 7 | return deferred; 8 | }; 9 | 10 | export default defer; 11 | --------------------------------------------------------------------------------