@nasser-sw/fabric 7.0.1-beta16 → 7.0.1-beta17

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.
Files changed (95) hide show
  1. package/.claude/settings.local.json +7 -0
  2. package/dist/index.js +1982 -649
  3. package/dist/index.js.map +1 -1
  4. package/dist/index.min.js +1 -1
  5. package/dist/index.min.js.map +1 -1
  6. package/dist/index.min.mjs +1 -1
  7. package/dist/index.min.mjs.map +1 -1
  8. package/dist/index.mjs +1982 -649
  9. package/dist/index.mjs.map +1 -1
  10. package/dist/index.node.cjs +1982 -649
  11. package/dist/index.node.cjs.map +1 -1
  12. package/dist/index.node.mjs +1982 -649
  13. package/dist/index.node.mjs.map +1 -1
  14. package/dist/package.json.min.mjs +1 -1
  15. package/dist/package.json.mjs +1 -1
  16. package/dist/src/shapes/IText/IText.d.ts +31 -6
  17. package/dist/src/shapes/IText/IText.d.ts.map +1 -1
  18. package/dist/src/shapes/IText/IText.min.mjs +1 -1
  19. package/dist/src/shapes/IText/IText.min.mjs.map +1 -1
  20. package/dist/src/shapes/IText/IText.mjs +495 -126
  21. package/dist/src/shapes/IText/IText.mjs.map +1 -1
  22. package/dist/src/shapes/IText/ITextBehavior.d.ts +12 -0
  23. package/dist/src/shapes/IText/ITextBehavior.d.ts.map +1 -1
  24. package/dist/src/shapes/IText/ITextBehavior.min.mjs +1 -1
  25. package/dist/src/shapes/IText/ITextBehavior.min.mjs.map +1 -1
  26. package/dist/src/shapes/IText/ITextBehavior.mjs +127 -36
  27. package/dist/src/shapes/IText/ITextBehavior.mjs.map +1 -1
  28. package/dist/src/shapes/IText/ITextClickBehavior.d.ts.map +1 -1
  29. package/dist/src/shapes/IText/ITextClickBehavior.min.mjs +1 -1
  30. package/dist/src/shapes/IText/ITextClickBehavior.min.mjs.map +1 -1
  31. package/dist/src/shapes/IText/ITextClickBehavior.mjs +21 -4
  32. package/dist/src/shapes/IText/ITextClickBehavior.mjs.map +1 -1
  33. package/dist/src/shapes/IText/ITextKeyBehavior.min.mjs +1 -1
  34. package/dist/src/shapes/IText/ITextKeyBehavior.min.mjs.map +1 -1
  35. package/dist/src/shapes/IText/ITextKeyBehavior.mjs +17 -21
  36. package/dist/src/shapes/IText/ITextKeyBehavior.mjs.map +1 -1
  37. package/dist/src/shapes/Text/Text.d.ts +69 -1
  38. package/dist/src/shapes/Text/Text.d.ts.map +1 -1
  39. package/dist/src/shapes/Text/Text.min.mjs +1 -1
  40. package/dist/src/shapes/Text/Text.min.mjs.map +1 -1
  41. package/dist/src/shapes/Text/Text.mjs +374 -60
  42. package/dist/src/shapes/Text/Text.mjs.map +1 -1
  43. package/dist/src/shapes/Text/constants.d.ts.map +1 -1
  44. package/dist/src/shapes/Text/constants.min.mjs +1 -1
  45. package/dist/src/shapes/Text/constants.min.mjs.map +1 -1
  46. package/dist/src/shapes/Text/constants.mjs +2 -1
  47. package/dist/src/shapes/Text/constants.mjs.map +1 -1
  48. package/dist/src/shapes/Textbox.d.ts +8 -1
  49. package/dist/src/shapes/Textbox.d.ts.map +1 -1
  50. package/dist/src/shapes/Textbox.min.mjs +1 -1
  51. package/dist/src/shapes/Textbox.min.mjs.map +1 -1
  52. package/dist/src/shapes/Textbox.mjs +406 -63
  53. package/dist/src/shapes/Textbox.mjs.map +1 -1
  54. package/dist/src/text/hitTest.min.mjs +1 -1
  55. package/dist/src/text/hitTest.min.mjs.map +1 -1
  56. package/dist/src/text/hitTest.mjs +1 -198
  57. package/dist/src/text/hitTest.mjs.map +1 -1
  58. package/dist/src/text/layout.min.mjs +1 -1
  59. package/dist/src/text/layout.min.mjs.map +1 -1
  60. package/dist/src/text/layout.mjs +122 -5
  61. package/dist/src/text/layout.mjs.map +1 -1
  62. package/dist/src/text/overlayEditor.min.mjs +1 -1
  63. package/dist/src/text/overlayEditor.min.mjs.map +1 -1
  64. package/dist/src/text/overlayEditor.mjs +132 -142
  65. package/dist/src/text/overlayEditor.mjs.map +1 -1
  66. package/dist/src/text/unicode.d.ts +28 -0
  67. package/dist/src/text/unicode.d.ts.map +1 -1
  68. package/dist/src/text/unicode.min.mjs +1 -1
  69. package/dist/src/text/unicode.min.mjs.map +1 -1
  70. package/dist/src/text/unicode.mjs +294 -1
  71. package/dist/src/text/unicode.mjs.map +1 -1
  72. package/dist-extensions/src/shapes/IText/IText.d.ts +31 -6
  73. package/dist-extensions/src/shapes/IText/IText.d.ts.map +1 -1
  74. package/dist-extensions/src/shapes/IText/ITextBehavior.d.ts +12 -0
  75. package/dist-extensions/src/shapes/IText/ITextBehavior.d.ts.map +1 -1
  76. package/dist-extensions/src/shapes/IText/ITextClickBehavior.d.ts.map +1 -1
  77. package/dist-extensions/src/shapes/Text/Text.d.ts +69 -1
  78. package/dist-extensions/src/shapes/Text/Text.d.ts.map +1 -1
  79. package/dist-extensions/src/shapes/Text/constants.d.ts.map +1 -1
  80. package/dist-extensions/src/shapes/Textbox.d.ts +8 -1
  81. package/dist-extensions/src/shapes/Textbox.d.ts.map +1 -1
  82. package/dist-extensions/src/text/unicode.d.ts +28 -0
  83. package/dist-extensions/src/text/unicode.d.ts.map +1 -1
  84. package/package.json +164 -164
  85. package/rtl-debug.html +358 -200
  86. package/src/shapes/IText/IText.ts +524 -110
  87. package/src/shapes/IText/ITextBehavior.ts +174 -80
  88. package/src/shapes/IText/ITextClickBehavior.ts +20 -6
  89. package/src/shapes/IText/ITextKeyBehavior.ts +15 -15
  90. package/src/shapes/Text/Text.ts +488 -107
  91. package/src/shapes/Text/constants.ts +4 -2
  92. package/src/shapes/Textbox.ts +414 -65
  93. package/src/text/layout.ts +150 -23
  94. package/src/text/overlayEditor.ts +148 -148
  95. package/src/text/unicode.ts +177 -2
@@ -1,14 +1,14 @@
1
1
  import { defineProperty as _defineProperty } from '../../../_virtual/_rollupPluginBabelHelpers.mjs';
2
2
  import { Canvas } from '../../canvas/Canvas.mjs';
3
3
  import { ITextClickBehavior } from './ITextClickBehavior.mjs';
4
- import { getCursorRect, hitTest } from '../../text/hitTest.mjs';
4
+ import { getCursorRect } from '../../text/hitTest.mjs';
5
+ import { analyzeBiDi } from '../../text/unicode.mjs';
5
6
  import { ctrlKeysMapUp, ctrlKeysMapDown, keysMapRtl, keysMap } from './constants.mjs';
6
7
  import { classRegistry } from '../../ClassRegistry.mjs';
7
- import { JUSTIFY, JUSTIFY_RIGHT, JUSTIFY_LEFT, JUSTIFY_CENTER } from '../Text/constants.mjs';
8
- import { RIGHT, LEFT, CENTER, FILL } from '../../constants.mjs';
8
+ import { JUSTIFY } from '../Text/constants.mjs';
9
+ import { FILL } from '../../constants.mjs';
9
10
  import { createCanvasElementFor } from '../../util/misc/dom.mjs';
10
11
  import { applyCanvasTransform } from '../../util/internals/applyCanvasTransform.mjs';
11
- import { Point } from '../../Point.mjs';
12
12
  import { invertTransform } from '../../util/misc/matrix.mjs';
13
13
 
14
14
  // Declare IText protected properties to workaround TS
@@ -105,6 +105,20 @@ class IText extends ITextClickBehavior {
105
105
  ...IText.ownDefaults,
106
106
  ...options
107
107
  });
108
+ /**
109
+ * Index where text selection starts (or where cursor is when there is no selection)
110
+ * @type Number
111
+ */
112
+ /**
113
+ * Index where text selection ends
114
+ * @type Number
115
+ */
116
+ /**
117
+ * Cache for visual positions per line to ensure consistency
118
+ * during selection operations
119
+ * @private
120
+ */
121
+ _defineProperty(this, "_visualPositionsCache", new Map());
108
122
  this.initBehavior();
109
123
  }
110
124
 
@@ -228,6 +242,8 @@ class IText extends ITextClickBehavior {
228
242
  // clear the cursorOffsetCache, so we ensure to calculate once per renderCursor
229
243
  // the correct position but not at every cursor animation.
230
244
  this.cursorOffsetCache = {};
245
+ // Clear visual positions cache on full render since dimensions may have changed
246
+ this._clearVisualPositionsCache();
231
247
  this.renderCursorOrSelection();
232
248
  }
233
249
 
@@ -255,6 +271,9 @@ class IText extends ITextClickBehavior {
255
271
  if (!ctx) {
256
272
  return;
257
273
  }
274
+ // Clear cache to ensure fresh cursor position calculation
275
+ // This is important during selection drag when positions change frequently
276
+ this.cursorOffsetCache = {};
258
277
  const boundaries = this._getCursorBoundaries();
259
278
  const ancestors = this.findAncestorsWithClipPath();
260
279
  const hasAncestorsWithClipping = ancestors.length > 0;
@@ -332,12 +351,8 @@ class IText extends ITextClickBehavior {
332
351
  _getCursorBoundaries() {
333
352
  let index = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : this.selectionStart;
334
353
  let skipCaching = arguments.length > 1 ? arguments[1] : undefined;
335
- // Use advanced cursor positioning if available
336
- if (this.enableAdvancedLayout) {
337
- return this._getCursorBoundariesAdvanced(index);
338
- }
339
-
340
- // Fall back to original method
354
+ // Always use original method which uses __charBounds directly
355
+ // and has proper RTL handling built-in
341
356
  return this._getCursorBoundariesOriginal(index, skipCaching);
342
357
  }
343
358
 
@@ -376,19 +391,261 @@ class IText extends ITextClickBehavior {
376
391
  }
377
392
 
378
393
  /**
379
- * Enhanced selection start from pointer using BiDi-aware hit testing
380
- * @override
394
+ * Override selection to use measureText-based visual positions
395
+ * This ensures hit testing matches actual browser BiDi rendering
381
396
  */
382
397
  getSelectionStartFromPointer(e) {
383
- if (!this.enableAdvancedLayout || !this._layoutTextAdvanced) {
384
- return super.getSelectionStartFromPointer(e);
398
+ // Get mouse position in object-local coordinates (origin at center)
399
+ const scenePoint = this.canvas.getScenePoint(e);
400
+ const localPoint = scenePoint.transform(invertTransform(this.calcTransformMatrix()));
401
+
402
+ // Convert to top-left origin coordinates
403
+ const mouseX = localPoint.x + this.width / 2;
404
+ const mouseY = localPoint.y + this.height / 2;
405
+
406
+ // Find the line based on Y position
407
+ let height = 0,
408
+ lineIndex = 0;
409
+ for (let i = 0; i < this._textLines.length; i++) {
410
+ const lineHeight = this.getHeightOfLine(i);
411
+ if (mouseY >= height && mouseY < height + lineHeight) {
412
+ lineIndex = i;
413
+ break;
414
+ }
415
+ height += lineHeight;
416
+ if (i === this._textLines.length - 1) {
417
+ lineIndex = i;
418
+ }
385
419
  }
386
- const mouseOffset = this.canvas.getScenePoint(e).transform(invertTransform(this.calcTransformMatrix())).add(new Point(-this._getLeftOffset(), -this._getTopOffset()));
387
420
 
388
- // Use BiDi-aware hit testing instead of naive RTL coordinate flipping
389
- const layout = this._layoutTextAdvanced();
390
- const hitResult = hitTest(mouseOffset.x, mouseOffset.y, layout, this._getAdvancedLayoutOptions());
391
- return Math.min(hitResult.charIndex, this._text.length);
421
+ // Calculate line start index using ORIGINAL line lengths (without tatweels)
422
+ // This ensures selection indices refer to the original text, not the display text
423
+ let lineStartIndex = 0;
424
+ for (let i = 0; i < lineIndex; i++) {
425
+ const origLen = this._getOriginalLineLength(i);
426
+ const newlineOffset = this.missingNewlineOffset(i);
427
+ console.log(`📍 Line ${i}: origLen=${origLen}, displayLen=${this._textLines[i].length}, tatweels=${this._getTatweelCountForLine(i)}, newlineOffset=${newlineOffset}`);
428
+ lineStartIndex += origLen + newlineOffset;
429
+ }
430
+ console.log(`📍 Click on line ${lineIndex}, lineStartIndex=${lineStartIndex}`);
431
+ const line = this._textLines[lineIndex];
432
+ const lineText = line.join('');
433
+ const displayCharLength = line.length;
434
+ const originalCharLength = this._getOriginalLineLength(lineIndex);
435
+ if (displayCharLength === 0) {
436
+ return lineStartIndex;
437
+ }
438
+
439
+ // Use measureText to get actual visual character positions
440
+ // This matches exactly how the canvas renders BiDi text
441
+ const visualPositions = this._measureVisualPositions(lineIndex, lineText);
442
+
443
+ // Calculate line offset based on alignment
444
+ const lineWidth = this.getLineWidth(lineIndex);
445
+ let lineStartX = 0;
446
+ if (this.textAlign === 'center' || this.textAlign === 'justify-center') {
447
+ lineStartX = (this.width - lineWidth) / 2;
448
+ } else if (this.textAlign === 'right' || this.textAlign === 'justify-right') {
449
+ lineStartX = this.width - lineWidth;
450
+ } else if (this.direction === 'rtl' && (this.textAlign === 'justify' || this.textAlign === 'left')) {
451
+ // For RTL with left/justify, text starts from right
452
+ lineStartX = this.width - lineWidth;
453
+ }
454
+
455
+ // Find which character was clicked based on visual position
456
+ const clickX = mouseX - lineStartX;
457
+
458
+ // Sort positions by visual X for hit testing
459
+ const sortedPositions = [...visualPositions].sort((a, b) => a.visualX - b.visualX);
460
+
461
+ // Handle click before first character
462
+ if (sortedPositions.length > 0 && clickX < sortedPositions[0].visualX) {
463
+ // Before first visual character - cursor at visual left edge
464
+ // For RTL base direction, this means logical end of line
465
+ return this.direction === 'rtl' ? lineStartIndex + originalCharLength : lineStartIndex;
466
+ }
467
+
468
+ // Handle click after last character
469
+ if (sortedPositions.length > 0) {
470
+ const lastPos = sortedPositions[sortedPositions.length - 1];
471
+ if (clickX > lastPos.visualX + lastPos.width) {
472
+ // After last visual character - cursor at visual right edge
473
+ // For RTL base direction, this means logical start of line
474
+ return this.direction === 'rtl' ? lineStartIndex : lineStartIndex + originalCharLength;
475
+ }
476
+ }
477
+
478
+ // Find the character at click position
479
+ for (let i = 0; i < sortedPositions.length; i++) {
480
+ const pos = sortedPositions[i];
481
+ const charEnd = pos.visualX + pos.width;
482
+ if (clickX >= pos.visualX && clickX <= charEnd) {
483
+ // Convert display index to original index
484
+ // This also handles tatweels - they map to the character they extend
485
+ const originalCharIndex = this._displayToOriginalIndex(lineIndex, pos.logicalIndex);
486
+
487
+ // Check if this is a tatweel - if so, treat click as clicking on the extended character
488
+ const isTatweel = this._isTatweelAtDisplayIndex(lineIndex, pos.logicalIndex);
489
+ console.log(`📍 Hit char: displayIdx=${pos.logicalIndex}, origIdx=${originalCharIndex}, isTatweel=${isTatweel}, char="${this._textLines[lineIndex][pos.logicalIndex]}"`);
490
+ const charMiddle = pos.visualX + pos.width / 2;
491
+ const clickedLeftHalf = clickX <= charMiddle;
492
+
493
+ // For tatweels, clicking anywhere on it should place cursor after the extended character
494
+ if (isTatweel) {
495
+ // Tatweel extends the character before it, so cursor goes after that character
496
+ // originalCharIndex from _displayToOriginalIndex already maps tatweel to char+1
497
+ const result = lineStartIndex + originalCharIndex;
498
+ console.log(`📍 Tatweel click result: ${result}`);
499
+ return result;
500
+ }
501
+
502
+ // For RTL characters: left visual half means cursor AFTER (higher logical index)
503
+ // For LTR characters: left visual half means cursor BEFORE (lower logical index)
504
+ if (pos.isRtl) {
505
+ // RTL character
506
+ const result = lineStartIndex + (clickedLeftHalf ? originalCharIndex + 1 : originalCharIndex);
507
+ console.log(`📍 RTL char result: ${result} (clickedLeftHalf=${clickedLeftHalf})`);
508
+ return result;
509
+ } else {
510
+ // LTR character
511
+ const result = lineStartIndex + (clickedLeftHalf ? originalCharIndex : originalCharIndex + 1);
512
+ console.log(`📍 LTR char result: ${result} (clickedLeftHalf=${clickedLeftHalf})`);
513
+ return result;
514
+ }
515
+ }
516
+ }
517
+
518
+ // console.log(`📍 No match, returning end: ${lineStartIndex + originalCharLength}`);
519
+ return lineStartIndex + originalCharLength;
520
+ }
521
+
522
+ /**
523
+ * Clear the visual positions cache
524
+ * Should be called when text content or dimensions change
525
+ */
526
+ _clearVisualPositionsCache() {
527
+ this._visualPositionsCache.clear();
528
+ }
529
+
530
+ /**
531
+ * Measure visual character positions for hit testing using BiDi analysis
532
+ * This properly handles mixed RTL/LTR text by analyzing BiDi runs
533
+ * Results are cached per line for consistency during selection operations
534
+ */
535
+ _measureVisualPositions(lineIndex, lineText) {
536
+ // Check cache first
537
+ if (this._visualPositionsCache.has(lineIndex)) {
538
+ return this._visualPositionsCache.get(lineIndex);
539
+ }
540
+ const line = this._textLines[lineIndex];
541
+ const positions = [];
542
+ const chars = this.__charBounds[lineIndex];
543
+ if (!chars || chars.length === 0) {
544
+ this._visualPositionsCache.set(lineIndex, positions);
545
+ return positions;
546
+ }
547
+
548
+ // For LTR direction, use logical positions directly
549
+ if (this.direction !== 'rtl') {
550
+ for (let i = 0; i < line.length; i++) {
551
+ var _chars$i, _chars$i2;
552
+ positions.push({
553
+ logicalIndex: i,
554
+ visualX: ((_chars$i = chars[i]) === null || _chars$i === void 0 ? void 0 : _chars$i.left) || 0,
555
+ width: ((_chars$i2 = chars[i]) === null || _chars$i2 === void 0 ? void 0 : _chars$i2.kernedWidth) || 0,
556
+ isRtl: false
557
+ });
558
+ }
559
+ this._visualPositionsCache.set(lineIndex, positions);
560
+ return positions;
561
+ }
562
+
563
+ // For RTL, use BiDi analysis to determine visual positions
564
+ const runs = analyzeBiDi(lineText, 'rtl');
565
+
566
+ // Build mapping from string position to grapheme index
567
+ // This is needed because analyzeBiDi works on string positions (code points)
568
+ // but we need grapheme indices for charBounds
569
+ const stringPosToGrapheme = [];
570
+ let strPos = 0;
571
+ for (let gi = 0; gi < line.length; gi++) {
572
+ const grapheme = line[gi];
573
+ for (let j = 0; j < grapheme.length; j++) {
574
+ stringPosToGrapheme[strPos + j] = gi;
575
+ }
576
+ strPos += grapheme.length;
577
+ }
578
+
579
+ // Calculate width for each run
580
+
581
+ const runInfos = [];
582
+ for (const run of runs) {
583
+ const runChars = [];
584
+ let runWidth = 0;
585
+ const seenGraphemes = new Set();
586
+
587
+ // Map string positions in this run to grapheme indices
588
+ for (let sp = run.start; sp < run.end; sp++) {
589
+ const gi = stringPosToGrapheme[sp];
590
+ if (gi !== undefined && !seenGraphemes.has(gi)) {
591
+ var _chars$gi;
592
+ seenGraphemes.add(gi);
593
+ runChars.push(gi);
594
+ runWidth += ((_chars$gi = chars[gi]) === null || _chars$gi === void 0 ? void 0 : _chars$gi.kernedWidth) || 0;
595
+ }
596
+ }
597
+ runInfos.push({
598
+ run,
599
+ width: runWidth,
600
+ charIndices: runChars
601
+ });
602
+ }
603
+
604
+ // For RTL base direction, runs are displayed right-to-left
605
+ // So first run appears on the right, last run on the left
606
+ const totalWidth = this.getLineWidth(lineIndex);
607
+ let visualX = totalWidth; // Start from right edge
608
+
609
+ for (const runInfo of runInfos) {
610
+ visualX -= runInfo.width; // Move left by run width
611
+
612
+ const isRtlRun = runInfo.run.direction === 'rtl';
613
+ if (isRtlRun) {
614
+ // RTL run: characters displayed right-to-left within run
615
+ // First char of run at visual right of run, last at visual left
616
+ let charX = visualX + runInfo.width;
617
+ for (const idx of runInfo.charIndices) {
618
+ var _chars$idx;
619
+ const charWidth = ((_chars$idx = chars[idx]) === null || _chars$idx === void 0 ? void 0 : _chars$idx.kernedWidth) || 0;
620
+ charX -= charWidth;
621
+ positions.push({
622
+ logicalIndex: idx,
623
+ visualX: charX,
624
+ width: charWidth,
625
+ isRtl: true
626
+ });
627
+ }
628
+ } else {
629
+ // LTR run: characters displayed left-to-right within run
630
+ // First char of run at visual left of run, last at visual right
631
+ let charX = visualX;
632
+ for (const idx of runInfo.charIndices) {
633
+ var _chars$idx2;
634
+ const charWidth = ((_chars$idx2 = chars[idx]) === null || _chars$idx2 === void 0 ? void 0 : _chars$idx2.kernedWidth) || 0;
635
+ positions.push({
636
+ logicalIndex: idx,
637
+ visualX: charX,
638
+ width: charWidth,
639
+ isRtl: false
640
+ });
641
+ charX += charWidth;
642
+ }
643
+ }
644
+ }
645
+
646
+ // Cache the result
647
+ this._visualPositionsCache.set(lineIndex, positions);
648
+ return positions;
392
649
  }
393
650
 
394
651
  /**
@@ -408,40 +665,140 @@ class IText extends ITextClickBehavior {
408
665
  }
409
666
 
410
667
  /**
411
- * Calculates cursor left/top offset relative to instance's center point
668
+ * Calculates cursor left/top offset relative to _getLeftOffset()
669
+ * Uses visual positions for BiDi text support
670
+ * Handles kashida by converting original indices to display indices
412
671
  * @private
413
- * @param {number} index index from start
672
+ * @param {number} index index from start (in original text space, without tatweels)
414
673
  */
415
674
  __getCursorBoundariesOffsets(index) {
416
- let topOffset = 0,
417
- leftOffset = 0;
418
- const {
419
- charIndex,
420
- lineIndex
421
- } = this.get2DCursorLocation(index);
675
+ let topOffset = 0;
676
+
677
+ // Find line index and original char index using original line lengths
678
+ let lineIndex = 0;
679
+ let originalCharIndex = index;
680
+ for (let i = 0; i < this._textLines.length; i++) {
681
+ const originalLineLength = this._getOriginalLineLength(i);
682
+ if (originalCharIndex <= originalLineLength) {
683
+ lineIndex = i;
684
+ break;
685
+ }
686
+ originalCharIndex -= originalLineLength + this.missingNewlineOffset(i);
687
+ lineIndex = i + 1;
688
+ }
689
+
690
+ // Clamp lineIndex to valid range
691
+ if (lineIndex >= this._textLines.length) {
692
+ lineIndex = this._textLines.length - 1;
693
+ originalCharIndex = this._getOriginalLineLength(lineIndex);
694
+ }
422
695
  for (let i = 0; i < lineIndex; i++) {
423
696
  topOffset += this.getHeightOfLine(i);
424
697
  }
425
- const lineLeftOffset = this._getLineLeftOffset(lineIndex);
426
- const bound = this.__charBounds[lineIndex][charIndex];
427
- bound && (leftOffset = bound.left);
428
- if (this.charSpacing !== 0 && charIndex === this._textLines[lineIndex].length) {
429
- leftOffset -= this._getWidthOfCharSpacing();
698
+
699
+ // Convert original char index to display char index for visual lookup
700
+ const displayCharIndex = this._originalToDisplayIndex(lineIndex, originalCharIndex);
701
+
702
+ // Get visual positions for cursor placement
703
+ const lineText = this._textLines[lineIndex].join('');
704
+ const visualPositions = this._measureVisualPositions(lineIndex, lineText);
705
+ const lineWidth = this.getLineWidth(lineIndex);
706
+ this._textLines[lineIndex].length;
707
+ const originalLineLength = this._getOriginalLineLength(lineIndex);
708
+
709
+ // Find visual X position for cursor (0 to lineWidth, from visual left)
710
+ let visualX = 0;
711
+ if (visualPositions.length === 0) {
712
+ // Fallback for empty line
713
+ return {
714
+ top: topOffset,
715
+ left: 0
716
+ };
430
717
  }
431
- const boundaries = {
432
- top: topOffset,
433
- left: lineLeftOffset + (leftOffset > 0 ? leftOffset : 0)
434
- };
435
- if (this.direction === 'rtl') {
436
- if (this.textAlign === RIGHT || this.textAlign === JUSTIFY || this.textAlign === JUSTIFY_RIGHT) {
437
- boundaries.left *= -1;
438
- } else if (this.textAlign === LEFT || this.textAlign === JUSTIFY_LEFT) {
439
- boundaries.left = lineLeftOffset - (leftOffset > 0 ? leftOffset : 0);
440
- } else if (this.textAlign === CENTER || this.textAlign === JUSTIFY_CENTER) {
441
- boundaries.left = lineLeftOffset - (leftOffset > 0 ? leftOffset : 0);
718
+ if (originalCharIndex === 0) {
719
+ // Cursor at logical start
720
+ // For RTL base direction, logical start is at visual right
721
+ if (this.direction === 'rtl') {
722
+ visualX = lineWidth; // Right edge
723
+ } else {
724
+ visualX = 0; // Left edge
725
+ }
726
+ } else if (originalCharIndex >= originalLineLength) {
727
+ // Cursor at logical end
728
+ // For RTL base direction, logical end is at visual left
729
+ if (this.direction === 'rtl') {
730
+ visualX = 0; // Left edge
731
+ } else {
732
+ visualX = lineWidth; // Right edge
442
733
  }
734
+ } else {
735
+ // Cursor between characters - find visual position of character at displayCharIndex
736
+ const charPos = visualPositions.find(p => p.logicalIndex === displayCharIndex);
737
+ if (charPos) {
738
+ // Use character's direction to determine cursor position
739
+ // For RTL char: cursor "before" it appears at its right visual edge
740
+ // For LTR char: cursor "before" it appears at its left visual edge
741
+ if (charPos.isRtl) {
742
+ visualX = charPos.visualX + charPos.width;
743
+ } else {
744
+ visualX = charPos.visualX;
745
+ }
746
+ } else {
747
+ // Fallback - try the previous character in display space
748
+ const prevDisplayIndex = displayCharIndex > 0 ? displayCharIndex - 1 : 0;
749
+ const prevCharPos = visualPositions.find(p => p.logicalIndex === prevDisplayIndex);
750
+ if (prevCharPos) {
751
+ // Cursor after previous character
752
+ if (prevCharPos.isRtl) {
753
+ visualX = prevCharPos.visualX;
754
+ } else {
755
+ visualX = prevCharPos.visualX + prevCharPos.width;
756
+ }
757
+ } else {
758
+ // Ultimate fallback
759
+ const bound = this.__charBounds[lineIndex][displayCharIndex];
760
+ visualX = (bound === null || bound === void 0 ? void 0 : bound.left) || 0;
761
+ }
762
+ }
763
+ }
764
+
765
+ // Calculate alignment offset (how much line is shifted from left edge)
766
+ let alignOffset = 0;
767
+ if (this.textAlign === 'center' || this.textAlign === 'justify-center') {
768
+ alignOffset = (this.width - lineWidth) / 2;
769
+ } else if (this.textAlign === 'right' || this.textAlign === 'justify-right') {
770
+ alignOffset = this.width - lineWidth;
771
+ } else if (this.direction === 'rtl' && (this.textAlign === 'justify' || this.textAlign === 'left')) {
772
+ alignOffset = this.width - lineWidth;
443
773
  }
444
- return boundaries;
774
+
775
+ // The returned left value is added to _getLeftOffset() in _getCursorBoundaries
776
+ // _getLeftOffset() returns -width/2 for LTR, +width/2 for RTL
777
+ // Final cursor X = _getLeftOffset() + leftOffset
778
+ //
779
+ // For LTR: cursor X = -width/2 + (alignOffset + visualX)
780
+ // For RTL: cursor X = +width/2 + leftOffset
781
+ // We want cursor at: -width/2 + alignOffset + visualX
782
+ // So leftOffset = -width/2 + alignOffset + visualX - width/2 = alignOffset + visualX - width
783
+
784
+ let leftOffset;
785
+ if (this.direction === 'rtl') {
786
+ // For RTL, _getLeftOffset() = +width/2
787
+ // We want final X = -width/2 + alignOffset + visualX
788
+ // So: +width/2 + leftOffset = -width/2 + alignOffset + visualX
789
+ // leftOffset = -width + alignOffset + visualX
790
+ leftOffset = -this.width + alignOffset + visualX;
791
+ } else {
792
+ // For LTR, _getLeftOffset() = -width/2
793
+ // We want final X = -width/2 + alignOffset + visualX
794
+ // So: -width/2 + leftOffset = -width/2 + alignOffset + visualX
795
+ // leftOffset = alignOffset + visualX
796
+ leftOffset = alignOffset + visualX;
797
+ }
798
+ return {
799
+ top: topOffset,
800
+ left: leftOffset
801
+ };
445
802
  }
446
803
 
447
804
  /**
@@ -533,51 +890,119 @@ class IText extends ITextClickBehavior {
533
890
  }
534
891
 
535
892
  /**
536
- * Renders text selection
893
+ * Renders text selection using visual positions for BiDi support
894
+ * Handles kashida by converting original indices to display indices
537
895
  * @private
538
- * @param {{ selectionStart: number, selectionEnd: number }} selection
896
+ * @param {{ selectionStart: number, selectionEnd: number }} selection (in original text space)
539
897
  * @param {Object} boundaries Object with left/top/leftOffset/topOffset
540
898
  * @param {CanvasRenderingContext2D} ctx transformed context to draw on
541
899
  */
542
900
  _renderSelection(ctx, selection, boundaries) {
543
901
  const selectionStart = selection.selectionStart,
544
902
  selectionEnd = selection.selectionEnd,
545
- isJustify = this.textAlign.includes(JUSTIFY),
546
- start = this.get2DCursorLocation(selectionStart),
547
- end = this.get2DCursorLocation(selectionEnd),
548
- startLine = start.lineIndex,
549
- endLine = end.lineIndex,
550
- startChar = start.charIndex < 0 ? 0 : start.charIndex,
551
- endChar = end.charIndex < 0 ? 0 : end.charIndex;
903
+ isJustify = this.textAlign.includes(JUSTIFY);
904
+
905
+ // Convert selection indices to line/char using original text space
906
+ // This handles kashida properly since selection indices don't include tatweels
907
+ let startLine = 0,
908
+ endLine = 0;
909
+ let originalStartChar = selectionStart,
910
+ originalEndChar = selectionEnd;
911
+
912
+ // Find start line and char
913
+ let charCount = 0;
914
+ for (let i = 0; i < this._textLines.length; i++) {
915
+ const originalLineLength = this._getOriginalLineLength(i);
916
+ if (charCount + originalLineLength >= selectionStart) {
917
+ startLine = i;
918
+ originalStartChar = selectionStart - charCount;
919
+ break;
920
+ }
921
+ charCount += originalLineLength + this.missingNewlineOffset(i);
922
+ }
923
+
924
+ // Find end line and char
925
+ charCount = 0;
926
+ for (let i = 0; i < this._textLines.length; i++) {
927
+ const originalLineLength = this._getOriginalLineLength(i);
928
+ if (charCount + originalLineLength >= selectionEnd) {
929
+ endLine = i;
930
+ originalEndChar = selectionEnd - charCount;
931
+ break;
932
+ }
933
+ charCount += originalLineLength + this.missingNewlineOffset(i);
934
+ if (i === this._textLines.length - 1) {
935
+ endLine = i;
936
+ originalEndChar = originalLineLength;
937
+ }
938
+ }
552
939
  for (let i = startLine; i <= endLine; i++) {
553
- const lineOffset = this._getLineLeftOffset(i) || 0;
554
940
  let lineHeight = this.getHeightOfLine(i),
555
- realLineHeight = 0,
556
- boxStart = 0,
557
- boxEnd = 0;
941
+ realLineHeight = 0;
558
942
 
559
- // Simplified selection rendering that works for both LTR and RTL
943
+ // Get visual positions for this line
944
+ const lineText = this._textLines[i].join('');
945
+ const visualPositions = this._measureVisualPositions(i, lineText);
946
+ this._textLines[i].length;
947
+ const originalLineLength = this._getOriginalLineLength(i);
948
+
949
+ // Calculate selection bounds in original space, then convert to display
950
+ let originalLineStartChar = 0;
951
+ let originalLineEndChar = originalLineLength;
560
952
  if (i === startLine) {
561
- boxStart = this.__charBounds[startLine][startChar].left;
953
+ originalLineStartChar = originalStartChar;
954
+ }
955
+ if (i === endLine) {
956
+ originalLineEndChar = originalEndChar;
562
957
  }
563
- if (i >= startLine && i < endLine) {
564
- boxEnd = isJustify && !this.isEndOfWrapping(i) ? this.width : this.getLineWidth(i) || 5;
565
- } else if (i === endLine) {
566
- if (endChar === 0) {
567
- boxEnd = this.__charBounds[endLine][endChar].left;
958
+
959
+ // Convert original char indices to display indices for visual lookup
960
+ const displayLineStartChar = this._originalToDisplayIndex(i, originalLineStartChar);
961
+ const displayLineEndChar = this._originalToDisplayIndex(i, originalLineEndChar);
962
+
963
+ // Get visual X positions for selection range
964
+ let minVisualX = Infinity;
965
+ let maxVisualX = -Infinity;
966
+ for (const pos of visualPositions) {
967
+ if (pos.logicalIndex >= displayLineStartChar && pos.logicalIndex < displayLineEndChar) {
968
+ minVisualX = Math.min(minVisualX, pos.visualX);
969
+ maxVisualX = Math.max(maxVisualX, pos.visualX + pos.width);
970
+ }
971
+ }
972
+
973
+ // Handle edge cases
974
+ if (minVisualX === Infinity || maxVisualX === -Infinity) {
975
+ if (i >= startLine && i < endLine) {
976
+ // Full line selection
977
+ minVisualX = 0;
978
+ maxVisualX = isJustify && !this.isEndOfWrapping(i) ? this.width : this.getLineWidth(i) || 5;
568
979
  } else {
569
- const charSpacing = this._getWidthOfCharSpacing();
570
- boxEnd = this.__charBounds[endLine][endChar - 1].left + this.__charBounds[endLine][endChar - 1].width - charSpacing;
980
+ continue; // No selection on this line
571
981
  }
572
982
  }
573
983
  realLineHeight = lineHeight;
574
984
  if (this.lineHeight < 1 || i === endLine && this.lineHeight > 1) {
575
985
  lineHeight /= this.lineHeight;
576
986
  }
577
- let drawStart = boundaries.left + lineOffset + boxStart,
578
- drawHeight = lineHeight,
579
- extraTop = 0;
580
- const drawWidth = boxEnd - boxStart;
987
+
988
+ // Calculate draw position
989
+ // Visual positions are relative to line start (0 to lineWidth)
990
+ // Need to add alignment offset
991
+ const lineWidth = this.getLineWidth(i);
992
+ let alignOffset = 0;
993
+ if (this.textAlign === 'center' || this.textAlign === 'justify-center') {
994
+ alignOffset = (this.width - lineWidth) / 2;
995
+ } else if (this.textAlign === 'right' || this.textAlign === 'justify-right') {
996
+ alignOffset = this.width - lineWidth;
997
+ } else if (this.direction === 'rtl' && (this.textAlign === 'justify' || this.textAlign === 'left')) {
998
+ alignOffset = this.width - lineWidth;
999
+ }
1000
+
1001
+ // Draw from center origin (-width/2 to width/2)
1002
+ const drawStart = -this.width / 2 + alignOffset + minVisualX;
1003
+ const drawWidth = maxVisualX - minVisualX;
1004
+ let drawHeight = lineHeight;
1005
+ let extraTop = 0;
581
1006
  if (this.inCompositionMode) {
582
1007
  ctx.fillStyle = this.compositionColor || 'black';
583
1008
  drawHeight = 1;
@@ -585,15 +1010,6 @@ class IText extends ITextClickBehavior {
585
1010
  } else {
586
1011
  ctx.fillStyle = this.selectionColor;
587
1012
  }
588
- if (this.direction === 'rtl') {
589
- if (this.textAlign === RIGHT || this.textAlign === JUSTIFY || this.textAlign === JUSTIFY_RIGHT) {
590
- drawStart = this.width - drawStart - drawWidth;
591
- } else if (this.textAlign === LEFT || this.textAlign === JUSTIFY_LEFT) {
592
- drawStart = boundaries.left + lineOffset - boxEnd;
593
- } else if (this.textAlign === CENTER || this.textAlign === JUSTIFY_CENTER) {
594
- drawStart = boundaries.left + lineOffset - boxEnd;
595
- }
596
- }
597
1013
  ctx.fillRect(drawStart, boundaries.top + boundaries.topOffset + extraTop, drawWidth, drawHeight);
598
1014
  boundaries.topOffset += realLineHeight;
599
1015
  }
@@ -642,53 +1058,6 @@ class IText extends ITextClickBehavior {
642
1058
  super.dispose();
643
1059
  }
644
1060
  }
645
- /**
646
- * Index where text selection starts (or where cursor is when there is no selection)
647
- * @type Number
648
- */
649
- /**
650
- * Index where text selection ends
651
- * @type Number
652
- */
653
- /**
654
- * Color of text selection
655
- * @type String
656
- */
657
- /**
658
- * Indicates whether text is in editing mode
659
- * @type Boolean
660
- */
661
- /**
662
- * Indicates whether a text can be edited
663
- * @type Boolean
664
- */
665
- /**
666
- * Border color of text object while it's in editing mode
667
- * @type String
668
- */
669
- /**
670
- * Width of cursor (in px)
671
- * @type Number
672
- */
673
- /**
674
- * Color of text cursor color in editing mode.
675
- * if not set (default) will take color from the text.
676
- * if set to a color value that fabric can understand, it will
677
- * be used instead of the color of the text at the current position.
678
- * @type String
679
- */
680
- /**
681
- * Delay between cursor blink (in ms)
682
- * @type Number
683
- */
684
- /**
685
- * Duration of cursor fade in (in ms)
686
- * @type Number
687
- */
688
- /**
689
- * Indicates whether internal text char widths can be cached
690
- * @type Boolean
691
- */
692
1061
  _defineProperty(IText, "ownDefaults", iTextDefaultValues);
693
1062
  _defineProperty(IText, "type", 'IText');
694
1063
  classRegistry.setClass(IText);