@codehz/draw-call 0.1.2 → 0.2.0
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.
- package/README.md +374 -6
- package/canvas.d.cts +44 -3
- package/canvas.d.mts +44 -3
- package/examples/card.ts +155 -0
- package/examples/demo.ts +478 -0
- package/examples/richtext.ts +284 -0
- package/index.cjs +70 -0
- package/index.d.cts +26 -7
- package/index.d.mts +26 -7
- package/index.mjs +68 -1
- package/node.cjs +2 -2
- package/node.d.cts +2 -2
- package/node.d.mts +2 -2
- package/node.mjs +4 -4
- package/package.json +1 -1
- package/render.cjs +245 -2
- package/render.mjs +245 -2
package/render.cjs
CHANGED
|
@@ -195,6 +195,174 @@ function measureImageSize(element, _ctx, _availableWidth) {
|
|
|
195
195
|
};
|
|
196
196
|
}
|
|
197
197
|
|
|
198
|
+
//#endregion
|
|
199
|
+
//#region src/layout/components/richtext.ts
|
|
200
|
+
/**
|
|
201
|
+
* 合并 span 样式和元素级别样式
|
|
202
|
+
* 优先级:span 样式 > 元素样式 > 默认值
|
|
203
|
+
* font 属性进行深度合并,允许 span 部分覆盖 element 的 font
|
|
204
|
+
*/
|
|
205
|
+
function mergeSpanStyle(span, elementStyle) {
|
|
206
|
+
return {
|
|
207
|
+
font: {
|
|
208
|
+
...elementStyle.font || {},
|
|
209
|
+
...span.font || {}
|
|
210
|
+
},
|
|
211
|
+
color: span.color ?? elementStyle.color,
|
|
212
|
+
background: span.background ?? elementStyle.background,
|
|
213
|
+
underline: span.underline ?? elementStyle.underline ?? false,
|
|
214
|
+
strikethrough: span.strikethrough ?? elementStyle.strikethrough ?? false
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* 测量富文本元素的固有尺寸
|
|
219
|
+
*/
|
|
220
|
+
function measureRichTextSize(element, ctx, availableWidth) {
|
|
221
|
+
const lineHeight = element.lineHeight ?? 1.2;
|
|
222
|
+
const elementStyle = {
|
|
223
|
+
font: element.font,
|
|
224
|
+
color: element.color,
|
|
225
|
+
background: element.background,
|
|
226
|
+
underline: element.underline,
|
|
227
|
+
strikethrough: element.strikethrough
|
|
228
|
+
};
|
|
229
|
+
const richLines = wrapRichText(ctx, element.spans, availableWidth, lineHeight, elementStyle);
|
|
230
|
+
let maxWidth = 0;
|
|
231
|
+
let totalHeight = 0;
|
|
232
|
+
for (const line of richLines) {
|
|
233
|
+
maxWidth = Math.max(maxWidth, line.width);
|
|
234
|
+
totalHeight += line.height;
|
|
235
|
+
}
|
|
236
|
+
return {
|
|
237
|
+
width: maxWidth,
|
|
238
|
+
height: totalHeight
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* 将富文本内容拆分为行
|
|
243
|
+
*/
|
|
244
|
+
function wrapRichText(ctx, spans, maxWidth, lineHeightScale = 1.2, elementStyle = {}) {
|
|
245
|
+
const lines = [];
|
|
246
|
+
let currentSegments = [];
|
|
247
|
+
let currentLineWidth = 0;
|
|
248
|
+
const pushLine = () => {
|
|
249
|
+
if (currentSegments.length === 0) return;
|
|
250
|
+
let maxTopDist = 0;
|
|
251
|
+
let maxBottomDist = 0;
|
|
252
|
+
let maxLineHeight = 0;
|
|
253
|
+
for (const seg of currentSegments) {
|
|
254
|
+
const topDist = seg.ascent - seg.offset;
|
|
255
|
+
const bottomDist = seg.descent + seg.offset;
|
|
256
|
+
maxTopDist = Math.max(maxTopDist, topDist);
|
|
257
|
+
maxBottomDist = Math.max(maxBottomDist, bottomDist);
|
|
258
|
+
maxLineHeight = Math.max(maxLineHeight, seg.height);
|
|
259
|
+
}
|
|
260
|
+
const contentHeight = maxTopDist + maxBottomDist;
|
|
261
|
+
const finalHeight = Math.max(contentHeight, maxLineHeight);
|
|
262
|
+
const extra = (finalHeight - contentHeight) / 2;
|
|
263
|
+
lines.push({
|
|
264
|
+
segments: [...currentSegments],
|
|
265
|
+
width: currentLineWidth,
|
|
266
|
+
height: finalHeight,
|
|
267
|
+
baseline: maxTopDist + extra
|
|
268
|
+
});
|
|
269
|
+
currentSegments = [];
|
|
270
|
+
currentLineWidth = 0;
|
|
271
|
+
};
|
|
272
|
+
for (const span of spans) {
|
|
273
|
+
const mergedStyle = mergeSpanStyle(span, elementStyle);
|
|
274
|
+
const font = mergedStyle.font;
|
|
275
|
+
const lh = (font.size ?? 16) * lineHeightScale;
|
|
276
|
+
const words = span.text.split(/(\s+)/);
|
|
277
|
+
for (const word of words) {
|
|
278
|
+
if (word === "") continue;
|
|
279
|
+
if (/^\s+$/.test(word)) {
|
|
280
|
+
const metrics = ctx.measureText(word, font);
|
|
281
|
+
const wordWidth = metrics.width;
|
|
282
|
+
if (maxWidth > 0 && currentLineWidth + wordWidth > maxWidth && currentSegments.length > 0) pushLine();
|
|
283
|
+
currentSegments.push({
|
|
284
|
+
text: word,
|
|
285
|
+
font: mergedStyle.font,
|
|
286
|
+
color: mergedStyle.color,
|
|
287
|
+
background: mergedStyle.background,
|
|
288
|
+
underline: mergedStyle.underline,
|
|
289
|
+
strikethrough: mergedStyle.strikethrough,
|
|
290
|
+
width: wordWidth,
|
|
291
|
+
height: lh,
|
|
292
|
+
ascent: metrics.ascent,
|
|
293
|
+
descent: metrics.descent,
|
|
294
|
+
offset: metrics.offset
|
|
295
|
+
});
|
|
296
|
+
currentLineWidth += wordWidth;
|
|
297
|
+
} else {
|
|
298
|
+
const metrics = ctx.measureText(word, font);
|
|
299
|
+
const wordWidth = metrics.width;
|
|
300
|
+
if (maxWidth <= 0 || currentLineWidth + wordWidth <= maxWidth) {
|
|
301
|
+
currentSegments.push({
|
|
302
|
+
text: word,
|
|
303
|
+
font: mergedStyle.font,
|
|
304
|
+
color: mergedStyle.color,
|
|
305
|
+
background: mergedStyle.background,
|
|
306
|
+
underline: mergedStyle.underline,
|
|
307
|
+
strikethrough: mergedStyle.strikethrough,
|
|
308
|
+
width: wordWidth,
|
|
309
|
+
height: lh,
|
|
310
|
+
ascent: metrics.ascent,
|
|
311
|
+
descent: metrics.descent,
|
|
312
|
+
offset: metrics.offset
|
|
313
|
+
});
|
|
314
|
+
currentLineWidth += wordWidth;
|
|
315
|
+
} else {
|
|
316
|
+
if (currentSegments.length > 0) pushLine();
|
|
317
|
+
const remainingWidth = maxWidth;
|
|
318
|
+
let currentPos = 0;
|
|
319
|
+
while (currentPos < word.length) {
|
|
320
|
+
let bestLen = 0;
|
|
321
|
+
for (let len = word.length - currentPos; len > 0; len--) {
|
|
322
|
+
const substr = word.substring(currentPos, currentPos + len);
|
|
323
|
+
const m = ctx.measureText(substr, font);
|
|
324
|
+
if (currentLineWidth + m.width <= remainingWidth) {
|
|
325
|
+
bestLen = len;
|
|
326
|
+
if (len < word.length - currentPos) break;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
if (bestLen === 0) {
|
|
330
|
+
if (currentSegments.length > 0) pushLine();
|
|
331
|
+
bestLen = 1;
|
|
332
|
+
}
|
|
333
|
+
const substr = word.substring(currentPos, currentPos + bestLen);
|
|
334
|
+
const m = ctx.measureText(substr, font);
|
|
335
|
+
currentSegments.push({
|
|
336
|
+
text: substr,
|
|
337
|
+
font: mergedStyle.font,
|
|
338
|
+
color: mergedStyle.color,
|
|
339
|
+
background: mergedStyle.background,
|
|
340
|
+
underline: mergedStyle.underline,
|
|
341
|
+
strikethrough: mergedStyle.strikethrough,
|
|
342
|
+
width: m.width,
|
|
343
|
+
height: lh,
|
|
344
|
+
ascent: m.ascent,
|
|
345
|
+
descent: m.descent,
|
|
346
|
+
offset: m.offset
|
|
347
|
+
});
|
|
348
|
+
currentLineWidth += m.width;
|
|
349
|
+
currentPos += bestLen;
|
|
350
|
+
if (currentPos < word.length && currentLineWidth >= remainingWidth) pushLine();
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
pushLine();
|
|
357
|
+
if (lines.length === 0) return [{
|
|
358
|
+
segments: [],
|
|
359
|
+
width: 0,
|
|
360
|
+
height: 0,
|
|
361
|
+
baseline: 0
|
|
362
|
+
}];
|
|
363
|
+
return lines;
|
|
364
|
+
}
|
|
365
|
+
|
|
198
366
|
//#endregion
|
|
199
367
|
//#region src/layout/components/stack.ts
|
|
200
368
|
/**
|
|
@@ -272,10 +440,13 @@ function createCanvasMeasureContext(ctx) {
|
|
|
272
440
|
ctx.textBaseline = "middle";
|
|
273
441
|
const metrics = ctx.measureText(text);
|
|
274
442
|
const height = metrics.actualBoundingBoxAscent + metrics.actualBoundingBoxDescent;
|
|
443
|
+
const fontSize = font.size || 16;
|
|
275
444
|
return {
|
|
276
445
|
width: metrics.width,
|
|
277
|
-
height: height ||
|
|
278
|
-
offset: (metrics.actualBoundingBoxAscent - metrics.actualBoundingBoxDescent) / 2
|
|
446
|
+
height: height || fontSize,
|
|
447
|
+
offset: (metrics.actualBoundingBoxAscent - metrics.actualBoundingBoxDescent) / 2,
|
|
448
|
+
ascent: metrics.actualBoundingBoxAscent,
|
|
449
|
+
descent: metrics.actualBoundingBoxDescent
|
|
279
450
|
};
|
|
280
451
|
} };
|
|
281
452
|
}
|
|
@@ -387,6 +558,7 @@ function measureTextSize(element, ctx, availableWidth) {
|
|
|
387
558
|
function measureIntrinsicSize(element, ctx, availableWidth) {
|
|
388
559
|
switch (element.type) {
|
|
389
560
|
case "text": return measureTextSize(element, ctx, availableWidth);
|
|
561
|
+
case "richtext": return measureRichTextSize(element, ctx, availableWidth);
|
|
390
562
|
case "box": return measureBoxSize(element, ctx, availableWidth, measureIntrinsicSize);
|
|
391
563
|
case "stack": return measureStackSize(element, ctx, availableWidth, measureIntrinsicSize);
|
|
392
564
|
case "image": return measureImageSize(element, ctx, availableWidth);
|
|
@@ -482,6 +654,23 @@ function computeLayout(element, ctx, constraints, x = 0, y = 0) {
|
|
|
482
654
|
node.lineOffsets = [offset];
|
|
483
655
|
}
|
|
484
656
|
}
|
|
657
|
+
if (element.type === "richtext") {
|
|
658
|
+
const lineHeight = element.lineHeight ?? 1.2;
|
|
659
|
+
let lines = wrapRichText(ctx, element.spans, contentWidth, lineHeight);
|
|
660
|
+
if (element.maxLines && lines.length > element.maxLines) {
|
|
661
|
+
lines = lines.slice(0, element.maxLines);
|
|
662
|
+
if (element.ellipsis && lines.length > 0) {
|
|
663
|
+
const lastLine = lines[lines.length - 1];
|
|
664
|
+
if (lastLine.segments.length > 0) {
|
|
665
|
+
const lastSeg = lastLine.segments[lastLine.segments.length - 1];
|
|
666
|
+
lastSeg.text += "...";
|
|
667
|
+
lastSeg.width = ctx.measureText(lastSeg.text, lastSeg.font ?? {}).width;
|
|
668
|
+
lastLine.width = lastLine.segments.reduce((sum, s) => sum + s.width, 0);
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
node.richLines = lines;
|
|
673
|
+
}
|
|
485
674
|
if (element.type === "box" || element.type === "stack") {
|
|
486
675
|
const children = element.children ?? [];
|
|
487
676
|
if (element.type === "stack") {
|
|
@@ -925,6 +1114,57 @@ function renderImage(ctx, node) {
|
|
|
925
1114
|
if (element.opacity !== void 0 && element.opacity < 1) ctx.globalAlpha = 1;
|
|
926
1115
|
}
|
|
927
1116
|
|
|
1117
|
+
//#endregion
|
|
1118
|
+
//#region src/render/components/richtext.ts
|
|
1119
|
+
function renderRichText(ctx, node) {
|
|
1120
|
+
const element = node.element;
|
|
1121
|
+
const { contentX, contentY, contentWidth, contentHeight } = node.layout;
|
|
1122
|
+
const lines = node.richLines ?? [];
|
|
1123
|
+
if (lines.length === 0) return;
|
|
1124
|
+
const totalTextHeight = lines.reduce((sum, line) => sum + line.height, 0);
|
|
1125
|
+
let verticalOffset = 0;
|
|
1126
|
+
if (element.verticalAlign === "middle") verticalOffset = (contentHeight - totalTextHeight) / 2;
|
|
1127
|
+
else if (element.verticalAlign === "bottom") verticalOffset = contentHeight - totalTextHeight;
|
|
1128
|
+
let currentY = contentY + verticalOffset;
|
|
1129
|
+
for (const line of lines) {
|
|
1130
|
+
let lineX = contentX;
|
|
1131
|
+
if (element.align === "center") lineX = contentX + (contentWidth - line.width) / 2;
|
|
1132
|
+
else if (element.align === "right") lineX = contentX + (contentWidth - line.width);
|
|
1133
|
+
const baselineY = currentY + line.baseline;
|
|
1134
|
+
for (const seg of line.segments) {
|
|
1135
|
+
ctx.save();
|
|
1136
|
+
ctx.font = buildFontString(seg.font ?? {});
|
|
1137
|
+
if (seg.background) {
|
|
1138
|
+
ctx.fillStyle = resolveColor$1(ctx, seg.background, lineX, currentY, seg.width, line.height);
|
|
1139
|
+
ctx.fillRect(lineX, currentY, seg.width, line.height);
|
|
1140
|
+
}
|
|
1141
|
+
ctx.fillStyle = seg.color ? resolveColor$1(ctx, seg.color, lineX, currentY, seg.width, line.height) : "#000";
|
|
1142
|
+
ctx.textBaseline = "middle";
|
|
1143
|
+
ctx.fillText(seg.text, lineX, baselineY - seg.offset);
|
|
1144
|
+
if (seg.underline) {
|
|
1145
|
+
ctx.beginPath();
|
|
1146
|
+
ctx.strokeStyle = ctx.fillStyle;
|
|
1147
|
+
ctx.lineWidth = 1;
|
|
1148
|
+
ctx.moveTo(lineX, currentY + seg.height);
|
|
1149
|
+
ctx.lineTo(lineX + seg.width, currentY + seg.height);
|
|
1150
|
+
ctx.stroke();
|
|
1151
|
+
}
|
|
1152
|
+
if (seg.strikethrough) {
|
|
1153
|
+
ctx.beginPath();
|
|
1154
|
+
ctx.strokeStyle = ctx.fillStyle;
|
|
1155
|
+
ctx.lineWidth = 1;
|
|
1156
|
+
const strikeY = currentY + seg.height / 2 + seg.offset;
|
|
1157
|
+
ctx.moveTo(lineX, strikeY);
|
|
1158
|
+
ctx.lineTo(lineX + seg.width, strikeY);
|
|
1159
|
+
ctx.stroke();
|
|
1160
|
+
}
|
|
1161
|
+
ctx.restore();
|
|
1162
|
+
lineX += seg.width;
|
|
1163
|
+
}
|
|
1164
|
+
currentY += line.height;
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
|
|
928
1168
|
//#endregion
|
|
929
1169
|
//#region src/compat/DOMMatrix.ts
|
|
930
1170
|
const DOMMatrixCompat = (() => {
|
|
@@ -1270,6 +1510,9 @@ function renderNode(ctx, node) {
|
|
|
1270
1510
|
case "text":
|
|
1271
1511
|
renderText(ctx, node);
|
|
1272
1512
|
break;
|
|
1513
|
+
case "richtext":
|
|
1514
|
+
renderRichText(ctx, node);
|
|
1515
|
+
break;
|
|
1273
1516
|
case "image":
|
|
1274
1517
|
renderImage(ctx, node);
|
|
1275
1518
|
break;
|
package/render.mjs
CHANGED
|
@@ -200,6 +200,174 @@ function measureImageSize(element, _ctx, _availableWidth) {
|
|
|
200
200
|
};
|
|
201
201
|
}
|
|
202
202
|
|
|
203
|
+
//#endregion
|
|
204
|
+
//#region src/layout/components/richtext.ts
|
|
205
|
+
/**
|
|
206
|
+
* 合并 span 样式和元素级别样式
|
|
207
|
+
* 优先级:span 样式 > 元素样式 > 默认值
|
|
208
|
+
* font 属性进行深度合并,允许 span 部分覆盖 element 的 font
|
|
209
|
+
*/
|
|
210
|
+
function mergeSpanStyle(span, elementStyle) {
|
|
211
|
+
return {
|
|
212
|
+
font: {
|
|
213
|
+
...elementStyle.font || {},
|
|
214
|
+
...span.font || {}
|
|
215
|
+
},
|
|
216
|
+
color: span.color ?? elementStyle.color,
|
|
217
|
+
background: span.background ?? elementStyle.background,
|
|
218
|
+
underline: span.underline ?? elementStyle.underline ?? false,
|
|
219
|
+
strikethrough: span.strikethrough ?? elementStyle.strikethrough ?? false
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* 测量富文本元素的固有尺寸
|
|
224
|
+
*/
|
|
225
|
+
function measureRichTextSize(element, ctx, availableWidth) {
|
|
226
|
+
const lineHeight = element.lineHeight ?? 1.2;
|
|
227
|
+
const elementStyle = {
|
|
228
|
+
font: element.font,
|
|
229
|
+
color: element.color,
|
|
230
|
+
background: element.background,
|
|
231
|
+
underline: element.underline,
|
|
232
|
+
strikethrough: element.strikethrough
|
|
233
|
+
};
|
|
234
|
+
const richLines = wrapRichText(ctx, element.spans, availableWidth, lineHeight, elementStyle);
|
|
235
|
+
let maxWidth = 0;
|
|
236
|
+
let totalHeight = 0;
|
|
237
|
+
for (const line of richLines) {
|
|
238
|
+
maxWidth = Math.max(maxWidth, line.width);
|
|
239
|
+
totalHeight += line.height;
|
|
240
|
+
}
|
|
241
|
+
return {
|
|
242
|
+
width: maxWidth,
|
|
243
|
+
height: totalHeight
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* 将富文本内容拆分为行
|
|
248
|
+
*/
|
|
249
|
+
function wrapRichText(ctx, spans, maxWidth, lineHeightScale = 1.2, elementStyle = {}) {
|
|
250
|
+
const lines = [];
|
|
251
|
+
let currentSegments = [];
|
|
252
|
+
let currentLineWidth = 0;
|
|
253
|
+
const pushLine = () => {
|
|
254
|
+
if (currentSegments.length === 0) return;
|
|
255
|
+
let maxTopDist = 0;
|
|
256
|
+
let maxBottomDist = 0;
|
|
257
|
+
let maxLineHeight = 0;
|
|
258
|
+
for (const seg of currentSegments) {
|
|
259
|
+
const topDist = seg.ascent - seg.offset;
|
|
260
|
+
const bottomDist = seg.descent + seg.offset;
|
|
261
|
+
maxTopDist = Math.max(maxTopDist, topDist);
|
|
262
|
+
maxBottomDist = Math.max(maxBottomDist, bottomDist);
|
|
263
|
+
maxLineHeight = Math.max(maxLineHeight, seg.height);
|
|
264
|
+
}
|
|
265
|
+
const contentHeight = maxTopDist + maxBottomDist;
|
|
266
|
+
const finalHeight = Math.max(contentHeight, maxLineHeight);
|
|
267
|
+
const extra = (finalHeight - contentHeight) / 2;
|
|
268
|
+
lines.push({
|
|
269
|
+
segments: [...currentSegments],
|
|
270
|
+
width: currentLineWidth,
|
|
271
|
+
height: finalHeight,
|
|
272
|
+
baseline: maxTopDist + extra
|
|
273
|
+
});
|
|
274
|
+
currentSegments = [];
|
|
275
|
+
currentLineWidth = 0;
|
|
276
|
+
};
|
|
277
|
+
for (const span of spans) {
|
|
278
|
+
const mergedStyle = mergeSpanStyle(span, elementStyle);
|
|
279
|
+
const font = mergedStyle.font;
|
|
280
|
+
const lh = (font.size ?? 16) * lineHeightScale;
|
|
281
|
+
const words = span.text.split(/(\s+)/);
|
|
282
|
+
for (const word of words) {
|
|
283
|
+
if (word === "") continue;
|
|
284
|
+
if (/^\s+$/.test(word)) {
|
|
285
|
+
const metrics = ctx.measureText(word, font);
|
|
286
|
+
const wordWidth = metrics.width;
|
|
287
|
+
if (maxWidth > 0 && currentLineWidth + wordWidth > maxWidth && currentSegments.length > 0) pushLine();
|
|
288
|
+
currentSegments.push({
|
|
289
|
+
text: word,
|
|
290
|
+
font: mergedStyle.font,
|
|
291
|
+
color: mergedStyle.color,
|
|
292
|
+
background: mergedStyle.background,
|
|
293
|
+
underline: mergedStyle.underline,
|
|
294
|
+
strikethrough: mergedStyle.strikethrough,
|
|
295
|
+
width: wordWidth,
|
|
296
|
+
height: lh,
|
|
297
|
+
ascent: metrics.ascent,
|
|
298
|
+
descent: metrics.descent,
|
|
299
|
+
offset: metrics.offset
|
|
300
|
+
});
|
|
301
|
+
currentLineWidth += wordWidth;
|
|
302
|
+
} else {
|
|
303
|
+
const metrics = ctx.measureText(word, font);
|
|
304
|
+
const wordWidth = metrics.width;
|
|
305
|
+
if (maxWidth <= 0 || currentLineWidth + wordWidth <= maxWidth) {
|
|
306
|
+
currentSegments.push({
|
|
307
|
+
text: word,
|
|
308
|
+
font: mergedStyle.font,
|
|
309
|
+
color: mergedStyle.color,
|
|
310
|
+
background: mergedStyle.background,
|
|
311
|
+
underline: mergedStyle.underline,
|
|
312
|
+
strikethrough: mergedStyle.strikethrough,
|
|
313
|
+
width: wordWidth,
|
|
314
|
+
height: lh,
|
|
315
|
+
ascent: metrics.ascent,
|
|
316
|
+
descent: metrics.descent,
|
|
317
|
+
offset: metrics.offset
|
|
318
|
+
});
|
|
319
|
+
currentLineWidth += wordWidth;
|
|
320
|
+
} else {
|
|
321
|
+
if (currentSegments.length > 0) pushLine();
|
|
322
|
+
const remainingWidth = maxWidth;
|
|
323
|
+
let currentPos = 0;
|
|
324
|
+
while (currentPos < word.length) {
|
|
325
|
+
let bestLen = 0;
|
|
326
|
+
for (let len = word.length - currentPos; len > 0; len--) {
|
|
327
|
+
const substr = word.substring(currentPos, currentPos + len);
|
|
328
|
+
const m = ctx.measureText(substr, font);
|
|
329
|
+
if (currentLineWidth + m.width <= remainingWidth) {
|
|
330
|
+
bestLen = len;
|
|
331
|
+
if (len < word.length - currentPos) break;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
if (bestLen === 0) {
|
|
335
|
+
if (currentSegments.length > 0) pushLine();
|
|
336
|
+
bestLen = 1;
|
|
337
|
+
}
|
|
338
|
+
const substr = word.substring(currentPos, currentPos + bestLen);
|
|
339
|
+
const m = ctx.measureText(substr, font);
|
|
340
|
+
currentSegments.push({
|
|
341
|
+
text: substr,
|
|
342
|
+
font: mergedStyle.font,
|
|
343
|
+
color: mergedStyle.color,
|
|
344
|
+
background: mergedStyle.background,
|
|
345
|
+
underline: mergedStyle.underline,
|
|
346
|
+
strikethrough: mergedStyle.strikethrough,
|
|
347
|
+
width: m.width,
|
|
348
|
+
height: lh,
|
|
349
|
+
ascent: m.ascent,
|
|
350
|
+
descent: m.descent,
|
|
351
|
+
offset: m.offset
|
|
352
|
+
});
|
|
353
|
+
currentLineWidth += m.width;
|
|
354
|
+
currentPos += bestLen;
|
|
355
|
+
if (currentPos < word.length && currentLineWidth >= remainingWidth) pushLine();
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
pushLine();
|
|
362
|
+
if (lines.length === 0) return [{
|
|
363
|
+
segments: [],
|
|
364
|
+
width: 0,
|
|
365
|
+
height: 0,
|
|
366
|
+
baseline: 0
|
|
367
|
+
}];
|
|
368
|
+
return lines;
|
|
369
|
+
}
|
|
370
|
+
|
|
203
371
|
//#endregion
|
|
204
372
|
//#region src/layout/components/stack.ts
|
|
205
373
|
/**
|
|
@@ -277,10 +445,13 @@ function createCanvasMeasureContext(ctx) {
|
|
|
277
445
|
ctx.textBaseline = "middle";
|
|
278
446
|
const metrics = ctx.measureText(text);
|
|
279
447
|
const height = metrics.actualBoundingBoxAscent + metrics.actualBoundingBoxDescent;
|
|
448
|
+
const fontSize = font.size || 16;
|
|
280
449
|
return {
|
|
281
450
|
width: metrics.width,
|
|
282
|
-
height: height ||
|
|
283
|
-
offset: (metrics.actualBoundingBoxAscent - metrics.actualBoundingBoxDescent) / 2
|
|
451
|
+
height: height || fontSize,
|
|
452
|
+
offset: (metrics.actualBoundingBoxAscent - metrics.actualBoundingBoxDescent) / 2,
|
|
453
|
+
ascent: metrics.actualBoundingBoxAscent,
|
|
454
|
+
descent: metrics.actualBoundingBoxDescent
|
|
284
455
|
};
|
|
285
456
|
} };
|
|
286
457
|
}
|
|
@@ -392,6 +563,7 @@ function measureTextSize(element, ctx, availableWidth) {
|
|
|
392
563
|
function measureIntrinsicSize(element, ctx, availableWidth) {
|
|
393
564
|
switch (element.type) {
|
|
394
565
|
case "text": return measureTextSize(element, ctx, availableWidth);
|
|
566
|
+
case "richtext": return measureRichTextSize(element, ctx, availableWidth);
|
|
395
567
|
case "box": return measureBoxSize(element, ctx, availableWidth, measureIntrinsicSize);
|
|
396
568
|
case "stack": return measureStackSize(element, ctx, availableWidth, measureIntrinsicSize);
|
|
397
569
|
case "image": return measureImageSize(element, ctx, availableWidth);
|
|
@@ -487,6 +659,23 @@ function computeLayout(element, ctx, constraints, x = 0, y = 0) {
|
|
|
487
659
|
node.lineOffsets = [offset];
|
|
488
660
|
}
|
|
489
661
|
}
|
|
662
|
+
if (element.type === "richtext") {
|
|
663
|
+
const lineHeight = element.lineHeight ?? 1.2;
|
|
664
|
+
let lines = wrapRichText(ctx, element.spans, contentWidth, lineHeight);
|
|
665
|
+
if (element.maxLines && lines.length > element.maxLines) {
|
|
666
|
+
lines = lines.slice(0, element.maxLines);
|
|
667
|
+
if (element.ellipsis && lines.length > 0) {
|
|
668
|
+
const lastLine = lines[lines.length - 1];
|
|
669
|
+
if (lastLine.segments.length > 0) {
|
|
670
|
+
const lastSeg = lastLine.segments[lastLine.segments.length - 1];
|
|
671
|
+
lastSeg.text += "...";
|
|
672
|
+
lastSeg.width = ctx.measureText(lastSeg.text, lastSeg.font ?? {}).width;
|
|
673
|
+
lastLine.width = lastLine.segments.reduce((sum, s) => sum + s.width, 0);
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
node.richLines = lines;
|
|
678
|
+
}
|
|
490
679
|
if (element.type === "box" || element.type === "stack") {
|
|
491
680
|
const children = element.children ?? [];
|
|
492
681
|
if (element.type === "stack") {
|
|
@@ -930,6 +1119,57 @@ function renderImage(ctx, node) {
|
|
|
930
1119
|
if (element.opacity !== void 0 && element.opacity < 1) ctx.globalAlpha = 1;
|
|
931
1120
|
}
|
|
932
1121
|
|
|
1122
|
+
//#endregion
|
|
1123
|
+
//#region src/render/components/richtext.ts
|
|
1124
|
+
function renderRichText(ctx, node) {
|
|
1125
|
+
const element = node.element;
|
|
1126
|
+
const { contentX, contentY, contentWidth, contentHeight } = node.layout;
|
|
1127
|
+
const lines = node.richLines ?? [];
|
|
1128
|
+
if (lines.length === 0) return;
|
|
1129
|
+
const totalTextHeight = lines.reduce((sum, line) => sum + line.height, 0);
|
|
1130
|
+
let verticalOffset = 0;
|
|
1131
|
+
if (element.verticalAlign === "middle") verticalOffset = (contentHeight - totalTextHeight) / 2;
|
|
1132
|
+
else if (element.verticalAlign === "bottom") verticalOffset = contentHeight - totalTextHeight;
|
|
1133
|
+
let currentY = contentY + verticalOffset;
|
|
1134
|
+
for (const line of lines) {
|
|
1135
|
+
let lineX = contentX;
|
|
1136
|
+
if (element.align === "center") lineX = contentX + (contentWidth - line.width) / 2;
|
|
1137
|
+
else if (element.align === "right") lineX = contentX + (contentWidth - line.width);
|
|
1138
|
+
const baselineY = currentY + line.baseline;
|
|
1139
|
+
for (const seg of line.segments) {
|
|
1140
|
+
ctx.save();
|
|
1141
|
+
ctx.font = buildFontString(seg.font ?? {});
|
|
1142
|
+
if (seg.background) {
|
|
1143
|
+
ctx.fillStyle = resolveColor$1(ctx, seg.background, lineX, currentY, seg.width, line.height);
|
|
1144
|
+
ctx.fillRect(lineX, currentY, seg.width, line.height);
|
|
1145
|
+
}
|
|
1146
|
+
ctx.fillStyle = seg.color ? resolveColor$1(ctx, seg.color, lineX, currentY, seg.width, line.height) : "#000";
|
|
1147
|
+
ctx.textBaseline = "middle";
|
|
1148
|
+
ctx.fillText(seg.text, lineX, baselineY - seg.offset);
|
|
1149
|
+
if (seg.underline) {
|
|
1150
|
+
ctx.beginPath();
|
|
1151
|
+
ctx.strokeStyle = ctx.fillStyle;
|
|
1152
|
+
ctx.lineWidth = 1;
|
|
1153
|
+
ctx.moveTo(lineX, currentY + seg.height);
|
|
1154
|
+
ctx.lineTo(lineX + seg.width, currentY + seg.height);
|
|
1155
|
+
ctx.stroke();
|
|
1156
|
+
}
|
|
1157
|
+
if (seg.strikethrough) {
|
|
1158
|
+
ctx.beginPath();
|
|
1159
|
+
ctx.strokeStyle = ctx.fillStyle;
|
|
1160
|
+
ctx.lineWidth = 1;
|
|
1161
|
+
const strikeY = currentY + seg.height / 2 + seg.offset;
|
|
1162
|
+
ctx.moveTo(lineX, strikeY);
|
|
1163
|
+
ctx.lineTo(lineX + seg.width, strikeY);
|
|
1164
|
+
ctx.stroke();
|
|
1165
|
+
}
|
|
1166
|
+
ctx.restore();
|
|
1167
|
+
lineX += seg.width;
|
|
1168
|
+
}
|
|
1169
|
+
currentY += line.height;
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
|
|
933
1173
|
//#endregion
|
|
934
1174
|
//#region src/compat/DOMMatrix.ts
|
|
935
1175
|
const DOMMatrixCompat = (() => {
|
|
@@ -1275,6 +1515,9 @@ function renderNode(ctx, node) {
|
|
|
1275
1515
|
case "text":
|
|
1276
1516
|
renderText(ctx, node);
|
|
1277
1517
|
break;
|
|
1518
|
+
case "richtext":
|
|
1519
|
+
renderRichText(ctx, node);
|
|
1520
|
+
break;
|
|
1278
1521
|
case "image":
|
|
1279
1522
|
renderImage(ctx, node);
|
|
1280
1523
|
break;
|