uicore-ts 1.1.52 → 1.1.56
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/compiledScripts/UIRectangle.d.ts +12 -8
- package/compiledScripts/UIRectangle.js +43 -35
- package/compiledScripts/UIRectangle.js.map +2 -2
- package/compiledScripts/UITextMeasurement.d.ts +65 -0
- package/compiledScripts/UITextMeasurement.js +268 -0
- package/compiledScripts/UITextMeasurement.js.map +7 -0
- package/compiledScripts/UITextView.d.ts +5 -0
- package/compiledScripts/UITextView.js +45 -0
- package/compiledScripts/UITextView.js.map +2 -2
- package/compiledScripts/UIView.d.ts +1 -1
- package/compiledScripts/UIView.js.map +2 -2
- package/package.json +1 -1
- package/scripts/UIRectangle.ts +74 -61
- package/scripts/UITextMeasurement.ts +399 -0
- package/scripts/UITextView.ts +73 -9
- package/scripts/UIView.ts +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "uicore-ts",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.56",
|
|
4
4
|
"description": "UICore is a library to build native-like user interfaces using pure Typescript. No HTML is needed at all. Components are described as TS classes and all user interactions are handled explicitly. This library is strongly inspired by the UIKit framework that is used in IOS. In addition, UICore has tools to handle URL based routing, array sorting and filtering and adds a number of other utilities for convenience.",
|
|
5
5
|
"main": "compiledScripts/index.js",
|
|
6
6
|
"types": "compiledScripts/index.d.ts",
|
package/scripts/UIRectangle.ts
CHANGED
|
@@ -3,6 +3,8 @@ import { UIPoint } from "./UIPoint"
|
|
|
3
3
|
import { UIView } from "./UIView"
|
|
4
4
|
|
|
5
5
|
|
|
6
|
+
type SizeNumberOrFunctionOrView = number | ((constrainingOrthogonalSize: number) => number) | UIView
|
|
7
|
+
|
|
6
8
|
export class UIRectangle extends UIObject {
|
|
7
9
|
|
|
8
10
|
_isBeingUpdated: boolean
|
|
@@ -10,9 +12,10 @@ export class UIRectangle extends UIObject {
|
|
|
10
12
|
max: UIPoint
|
|
11
13
|
min: UIPoint
|
|
12
14
|
|
|
15
|
+
// The min and max values are just for storage.
|
|
16
|
+
// You need to call rectangleByEnforcingMinAndMaxSizes to make use of them.
|
|
13
17
|
minHeight?: number
|
|
14
18
|
maxHeight?: number
|
|
15
|
-
|
|
16
19
|
minWidth?: number
|
|
17
20
|
maxWidth?: number
|
|
18
21
|
|
|
@@ -456,9 +459,9 @@ export class UIRectangle extends UIObject {
|
|
|
456
459
|
|
|
457
460
|
|
|
458
461
|
rectanglesBySplittingWidth(
|
|
459
|
-
weights:
|
|
460
|
-
paddings:
|
|
461
|
-
absoluteWidths:
|
|
462
|
+
weights: SizeNumberOrFunctionOrView[],
|
|
463
|
+
paddings: SizeNumberOrFunctionOrView | SizeNumberOrFunctionOrView[] = 0,
|
|
464
|
+
absoluteWidths: SizeNumberOrFunctionOrView | SizeNumberOrFunctionOrView[] = nil
|
|
462
465
|
) {
|
|
463
466
|
|
|
464
467
|
if (IS_NIL(paddings)) {
|
|
@@ -467,13 +470,18 @@ export class UIRectangle extends UIObject {
|
|
|
467
470
|
if (!((paddings as any) instanceof Array)) {
|
|
468
471
|
paddings = [paddings].arrayByRepeating(weights.length - 1)
|
|
469
472
|
}
|
|
470
|
-
paddings = (paddings as
|
|
473
|
+
paddings = (paddings as any[]).arrayByTrimmingToLengthIfLonger(weights.length - 1)
|
|
474
|
+
paddings = paddings.map(padding => this._widthNumberFromSizeNumberOrFunctionOrView(padding))
|
|
471
475
|
if (!(absoluteWidths instanceof Array) && IS_NOT_NIL(absoluteWidths)) {
|
|
472
476
|
absoluteWidths = [absoluteWidths].arrayByRepeating(weights.length)
|
|
473
477
|
}
|
|
478
|
+
absoluteWidths = absoluteWidths.map(
|
|
479
|
+
width => this._widthNumberFromSizeNumberOrFunctionOrView(width)
|
|
480
|
+
)
|
|
474
481
|
|
|
482
|
+
weights = weights.map(weight => this._widthNumberFromSizeNumberOrFunctionOrView(weight))
|
|
475
483
|
const result: UIRectangle[] = []
|
|
476
|
-
const sumOfWeights = weights.reduce(
|
|
484
|
+
const sumOfWeights = (weights as number[]).reduce(
|
|
477
485
|
(a, b, index) => {
|
|
478
486
|
if (IS_NOT_NIL((absoluteWidths as number[])[index])) {
|
|
479
487
|
b = 0
|
|
@@ -482,7 +490,7 @@ export class UIRectangle extends UIObject {
|
|
|
482
490
|
},
|
|
483
491
|
0
|
|
484
492
|
)
|
|
485
|
-
const sumOfPaddings = paddings.summedValue
|
|
493
|
+
const sumOfPaddings = paddings.summedValue as number
|
|
486
494
|
const sumOfAbsoluteWidths = (absoluteWidths as number[]).summedValue
|
|
487
495
|
const totalRelativeWidth = this.width - sumOfPaddings - sumOfAbsoluteWidths
|
|
488
496
|
let previousCellMaxX = this.x
|
|
@@ -491,17 +499,17 @@ export class UIRectangle extends UIObject {
|
|
|
491
499
|
|
|
492
500
|
let resultWidth: number
|
|
493
501
|
if (IS_NOT_NIL(absoluteWidths[i])) {
|
|
494
|
-
resultWidth = absoluteWidths[i] || 0
|
|
502
|
+
resultWidth = (absoluteWidths[i] || 0) as number
|
|
495
503
|
}
|
|
496
504
|
else {
|
|
497
|
-
resultWidth = totalRelativeWidth * (weights[i] / sumOfWeights)
|
|
505
|
+
resultWidth = totalRelativeWidth * (weights[i] as number / sumOfWeights)
|
|
498
506
|
}
|
|
499
507
|
|
|
500
508
|
const rectangle = this.rectangleWithWidth(resultWidth)
|
|
501
509
|
|
|
502
510
|
let padding = 0
|
|
503
511
|
if (paddings.length > i && paddings[i]) {
|
|
504
|
-
padding = paddings[i]
|
|
512
|
+
padding = paddings[i] as number
|
|
505
513
|
}
|
|
506
514
|
|
|
507
515
|
rectangle.x = previousCellMaxX
|
|
@@ -515,9 +523,9 @@ export class UIRectangle extends UIObject {
|
|
|
515
523
|
}
|
|
516
524
|
|
|
517
525
|
rectanglesBySplittingHeight(
|
|
518
|
-
weights:
|
|
519
|
-
paddings:
|
|
520
|
-
absoluteHeights:
|
|
526
|
+
weights: SizeNumberOrFunctionOrView[],
|
|
527
|
+
paddings: SizeNumberOrFunctionOrView | SizeNumberOrFunctionOrView[] = 0,
|
|
528
|
+
absoluteHeights: SizeNumberOrFunctionOrView | SizeNumberOrFunctionOrView[] = nil
|
|
521
529
|
) {
|
|
522
530
|
|
|
523
531
|
if (IS_NIL(paddings)) {
|
|
@@ -527,12 +535,17 @@ export class UIRectangle extends UIObject {
|
|
|
527
535
|
paddings = [paddings].arrayByRepeating(weights.length - 1)
|
|
528
536
|
}
|
|
529
537
|
paddings = (paddings as number[]).arrayByTrimmingToLengthIfLonger(weights.length - 1)
|
|
538
|
+
paddings = paddings.map(padding => this._heightNumberFromSizeNumberOrFunctionOrView(padding))
|
|
530
539
|
if (!(absoluteHeights instanceof Array) && IS_NOT_NIL(absoluteHeights)) {
|
|
531
540
|
absoluteHeights = [absoluteHeights].arrayByRepeating(weights.length)
|
|
532
541
|
}
|
|
542
|
+
absoluteHeights = absoluteHeights.map(
|
|
543
|
+
height => this._heightNumberFromSizeNumberOrFunctionOrView(height)
|
|
544
|
+
)
|
|
533
545
|
|
|
546
|
+
weights = weights.map(weight => this._heightNumberFromSizeNumberOrFunctionOrView(weight))
|
|
534
547
|
const result: UIRectangle[] = []
|
|
535
|
-
const sumOfWeights = weights.reduce(
|
|
548
|
+
const sumOfWeights = (weights as number[]).reduce(
|
|
536
549
|
(a, b, index) => {
|
|
537
550
|
if (IS_NOT_NIL((absoluteHeights as number[])[index])) {
|
|
538
551
|
b = 0
|
|
@@ -541,29 +554,29 @@ export class UIRectangle extends UIObject {
|
|
|
541
554
|
},
|
|
542
555
|
0
|
|
543
556
|
)
|
|
544
|
-
const sumOfPaddings = paddings.summedValue
|
|
557
|
+
const sumOfPaddings = paddings.summedValue as number
|
|
545
558
|
const sumOfAbsoluteHeights = (absoluteHeights as number[]).summedValue
|
|
546
559
|
const totalRelativeHeight = this.height - sumOfPaddings - sumOfAbsoluteHeights
|
|
547
|
-
|
|
560
|
+
let previousCellMaxY = this.y
|
|
548
561
|
|
|
549
|
-
for (
|
|
550
|
-
|
|
562
|
+
for (let i = 0; i < weights.length; i++) {
|
|
563
|
+
let resultHeight: number
|
|
551
564
|
if (IS_NOT_NIL(absoluteHeights[i])) {
|
|
552
565
|
|
|
553
|
-
resultHeight = absoluteHeights[i] || 0
|
|
566
|
+
resultHeight = (absoluteHeights[i] || 0) as number
|
|
554
567
|
|
|
555
568
|
}
|
|
556
569
|
else {
|
|
557
570
|
|
|
558
|
-
resultHeight = totalRelativeHeight * (weights[i] / sumOfWeights)
|
|
571
|
+
resultHeight = totalRelativeHeight * (weights[i] as number / sumOfWeights)
|
|
559
572
|
|
|
560
573
|
}
|
|
561
574
|
|
|
562
575
|
const rectangle = this.rectangleWithHeight(resultHeight)
|
|
563
576
|
|
|
564
|
-
|
|
577
|
+
let padding = 0
|
|
565
578
|
if (paddings.length > i && paddings[i]) {
|
|
566
|
-
padding = paddings[i]
|
|
579
|
+
padding = paddings[i] as number
|
|
567
580
|
}
|
|
568
581
|
|
|
569
582
|
rectangle.y = previousCellMaxY
|
|
@@ -602,9 +615,9 @@ export class UIRectangle extends UIObject {
|
|
|
602
615
|
|
|
603
616
|
distributeViewsAlongWidth(
|
|
604
617
|
views: UIView[],
|
|
605
|
-
weights:
|
|
606
|
-
paddings?:
|
|
607
|
-
absoluteWidths?:
|
|
618
|
+
weights: SizeNumberOrFunctionOrView | SizeNumberOrFunctionOrView[] = 1,
|
|
619
|
+
paddings?: SizeNumberOrFunctionOrView | SizeNumberOrFunctionOrView[],
|
|
620
|
+
absoluteWidths?: SizeNumberOrFunctionOrView | SizeNumberOrFunctionOrView[]
|
|
608
621
|
) {
|
|
609
622
|
if (!(weights instanceof Array)) {
|
|
610
623
|
weights = [weights].arrayByRepeating(views.length)
|
|
@@ -616,9 +629,9 @@ export class UIRectangle extends UIObject {
|
|
|
616
629
|
|
|
617
630
|
distributeViewsAlongHeight(
|
|
618
631
|
views: UIView[],
|
|
619
|
-
weights:
|
|
620
|
-
paddings?:
|
|
621
|
-
absoluteHeights?:
|
|
632
|
+
weights: SizeNumberOrFunctionOrView | SizeNumberOrFunctionOrView[] = 1,
|
|
633
|
+
paddings?: SizeNumberOrFunctionOrView | SizeNumberOrFunctionOrView[],
|
|
634
|
+
absoluteHeights?: SizeNumberOrFunctionOrView | SizeNumberOrFunctionOrView[]
|
|
622
635
|
) {
|
|
623
636
|
if (!(weights instanceof Array)) {
|
|
624
637
|
weights = [weights].arrayByRepeating(views.length)
|
|
@@ -642,58 +655,58 @@ export class UIRectangle extends UIObject {
|
|
|
642
655
|
}
|
|
643
656
|
|
|
644
657
|
|
|
645
|
-
|
|
658
|
+
_heightNumberFromSizeNumberOrFunctionOrView(height: SizeNumberOrFunctionOrView) {
|
|
646
659
|
if (height instanceof Function) {
|
|
647
|
-
|
|
660
|
+
return height(this.width)
|
|
648
661
|
}
|
|
649
662
|
if (height instanceof UIView) {
|
|
650
|
-
|
|
663
|
+
return height.intrinsicContentHeight(this.width)
|
|
651
664
|
}
|
|
652
|
-
|
|
653
|
-
if (height != this.height) {
|
|
654
|
-
result.height = height
|
|
655
|
-
}
|
|
656
|
-
return result
|
|
665
|
+
return height
|
|
657
666
|
}
|
|
658
667
|
|
|
659
|
-
|
|
668
|
+
_widthNumberFromSizeNumberOrFunctionOrView(width: SizeNumberOrFunctionOrView) {
|
|
660
669
|
if (width instanceof Function) {
|
|
661
|
-
|
|
670
|
+
return width(this.height)
|
|
662
671
|
}
|
|
663
672
|
if (width instanceof UIView) {
|
|
664
|
-
|
|
673
|
+
return width.intrinsicContentWidth(this.height)
|
|
665
674
|
}
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
675
|
+
return width
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
rectangleForNextRow(padding: number = 0, height: SizeNumberOrFunctionOrView = this.height) {
|
|
679
|
+
const heightNumber = this._heightNumberFromSizeNumberOrFunctionOrView(height)
|
|
680
|
+
const result = this.rectangleWithY(this.max.y + padding)
|
|
681
|
+
if (heightNumber != this.height) {
|
|
682
|
+
result.height = heightNumber
|
|
669
683
|
}
|
|
670
684
|
return result
|
|
671
685
|
}
|
|
672
686
|
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
height = height.intrinsicContentHeight(this.width)
|
|
679
|
-
}
|
|
680
|
-
const result = this.rectangleWithY(this.min.y - height - padding)
|
|
681
|
-
if (height != this.height) {
|
|
682
|
-
result.height = height
|
|
687
|
+
rectangleForNextColumn(padding: number = 0, width: SizeNumberOrFunctionOrView = this.width) {
|
|
688
|
+
const widthNumber = this._widthNumberFromSizeNumberOrFunctionOrView(width)
|
|
689
|
+
const result = this.rectangleWithX(this.max.x + padding)
|
|
690
|
+
if (widthNumber != this.width) {
|
|
691
|
+
result.width = widthNumber
|
|
683
692
|
}
|
|
684
693
|
return result
|
|
685
694
|
}
|
|
686
695
|
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
width = width.intrinsicContentWidth(this.height)
|
|
696
|
+
rectangleForPreviousRow(padding: number = 0, height: SizeNumberOrFunctionOrView = this.height) {
|
|
697
|
+
const heightNumber = this._heightNumberFromSizeNumberOrFunctionOrView(height)
|
|
698
|
+
const result = this.rectangleWithY(this.min.y - heightNumber - padding)
|
|
699
|
+
if (heightNumber != this.height) {
|
|
700
|
+
result.height = heightNumber
|
|
693
701
|
}
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
702
|
+
return result
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
rectangleForPreviousColumn(padding: number = 0, width: SizeNumberOrFunctionOrView = this.width) {
|
|
706
|
+
const widthNumber = this._widthNumberFromSizeNumberOrFunctionOrView(width)
|
|
707
|
+
const result = this.rectangleWithX(this.min.x - widthNumber - padding)
|
|
708
|
+
if (widthNumber != this.width) {
|
|
709
|
+
result.width = widthNumber
|
|
697
710
|
}
|
|
698
711
|
return result
|
|
699
712
|
|
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
// UITextMeasurement.ts - Efficient text measurement without DOM reflows
|
|
2
|
+
|
|
3
|
+
export class UITextMeasurement {
|
|
4
|
+
|
|
5
|
+
private static canvas: HTMLCanvasElement | null = null;
|
|
6
|
+
private static context: CanvasRenderingContext2D | null = null;
|
|
7
|
+
|
|
8
|
+
// Cache for computed font strings to avoid repeated style parsing
|
|
9
|
+
private static fontCache = new Map<string, string>();
|
|
10
|
+
|
|
11
|
+
// Cache for line height calculations
|
|
12
|
+
private static lineHeightCache = new Map<string, number>();
|
|
13
|
+
|
|
14
|
+
// Temporary element for complex HTML measurements (reused to avoid allocations)
|
|
15
|
+
private static measurementElement: HTMLDivElement | null = null;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Get or create the canvas context for text measurement
|
|
19
|
+
*/
|
|
20
|
+
private static getContext(): CanvasRenderingContext2D {
|
|
21
|
+
if (!this.context) {
|
|
22
|
+
this.canvas = document.createElement('canvas');
|
|
23
|
+
this.context = this.canvas.getContext('2d')!;
|
|
24
|
+
}
|
|
25
|
+
return this.context;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Detect if content is plain text or complex HTML
|
|
30
|
+
*/
|
|
31
|
+
private static isPlainText(content: string): boolean {
|
|
32
|
+
// Check for HTML tags (excluding simple formatting like <b>, <i>, <span>)
|
|
33
|
+
const hasComplexHTML = /<(?!\/?(b|i|em|strong|span|br)\b)[^>]+>/i.test(content);
|
|
34
|
+
return !hasComplexHTML;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Check if content has only simple inline formatting
|
|
39
|
+
*/
|
|
40
|
+
private static hasSimpleFormatting(content: string): boolean {
|
|
41
|
+
// Only <b>, <i>, <strong>, <em>, <span> with inline styles
|
|
42
|
+
const simpleTagPattern = /^[^<]*(?:<\/?(?:b|i|em|strong|span)(?:\s+style="[^"]*")?>[^<]*)*$/i;
|
|
43
|
+
return simpleTagPattern.test(content);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Get or create measurement element for complex HTML
|
|
48
|
+
*/
|
|
49
|
+
private static getMeasurementElement(): HTMLDivElement {
|
|
50
|
+
if (!this.measurementElement) {
|
|
51
|
+
this.measurementElement = document.createElement('div');
|
|
52
|
+
this.measurementElement.style.cssText = `
|
|
53
|
+
position: absolute;
|
|
54
|
+
visibility: hidden;
|
|
55
|
+
pointer-events: none;
|
|
56
|
+
top: -9999px;
|
|
57
|
+
left: -9999px;
|
|
58
|
+
width: auto;
|
|
59
|
+
height: auto;
|
|
60
|
+
`;
|
|
61
|
+
}
|
|
62
|
+
return this.measurementElement;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Fast measurement using DOM (but optimized to minimize reflows)
|
|
67
|
+
*/
|
|
68
|
+
private static measureWithDOM(
|
|
69
|
+
element: HTMLElement,
|
|
70
|
+
content: string,
|
|
71
|
+
constrainingWidth?: number,
|
|
72
|
+
constrainingHeight?: number
|
|
73
|
+
): { width: number; height: number } {
|
|
74
|
+
const measureEl = this.getMeasurementElement();
|
|
75
|
+
const style = window.getComputedStyle(element);
|
|
76
|
+
|
|
77
|
+
// Copy relevant styles
|
|
78
|
+
measureEl.style.font = this.getFontString(element);
|
|
79
|
+
measureEl.style.lineHeight = style.lineHeight;
|
|
80
|
+
measureEl.style.whiteSpace = style.whiteSpace;
|
|
81
|
+
measureEl.style.wordBreak = style.wordBreak;
|
|
82
|
+
measureEl.style.wordWrap = style.wordWrap;
|
|
83
|
+
measureEl.style.padding = style.padding;
|
|
84
|
+
measureEl.style.border = style.border;
|
|
85
|
+
measureEl.style.boxSizing = style.boxSizing;
|
|
86
|
+
|
|
87
|
+
// Set constraints
|
|
88
|
+
if (constrainingWidth) {
|
|
89
|
+
measureEl.style.width = constrainingWidth + 'px';
|
|
90
|
+
measureEl.style.maxWidth = constrainingWidth + 'px';
|
|
91
|
+
} else {
|
|
92
|
+
measureEl.style.width = 'auto';
|
|
93
|
+
measureEl.style.maxWidth = 'none';
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (constrainingHeight) {
|
|
97
|
+
measureEl.style.height = constrainingHeight + 'px';
|
|
98
|
+
measureEl.style.maxHeight = constrainingHeight + 'px';
|
|
99
|
+
} else {
|
|
100
|
+
measureEl.style.height = 'auto';
|
|
101
|
+
measureEl.style.maxHeight = 'none';
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Set content
|
|
105
|
+
measureEl.innerHTML = content;
|
|
106
|
+
|
|
107
|
+
// Add to DOM only if not already there
|
|
108
|
+
if (!measureEl.parentElement) {
|
|
109
|
+
document.body.appendChild(measureEl);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Single reflow for both measurements
|
|
113
|
+
const rect = measureEl.getBoundingClientRect();
|
|
114
|
+
const result = {
|
|
115
|
+
width: rect.width || measureEl.scrollWidth,
|
|
116
|
+
height: rect.height || measureEl.scrollHeight
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
return result;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Extract font properties from computed style and create font string
|
|
124
|
+
*/
|
|
125
|
+
private static getFontString(element: HTMLElement): string {
|
|
126
|
+
const cacheKey = element.className + element.id;
|
|
127
|
+
|
|
128
|
+
if (this.fontCache.has(cacheKey)) {
|
|
129
|
+
return this.fontCache.get(cacheKey)!;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const style = window.getComputedStyle(element);
|
|
133
|
+
const font = [
|
|
134
|
+
style.fontStyle,
|
|
135
|
+
style.fontVariant,
|
|
136
|
+
style.fontWeight,
|
|
137
|
+
style.fontSize,
|
|
138
|
+
style.fontFamily
|
|
139
|
+
].join(' ');
|
|
140
|
+
|
|
141
|
+
this.fontCache.set(cacheKey, font);
|
|
142
|
+
return font;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Get line height in pixels
|
|
147
|
+
*/
|
|
148
|
+
private static getLineHeight(element: HTMLElement, fontSize: number): number {
|
|
149
|
+
const style = window.getComputedStyle(element);
|
|
150
|
+
const lineHeight = style.lineHeight;
|
|
151
|
+
|
|
152
|
+
if (lineHeight === 'normal') {
|
|
153
|
+
// Browsers typically use 1.2 as default line height
|
|
154
|
+
return fontSize * 1.2;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (lineHeight.endsWith('px')) {
|
|
158
|
+
return parseFloat(lineHeight);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// If it's a unitless number, multiply by font size
|
|
162
|
+
const numericLineHeight = parseFloat(lineHeight);
|
|
163
|
+
if (!isNaN(numericLineHeight)) {
|
|
164
|
+
return fontSize * numericLineHeight;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return fontSize * 1.2; // fallback
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Measure text width using Canvas API
|
|
172
|
+
*/
|
|
173
|
+
static measureTextWidth(text: string, font: string): number {
|
|
174
|
+
const ctx = this.getContext();
|
|
175
|
+
ctx.font = font;
|
|
176
|
+
return ctx.measureText(text).width;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Split text into lines based on width constraint
|
|
181
|
+
*/
|
|
182
|
+
private static wrapText(
|
|
183
|
+
text: string,
|
|
184
|
+
maxWidth: number,
|
|
185
|
+
font: string,
|
|
186
|
+
whiteSpace: string
|
|
187
|
+
): string[] {
|
|
188
|
+
// No wrapping needed
|
|
189
|
+
if (whiteSpace === 'nowrap' || whiteSpace === 'pre') {
|
|
190
|
+
return [text];
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const ctx = this.getContext();
|
|
194
|
+
ctx.font = font;
|
|
195
|
+
|
|
196
|
+
const lines: string[] = [];
|
|
197
|
+
const paragraphs = text.split('\n');
|
|
198
|
+
|
|
199
|
+
for (const paragraph of paragraphs) {
|
|
200
|
+
if (whiteSpace === 'pre-wrap') {
|
|
201
|
+
// Preserve whitespace but wrap at maxWidth
|
|
202
|
+
lines.push(...this.wrapPreservingWhitespace(paragraph, maxWidth, ctx));
|
|
203
|
+
} else {
|
|
204
|
+
// Normal wrapping (collapse whitespace)
|
|
205
|
+
lines.push(...this.wrapNormal(paragraph, maxWidth, ctx));
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return lines;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
private static wrapNormal(
|
|
213
|
+
text: string,
|
|
214
|
+
maxWidth: number,
|
|
215
|
+
ctx: CanvasRenderingContext2D
|
|
216
|
+
): string[] {
|
|
217
|
+
const words = text.split(/\s+/).filter(w => w.length > 0);
|
|
218
|
+
const lines: string[] = [];
|
|
219
|
+
let currentLine = '';
|
|
220
|
+
|
|
221
|
+
for (const word of words) {
|
|
222
|
+
const testLine = currentLine ? `${currentLine} ${word}` : word;
|
|
223
|
+
const metrics = ctx.measureText(testLine);
|
|
224
|
+
|
|
225
|
+
if (metrics.width > maxWidth && currentLine) {
|
|
226
|
+
lines.push(currentLine);
|
|
227
|
+
currentLine = word;
|
|
228
|
+
} else {
|
|
229
|
+
currentLine = testLine;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (currentLine) {
|
|
234
|
+
lines.push(currentLine);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return lines.length > 0 ? lines : [''];
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
private static wrapPreservingWhitespace(
|
|
241
|
+
text: string,
|
|
242
|
+
maxWidth: number,
|
|
243
|
+
ctx: CanvasRenderingContext2D
|
|
244
|
+
): string[] {
|
|
245
|
+
const lines: string[] = [];
|
|
246
|
+
let currentLine = '';
|
|
247
|
+
|
|
248
|
+
for (let i = 0; i < text.length; i++) {
|
|
249
|
+
const char = text[i];
|
|
250
|
+
const testLine = currentLine + char;
|
|
251
|
+
const metrics = ctx.measureText(testLine);
|
|
252
|
+
|
|
253
|
+
if (metrics.width > maxWidth && currentLine) {
|
|
254
|
+
lines.push(currentLine);
|
|
255
|
+
currentLine = char;
|
|
256
|
+
} else {
|
|
257
|
+
currentLine = testLine;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (currentLine) {
|
|
262
|
+
lines.push(currentLine);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return lines.length > 0 ? lines : [''];
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Calculate intrinsic content size for text - SMART METHOD
|
|
270
|
+
* Automatically chooses the most efficient measurement technique
|
|
271
|
+
*/
|
|
272
|
+
static calculateTextSize(
|
|
273
|
+
element: HTMLElement,
|
|
274
|
+
content: string,
|
|
275
|
+
constrainingWidth?: number,
|
|
276
|
+
constrainingHeight?: number
|
|
277
|
+
): { width: number; height: number } {
|
|
278
|
+
// Empty content
|
|
279
|
+
if (!content || content.length === 0) {
|
|
280
|
+
const style = window.getComputedStyle(element);
|
|
281
|
+
const paddingTop = parseFloat(style.paddingTop) || 0;
|
|
282
|
+
const paddingBottom = parseFloat(style.paddingBottom) || 0;
|
|
283
|
+
const paddingLeft = parseFloat(style.paddingLeft) || 0;
|
|
284
|
+
const paddingRight = parseFloat(style.paddingRight) || 0;
|
|
285
|
+
|
|
286
|
+
return {
|
|
287
|
+
width: paddingLeft + paddingRight,
|
|
288
|
+
height: paddingTop + paddingBottom
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Check complexity of content
|
|
293
|
+
const isPlain = this.isPlainText(content);
|
|
294
|
+
const hasSimple = this.hasSimpleFormatting(content);
|
|
295
|
+
|
|
296
|
+
// Strategy 1: Pure canvas for plain text (fastest)
|
|
297
|
+
if (isPlain) {
|
|
298
|
+
return this.calculatePlainTextSize(
|
|
299
|
+
element,
|
|
300
|
+
content,
|
|
301
|
+
constrainingWidth,
|
|
302
|
+
constrainingHeight
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Strategy 2: Optimized DOM for simple formatting (fast)
|
|
307
|
+
if (hasSimple) {
|
|
308
|
+
// For simple formatting, we can still use canvas but need to strip tags
|
|
309
|
+
const plainText = content.replace(/<[^>]+>/g, '');
|
|
310
|
+
return this.calculatePlainTextSize(
|
|
311
|
+
element,
|
|
312
|
+
plainText,
|
|
313
|
+
constrainingWidth,
|
|
314
|
+
constrainingHeight
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Strategy 3: DOM measurement for complex HTML (slower but accurate)
|
|
319
|
+
return this.measureWithDOM(
|
|
320
|
+
element,
|
|
321
|
+
content,
|
|
322
|
+
constrainingWidth,
|
|
323
|
+
constrainingHeight
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Calculate size for plain text using canvas (no HTML)
|
|
329
|
+
*/
|
|
330
|
+
private static calculatePlainTextSize(
|
|
331
|
+
element: HTMLElement,
|
|
332
|
+
text: string,
|
|
333
|
+
constrainingWidth?: number,
|
|
334
|
+
constrainingHeight?: number
|
|
335
|
+
): { width: number; height: number } {
|
|
336
|
+
const font = this.getFontString(element);
|
|
337
|
+
const style = window.getComputedStyle(element);
|
|
338
|
+
const whiteSpace = style.whiteSpace;
|
|
339
|
+
|
|
340
|
+
// Get font size in pixels
|
|
341
|
+
const fontSize = parseFloat(style.fontSize);
|
|
342
|
+
const lineHeight = this.getLineHeight(element, fontSize);
|
|
343
|
+
|
|
344
|
+
// Get padding
|
|
345
|
+
const paddingLeft = parseFloat(style.paddingLeft) || 0;
|
|
346
|
+
const paddingRight = parseFloat(style.paddingRight) || 0;
|
|
347
|
+
const paddingTop = parseFloat(style.paddingTop) || 0;
|
|
348
|
+
const paddingBottom = parseFloat(style.paddingBottom) || 0;
|
|
349
|
+
|
|
350
|
+
// Adjust constraining width for padding
|
|
351
|
+
const availableWidth = constrainingWidth
|
|
352
|
+
? constrainingWidth - paddingLeft - paddingRight
|
|
353
|
+
: Infinity;
|
|
354
|
+
|
|
355
|
+
// Calculate dimensions
|
|
356
|
+
let width: number;
|
|
357
|
+
let height: number;
|
|
358
|
+
|
|
359
|
+
if (whiteSpace === 'nowrap' || whiteSpace === 'pre' || !constrainingWidth) {
|
|
360
|
+
// Single line or no width constraint
|
|
361
|
+
width = this.measureTextWidth(text, font) + paddingLeft + paddingRight;
|
|
362
|
+
height = lineHeight + paddingTop + paddingBottom;
|
|
363
|
+
} else {
|
|
364
|
+
// Multi-line text
|
|
365
|
+
const lines = this.wrapText(text, availableWidth, font, whiteSpace);
|
|
366
|
+
|
|
367
|
+
// Find the widest line
|
|
368
|
+
width = Math.max(
|
|
369
|
+
...lines.map(line => this.measureTextWidth(line, font))
|
|
370
|
+
) + paddingLeft + paddingRight;
|
|
371
|
+
|
|
372
|
+
height = (lines.length * lineHeight) + paddingTop + paddingBottom;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
return { width, height };
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Clear all caches (call when fonts change or for cleanup)
|
|
380
|
+
*/
|
|
381
|
+
static clearCaches(): void {
|
|
382
|
+
this.fontCache.clear();
|
|
383
|
+
this.lineHeightCache.clear();
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Clean up measurement element (call on app cleanup)
|
|
388
|
+
*/
|
|
389
|
+
static cleanup(): void {
|
|
390
|
+
if (this.measurementElement && this.measurementElement.parentElement) {
|
|
391
|
+
document.body.removeChild(this.measurementElement);
|
|
392
|
+
}
|
|
393
|
+
this.measurementElement = null;
|
|
394
|
+
this.canvas = null;
|
|
395
|
+
this.context = null;
|
|
396
|
+
this.clearCaches();
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|