@nasser-sw/fabric 7.0.0-beta1 → 7.0.1-beta1

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.
@@ -82,10 +82,10 @@ export class OverlayEditor {
82
82
  if (!container) {
83
83
  throw new Error('Canvas must be mounted in DOM to use overlay editing');
84
84
  }
85
-
85
+
86
86
  // Ensure the container is positioned for absolute overlay positioning
87
87
  container.style.position = 'relative';
88
-
88
+
89
89
  return container;
90
90
  }
91
91
 
@@ -109,10 +109,17 @@ export class OverlayEditor {
109
109
  this.textarea.style.resize = 'none';
110
110
  this.textarea.style.pointerEvents = 'auto';
111
111
  // Set appropriate unicodeBidi based on content and direction
112
- const hasArabicText = /[\u0600-\u06FF\u0750-\u077F\uFB50-\uFDFF\uFE70-\uFEFF]/.test(this.target.text || '');
112
+ const hasArabicText =
113
+ /[\u0600-\u06FF\u0750-\u077F\uFB50-\uFDFF\uFE70-\uFEFF]/.test(
114
+ this.target.text || '',
115
+ );
116
+ const hasLatinText = /[a-zA-Z]/.test(this.target.text || '');
113
117
  const isLTRDirection = (this.target as any).direction === 'ltr';
114
-
115
- if (hasArabicText && isLTRDirection) {
118
+
119
+ if (hasArabicText && hasLatinText && isLTRDirection) {
120
+ // For mixed Arabic/Latin text in LTR mode, use embed for consistent line wrapping
121
+ this.textarea.style.unicodeBidi = 'embed';
122
+ } else if (hasArabicText && isLTRDirection) {
116
123
  // For Arabic text in LTR mode, use embed to preserve shaping while respecting direction
117
124
  this.textarea.style.unicodeBidi = 'embed';
118
125
  } else {
@@ -165,7 +172,7 @@ export class OverlayEditor {
165
172
  this.canvas.on('after:render', this.boundHandlers.onAfterRender);
166
173
  this.canvas.on('mouse:wheel', this.boundHandlers.onMouseWheel);
167
174
  this.canvas.on('mouse:down', this.boundHandlers.onMouseDown);
168
-
175
+
169
176
  // Store original methods to detect viewport changes
170
177
  this.setupViewportChangeDetection();
171
178
  }
@@ -190,7 +197,7 @@ export class OverlayEditor {
190
197
  this.canvas.off('after:render', this.boundHandlers.onAfterRender);
191
198
  this.canvas.off('mouse:wheel', this.boundHandlers.onMouseWheel);
192
199
  this.canvas.off('mouse:down', this.boundHandlers.onMouseDown);
193
-
200
+
194
201
  // Restore original methods
195
202
  this.restoreViewportChangeDetection();
196
203
  }
@@ -211,19 +218,33 @@ export class OverlayEditor {
211
218
 
212
219
  const target = this.target;
213
220
  const zoom = this.canvas.getZoom();
214
-
221
+
215
222
  // Get current textbox dimensions from the host div (in canvas coordinates)
216
223
  const currentWidth = parseFloat(this.hostDiv.style.width) / zoom;
217
224
  const currentHeight = parseFloat(this.hostDiv.style.height) / zoom;
218
-
219
- // Only update if there's a meaningful change (avoid float precision issues)
225
+
226
+ // Always update height for responsive controls (especially important for line deletion)
220
227
  const heightDiff = Math.abs(currentHeight - target.height);
221
- const threshold = 1; // 1px threshold to avoid micro-changes
222
-
228
+ const threshold = 0.5; // Lower threshold for better responsiveness to line changes
229
+
223
230
  if (heightDiff > threshold) {
231
+ const oldHeight = target.height;
224
232
  target.height = currentHeight;
225
233
  target.setCoords(); // Update control positions
234
+
235
+ // Force dirty to ensure proper re-rendering
236
+ target.dirty = true;
226
237
  this.canvas.requestRenderAll(); // Re-render to show updated selection
238
+
239
+ // IMPORTANT: Reposition overlay after height change
240
+ requestAnimationFrame(() => {
241
+ if (!this.isDestroyed) {
242
+ this.applyOverlayStyle();
243
+ console.log(
244
+ '📐 Height changed - rechecking alignment after repositioning:',
245
+ );
246
+ }
247
+ });
227
248
  }
228
249
  }
229
250
 
@@ -251,15 +272,7 @@ export class OverlayEditor {
251
272
  // 1. Freshen object's transformations - use aCoords like rtl-test.html
252
273
  target.setCoords();
253
274
  const aCoords = target.aCoords;
254
-
255
- // DEBUG: Log dimensions before edit
256
- console.log('BEFORE EDIT:');
257
- console.log(' target.width =', (target as any).width);
258
- console.log(' target.height =', target.height);
259
- console.log(' target.getScaledWidth() =', target.getScaledWidth());
260
- console.log(' target.getScaledHeight() =', target.getScaledHeight());
261
- console.log(' target.padding =', (target as any).padding);
262
-
275
+
263
276
  // 2. Get canvas position and scroll offsets (like rtl-test.html)
264
277
  const canvasEl = canvas.upperCanvasEl;
265
278
  const canvasRect = canvasEl.getBoundingClientRect();
@@ -275,20 +288,20 @@ export class OverlayEditor {
275
288
 
276
289
  // Transform object's top-left corner coordinates to screen coordinates using viewport transform
277
290
  // aCoords.tl already accounts for object positioning and scaling, just need viewport transform
278
- const screenPoint = transformPoint({ x: aCoords.tl.x, y: aCoords.tl.y }, vpt);
279
-
291
+ const screenPoint = transformPoint(
292
+ { x: aCoords.tl.x, y: aCoords.tl.y },
293
+ vpt,
294
+ );
295
+
280
296
  const left = canvasRect.left + scrollX + screenPoint.x;
281
297
  const top = canvasRect.top + scrollY + screenPoint.y;
282
298
 
283
- // 4. Get dimensions with zoom scaling - use target.width for text wrapping, scaled height for container
284
- const width = (target as any).width * (target.scaleX || 1) * zoom; // Account for object scale and viewport zoom
285
- const height = target.height * (target.scaleY || 1) * zoom;
286
-
287
- console.log('WIDTH CALCULATION:');
288
- console.log(' target.width =', (target as any).width);
289
- console.log(' scaledWidth =', target.getScaledWidth());
290
- console.log(' zoom =', zoom);
291
- console.log(' final width =', width);
299
+ // 4. Calculate the precise width and height for the container
300
+ // **THE FIX:** Use getBoundingRect() for BOTH width and height.
301
+ // This is the most reliable measure of the object's final rendered dimensions.
302
+ const objectBounds = target.getBoundingRect();
303
+ const width = Math.round(objectBounds.width * zoom);
304
+ const height = Math.round(objectBounds.height * zoom);
292
305
 
293
306
  // 5. Apply styles to host DIV - absolute positioning like rtl-test.html
294
307
  this.hostDiv.style.position = 'absolute';
@@ -307,99 +320,308 @@ export class OverlayEditor {
307
320
  }
308
321
 
309
322
  // 6. Style the textarea - match Fabric's exact rendering with padding
310
- const baseFontSize = (target.fontSize ?? 16);
323
+ const baseFontSize = target.fontSize ?? 16;
311
324
  // Use scaleX for font scaling to match Fabric text scaling exactly
312
325
  const scaleX = target.scaleX || 1;
313
326
  const finalFontSize = baseFontSize * scaleX * zoom;
314
327
  const fabricLineHeight = target.lineHeight || 1.16;
315
- // Apply padding and dimensions to textarea
316
- const textareaWidth = paddingX > 0 ? `calc(100% - ${2 * paddingX}px)` : '100%';
317
- const textareaHeight = paddingY > 0 ? `calc(100% - ${2 * paddingY}px)` : '100%';
318
-
319
- this.textarea.style.width = textareaWidth;
320
- this.textarea.style.height = textareaHeight;
328
+ // **THE FIX:** Use 'border-box' so the width property includes padding.
329
+ // This makes alignment much easier and more reliable.
330
+ this.textarea.style.boxSizing = 'border-box';
331
+
332
+ // **THE FIX:** Set the textarea width to be IDENTICAL to the host div's width.
333
+ // The padding will now be correctly contained *inside* this width.
334
+ this.textarea.style.width = `${width}px`;
335
+ this.textarea.style.height = '100%'; // Let hostDiv control height
321
336
  this.textarea.style.padding = `${paddingY}px ${paddingX}px`;
322
-
337
+
338
+ // Apply all other font and text styles to match Fabric
339
+ const letterSpacingPx = ((target.charSpacing || 0) / 1000) * finalFontSize;
340
+
323
341
  this.textarea.style.fontSize = `${finalFontSize}px`;
324
- this.textarea.style.lineHeight = String(fabricLineHeight); // Use unit-less multiplier
342
+ this.textarea.style.lineHeight = String(fabricLineHeight);
325
343
  this.textarea.style.fontFamily = target.fontFamily || 'Arial';
326
344
  this.textarea.style.fontWeight = String(target.fontWeight || 'normal');
327
345
  this.textarea.style.fontStyle = target.fontStyle || 'normal';
328
346
  this.textarea.style.textAlign = (target as any).textAlign || 'left';
329
347
  this.textarea.style.color = target.fill?.toString() || '#000';
330
- this.textarea.style.letterSpacing = `${((target.charSpacing || 0) / 1000)}em`;
331
- this.textarea.style.direction = (target as any).direction || this.firstStrongDir(this.textarea.value || '');
332
-
333
- // Ensure consistent font rendering between Fabric and CSS
348
+ this.textarea.style.letterSpacing = `${letterSpacingPx}px`;
349
+ this.textarea.style.direction =
350
+ (target as any).direction ||
351
+ this.firstStrongDir(this.textarea.value || '');
334
352
  this.textarea.style.fontVariant = 'normal';
335
353
  this.textarea.style.fontStretch = 'normal';
336
- this.textarea.style.textRendering = 'auto';
337
- this.textarea.style.fontKerning = 'auto';
338
- this.textarea.style.boxSizing = 'content-box'; // Padding is added outside width/height
354
+ this.textarea.style.textRendering = 'optimizeLegibility';
355
+ this.textarea.style.fontKerning = 'normal';
356
+ this.textarea.style.fontFeatureSettings = 'normal';
357
+ this.textarea.style.fontVariationSettings = 'normal';
339
358
  this.textarea.style.margin = '0';
340
359
  this.textarea.style.border = 'none';
341
360
  this.textarea.style.outline = 'none';
342
361
  this.textarea.style.background = 'transparent';
343
- this.textarea.style.wordWrap = 'break-word';
362
+ this.textarea.style.overflowWrap = 'break-word';
344
363
  this.textarea.style.whiteSpace = 'pre-wrap';
364
+ this.textarea.style.hyphens = 'none';
345
365
 
346
- // DEBUG: Log final textarea dimensions
347
- console.log('TEXTAREA AFTER SETUP:');
348
- console.log(' textarea width =', this.textarea.style.width);
349
- console.log(' textarea height =', this.textarea.style.height);
350
- console.log(' textarea padding =', this.textarea.style.padding);
351
- console.log(' paddingX =', paddingX, 'paddingY =', paddingY);
352
- console.log(' baseFontSize =', baseFontSize);
353
- console.log(' scaleX =', scaleX);
354
- console.log(' zoom =', zoom);
355
- console.log(' finalFontSize =', finalFontSize);
356
- console.log(' fabricLineHeight =', fabricLineHeight);
366
+ (this.textarea.style as any).webkitFontSmoothing = 'antialiased';
367
+ (this.textarea.style as any).mozOsxFontSmoothing = 'grayscale';
368
+
369
+ // Debug: Compare textarea and canvas object bounding boxes
370
+ this.debugBoundingBoxComparison();
371
+
372
+ // Debug: Compare text wrapping behavior
373
+ this.debugTextWrapping();
357
374
 
358
375
  // Initial bounds are set correctly by Fabric.js - don't force update here
376
+ }
359
377
 
360
-
378
+ /**
379
+ * Debug method to compare textarea and canvas object bounding boxes
380
+ */
381
+ private debugBoundingBoxComparison(): void {
382
+ const target = this.target;
383
+ const canvas = this.canvas;
384
+ const zoom = canvas.getZoom();
385
+
386
+ // Get textarea bounding box (in screen coordinates)
387
+ const textareaRect = this.textarea.getBoundingClientRect();
388
+ const hostRect = this.hostDiv.getBoundingClientRect();
389
+
390
+ // Get canvas object bounding box (in screen coordinates)
391
+ const canvasBounds = target.getBoundingRect();
392
+ const canvasRect = canvas.upperCanvasEl.getBoundingClientRect();
393
+
394
+ // Convert canvas object bounds to screen coordinates
395
+ const vpt = canvas.viewportTransform;
396
+ const screenObjectBounds = {
397
+ left: canvasRect.left + canvasBounds.left * zoom + vpt[4],
398
+ top: canvasRect.top + canvasBounds.top * zoom + vpt[5],
399
+ width: canvasBounds.width * zoom,
400
+ height: canvasBounds.height * zoom,
401
+ };
402
+
403
+ console.log('🔍 BOUNDING BOX COMPARISON:');
404
+ console.log('📦 Textarea Rect:', {
405
+ left: Math.round(textareaRect.left * 100) / 100,
406
+ top: Math.round(textareaRect.top * 100) / 100,
407
+ width: Math.round(textareaRect.width * 100) / 100,
408
+ height: Math.round(textareaRect.height * 100) / 100,
409
+ });
410
+ console.log('📦 Host Div Rect:', {
411
+ left: Math.round(hostRect.left * 100) / 100,
412
+ top: Math.round(hostRect.top * 100) / 100,
413
+ width: Math.round(hostRect.width * 100) / 100,
414
+ height: Math.round(hostRect.height * 100) / 100,
415
+ });
416
+ console.log('📦 Canvas Object Bounds (screen):', {
417
+ left: Math.round(screenObjectBounds.left * 100) / 100,
418
+ top: Math.round(screenObjectBounds.top * 100) / 100,
419
+ width: Math.round(screenObjectBounds.width * 100) / 100,
420
+ height: Math.round(screenObjectBounds.height * 100) / 100,
421
+ });
422
+ console.log('📦 Canvas Object Bounds (canvas):', canvasBounds);
423
+
424
+ // Calculate differences
425
+ const hostVsObject = {
426
+ leftDiff:
427
+ Math.round((hostRect.left - screenObjectBounds.left) * 100) / 100,
428
+ topDiff: Math.round((hostRect.top - screenObjectBounds.top) * 100) / 100,
429
+ widthDiff:
430
+ Math.round((hostRect.width - screenObjectBounds.width) * 100) / 100,
431
+ heightDiff:
432
+ Math.round((hostRect.height - screenObjectBounds.height) * 100) / 100,
433
+ };
434
+
435
+ const textareaVsObject = {
436
+ leftDiff:
437
+ Math.round((textareaRect.left - screenObjectBounds.left) * 100) / 100,
438
+ topDiff:
439
+ Math.round((textareaRect.top - screenObjectBounds.top) * 100) / 100,
440
+ widthDiff:
441
+ Math.round((textareaRect.width - screenObjectBounds.width) * 100) / 100,
442
+ heightDiff:
443
+ Math.round((textareaRect.height - screenObjectBounds.height) * 100) /
444
+ 100,
445
+ };
446
+
447
+ console.log('📏 Host Div vs Canvas Object Diff:', hostVsObject);
448
+ console.log('📏 Textarea vs Canvas Object Diff:', textareaVsObject);
449
+
450
+ // Check if they're aligned (within 2px tolerance)
451
+ const tolerance = 2;
452
+ const hostAligned =
453
+ Math.abs(hostVsObject.leftDiff) < tolerance &&
454
+ Math.abs(hostVsObject.topDiff) < tolerance &&
455
+ Math.abs(hostVsObject.widthDiff) < tolerance &&
456
+ Math.abs(hostVsObject.heightDiff) < tolerance;
457
+
458
+ const textareaAligned =
459
+ Math.abs(textareaVsObject.leftDiff) < tolerance &&
460
+ Math.abs(textareaVsObject.topDiff) < tolerance &&
461
+ Math.abs(textareaVsObject.widthDiff) < tolerance &&
462
+ Math.abs(textareaVsObject.heightDiff) < tolerance;
463
+
464
+ console.log(
465
+ hostAligned
466
+ ? '✅ Host Div ALIGNED with canvas object'
467
+ : '❌ Host Div MISALIGNED with canvas object',
468
+ );
469
+ console.log(
470
+ textareaAligned
471
+ ? '✅ Textarea ALIGNED with canvas object'
472
+ : '❌ Textarea MISALIGNED with canvas object',
473
+ );
474
+ console.log('🔍 Zoom:', zoom, 'Viewport Transform:', vpt);
475
+ console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
361
476
  }
362
477
 
478
+ /**
479
+ * Debug method to compare text wrapping between textarea and Fabric text object
480
+ */
481
+ private debugTextWrapping(): void {
482
+ const target = this.target;
483
+ const text = this.textarea.value;
484
+
485
+ console.log('📝 TEXT WRAPPING COMPARISON:');
486
+ console.log('📄 Text Content:', `"${text}"`);
487
+ console.log('📄 Text Length:', text.length);
488
+
489
+ // Analyze line breaks
490
+ const explicitLines = text.split('\n');
491
+ console.log('📄 Explicit Lines (\\n):', explicitLines.length);
492
+ explicitLines.forEach((line, i) => {
493
+ console.log(` Line ${i + 1}: "${line}" (${line.length} chars)`);
494
+ });
495
+
496
+ // Get textarea computed styles for wrapping analysis
497
+ const textareaStyles = window.getComputedStyle(this.textarea);
498
+ console.log('📐 Textarea Wrapping Styles:');
499
+ console.log(' width:', textareaStyles.width);
500
+ console.log(' fontSize:', textareaStyles.fontSize);
501
+ console.log(' fontFamily:', textareaStyles.fontFamily);
502
+ console.log(' fontWeight:', textareaStyles.fontWeight);
503
+ console.log(' letterSpacing:', textareaStyles.letterSpacing);
504
+ console.log(' lineHeight:', textareaStyles.lineHeight);
505
+ console.log(' whiteSpace:', textareaStyles.whiteSpace);
506
+ console.log(' wordWrap:', textareaStyles.wordWrap);
507
+ console.log(' overflowWrap:', textareaStyles.overflowWrap);
508
+ console.log(' direction:', textareaStyles.direction);
509
+ console.log(' textAlign:', textareaStyles.textAlign);
510
+
511
+ // Get Fabric text object properties for comparison
512
+ console.log('📐 Fabric Text Object Properties:');
513
+ console.log(' width:', (target as any).width);
514
+ console.log(' fontSize:', target.fontSize);
515
+ console.log(' fontFamily:', target.fontFamily);
516
+ console.log(' fontWeight:', target.fontWeight);
517
+ console.log(' charSpacing:', target.charSpacing);
518
+ console.log(' lineHeight:', target.lineHeight);
519
+ console.log(' direction:', (target as any).direction);
520
+ console.log(' textAlign:', (target as any).textAlign);
521
+ console.log(' scaleX:', target.scaleX);
522
+ console.log(' scaleY:', target.scaleY);
523
+
524
+ // Calculate effective dimensions for comparison - use actual rendered width
525
+ // **THE FIX:** Use getBoundingRect to get the *actual rendered width* of the Fabric object.
526
+ const fabricEffectiveWidth = this.target.getBoundingRect().width;
527
+ // Use the exact width set on textarea for comparison
528
+ const textareaComputedWidth = parseFloat(
529
+ window.getComputedStyle(this.textarea).width,
530
+ );
531
+ const textareaEffectiveWidth =
532
+ textareaComputedWidth / this.canvas.getZoom();
533
+ const widthDiff = Math.abs(textareaEffectiveWidth - fabricEffectiveWidth);
534
+
535
+ console.log('📏 Effective Width Comparison:');
536
+ console.log(' Textarea Effective Width:', textareaEffectiveWidth);
537
+ console.log(' Fabric Effective Width:', fabricEffectiveWidth);
538
+ console.log(' Width Difference:', widthDiff.toFixed(2) + 'px');
539
+ console.log(
540
+ widthDiff < 1
541
+ ? '✅ Widths MATCH for wrapping'
542
+ : '❌ Width MISMATCH may cause different wrapping',
543
+ );
544
+
545
+ // Check text direction and bidi handling
546
+ const hasRTLText =
547
+ /[\u0590-\u05FF\u0600-\u06FF\u0750-\u077F\uFB50-\uFDFF\uFE70-\uFEFF]/.test(
548
+ text,
549
+ );
550
+ const hasBidiText = /[\u0590-\u06FF]/.test(text) && /[a-zA-Z]/.test(text);
551
+
552
+ console.log('🌍 Text Direction Analysis:');
553
+ console.log(' Has RTL characters:', hasRTLText);
554
+ console.log(' Has mixed Bidi text:', hasBidiText);
555
+ console.log(' Textarea direction:', textareaStyles.direction);
556
+ console.log(' Fabric direction:', (target as any).direction || 'auto');
557
+ console.log(' Textarea unicodeBidi:', textareaStyles.unicodeBidi);
558
+
559
+ // Measure actual rendered line count
560
+ const textareaScrollHeight = this.textarea.scrollHeight;
561
+ const textareaLineHeight =
562
+ parseFloat(textareaStyles.lineHeight) ||
563
+ parseFloat(textareaStyles.fontSize) * 1.2;
564
+ const estimatedTextareaLines = Math.round(
565
+ textareaScrollHeight / textareaLineHeight,
566
+ );
567
+
568
+ console.log('📊 Line Count Analysis:');
569
+ console.log(' Textarea scrollHeight:', textareaScrollHeight);
570
+ console.log(' Textarea lineHeight:', textareaLineHeight);
571
+ console.log(' Estimated rendered lines:', estimatedTextareaLines);
572
+ console.log(' Explicit line breaks:', explicitLines.length);
573
+
574
+ if (estimatedTextareaLines > explicitLines.length) {
575
+ console.log('🔄 Text wrapping detected in textarea');
576
+ console.log(
577
+ ' Wrapped lines:',
578
+ estimatedTextareaLines - explicitLines.length,
579
+ );
580
+ } else {
581
+ console.log('📏 No text wrapping in textarea');
582
+ }
583
+
584
+ console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
585
+ }
586
+
587
+
363
588
  /**
364
589
  * Focus the textarea and position cursor at end
365
590
  */
366
591
  private focusTextarea(): void {
367
-
368
-
369
592
  // For overlay editing, we want to keep the object in "selection mode" not "editing mode"
370
593
  // This means keeping selected=true and isEditing=false to show boundaries
371
-
594
+
372
595
  // Hide the text content only (not the entire object)
373
596
  this.target.opacity = 0.01; // Nearly transparent but not fully hidden
374
-
597
+
375
598
  // Ensure object stays selected to show boundaries
376
599
  (this.target as any).selected = true;
377
600
  (this.target as any).isEditing = false; // Override any editing state
378
-
601
+
379
602
  // Make sure controls are enabled and movement is allowed during overlay editing
380
603
  this.target.set({
381
604
  hasControls: true,
382
605
  hasBorders: true,
383
606
  selectable: true,
384
607
  lockMovementX: false,
385
- lockMovementY: false
608
+ lockMovementY: false,
386
609
  });
387
-
610
+
388
611
  // Keep as active object
389
612
  this.canvas.setActiveObject(this.target);
390
-
613
+
391
614
  this.canvas.requestRenderAll();
392
615
  this.target.setCoords();
393
616
  this.applyOverlayStyle();
394
617
 
395
-
396
-
397
618
  this.textarea.focus();
619
+
398
620
  this.textarea.setSelectionRange(
399
621
  this.textarea.value.length,
400
622
  this.textarea.value.length,
401
623
  );
402
-
624
+
403
625
  // Ensure the object stays selected even after textarea focus
404
626
  this.canvas.setActiveObject(this.target);
405
627
  this.canvas.requestRenderAll();
@@ -470,6 +692,7 @@ export class OverlayEditor {
470
692
  // Live update target text
471
693
  this.target.text = this.textarea.value;
472
694
 
695
+
473
696
  // Auto-resize textarea to match new content
474
697
  this.autoResizeTextarea();
475
698
 
@@ -482,25 +705,40 @@ export class OverlayEditor {
482
705
  }
483
706
 
484
707
  private autoResizeTextarea(): void {
485
- // Allow both vertical growth and shrinking; host width stays fixed
486
- const oldHeight = parseFloat(window.getComputedStyle(this.textarea).height);
487
-
488
- // Reset height to measure actual needed height
489
- this.textarea.style.height = 'auto';
708
+ // Store the scroll position and the container's old height for comparison.
709
+ const scrollTop = this.textarea.scrollTop;
710
+ const oldHeight = parseFloat(this.hostDiv.style.height || '0');
711
+
712
+ // 1. **Force a reliable reflow.**
713
+ // First, reset the textarea's height to a minimal value. This is the crucial step
714
+ // that forces the browser to recalculate the content's height from scratch,
715
+ // ignoring the hostDiv's larger, stale height.
716
+ this.textarea.style.height = '1px';
717
+
718
+ // 2. Read the now-accurate scrollHeight. This value reflects the minimum
719
+ // height required for the content, whether it's single or multi-line.
490
720
  const scrollHeight = this.textarea.scrollHeight;
491
-
492
- // Add extra padding to prevent text clipping (especially for line height)
493
- const lineHeightBuffer = 8; // Extra space to prevent clipping
494
- const newHeight = Math.max(scrollHeight + lineHeightBuffer, 25); // Minimum height with buffer
495
- const heightChanged = Math.abs(newHeight - oldHeight) > 2; // Only if meaningful change
496
-
497
- this.textarea.style.height = `${newHeight}px`;
498
- this.hostDiv.style.height = `${newHeight}px`; // Match exactly
499
-
500
- // Only update object bounds if height actually changed
721
+
722
+ // A small buffer for rendering consistency across browsers.
723
+ const buffer = 2;
724
+ const newHeight = scrollHeight + buffer;
725
+
726
+ // Check if the height has changed significantly.
727
+ const heightChanged = Math.abs(newHeight - oldHeight) > 1;
728
+
729
+ // 4. Only update heights and object bounds if there was a change.
501
730
  if (heightChanged) {
731
+ this.textarea.style.height = `${newHeight}px`;
732
+ this.hostDiv.style.height = `${newHeight}px`;
502
733
  this.updateObjectBounds();
734
+ } else {
735
+ // If no significant change, ensure the textarea's height matches the container
736
+ // to prevent any minor visual misalignment.
737
+ this.textarea.style.height = this.hostDiv.style.height;
503
738
  }
739
+
740
+ // 5. Restore the original scroll position.
741
+ this.textarea.scrollTop = scrollTop;
504
742
  }
505
743
 
506
744
  private handleKeyDown(e: KeyboardEvent): void {
@@ -510,6 +748,23 @@ export class OverlayEditor {
510
748
  } else if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
511
749
  e.preventDefault();
512
750
  this.destroy(true); // Commit
751
+ } else if (
752
+ e.key === 'Enter' ||
753
+ e.key === 'Backspace' ||
754
+ e.key === 'Delete'
755
+ ) {
756
+ // For keys that might change the height, schedule a resize check
757
+ // Use both immediate and delayed checks to catch all scenarios
758
+ requestAnimationFrame(() => {
759
+ if (!this.isDestroyed) {
760
+ this.autoResizeTextarea();
761
+ }
762
+ });
763
+ setTimeout(() => {
764
+ if (!this.isDestroyed) {
765
+ this.autoResizeTextarea();
766
+ }
767
+ }, 10); // Small delay to ensure DOM is updated
513
768
  }
514
769
  }
515
770
 
@@ -553,9 +808,10 @@ export class OverlayEditor {
553
808
  private setupViewportChangeDetection(): void {
554
809
  // Store original methods
555
810
  (this.canvas as any).__originalSetZoom = this.canvas.setZoom;
556
- (this.canvas as any).__originalSetViewportTransform = this.canvas.setViewportTransform;
811
+ (this.canvas as any).__originalSetViewportTransform =
812
+ this.canvas.setViewportTransform;
557
813
  (this.canvas as any).__overlayEditor = this;
558
-
814
+
559
815
  // Override setZoom to detect zoom changes
560
816
  const originalSetZoom = this.canvas.setZoom.bind(this.canvas);
561
817
  this.canvas.setZoom = (value: number) => {
@@ -565,9 +821,11 @@ export class OverlayEditor {
565
821
  }
566
822
  return result;
567
823
  };
568
-
824
+
569
825
  // Override setViewportTransform to detect pan changes
570
- const originalSetViewportTransform = this.canvas.setViewportTransform.bind(this.canvas);
826
+ const originalSetViewportTransform = this.canvas.setViewportTransform.bind(
827
+ this.canvas,
828
+ );
571
829
  this.canvas.setViewportTransform = (vpt: TMat2D) => {
572
830
  const result = originalSetViewportTransform(vpt);
573
831
  if ((this.canvas as any).__overlayEditor && !this.isDestroyed) {
@@ -576,7 +834,7 @@ export class OverlayEditor {
576
834
  return result;
577
835
  };
578
836
  }
579
-
837
+
580
838
  /**
581
839
  * Restore original viewport methods
582
840
  */
@@ -586,13 +844,13 @@ export class OverlayEditor {
586
844
  delete (this.canvas as any).__originalSetZoom;
587
845
  }
588
846
  if ((this.canvas as any).__originalSetViewportTransform) {
589
- this.canvas.setViewportTransform = (this.canvas as any).__originalSetViewportTransform;
847
+ this.canvas.setViewportTransform = (
848
+ this.canvas as any
849
+ ).__originalSetViewportTransform;
590
850
  delete (this.canvas as any).__originalSetViewportTransform;
591
851
  }
592
852
  delete (this.canvas as any).__overlayEditor;
593
853
  }
594
-
595
-
596
854
  }
597
855
 
598
856
  /**
@@ -622,7 +880,7 @@ export function enterTextOverlayEdit(
622
880
  });
623
881
 
624
882
  // We no longer change fill, so no need to store it
625
-
883
+
626
884
  // Store reference on target for cleanup
627
885
  (target as any).__overlayEditor = editor;
628
886