uicore-ts 1.1.55 → 1.1.57

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.55",
3
+ "version": "1.1.57",
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",
@@ -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
+
@@ -1,8 +1,9 @@
1
1
  import { UIColor } from "./UIColor"
2
2
  import { UILocalizedTextObject } from "./UIInterfaces"
3
- import { FIRST, IS_LIKE_NULL, nil, NO, UIObject, YES } from "./UIObject"
3
+ import { FIRST, IS, IS_LIKE_NULL, nil, NO, UIObject, YES } from "./UIObject"
4
4
  import { UIRectangle } from "./UIRectangle"
5
5
  import type { ValueOf } from "./UIObject"
6
+ import { UITextMeasurement } from "./UITextMeasurement"
6
7
  import { UIView, UIViewBroadcastEvent } from "./UIView"
7
8
 
8
9
 
@@ -40,6 +41,8 @@ export class UITextView extends UIView {
40
41
  static _pxToPt: number
41
42
  _text?: string
42
43
 
44
+ private _useFastMeasurement: boolean | undefined;
45
+
43
46
 
44
47
  constructor(
45
48
  elementID?: string,
@@ -204,30 +207,24 @@ export class UITextView extends UIView {
204
207
  }
205
208
 
206
209
  set text(text) {
207
-
208
210
  this._text = text
209
-
210
211
  var notificationText = ""
211
-
212
212
  if (this.notificationAmount) {
213
-
214
213
  notificationText = "<span style=\"color: " + UITextView.notificationTextColor.stringValue + ";\">" +
215
214
  (" (" + this.notificationAmount + ")").bold() + "</span>"
216
-
217
215
  }
218
216
 
219
217
  if (this.viewHTMLElement.innerHTML != this.textPrefix + text + this.textSuffix + notificationText) {
220
-
221
218
  this.viewHTMLElement.innerHTML = this.textPrefix + FIRST(text, "") + this.textSuffix + notificationText
222
-
223
219
  }
224
220
 
225
221
  if (this.changesOften) {
226
-
227
222
  this._intrinsicHeightCache = new UIObject() as any
228
223
  this._intrinsicWidthCache = new UIObject() as any
229
-
230
224
  }
225
+ // Invalidate measurement strategy when text changes significantly
226
+ this._useFastMeasurement = undefined;
227
+ this._intrinsicSizesCache = {};
231
228
 
232
229
  this.setNeedsLayout()
233
230
 
@@ -452,6 +449,73 @@ export class UITextView extends UIView {
452
449
  }
453
450
 
454
451
 
452
+ override intrinsicContentSizeWithConstraints(
453
+ constrainingHeight: number = 0,
454
+ constrainingWidth: number = 0
455
+ ): UIRectangle {
456
+ const cacheKey = "h_" + constrainingHeight + "__w_" + constrainingWidth;
457
+ const cachedResult = this._intrinsicSizesCache[cacheKey];
458
+ if (cachedResult) {
459
+ return cachedResult;
460
+ }
461
+
462
+ // Determine measurement strategy
463
+ const shouldUseFastPath = this._useFastMeasurement ?? this._shouldUseFastMeasurement();
464
+
465
+ let result: UIRectangle;
466
+
467
+ if (shouldUseFastPath) {
468
+ // Fast path: canvas-based measurement
469
+ const size = UITextMeasurement.calculateTextSize(
470
+ this.viewHTMLElement,
471
+ this.text || this.innerHTML,
472
+ constrainingWidth || undefined,
473
+ constrainingHeight || undefined
474
+ );
475
+ result = new UIRectangle(0, 0, size.height, size.width);
476
+ } else {
477
+ // Fallback: original DOM-based measurement for complex content
478
+ result = super.intrinsicContentSizeWithConstraints(constrainingHeight, constrainingWidth);
479
+ }
480
+
481
+ this._intrinsicSizesCache[cacheKey] = result.copy();
482
+ return result;
483
+ }
484
+
485
+ // Helper to determine if we can use fast measurement
486
+ private _shouldUseFastMeasurement(): boolean {
487
+ const content = this.text || this.innerHTML;
488
+
489
+ // If using dynamic innerHTML with parameters, use DOM measurement
490
+ if (IS(this._innerHTMLKey) || IS(this._localizedTextObject)) {
491
+ return false;
492
+ }
493
+
494
+ // Check for notification badges
495
+ if (this.notificationAmount > 0) {
496
+ return false; // Has span with colored text
497
+ }
498
+
499
+ // Check content complexity
500
+ const hasComplexHTML = /<(?!\/?(b|i|em|strong|span|br)\b)[^>]+>/i.test(content);
501
+
502
+ return !hasComplexHTML;
503
+ }
504
+
505
+ // Optional: Allow manual override for specific instances
506
+ setUseFastMeasurement(useFast: boolean): void {
507
+ this._useFastMeasurement = useFast;
508
+ this._intrinsicSizesCache = {};
509
+ }
510
+
511
+ // Optional: Force re-evaluation of measurement strategy
512
+ invalidateMeasurementStrategy(): void {
513
+ this._useFastMeasurement = undefined;
514
+ this._intrinsicSizesCache = {};
515
+ }
516
+
517
+
518
+
455
519
  override intrinsicContentSize() {
456
520
 
457
521
  // This works but is slow
package/scripts/UIView.ts CHANGED
@@ -223,7 +223,7 @@ export class UIView extends UIObject {
223
223
  static _onWindowMouseMove: (event: MouseEvent) => void = nil
224
224
  static _onWindowMouseup: (event: MouseEvent) => void = nil
225
225
  private _resizeObserverEntry?: ResizeObserverEntry
226
- private _intrinsicSizesCache: Record<string, UIRectangle> = {}
226
+ protected _intrinsicSizesCache: Record<string, UIRectangle> = {}
227
227
 
228
228
 
229
229
  constructor(