uicore-ts 1.1.57 → 1.1.59

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.57",
3
+ "version": "1.1.59",
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",
@@ -1,19 +1,97 @@
1
1
  // UITextMeasurement.ts - Efficient text measurement without DOM reflows
2
2
 
3
+ export interface TextMeasurementStyle {
4
+ font: string;
5
+ fontSize: number;
6
+ lineHeight: number;
7
+ whiteSpace: string;
8
+ paddingLeft: number;
9
+ paddingRight: number;
10
+ paddingTop: number;
11
+ paddingBottom: number;
12
+ }
13
+
3
14
  export class UITextMeasurement {
4
-
5
15
  private static canvas: HTMLCanvasElement | null = null;
6
16
  private static context: CanvasRenderingContext2D | null = null;
7
17
 
8
- // Cache for computed font strings to avoid repeated style parsing
9
- private static fontCache = new Map<string, string>();
18
+ // Global cache for style objects using semantic cache key
19
+ private static globalStyleCache = new Map<string, TextMeasurementStyle>();
10
20
 
11
- // Cache for line height calculations
12
- private static lineHeightCache = new Map<string, number>();
21
+ // Per-element cache to map element -> cache key
22
+ private static elementToCacheKey = new WeakMap<HTMLElement, string>();
13
23
 
14
24
  // Temporary element for complex HTML measurements (reused to avoid allocations)
15
25
  private static measurementElement: HTMLDivElement | null = null;
16
26
 
27
+ /**
28
+ * Generate a cache key based only on styles that affect text measurement
29
+ * Ignores position, color, transform, etc.
30
+ */
31
+ private static generateStyleCacheKey(computed: CSSStyleDeclaration): string {
32
+ // Only include properties that affect text layout
33
+ const relevantProps = [
34
+ computed.fontFamily,
35
+ computed.fontSize,
36
+ computed.fontWeight,
37
+ computed.fontStyle,
38
+ computed.fontVariant,
39
+ computed.lineHeight,
40
+ computed.letterSpacing,
41
+ computed.wordSpacing,
42
+ computed.textTransform,
43
+ computed.whiteSpace,
44
+ computed.wordBreak,
45
+ computed.wordWrap,
46
+ computed.paddingLeft,
47
+ computed.paddingRight,
48
+ computed.paddingTop,
49
+ computed.paddingBottom,
50
+ computed.borderLeftWidth,
51
+ computed.borderRightWidth,
52
+ computed.borderTopWidth,
53
+ computed.borderBottomWidth,
54
+ computed.boxSizing
55
+ ];
56
+
57
+ // Create a hash-like key from relevant properties
58
+ return relevantProps.join('|');
59
+ }
60
+
61
+ /**
62
+ * Extract cache key from element's classList
63
+ * Elements with same classes likely have same text measurement styles
64
+ */
65
+ private static getSemanticCacheKey(element: HTMLElement): string {
66
+ // Check if we already computed a cache key for this element
67
+ const existingKey = this.elementToCacheKey.get(element);
68
+ if (existingKey) {
69
+ return existingKey;
70
+ }
71
+
72
+ // Try to use class-based caching first (fastest)
73
+ const classList = Array.from(element.classList).sort().join(' ');
74
+ const tagName = element.tagName.toLowerCase();
75
+
76
+ // Semantic key based on tag + classes
77
+ const semanticKey = `${tagName}::${classList}`;
78
+
79
+ // Check if we have styles for this semantic key
80
+ if (this.globalStyleCache.has(semanticKey)) {
81
+ this.elementToCacheKey.set(element, semanticKey);
82
+ return semanticKey;
83
+ }
84
+
85
+ // If not cached, compute and use style-based key
86
+ const computed = window.getComputedStyle(element);
87
+ const styleCacheKey = this.generateStyleCacheKey(computed);
88
+
89
+ // Use the style-based key
90
+ this.elementToCacheKey.set(element, styleCacheKey);
91
+
92
+ return styleCacheKey;
93
+ }
94
+
17
95
  /**
18
96
  * Get or create the canvas context for text measurement
19
97
  */
@@ -69,20 +147,17 @@ export class UITextMeasurement {
69
147
  element: HTMLElement,
70
148
  content: string,
71
149
  constrainingWidth?: number,
72
- constrainingHeight?: number
150
+ constrainingHeight?: number,
151
+ providedStyles?: TextMeasurementStyle
73
152
  ): { width: number; height: number } {
74
153
  const measureEl = this.getMeasurementElement();
75
- const style = window.getComputedStyle(element);
154
+ const styles = this.getElementStyles(element, providedStyles);
76
155
 
77
156
  // 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;
157
+ measureEl.style.font = styles.font;
158
+ measureEl.style.lineHeight = styles.lineHeight + 'px';
159
+ measureEl.style.whiteSpace = styles.whiteSpace;
160
+ measureEl.style.padding = `${styles.paddingTop}px ${styles.paddingRight}px ${styles.paddingBottom}px ${styles.paddingLeft}px`;
86
161
 
87
162
  // Set constraints
88
163
  if (constrainingWidth) {
@@ -120,37 +195,54 @@ export class UITextMeasurement {
120
195
  }
121
196
 
122
197
  /**
123
- * Extract font properties from computed style and create font string
198
+ * Get or extract styles from element (with smart global caching)
199
+ * Returns cached styles if available, otherwise computes once and caches globally
124
200
  */
125
- private static getFontString(element: HTMLElement): string {
126
- const cacheKey = element.className + element.id;
201
+ private static getElementStyles(element: HTMLElement, providedStyles?: TextMeasurementStyle): TextMeasurementStyle {
202
+ // Use provided styles if available (avoids getComputedStyle entirely)
203
+ if (providedStyles) {
204
+ return providedStyles;
205
+ }
206
+
207
+ // Get semantic cache key
208
+ const cacheKey = this.getSemanticCacheKey(element);
127
209
 
128
- if (this.fontCache.has(cacheKey)) {
129
- return this.fontCache.get(cacheKey)!;
210
+ // Check global cache
211
+ const cached = this.globalStyleCache.get(cacheKey);
212
+ if (cached) {
213
+ return cached;
130
214
  }
131
215
 
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;
216
+ // Compute once and cache globally (this is the only getComputedStyle call)
217
+ const computed = window.getComputedStyle(element);
218
+ const fontSize = parseFloat(computed.fontSize);
219
+
220
+ const styles: TextMeasurementStyle = {
221
+ font: [
222
+ computed.fontStyle,
223
+ computed.fontVariant,
224
+ computed.fontWeight,
225
+ computed.fontSize,
226
+ computed.fontFamily
227
+ ].join(' '),
228
+ fontSize: fontSize,
229
+ lineHeight: this.parseLineHeight(computed.lineHeight, fontSize),
230
+ whiteSpace: computed.whiteSpace,
231
+ paddingLeft: parseFloat(computed.paddingLeft) || 0,
232
+ paddingRight: parseFloat(computed.paddingRight) || 0,
233
+ paddingTop: parseFloat(computed.paddingTop) || 0,
234
+ paddingBottom: parseFloat(computed.paddingBottom) || 0
235
+ };
236
+
237
+ this.globalStyleCache.set(cacheKey, styles);
238
+ return styles;
143
239
  }
144
240
 
145
241
  /**
146
- * Get line height in pixels
242
+ * Parse line height from computed style
147
243
  */
148
- private static getLineHeight(element: HTMLElement, fontSize: number): number {
149
- const style = window.getComputedStyle(element);
150
- const lineHeight = style.lineHeight;
151
-
244
+ private static parseLineHeight(lineHeight: string, fontSize: number): number {
152
245
  if (lineHeight === 'normal') {
153
- // Browsers typically use 1.2 as default line height
154
246
  return fontSize * 1.2;
155
247
  }
156
248
 
@@ -158,13 +250,12 @@ export class UITextMeasurement {
158
250
  return parseFloat(lineHeight);
159
251
  }
160
252
 
161
- // If it's a unitless number, multiply by font size
162
253
  const numericLineHeight = parseFloat(lineHeight);
163
254
  if (!isNaN(numericLineHeight)) {
164
255
  return fontSize * numericLineHeight;
165
256
  }
166
257
 
167
- return fontSize * 1.2; // fallback
258
+ return fontSize * 1.2;
168
259
  }
169
260
 
170
261
  /**
@@ -273,19 +364,15 @@ export class UITextMeasurement {
273
364
  element: HTMLElement,
274
365
  content: string,
275
366
  constrainingWidth?: number,
276
- constrainingHeight?: number
367
+ constrainingHeight?: number,
368
+ providedStyles?: TextMeasurementStyle
277
369
  ): { width: number; height: number } {
278
370
  // Empty content
279
371
  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
-
372
+ const styles = this.getElementStyles(element, providedStyles);
286
373
  return {
287
- width: paddingLeft + paddingRight,
288
- height: paddingTop + paddingBottom
374
+ width: styles.paddingLeft + styles.paddingRight,
375
+ height: styles.paddingTop + styles.paddingBottom
289
376
  };
290
377
  }
291
378
 
@@ -299,7 +386,8 @@ export class UITextMeasurement {
299
386
  element,
300
387
  content,
301
388
  constrainingWidth,
302
- constrainingHeight
389
+ constrainingHeight,
390
+ providedStyles
303
391
  );
304
392
  }
305
393
 
@@ -311,7 +399,8 @@ export class UITextMeasurement {
311
399
  element,
312
400
  plainText,
313
401
  constrainingWidth,
314
- constrainingHeight
402
+ constrainingHeight,
403
+ providedStyles
315
404
  );
316
405
  }
317
406
 
@@ -320,7 +409,8 @@ export class UITextMeasurement {
320
409
  element,
321
410
  content,
322
411
  constrainingWidth,
323
- constrainingHeight
412
+ constrainingHeight,
413
+ providedStyles
324
414
  );
325
415
  }
326
416
 
@@ -331,45 +421,34 @@ export class UITextMeasurement {
331
421
  element: HTMLElement,
332
422
  text: string,
333
423
  constrainingWidth?: number,
334
- constrainingHeight?: number
424
+ constrainingHeight?: number,
425
+ providedStyles?: TextMeasurementStyle
335
426
  ): { 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;
427
+ const styles = this.getElementStyles(element, providedStyles);
349
428
 
350
429
  // Adjust constraining width for padding
351
430
  const availableWidth = constrainingWidth
352
- ? constrainingWidth - paddingLeft - paddingRight
431
+ ? constrainingWidth - styles.paddingLeft - styles.paddingRight
353
432
  : Infinity;
354
433
 
355
434
  // Calculate dimensions
356
435
  let width: number;
357
436
  let height: number;
358
437
 
359
- if (whiteSpace === 'nowrap' || whiteSpace === 'pre' || !constrainingWidth) {
438
+ if (styles.whiteSpace === 'nowrap' || styles.whiteSpace === 'pre' || !constrainingWidth) {
360
439
  // Single line or no width constraint
361
- width = this.measureTextWidth(text, font) + paddingLeft + paddingRight;
362
- height = lineHeight + paddingTop + paddingBottom;
440
+ width = this.measureTextWidth(text, styles.font) + styles.paddingLeft + styles.paddingRight;
441
+ height = styles.lineHeight + styles.paddingTop + styles.paddingBottom;
363
442
  } else {
364
443
  // Multi-line text
365
- const lines = this.wrapText(text, availableWidth, font, whiteSpace);
444
+ const lines = this.wrapText(text, availableWidth, styles.font, styles.whiteSpace);
366
445
 
367
446
  // Find the widest line
368
447
  width = Math.max(
369
- ...lines.map(line => this.measureTextWidth(line, font))
370
- ) + paddingLeft + paddingRight;
448
+ ...lines.map(line => this.measureTextWidth(line, styles.font))
449
+ ) + styles.paddingLeft + styles.paddingRight;
371
450
 
372
- height = (lines.length * lineHeight) + paddingTop + paddingBottom;
451
+ height = (lines.length * styles.lineHeight) + styles.paddingTop + styles.paddingBottom;
373
452
  }
374
453
 
375
454
  return { width, height };
@@ -379,8 +458,42 @@ export class UITextMeasurement {
379
458
  * Clear all caches (call when fonts change or for cleanup)
380
459
  */
381
460
  static clearCaches(): void {
382
- this.fontCache.clear();
383
- this.lineHeightCache.clear();
461
+ this.globalStyleCache.clear();
462
+ this.elementToCacheKey = new WeakMap();
463
+ }
464
+
465
+ /**
466
+ * Invalidate cached styles for a specific element
467
+ */
468
+ static invalidateElement(element: HTMLElement): void {
469
+ const cacheKey = this.elementToCacheKey.get(element);
470
+ if (cacheKey) {
471
+ this.globalStyleCache.delete(cacheKey);
472
+ this.elementToCacheKey.delete(element);
473
+ }
474
+ }
475
+
476
+ /**
477
+ * Invalidate cache for elements with specific class
478
+ * Useful when you change a CSS class definition
479
+ */
480
+ static invalidateClass(className: string): void {
481
+ // Clear all cache keys that contain this class
482
+ for (const [key] of this.globalStyleCache.entries()) {
483
+ if (key.includes(className)) {
484
+ this.globalStyleCache.delete(key);
485
+ }
486
+ }
487
+ }
488
+
489
+ /**
490
+ * Pre-warm the cache by measuring a representative element
491
+ * Useful at app startup to avoid first-paint delays
492
+ */
493
+ static prewarmCache(elements: HTMLElement[]): void {
494
+ elements.forEach(el => {
495
+ this.getElementStyles(el);
496
+ });
384
497
  }
385
498
 
386
499
  /**
@@ -397,3 +510,175 @@ export class UITextMeasurement {
397
510
  }
398
511
  }
399
512
 
513
+ // Extension methods to add to UITextView
514
+ export interface UITextViewMeasurementMethods {
515
+ intrinsicContentSizeEfficient(
516
+ constrainingWidth?: number,
517
+ constrainingHeight?: number
518
+ ): { width: number; height: number };
519
+ }
520
+
521
+ // ==================== INTEGRATION CODE ====================
522
+ // Add these methods to UITextView class:
523
+
524
+ /*
525
+ // In UITextView class:
526
+
527
+ // Add this property to track content complexity and cached styles
528
+ private _useFastMeasurement: boolean | undefined;
529
+ private _cachedMeasurementStyles: TextMeasurementStyle | undefined;
530
+
531
+ // Call this when styles change (fontSize, padding, etc.)
532
+ private _invalidateMeasurementStyles(): void {
533
+ this._cachedMeasurementStyles = undefined;
534
+ UITextMeasurement.invalidateElement(this.viewHTMLElement);
535
+ this._intrinsicSizesCache = {};
536
+ }
537
+
538
+ // Extract styles ONCE and cache them (avoids getComputedStyle)
539
+ private _getMeasurementStyles(): TextMeasurementStyle {
540
+ if (this._cachedMeasurementStyles) {
541
+ return this._cachedMeasurementStyles;
542
+ }
543
+
544
+ // Only call getComputedStyle once and cache the result
545
+ const computed = window.getComputedStyle(this.viewHTMLElement);
546
+ const fontSize = parseFloat(computed.fontSize);
547
+
548
+ this._cachedMeasurementStyles = {
549
+ font: [
550
+ computed.fontStyle,
551
+ computed.fontVariant,
552
+ computed.fontWeight,
553
+ computed.fontSize,
554
+ computed.fontFamily
555
+ ].join(' '),
556
+ fontSize: fontSize,
557
+ lineHeight: this._parseLineHeight(computed.lineHeight, fontSize),
558
+ whiteSpace: computed.whiteSpace,
559
+ paddingLeft: parseFloat(computed.paddingLeft) || 0,
560
+ paddingRight: parseFloat(computed.paddingRight) || 0,
561
+ paddingTop: parseFloat(computed.paddingTop) || 0,
562
+ paddingBottom: parseFloat(computed.paddingBottom) || 0
563
+ };
564
+
565
+ return this._cachedMeasurementStyles;
566
+ }
567
+
568
+ private _parseLineHeight(lineHeight: string, fontSize: number): number {
569
+ if (lineHeight === 'normal') {
570
+ return fontSize * 1.2;
571
+ }
572
+ if (lineHeight.endsWith('px')) {
573
+ return parseFloat(lineHeight);
574
+ }
575
+ const numericLineHeight = parseFloat(lineHeight);
576
+ if (!isNaN(numericLineHeight)) {
577
+ return fontSize * numericLineHeight;
578
+ }
579
+ return fontSize * 1.2;
580
+ }
581
+
582
+ // Override the intrinsic size method
583
+ override intrinsicContentSizeWithConstraints(
584
+ constrainingHeight: number = 0,
585
+ constrainingWidth: number = 0
586
+ ): UIRectangle {
587
+ const cacheKey = "h_" + constrainingHeight + "__w_" + constrainingWidth;
588
+ const cachedResult = this._intrinsicSizesCache[cacheKey];
589
+ if (cachedResult) {
590
+ return cachedResult;
591
+ }
592
+
593
+ // Determine measurement strategy
594
+ const shouldUseFastPath = this._useFastMeasurement ?? this._shouldUseFastMeasurement();
595
+
596
+ let result: UIRectangle;
597
+
598
+ if (shouldUseFastPath) {
599
+ // Fast path: canvas-based measurement with pre-extracted styles
600
+ const styles = this._getMeasurementStyles();
601
+ const size = UITextMeasurement.calculateTextSize(
602
+ this.viewHTMLElement,
603
+ this.text || this.innerHTML,
604
+ constrainingWidth || undefined,
605
+ constrainingHeight || undefined,
606
+ styles // Pass pre-computed styles to avoid getComputedStyle!
607
+ );
608
+ result = new UIRectangle(0, 0, size.height, size.width);
609
+ } else {
610
+ // Fallback: original DOM-based measurement for complex content
611
+ result = super.intrinsicContentSizeWithConstraints(constrainingHeight, constrainingWidth);
612
+ }
613
+
614
+ this._intrinsicSizesCache[cacheKey] = result.copy();
615
+ return result;
616
+ }
617
+
618
+ // Helper to determine if we can use fast measurement
619
+ private _shouldUseFastMeasurement(): boolean {
620
+ const content = this.text || this.innerHTML;
621
+
622
+ // If using dynamic innerHTML with parameters, use DOM measurement
623
+ if (this._innerHTMLKey || this._localizedTextObject) {
624
+ return false;
625
+ }
626
+
627
+ // Check for notification badges
628
+ if (this.notificationAmount > 0) {
629
+ return false; // Has span with colored text
630
+ }
631
+
632
+ // Check content complexity
633
+ const hasComplexHTML = /<(?!\/?(b|i|em|strong|span|br)\b)[^>]+>/i.test(content);
634
+
635
+ return !hasComplexHTML;
636
+ }
637
+
638
+ // Optional: Allow manual override for specific instances
639
+ setUseFastMeasurement(useFast: boolean): void {
640
+ this._useFastMeasurement = useFast;
641
+ this._intrinsicSizesCache = {};
642
+ }
643
+
644
+ // Optional: Force re-evaluation of measurement strategy
645
+ invalidateMeasurementStrategy(): void {
646
+ this._useFastMeasurement = undefined;
647
+ this._invalidateMeasurementStyles();
648
+ }
649
+
650
+ // Update fontSize setter to invalidate cached styles
651
+ override set fontSize(fontSize: number) {
652
+ this.style.fontSize = "" + fontSize + "pt";
653
+ this._intrinsicHeightCache = new UIObject() as any;
654
+ this._intrinsicWidthCache = new UIObject() as any;
655
+ this._invalidateMeasurementStyles(); // Invalidate when font changes
656
+ }
657
+
658
+ // Update the text setter to invalidate measurement strategy
659
+ override set text(text: string) {
660
+ this._text = text;
661
+
662
+ var notificationText = "";
663
+
664
+ if (this.notificationAmount) {
665
+ notificationText = "<span style=\"color: " + UITextView.notificationTextColor.stringValue + ";\">" +
666
+ (" (" + this.notificationAmount + ")").bold() + "</span>";
667
+ }
668
+
669
+ if (this.viewHTMLElement.innerHTML != this.textPrefix + text + this.textSuffix + notificationText) {
670
+ this.viewHTMLElement.innerHTML = this.textPrefix + FIRST(text, "") + this.textSuffix + notificationText;
671
+ }
672
+
673
+ if (this.changesOften) {
674
+ this._intrinsicHeightCache = new UIObject() as any;
675
+ this._intrinsicWidthCache = new UIObject() as any;
676
+ }
677
+
678
+ // Invalidate measurement strategy when text changes significantly
679
+ this._useFastMeasurement = undefined;
680
+ this._intrinsicSizesCache = {};
681
+
682
+ this.setNeedsLayout();
683
+ }
684
+ */