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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "uicore-ts",
3
- "version": "1.1.52",
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",
@@ -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: number[],
460
- paddings: number | number[] = 0,
461
- absoluteWidths: number | number[] = nil
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 number[]).arrayByTrimmingToLengthIfLonger(weights.length - 1)
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: number[],
519
- paddings: number | number[] = 0,
520
- absoluteHeights: number | number[] = nil
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
- var previousCellMaxY = this.y
560
+ let previousCellMaxY = this.y
548
561
 
549
- for (var i = 0; i < weights.length; i++) {
550
- var resultHeight: number
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
- var padding = 0
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: number | number[] = 1,
606
- paddings?: number | number[],
607
- absoluteWidths?: number | number[]
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: number | number[] = 1,
620
- paddings?: number | number[],
621
- absoluteHeights?: number | number[]
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
- rectangleForNextRow(padding: number = 0, height: number | ((constrainingWidth: number) => number) | UIView = this.height) {
658
+ _heightNumberFromSizeNumberOrFunctionOrView(height: SizeNumberOrFunctionOrView) {
646
659
  if (height instanceof Function) {
647
- height = height(this.width)
660
+ return height(this.width)
648
661
  }
649
662
  if (height instanceof UIView) {
650
- height = height.intrinsicContentHeight(this.width)
663
+ return height.intrinsicContentHeight(this.width)
651
664
  }
652
- const result = this.rectangleWithY(this.max.y + padding)
653
- if (height != this.height) {
654
- result.height = height
655
- }
656
- return result
665
+ return height
657
666
  }
658
667
 
659
- rectangleForNextColumn(padding: number = 0, width: number | ((constrainingHeight: number) => number) | UIView = this.width) {
668
+ _widthNumberFromSizeNumberOrFunctionOrView(width: SizeNumberOrFunctionOrView) {
660
669
  if (width instanceof Function) {
661
- width = width(this.height)
670
+ return width(this.height)
662
671
  }
663
672
  if (width instanceof UIView) {
664
- width = width.intrinsicContentWidth(this.height)
673
+ return width.intrinsicContentWidth(this.height)
665
674
  }
666
- const result = this.rectangleWithX(this.max.x + padding)
667
- if (width != this.width) {
668
- result.width = width
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
- rectangleForPreviousRow(padding: number = 0, height: number | ((constrainingWidth: number) => number) | UIView = this.height) {
674
- if (height instanceof Function) {
675
- height = height(this.width)
676
- }
677
- if (height instanceof UIView) {
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
- rectangleForPreviousColumn(padding: number = 0, width: number | ((constrainingHeight: number) => number) | UIView = this.width) {
688
- if (width instanceof Function) {
689
- width = width(this.height)
690
- }
691
- if (width instanceof UIView) {
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
- const result = this.rectangleWithX(this.min.x - width - padding)
695
- if (width != this.width) {
696
- result.width = width
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
+