km-card-layout-component-miniprogram 0.1.17 → 0.1.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/miniprogram_dist/components/card-layout/elements/custom-element/index.wxml +6 -4
  2. package/miniprogram_dist/components/card-layout/elements/icon-element/index.wxml +6 -4
  3. package/miniprogram_dist/components/card-layout/elements/image-element/index.wxml +10 -8
  4. package/miniprogram_dist/components/card-layout/elements/text-element/index.wxml +2 -0
  5. package/miniprogram_dist/components/card-layout/elements/text-element/index.wxss +0 -4
  6. package/miniprogram_dist/components/card-layout/index.js +83 -15
  7. package/miniprogram_dist/components/card-layout/index.json +1 -7
  8. package/miniprogram_dist/components/card-layout/index.wxml +21 -4
  9. package/miniprogram_dist/components/card-layout/index.wxss +83 -0
  10. package/miniprogram_dist/vendor/wxml2canvas-2d/canvas.js +1116 -0
  11. package/miniprogram_dist/vendor/wxml2canvas-2d/constants.js +42 -0
  12. package/miniprogram_dist/vendor/wxml2canvas-2d/element.js +420 -0
  13. package/miniprogram_dist/vendor/wxml2canvas-2d/gradient.js +634 -0
  14. package/miniprogram_dist/vendor/wxml2canvas-2d/index.js +169 -0
  15. package/miniprogram_dist/vendor/wxml2canvas-2d/index.json +4 -0
  16. package/miniprogram_dist/vendor/wxml2canvas-2d/index.wxml +7 -0
  17. package/miniprogram_dist/vendor/wxml2canvas-2d/index.wxss +5 -0
  18. package/package.json +1 -1
  19. package/src/components/card-layout/elements/custom-element/index.wxml +6 -4
  20. package/src/components/card-layout/elements/icon-element/index.wxml +6 -4
  21. package/src/components/card-layout/elements/image-element/index.wxml +10 -8
  22. package/src/components/card-layout/elements/text-element/index.wxml +2 -0
  23. package/src/components/card-layout/elements/text-element/index.wxss +0 -4
  24. package/src/components/card-layout/index.json +1 -7
  25. package/src/components/card-layout/index.ts +108 -16
  26. package/src/components/card-layout/index.wxml +21 -4
  27. package/src/components/card-layout/index.wxss +83 -0
  28. package/src/vendor/km-card-layout-core/types.d.ts +7 -1
  29. package/src/vendor/wxml2canvas-2d/canvas.js +1116 -0
  30. package/src/vendor/wxml2canvas-2d/constants.js +42 -0
  31. package/src/vendor/wxml2canvas-2d/element.js +420 -0
  32. package/src/vendor/wxml2canvas-2d/gradient.js +634 -0
  33. package/src/vendor/wxml2canvas-2d/index.js +169 -0
  34. package/src/vendor/wxml2canvas-2d/index.json +4 -0
  35. package/src/vendor/wxml2canvas-2d/index.wxml +7 -0
  36. package/src/vendor/wxml2canvas-2d/index.wxss +5 -0
@@ -0,0 +1,1116 @@
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 {{
42
+ * segments: Array,
43
+ * isWordBased: Boolean,
44
+ * }} 单词与符号数组、是否由单词组成
45
+ */
46
+ const segmentTextIntoWords = (text) => {
47
+ /** 分隔符号计数 */
48
+ let delimitersCount = 0;
49
+ /** 单词计数 */
50
+ let wordsCount = 0;
51
+ /** 是否由单词组成 */
52
+ let isWordBased = false;
53
+ /** 单词与符号数组 */
54
+ let segments = [];
55
+ // 使用内置的 Intl.Segmenter API 进行拆分,安卓设备不支持
56
+ if (typeof Intl !== 'undefined' && Intl.Segmenter) {
57
+ const segmenter = new Intl.Segmenter(undefined, { granularity: 'word' });
58
+ segments = Array.from(segmenter.segment(text)).map((item) => {
59
+ if (!item.isWordLike) delimitersCount += 1;
60
+ else wordsCount += 1;
61
+ return {
62
+ value: item.segment,
63
+ isWord: item.isWordLike,
64
+ };
65
+ });
66
+ } else {
67
+ if (typeof text !== 'string') text = text.toString();
68
+ /** 分隔符号匹配 */
69
+ const delimiters = text.matchAll(/[,.!?; ]/g);
70
+ let delimiter = delimiters.next();
71
+ /** 连续分隔符号计数 */
72
+ let consecutiveNonWord = 0;
73
+ let lastIndex = 0;
74
+ while (!delimiter.done) {
75
+ const word = text.slice(lastIndex, delimiter.value.index);
76
+ if (word) {
77
+ // 单独处理换行符,不记入分隔符号
78
+ if (new RegExp(`${LINE_BREAK_SYMBOL}`).test(word)) {
79
+ // eslint-disable-next-line no-loop-func
80
+ word.split(LINE_BREAK_SYMBOL).map((item) => {
81
+ segments.push({
82
+ value: item,
83
+ isWord: true,
84
+ }, {
85
+ value: LINE_BREAK_SYMBOL,
86
+ isWord: false,
87
+ });
88
+ wordsCount += 1;
89
+ return item;
90
+ });
91
+ segments.splice(-1, 1);
92
+ } else {
93
+ segments.push({
94
+ value: word,
95
+ isWord: true,
96
+ });
97
+ wordsCount += 1;
98
+ }
99
+ consecutiveNonWord = 0;
100
+ }
101
+ segments.push({
102
+ value: delimiter.value[0],
103
+ isWord: false,
104
+ });
105
+ // 连续的分隔符号只计一次
106
+ if (consecutiveNonWord === 0) {
107
+ delimitersCount += 1;
108
+ }
109
+ consecutiveNonWord += 1;
110
+ lastIndex = delimiter.value.index + delimiter.value[0].length;
111
+ delimiter = delimiters.next();
112
+ }
113
+ if (lastIndex < text.length) {
114
+ segments.push({
115
+ value: text.slice(lastIndex),
116
+ isWord: true,
117
+ });
118
+ wordsCount += 1;
119
+ }
120
+ }
121
+ /**
122
+ * 判断是否由单词组成
123
+ *
124
+ * 1. 单词数量超过 1 个
125
+ * 2. 单词长度中位数不超过 13
126
+ * 3. 分隔符号占比超过 30%
127
+ */
128
+ isWordBased = wordsCount > 1 && getSegmentLengthMedian(segments) <= 13
129
+ && delimitersCount / (wordsCount + delimitersCount) > 0.3;
130
+ if (!isWordBased) {
131
+ segments = segmentText(text).map((item) => ({
132
+ value: item,
133
+ isWord: true,
134
+ }));
135
+ }
136
+ return { segments, isWordBased };
137
+ };
138
+
139
+ /**
140
+ * 获取画布对象
141
+ * @param {ComponentObject} component 组件实例对象
142
+ * @param {String} selector 选择器
143
+ * @returns {Promise<Canvas>} 画布对象
144
+ */
145
+ const getCanvas = (component, selector) => new Promise(
146
+ (resolve) => {
147
+ if (selector) {
148
+ const query = component.createSelectorQuery();
149
+ query.select(selector).fields({
150
+ node: true,
151
+ }).exec((res) => {
152
+ const [{ node: canvas }] = res;
153
+ resolve(canvas);
154
+ });
155
+ } else {
156
+ const canvas = wx.createOffscreenCanvas({
157
+ type: '2d',
158
+ compInst: component,
159
+ });
160
+ resolve(canvas);
161
+ }
162
+ },
163
+ );
164
+
165
+ /**
166
+ * 绘制重复背景图案
167
+ * @param {CanvasRenderingContext2D} ctx 画布上下文
168
+ * @param {Object} clipBox wxml 元素背景延伸的盒子模型
169
+ * @param {Image} image 图片元素对象
170
+ * @param {Number} x image 的左上角在目标画布上 X 轴坐标
171
+ * @param {Number} y image 的左上角在目标画布上 Y 轴坐标
172
+ * @param {Number} width image 在目标画布上绘制的宽度
173
+ * @param {Number} height image 在目标画布上绘制的高度
174
+ * @param {Boolean} repeatX X 轴是否重复绘制
175
+ * @param {Boolean} repeatY Y 轴是否重复绘制
176
+ * @param {Number} stepX X 轴坐标的步进数
177
+ * @param {Number} stepY Y 轴坐标的步进数
178
+ */
179
+ const drawImageRepeated = (
180
+ ctx, clipBox, image,
181
+ x, y, width, height,
182
+ repeatX = false, repeatY = false,
183
+ stepX = 0, stepY = 0,
184
+ ) => {
185
+ ctx.drawImage(
186
+ image,
187
+ 0, 0, image.width, image.height,
188
+ x + stepX * width, y + stepY * height, width, height,
189
+ );
190
+ if (repeatX) {
191
+ if (stepX > -1 && x + (stepX + 1) * width < clipBox.right) {
192
+ drawImageRepeated(
193
+ ctx, clipBox, image,
194
+ x, y, width, height,
195
+ true, false,
196
+ stepX + 1, stepY,
197
+ );
198
+ } if (stepX < 1 && x + stepX * width > clipBox.left) {
199
+ drawImageRepeated(
200
+ ctx, clipBox, image,
201
+ x, y, width, height,
202
+ true, false,
203
+ stepX - 1, stepY,
204
+ );
205
+ }
206
+ } if (repeatY) {
207
+ if (stepY > -1 && y + (stepY + 1) * height < clipBox.bottom) {
208
+ drawImageRepeated(
209
+ ctx, clipBox, image,
210
+ x, y, width, height,
211
+ repeatX && repeatY, true,
212
+ stepX, stepY + 1,
213
+ );
214
+ } if (stepY < 1 && y + stepY * height > clipBox.top) {
215
+ drawImageRepeated(
216
+ ctx, clipBox, image,
217
+ x, y, width, height,
218
+ repeatX && repeatY, true,
219
+ stepX, stepY - 1,
220
+ );
221
+ }
222
+ }
223
+ };
224
+
225
+ /**
226
+ * 获取等边三角形顶点坐标
227
+ * @param {Number} x 中心点 x 轴坐标
228
+ * @param {Number} y 中心点 y 轴坐标
229
+ * @param {Number} l 等边三角形边长
230
+ * @returns {Array} 顶点坐标数组
231
+ */
232
+ const getEquilateralTriangle = (x, y, l) => {
233
+ const area = (Math.sqrt(3) / 4) * l ** 2;
234
+ const halfLength = l / 2;
235
+ const centerToSide = ((area / 6) * 2) / halfLength;
236
+ const centerToCorner = (area * 2) / l - centerToSide;
237
+ const a = [x - centerToSide, y - halfLength];
238
+ const b = [x + centerToCorner, y];
239
+ const c = [x - centerToSide, y + halfLength];
240
+ return [a, b, c];
241
+ };
242
+
243
+ /**
244
+ * 变换矩阵逆运算,获取原始坐标
245
+ * @param {Number} m11 矩阵中第一行第一列的单元格
246
+ * @param {Number} m12 矩阵中第二行第一列的单元格
247
+ * @param {Number} m21 矩阵中第一行第二列的单元格
248
+ * @param {Number} m22 矩阵中第二行第二列的单元格
249
+ * @param {Number} m41 矩阵中第一行第三列的单元格
250
+ * @param {Number} m42 矩阵中第二行第三列的单元格
251
+ * @param {Number} xTransformed 变换后的 x 坐标
252
+ * @param {Number} yTransformed 变换后的 y 坐标
253
+ * @returns 原始坐标 (x, y)
254
+ */
255
+ const inverseTransform = (m11, m12, m21, m22, m41, m42, xTransformed, yTransformed) => {
256
+ // 计算行列式
257
+ const det = m11 * m22 - m12 * m21;
258
+ if (det === 0) {
259
+ throw new Error('Transform is not invertible (determinant is zero)');
260
+ }
261
+ // 计算原始坐标
262
+ const x = (m22 * (xTransformed - m41) - m21 * (yTransformed - m42)) / det;
263
+ const y = (-m12 * (xTransformed - m41) + m11 * (yTransformed - m42)) / det;
264
+ return { x, y };
265
+ };
266
+
267
+ /**
268
+ * 画布工具类
269
+ *
270
+ * 1.实例化:传入组件实例以及画布选择器
271
+ * ```javascript
272
+ * const canvas = new Canvas(componentInstance, canvasSelector);
273
+ * ```
274
+ * 2.初始化:传入容器元素节点信息以及缩放倍率(可选)
275
+ * ```javascript
276
+ * await canvas.init(containerNodeRef, scale);
277
+ * ```
278
+ * 3.执行方法:
279
+ * ```javascript
280
+ * canvas.xxx();
281
+ * ```
282
+ *
283
+ * **注意:**
284
+ *
285
+ * 切换元素节点进行绘制前,请先执行 `Canvas.setElement`
286
+ */
287
+ class Canvas {
288
+ /**
289
+ * @param {ComponentObject} component 组件实例对象
290
+ * @param {String} selector 画布选择器
291
+ */
292
+ constructor(component, selector) {
293
+ this.component = component;
294
+ this.selector = selector;
295
+ this.isOffscreen = !selector;
296
+ }
297
+
298
+ /**
299
+ * 初始化
300
+ * @param {NodesRef} container 容器元素节点信息
301
+ * @param {Number} scale 画布缩放倍数
302
+ */
303
+ async init(container, scale = 1) {
304
+ const canvas = this.canvas = await getCanvas(this.component, this.selector);
305
+ this.scale = scale;
306
+ this.container = container;
307
+ scale *= SYS_DPR;
308
+ canvas.width = container.width * scale;
309
+ canvas.height = container.height * scale;
310
+ const ctx = this.context = canvas.getContext('2d');
311
+ ctx.scale(scale, scale);
312
+ ctx.translate(-container.left, -container.top);
313
+ ctx.save();
314
+ }
315
+
316
+ /**
317
+ * 设置当前绘制的 wxml 元素
318
+ * @param {Element} element wxml 元素
319
+ */
320
+ setElement(element) {
321
+ this.element = element;
322
+ this.context.globalAlpha = +element.opacity;
323
+ // 仅在开发工具、Windows 及部分真机上生效
324
+ this.context.filter = element.filter;
325
+ this.context.save();
326
+ }
327
+
328
+ /**
329
+ * 创建图片对象
330
+ * @param {String} src 图片链接
331
+ * @returns {Promise<Image>} 图片对象
332
+ */
333
+ async createImage(src) {
334
+ return new Promise((resolve, reject) => {
335
+ const image = this.canvas.createImage();
336
+ image.src = src;
337
+ image.onload = () => resolve(image);
338
+ image.onerror = reject;
339
+ });
340
+ }
341
+
342
+ /** 重置画布上下文 */
343
+ restoreContext() {
344
+ this.context.restore();
345
+ this.context.save();
346
+ }
347
+
348
+ /**
349
+ * 绘制/裁切 wxml 元素的边框路径
350
+ * @param {String} sizing 盒子模型描述
351
+ */
352
+ clipElementPath(sizing = 'border') {
353
+ const { context: ctx, element } = this;
354
+ const content = element.getBoxSize(sizing);
355
+
356
+ ctx.beginPath();
357
+ if (element['border-radius'] !== '0px') {
358
+ const radius = element.getBorderRadius();
359
+ /** 旋转角度的单位:iOS 角度、Android 弧度 */
360
+ const unitRotateAngle = Math.PI / 180;
361
+
362
+ /** 元素左外边距 与 内容左外边距 的差值 */
363
+ const diffLeft = content.left - element.left;
364
+ /** 元素右外边距 与 内容右外边距 的差值 */
365
+ const diffRight = element.right - content.right;
366
+ /** 元素顶外边距 与 内容顶外边距 的差值 */
367
+ const diffTop = content.top - element.top;
368
+ /** 元素底外边距 与 内容底外边距 的差值 */
369
+ const diffBottom = element.bottom - content.bottom;
370
+
371
+ /** 元素左顶圆角 */
372
+ const leftTopRadius = radius.leftTop - diffLeft;
373
+ /** 元素顶左圆角 */
374
+ const topLeftRadius = radius.topLeft - diffTop;
375
+ /** 元素顶右圆角 */
376
+ const topRightRadius = radius.topRight - diffTop;
377
+ /** 元素右顶圆角 */
378
+ const rightTopRadius = radius.rightTop - diffRight;
379
+ /** 元素右底圆角 */
380
+ const rightBottomRadius = radius.rightBottom - diffRight;
381
+ /** 元素底右圆角 */
382
+ const bottomRightRadius = radius.bottomRight - diffBottom;
383
+ /** 元素底左圆角 */
384
+ const bottomLeftRadius = radius.bottomLeft - diffBottom;
385
+ /** 元素左底圆角 */
386
+ const leftBottomRadius = radius.leftBottom - diffLeft;
387
+
388
+ if (leftTopRadius === topLeftRadius) {
389
+ ctx.moveTo(content.left, content.top + topLeftRadius);
390
+ ctx.arcTo(
391
+ content.left,
392
+ content.top,
393
+ content.left + leftTopRadius,
394
+ content.top,
395
+ topLeftRadius,
396
+ );
397
+ } else {
398
+ ctx.ellipse(
399
+ content.left + leftTopRadius,
400
+ content.top + topLeftRadius,
401
+ leftTopRadius,
402
+ topLeftRadius,
403
+ -180 * unitRotateAngle,
404
+ 0,
405
+ Math.PI / 2,
406
+ );
407
+ }
408
+ ctx.lineTo(content.right - rightTopRadius, content.top);
409
+ if (rightTopRadius === topRightRadius) {
410
+ ctx.arcTo(
411
+ content.right,
412
+ content.top,
413
+ content.right,
414
+ content.top + topRightRadius,
415
+ topRightRadius,
416
+ );
417
+ } else {
418
+ ctx.ellipse(
419
+ content.right - rightTopRadius,
420
+ content.top + topRightRadius,
421
+ topRightRadius,
422
+ rightTopRadius,
423
+ -90 * unitRotateAngle,
424
+ 0,
425
+ Math.PI / 2,
426
+ );
427
+ }
428
+ ctx.lineTo(content.right, content.bottom - bottomRightRadius);
429
+ if (rightBottomRadius === bottomRightRadius) {
430
+ ctx.arcTo(
431
+ content.right,
432
+ content.bottom,
433
+ content.right - rightBottomRadius,
434
+ content.bottom,
435
+ bottomRightRadius,
436
+ );
437
+ } else {
438
+ ctx.ellipse(
439
+ content.right - rightBottomRadius,
440
+ content.bottom - bottomRightRadius,
441
+ rightBottomRadius,
442
+ bottomRightRadius,
443
+ 0,
444
+ 0,
445
+ Math.PI / 2,
446
+ );
447
+ }
448
+ ctx.lineTo(content.left + leftBottomRadius, content.bottom);
449
+ if (leftBottomRadius === bottomLeftRadius) {
450
+ ctx.arcTo(
451
+ content.left,
452
+ content.bottom,
453
+ content.left,
454
+ content.bottom - bottomLeftRadius,
455
+ bottomLeftRadius,
456
+ );
457
+ } else {
458
+ ctx.ellipse(
459
+ content.left + leftBottomRadius,
460
+ content.bottom - bottomLeftRadius,
461
+ bottomLeftRadius,
462
+ leftBottomRadius,
463
+ 90 * unitRotateAngle,
464
+ 0,
465
+ Math.PI / 2,
466
+ );
467
+ }
468
+ ctx.lineTo(content.left, content.top + topLeftRadius);
469
+ } else {
470
+ ctx.rect(
471
+ content.left,
472
+ content.top,
473
+ content.width,
474
+ content.height,
475
+ );
476
+ }
477
+ ctx.closePath();
478
+ }
479
+
480
+ /**
481
+ * 设置 wxml 元素的边界
482
+ * @param {String} sizing 盒子模型描述
483
+ */
484
+ setElementBoundary(sizing = 'border') {
485
+ this.clipElementPath(sizing);
486
+ this.context.clip();
487
+ }
488
+
489
+ /**
490
+ * 设置 wxml 元素的边框边界
491
+ * @param {String} borderSide 边框位置
492
+ * @param {String} outerSizing 外框盒子模型描述
493
+ * @param {String} innerSizing 内框盒子模型描述
494
+ */
495
+ setBorderBoundary(borderSide, outerSizing = 'border', innerSizing = 'padding') {
496
+ const { context: ctx, element } = this;
497
+ const outerVertex = element.getVertex(outerSizing);
498
+ const innerVertex = element.getVertex(innerSizing);
499
+ ctx.beginPath();
500
+ const start = POSITIONS.indexOf(borderSide);
501
+ const end = start === 0 ? POSITIONS.length - 1 : start - 1;
502
+ ctx.moveTo(...outerVertex[start]);
503
+ ctx.lineTo(...innerVertex[start]);
504
+ ctx.lineTo(...innerVertex[end]);
505
+ ctx.lineTo(...outerVertex[end]);
506
+ ctx.closePath();
507
+ ctx.clip();
508
+ }
509
+
510
+ /** 设置 wxml 元素的变换矩阵 */
511
+ setTransform() {
512
+ const { context: ctx, element } = this;
513
+ const { transform } = element;
514
+ if (!transform || transform === 'none') return;
515
+ const [m11, m12, m21, m22, m41, m42] = transform.slice(7).slice(0, -1).split(', ');
516
+ // 变换后的中心点
517
+ const xTransformed = element.left + element.width / 2;
518
+ const yTransformed = element.top + element.height / 2;
519
+ // 变换前的中心点
520
+ const { x, y } = inverseTransform(
521
+ m11, m12, m21, m22, m41, m42, xTransformed, yTransformed,
522
+ );
523
+ // 变换前的节点信息
524
+ Object.assign(element, {
525
+ left: x - element.__computedRect.width / 2,
526
+ top: y - element.__computedRect.height / 2,
527
+ right: x + element.__computedRect.width / 2,
528
+ bottom: y + element.__computedRect.height / 2,
529
+ width: element.__computedRect.width,
530
+ height: element.__computedRect.height,
531
+ });
532
+ ctx.transform(m11, m12, m21, m22, m41, m42);
533
+ ctx.save();
534
+ }
535
+
536
+ /** 重置 wxml 元素的变换矩阵 */
537
+ resetTransform() {
538
+ const { context: ctx, scale, container } = this;
539
+ const { transform } = this.element;
540
+ if (!transform || transform === 'none') return;
541
+ ctx.resetTransform();
542
+ ctx.scale(scale * SYS_DPR, scale * SYS_DPR);
543
+ ctx.translate(-container.left, -container.top);
544
+ ctx.save();
545
+ }
546
+
547
+ /**
548
+ * 绘制 wxml 元素的背景色
549
+ * @param {String} color 背景色
550
+ */
551
+ drawBackgroundColor(color) {
552
+ const { context: ctx, element } = this;
553
+ const clips = element['background-clip'].split(', ');
554
+ const colorClip = clips[clips.length - 1].slice(0, -4);
555
+
556
+ this.restoreContext();
557
+ if (colorClip !== 'border') {
558
+ this.setElementBoundary(colorClip);
559
+ } else {
560
+ this.setElementBoundary();
561
+ }
562
+ ctx.fillStyle = color ?? element['background-color'];
563
+ ctx.fillRect(element.left, element.top, element.width, element.height);
564
+
565
+ const gradientClip = clips[0].slice(0, -4);
566
+ if (gradientClip !== 'border') {
567
+ this.restoreContext();
568
+ this.setElementBoundary(gradientClip);
569
+ }
570
+ drawGradient(ctx, element);
571
+ this.restoreContext();
572
+ }
573
+
574
+ /** 绘制 wxml 元素的背景图案 */
575
+ async drawBackgroundImage() {
576
+ const { context: ctx, element } = this;
577
+ const backgroundImage = element['background-image'];
578
+ if (!backgroundImage || backgroundImage === 'none') return;
579
+
580
+ const content = element.getBoxSize('padding');
581
+ const images = backgroundImage.split(', ').reverse();
582
+ if (images.length === 0) return;
583
+ this.restoreContext();
584
+ this.setElementBoundary();
585
+
586
+ const clips = element['background-clip'].split(', ').reverse();
587
+ const sizes = element['background-size'].split(', ').reverse();
588
+ const positions = element['background-position'].split(', ').reverse();
589
+ const repeats = element['background-repeat'].split(', ').reverse();
590
+
591
+ /** 上个背景元素延伸模式是否为 border-box */
592
+ let isLast1BorderBox = true;
593
+ /** 所有背景元素延伸模式是否为 border-box */
594
+ let isAllBorderBox = true;
595
+ for (let index = 0; index < images.length; index++) {
596
+ if (!/url\(".*"\)/.test(images[index])) continue;
597
+ const src = images[index].slice(5, -2);
598
+ const image = await this.createImage(src);
599
+ let dx;
600
+ let dy;
601
+ let dWidth;
602
+ let dHeight;
603
+
604
+ const size = sizes[index];
605
+ if (size === 'auto') {
606
+ dWidth = image.width;
607
+ dHeight = image.height;
608
+ } else if (size === 'contain') {
609
+ // 对比宽高,根据长边计算缩放结果数值
610
+ if (image.width / image.height >= content.width / content.height) {
611
+ dWidth = content.width;
612
+ dx = content.left;
613
+ dHeight = image.height * (dWidth / image.width);
614
+ } else {
615
+ dHeight = content.height;
616
+ dy = content.top;
617
+ dWidth = image.width * (dHeight / image.height);
618
+ }
619
+ } else if (size === 'cover') {
620
+ // 对比宽高,根据短边计算缩放结果数值
621
+ if (image.width / image.height <= content.width / content.height) {
622
+ dWidth = content.width;
623
+ dx = content.left;
624
+ dHeight = image.height * (dWidth / image.width);
625
+ } else {
626
+ dHeight = content.height;
627
+ dy = content.top;
628
+ dWidth = image.width * (dHeight / image.height);
629
+ }
630
+ } else {
631
+ const [sizeWidth, sizeHeight] = size.split(' ');
632
+ dWidth = /%/.test(sizeWidth)
633
+ ? content.width * (parseFloat(sizeWidth) / 100)
634
+ : parseFloat(sizeWidth);
635
+ dHeight = /%/.test(sizeHeight)
636
+ ? content.height * (parseFloat(sizeHeight) / 100)
637
+ : parseFloat(sizeHeight);
638
+ }
639
+
640
+ // 关于背景图像位置的百分比偏移量计算方式,参考文档:
641
+ // 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
642
+ const position = positions[index];
643
+ const [positionX, positionY] = position.split(' ');
644
+ dx = dx ?? (
645
+ content.left + (/%/.test(positionX)
646
+ ? (content.width - dWidth) * (parseFloat(positionX) / 100)
647
+ : parseFloat(positionX))
648
+ );
649
+ dy = dy ?? (
650
+ content.top + (/%/.test(positionY)
651
+ ? (content.height - dHeight) * (parseFloat(positionY) / 100)
652
+ : parseFloat(positionY))
653
+ );
654
+
655
+ /** 当前背景元素重复模式 */
656
+ const repeat = repeats[index];
657
+ /** 当前背景元素延伸模式 */
658
+ const boxSizing = clips[index].slice(0, -4);
659
+ /** 当前背景元素延伸盒子大小 */
660
+ const clipBox = element.getBoxSize(boxSizing);
661
+ // 减少边缘裁剪绘制次数
662
+ if (!isLast1BorderBox || boxSizing !== 'border') {
663
+ this.restoreContext();
664
+ this.setElementBoundary(boxSizing);
665
+ if (isAllBorderBox) isAllBorderBox = false;
666
+ }
667
+ isLast1BorderBox = boxSizing === 'border';
668
+ drawImageRepeated(
669
+ ctx, clipBox, image,
670
+ dx, dy, dWidth, dHeight,
671
+ repeat === 'repeat' || repeat === 'repeat-x',
672
+ repeat === 'repeat' || repeat === 'repeat-y',
673
+ );
674
+ }
675
+ this.restoreContext();
676
+ }
677
+
678
+ /**
679
+ * 绘制 wxml 的 image 元素
680
+ * @param {String} src 图片链接
681
+ * @param {String} mode 图片裁剪、缩放的模式
682
+ */
683
+ async drawImage(src, mode) {
684
+ const { element } = this;
685
+ this.restoreContext();
686
+ this.setElementBoundary();
687
+ const image = await this.createImage(src ?? element.src);
688
+ let dx;
689
+ let dy;
690
+ let dWidth;
691
+ let dHeight;
692
+ let sx;
693
+ let sy;
694
+ let sWidth;
695
+ let sHeight;
696
+ const content = element.getBoxSize('content');
697
+ if ((mode ?? element.mode) === 'aspectFit') {
698
+ sx = 0;
699
+ sy = 0;
700
+ sWidth = image.width;
701
+ sHeight = image.height;
702
+ // 对比宽高,根据长边计算缩放结果数值
703
+ if (image.width / image.height >= content.width / content.height) {
704
+ dWidth = content.width;
705
+ dHeight = image.height * (dWidth / image.width);
706
+ dx = content.left;
707
+ dy = content.top + (content.height - dHeight) / 2;
708
+ } else {
709
+ dHeight = content.height;
710
+ dWidth = image.width * (dHeight / image.height);
711
+ dx = content.left + (content.width - dWidth) / 2;
712
+ dy = content.top;
713
+ }
714
+ } else if ((mode ?? element.mode) === 'aspectFill') {
715
+ dx = content.left;
716
+ dy = content.top;
717
+ dWidth = content.width;
718
+ dHeight = content.height;
719
+ // 对比宽高,根据短边计算缩放结果数值
720
+ if (image.width / image.height <= content.width / content.height) {
721
+ sWidth = image.width;
722
+ sHeight = sWidth * (content.height / content.width);
723
+ sx = 0;
724
+ sy = (image.height - sHeight) / 2;
725
+ } else {
726
+ sHeight = image.height;
727
+ sWidth = sHeight * (content.width / content.height);
728
+ sx = (image.width - sWidth) / 2;
729
+ sy = 0;
730
+ }
731
+ } else {
732
+ sx = 0;
733
+ sy = 0;
734
+ sWidth = image.width;
735
+ sHeight = image.height;
736
+ dx = content.left;
737
+ dy = content.top;
738
+ dWidth = content.width;
739
+ dHeight = content.height;
740
+ }
741
+ this.context.drawImage(image, sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight);
742
+ this.restoreContext();
743
+ }
744
+
745
+ /** 绘制 wxml 的 video 元素 */
746
+ async drawVideo() {
747
+ const { context: ctx, element } = this;
748
+ this.drawBackgroundColor('#000000');
749
+ if (element.poster) {
750
+ await this.drawImage(element.poster, VIDEO_POSTER_MODES[element.objectFit]);
751
+ }
752
+ this.restoreContext();
753
+ this.setElementBoundary();
754
+ /** 播放按钮边长 */
755
+ const LENGTH = 50;
756
+ /** 播放按钮顶点坐标 */
757
+ const vertexes = getEquilateralTriangle(
758
+ element.left + element.width / 2, element.top + element.height / 2, LENGTH,
759
+ );
760
+ /** 播放按钮圆角数据 */
761
+ const RADIUS = [0, 0, 8];
762
+ RADIUS[0] = RADIUS[2] / 2;
763
+ RADIUS[1] = Math.sqrt(3) * RADIUS[0];
764
+ ctx.beginPath();
765
+ ctx.moveTo(vertexes[0][0], vertexes[0][1] + RADIUS[2]);
766
+ ctx.quadraticCurveTo(
767
+ vertexes[0][0], vertexes[0][1],
768
+ vertexes[0][0] + RADIUS[1], vertexes[0][1] + RADIUS[0],
769
+ );
770
+ ctx.lineTo(vertexes[1][0] - RADIUS[1], vertexes[1][1] - RADIUS[0]);
771
+ ctx.quadraticCurveTo(
772
+ vertexes[1][0], vertexes[1][1],
773
+ vertexes[1][0] - RADIUS[1], vertexes[1][1] + RADIUS[0],
774
+ );
775
+ ctx.lineTo(vertexes[2][0] + RADIUS[1], vertexes[2][1] - RADIUS[0]);
776
+ ctx.quadraticCurveTo(
777
+ vertexes[2][0], vertexes[2][1],
778
+ vertexes[2][0], vertexes[2][1] - RADIUS[2],
779
+ );
780
+ ctx.closePath();
781
+ ctx.fillStyle = 'rgba(255, 255, 255, 0.5)';
782
+ ctx.fill();
783
+ this.restoreContext();
784
+ }
785
+
786
+ /**
787
+ * 绘制 wxml 的 canvas 元素
788
+ * @param {Object} instance canvas 元素所在页面/组件实例
789
+ */
790
+ async drawCanvas(instance) {
791
+ const { element } = this;
792
+ const payload = {
793
+ fileType: 'png',
794
+ };
795
+ if (element.type === '2d') {
796
+ Object.assign(payload, {
797
+ canvas: element.node,
798
+ });
799
+ } else {
800
+ Object.assign(payload, {
801
+ canvasId: element.canvasId,
802
+ });
803
+ }
804
+ const { tempFilePath } = await wx.canvasToTempFilePath(payload, instance);
805
+ await this.drawImage(tempFilePath);
806
+ }
807
+
808
+ /** 绘制 wxml 的 text 元素 */
809
+ drawText() {
810
+ const { context: ctx, element } = this;
811
+ const content = element.getBoxSize('content');
812
+ const shadow = element.getTextShadow();
813
+ this.restoreContext();
814
+ if (shadow.color) {
815
+ ctx.shadowColor = shadow.color;
816
+ ctx.shadowBlur = shadow.blur;
817
+ ctx.shadowOffsetX = shadow.offsetX;
818
+ ctx.shadowOffsetY = shadow.offsetY;
819
+ }
820
+
821
+ // 固定格式(不可缺省):font-weight font-size font-family
822
+ ctx.font = `${element['font-weight']} ${element['font-size']} ${element['font-family']}`;
823
+ ctx.textBaseline = 'top';
824
+ ctx.textAlign = element['text-align'];
825
+ ctx.fillStyle = element.color;
826
+
827
+ // 仅在 Windows 上生效,真机暂不支持
828
+ ctx.textLetterSpacing = parseFloat(element['letter-spacing']) || 0;
829
+ ctx.textWordSpacing = parseFloat(element['word-spacing']);
830
+ // 小程序画布中无实际表现,暂不支持
831
+ ctx.letterSpacing = element['letter-spacing'];
832
+ ctx.wordSpacing = element['word-spacing'];
833
+
834
+ const fontSize = parseFloat(element['font-size']);
835
+ const writingMode = element['writing-mode'];
836
+ /** 文本方向向右 */
837
+ const isTextRTL = element.direction === 'rtl';
838
+ const isTextCentered = element['text-align'] === 'center';
839
+ const isTextRightAlign = element['text-align'] === 'right' || (isTextRTL && element['text-align'] === 'start');
840
+ ctx.textAlign = isTextRightAlign ? 'right' : isTextCentered ? 'center' : 'left';
841
+
842
+ /** 文字行高 */
843
+ let lineHeight;
844
+ if (!Number.isNaN(+element['line-height'])) {
845
+ lineHeight = fontSize * +element['line-height'];
846
+ } else if (/px/.test(element['line-height'])) {
847
+ lineHeight = parseFloat(element['line-height']);
848
+ } else {
849
+ lineHeight = fontSize * DEFAULT_LINE_HEIGHT;
850
+ }
851
+ /** 首行缩进 */
852
+ let textIndent;
853
+ if (/em/.test(element['text-indent'])) {
854
+ textIndent = fontSize * +element['text-indent'];
855
+ } else if (/px/.test(element['text-indent'])) {
856
+ textIndent = parseFloat(element['text-indent']);
857
+ } else if (/%/.test(element['text-indent'])) {
858
+ textIndent = (parseFloat(element['text-indent']) / 100) * content.width;
859
+ }
860
+
861
+ /**
862
+ * 计算元素内实际显示最大行数
863
+ *
864
+ * 向上取整避免行高过大,文字错位
865
+ */
866
+ const maxLines = Math.max(Math.ceil(
867
+ Number(content.height / lineHeight).toFixed(1),
868
+ ), 1);
869
+ // 消除行高计算偏差
870
+ lineHeight = content.height / maxLines;
871
+ /** 单行内容,逐行显示 */
872
+ let lineText = '';
873
+ /** 内容基本单位拆分 */
874
+ const { segments, isWordBased } = segmentTextIntoWords(
875
+ element.dataset.icon
876
+ ? String.fromCharCode(parseInt(element.dataset.icon, 16))
877
+ : element.dataset.text,
878
+ );
879
+
880
+ let lines = 0;
881
+ let lastIndex = 0;
882
+ let segment = segments[lastIndex];
883
+ let lastSegment;
884
+ for (; lines < maxLines; lines += 1) {
885
+ /**
886
+ * 计算最大限制行宽
887
+ *
888
+ * 判断首行缩进,取整避免行宽过小,导致文字变形
889
+ */
890
+ const lineWidth = Math.ceil(content.width - (lines === 0 ? textIndent : 0));
891
+ while (segment && ctx.measureText(lineText + segment.value).width <= lineWidth) {
892
+ const isForcedLineBreak = segment.value === LINE_BREAK_SYMBOL;
893
+ lineText += segment.value;
894
+ lastSegment = segment;
895
+ lastIndex += 1;
896
+ segment = segments[lastIndex];
897
+ // 判断换行符强制换行
898
+ if (isForcedLineBreak) break;
899
+ }
900
+
901
+ /** 是否内容最后一行 */
902
+ const isLastLine = (lines + 1) === maxLines;
903
+ if (isLastLine && lastIndex < segments.length - 1 && element['text-overflow'] === 'ellipsis') {
904
+ let ellipsisLineText = isTextRTL && !IS_MOBILE ? `...${lineText}` : `${lineText}...`;
905
+ while (ctx.measureText(ellipsisLineText).width > lineWidth) {
906
+ lineText = lineText.slice(0, -1);
907
+ ellipsisLineText = isTextRTL && !IS_MOBILE ? `...${lineText}` : `${lineText}...`;
908
+ }
909
+ lineText = ellipsisLineText;
910
+ } else if (isLastLine && segment) {
911
+ // 因画布与页面文字表现不一致,溢出内容放置于末行
912
+ lineText += segment.value;
913
+ lastSegment = segment;
914
+ }
915
+ if (isTextRTL && !IS_MOBILE && lastSegment && !lastSegment.isWord) {
916
+ lineText = lineText.slice(0, -lastSegment.value.length);
917
+ lineText = `${lastSegment.value}${lineText}`;
918
+ }
919
+ lineText = lineText.trim();
920
+ if (isTextRTL && IS_MOBILE) {
921
+ lineText = segmentText(lineText).reverse().join('');
922
+ }
923
+
924
+ const lineLeft = (
925
+ isTextRightAlign ? content.right : content.left
926
+ ) + ( // 首行缩进位置偏移
927
+ (isTextRightAlign ? -1 : 1) * (lines === 0 ? textIndent : 0)
928
+ ) + ( // 文字居中位置偏移
929
+ (isTextRightAlign ? -1 : 1) * (isTextCentered ? lineWidth / 2 : 0)
930
+ );
931
+ const lineTop = content.top + lines * lineHeight;
932
+ const lineTopOffset = (
933
+ lineHeight - fontSize * FONT_SIZE_OFFSET
934
+ ) / 2;
935
+ ctx.fillText(
936
+ lineText,
937
+ lineLeft,
938
+ lineTop + lineTopOffset,
939
+ lineWidth,
940
+ );
941
+
942
+ /** 文字实际宽度 */
943
+ const textWidth = Math.min(ctx.measureText(lineText).width, lineWidth);
944
+ let decorLines = element['text-decoration-line'];
945
+ if (decorLines && decorLines !== 'none') {
946
+ const decorStyle = element['text-decoration-style'];
947
+ const decorColor = element['text-decoration-color'];
948
+ ctx.strokeStyle = decorColor;
949
+ ctx.lineWidth = 2 / RPX_RATIO;
950
+ if (decorStyle === 'dashed') {
951
+ ctx.setLineDash([4, 4]);
952
+ }
953
+
954
+ decorLines = decorLines.split(' ').map((decor) => {
955
+ let decorLineLeft = lineLeft;
956
+ let decorLineTop = lineTop;
957
+ if (isTextCentered) {
958
+ decorLineLeft -= textWidth / 2;
959
+ } else if (isTextRightAlign) {
960
+ decorLineLeft -= textWidth;
961
+ }
962
+ if (decor === 'line-through') {
963
+ decorLineTop += lineTopOffset + fontSize / 2;
964
+ } else if (decor === 'underline') {
965
+ decorLineTop += lineTopOffset + fontSize;
966
+ }
967
+ ctx.beginPath();
968
+ ctx.moveTo(decorLineLeft, decorLineTop);
969
+ ctx.lineTo(decorLineLeft + textWidth, decorLineTop);
970
+ ctx.closePath();
971
+ ctx.stroke();
972
+ if (decorStyle === 'double') {
973
+ decorLineTop += 2 * ctx.lineWidth;
974
+ ctx.beginPath();
975
+ ctx.moveTo(decorLineLeft, decorLineTop);
976
+ ctx.lineTo(decorLineLeft + textWidth, decorLineTop);
977
+ ctx.closePath();
978
+ ctx.stroke();
979
+ }
980
+ return decor;
981
+ });
982
+ }
983
+
984
+ lineText = '';
985
+ }
986
+ this.restoreContext();
987
+ }
988
+
989
+ /** 绘制 wxml 元素边框 */
990
+ drawBorder() {
991
+ const { context: ctx, element } = this;
992
+ const border = element.getBorder();
993
+ if (border.width > 0) {
994
+ this.restoreContext();
995
+ this.setElementBoundary();
996
+ ctx.strokeStyle = border.color;
997
+ ctx.lineWidth = border.width * 2;
998
+ if (border.style === 'dashed') {
999
+ ctx.lineDashOffset = -border.width * 2;
1000
+ ctx.setLineDash([2 * border.width, border.width]);
1001
+ }
1002
+ this.clipElementPath();
1003
+ ctx.stroke();
1004
+ this.restoreContext();
1005
+ } else {
1006
+ const vertex = element.getVertex();
1007
+ POSITIONS.map((key, index) => {
1008
+ if (border[key].width === 0) return key;
1009
+ this.restoreContext();
1010
+ this.setBorderBoundary(key);
1011
+ ctx.strokeStyle = border[key].color;
1012
+ if (border[key].style === 'double') {
1013
+ ctx.lineWidth = border[key].width * DOUBLE_LINE_RATIO * 2;
1014
+ const innerVertex = element.getVertex('padding');
1015
+ const point = [];
1016
+ // 双实线边框的宽高,加长避免露出矩形其他边
1017
+ const width = ['left', 'right'].indexOf(key) > -1 ? border[key].width : (element.width + 2 * ctx.lineWidth);
1018
+ const height = ['top', 'bottom'].indexOf(key) > -1 ? border[key].width : (element.height + 2 * ctx.lineWidth);
1019
+ if (key === 'right') {
1020
+ point.push(innerVertex[1][0], vertex[1][1] - ctx.lineWidth);
1021
+ } else if (key === 'bottom') {
1022
+ point.push(vertex[3][0] - ctx.lineWidth, innerVertex[3][1]);
1023
+ } else {
1024
+ point.push(
1025
+ vertex[0][0] - (key === 'top' ? ctx.lineWidth : 0),
1026
+ vertex[0][1] - (key === 'left' ? ctx.lineWidth : 0),
1027
+ );
1028
+ }
1029
+ ctx.beginPath();
1030
+ ctx.rect(...point, width, height);
1031
+ ctx.closePath();
1032
+ ctx.stroke();
1033
+ } else {
1034
+ ctx.lineWidth = border[key].width * 2;
1035
+ if (border[key].style === 'dashed') {
1036
+ ctx.lineDashOffset = -border[key].width * 2;
1037
+ ctx.setLineDash([2 * border[key].width, 2 * border[key].width]);
1038
+ }
1039
+ const line = [vertex[index], vertex[index === 0 ? POSITIONS.length - 1 : index - 1]];
1040
+ ctx.beginPath();
1041
+ ctx.moveTo(...line[0]);
1042
+ ctx.lineTo(...line[1]);
1043
+ ctx.closePath();
1044
+ ctx.stroke();
1045
+ }
1046
+ this.restoreContext();
1047
+ return key;
1048
+ });
1049
+ }
1050
+ }
1051
+
1052
+ /** 绘制 wxml 元素阴影 */
1053
+ drawBoxShadow() {
1054
+ const { context: ctx, element } = this;
1055
+ const shadow = element.getBoxShadow();
1056
+ if (!shadow.color) return;
1057
+ this.restoreContext();
1058
+ ctx.shadowColor = shadow.color;
1059
+ ctx.shadowBlur = shadow.blur;
1060
+ ctx.shadowOffsetX = shadow.offsetX;
1061
+ ctx.shadowOffsetY = shadow.offsetY;
1062
+ const background = element.getBackgroundColor();
1063
+ // 必须填充背景色,否则阴影不可见
1064
+ ctx.fillStyle = `rgba(${background.rColor}, ${background.gColor}, ${background.bColor}, 1)`;
1065
+ this.clipElementPath();
1066
+ ctx.fill();
1067
+ this.restoreContext();
1068
+ }
1069
+
1070
+ /**
1071
+ * 导出画布至临时图片
1072
+ * @param {Boolean} original 是否使用实机表现作为导出图片的尺寸;
1073
+ *
1074
+ * `true` 则导出当前实机设备渲染的尺寸,各设备的设备像素比不同,导出图片尺寸将有所不同;
1075
+ *
1076
+ * `false` 则导出以 750px 设计图为基准的尺寸,即与 WXSS 中设置的 rpx 大小一致,全设备导出图片尺寸一致;
1077
+ * @returns {Promise<String>} 图片临时路径
1078
+ */
1079
+ async toTempFilePath(original = true) {
1080
+ const payload = {
1081
+ canvas: this.canvas,
1082
+ fileType: 'png',
1083
+ };
1084
+ if (!original) {
1085
+ Object.assign(payload, {
1086
+ destWidth: this.container.width * RPX_RATIO * this.scale,
1087
+ destHeight: this.container.height * RPX_RATIO * this.scale,
1088
+ });
1089
+ }
1090
+ const { tempFilePath } = await wx.canvasToTempFilePath(payload, this.component);
1091
+ return tempFilePath;
1092
+ }
1093
+
1094
+ /**
1095
+ * 导出画布至 Data URI(base64 编码)
1096
+ *
1097
+ * iOS、Mac 与 Windows 平台在离屏 Canvas 模式下使用 `wx.canvasToTempFilePath` 导出时会报错
1098
+ *
1099
+ * 可以使用 `Canvas.toDataURL` 搭配 `FileSystemManager.saveFile` 保存导出的图片
1100
+ * @returns {String} URI
1101
+ */
1102
+ toDataURL() {
1103
+ return this.canvas.toDataURL();
1104
+ }
1105
+
1106
+ /**
1107
+ * 获取画布的像素数据
1108
+ */
1109
+ getImageData() {
1110
+ return this.context.getImageData(
1111
+ 0, 0, this.canvas.width, this.canvas.height,
1112
+ );
1113
+ }
1114
+ }
1115
+
1116
+ export default Canvas;