@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,8 +1,8 @@
1
1
  import { Canvas } from '../../canvas/Canvas';
2
2
  import type { ITextEvents } from './ITextBehavior';
3
3
  import { ITextClickBehavior } from './ITextClickBehavior';
4
- import { hitTest, getCursorRect, getSelectionRects } from '../../text/hitTest';
5
- import type { HitTestResult, CursorRect } from '../../text/hitTest';
4
+ import { getCursorRect } from '../../text/hitTest';
5
+ import { analyzeBiDi } from '../../text/unicode';
6
6
  import {
7
7
  ctrlKeysMapDown,
8
8
  ctrlKeysMapUp,
@@ -24,7 +24,6 @@ import type { ObjectToCanvasElementOptions } from '../Object/Object';
24
24
  import type { FabricObject } from '../Object/FabricObject';
25
25
  import { createCanvasElementFor } from '../../util/misc/dom';
26
26
  import { applyCanvasTransform } from '../../util/internals/applyCanvasTransform';
27
- import { Point } from '../../Point';
28
27
  import { invertTransform } from '../../util/misc/matrix';
29
28
 
30
29
  export type CursorBoundaries = {
@@ -78,9 +77,9 @@ interface UniqueITextProps {
78
77
 
79
78
  export interface SerializedITextProps
80
79
  extends SerializedTextProps,
81
- UniqueITextProps {}
80
+ UniqueITextProps { }
82
81
 
83
- export interface ITextProps extends TextProps, UniqueITextProps {}
82
+ export interface ITextProps extends TextProps, UniqueITextProps { }
84
83
 
85
84
  /**
86
85
  * @fires changed
@@ -126,13 +125,12 @@ export interface ITextProps extends TextProps, UniqueITextProps {}
126
125
  * ```
127
126
  */
128
127
  export class IText<
129
- Props extends TOptions<ITextProps> = Partial<ITextProps>,
130
- SProps extends SerializedITextProps = SerializedITextProps,
131
- EventSpec extends ITextEvents = ITextEvents,
132
- >
128
+ Props extends TOptions<ITextProps> = Partial<ITextProps>,
129
+ SProps extends SerializedITextProps = SerializedITextProps,
130
+ EventSpec extends ITextEvents = ITextEvents,
131
+ >
133
132
  extends ITextClickBehavior<Props, SProps, EventSpec>
134
- implements UniqueITextProps
135
- {
133
+ implements UniqueITextProps {
136
134
  /**
137
135
  * Index where text selection starts (or where cursor is when there is no selection)
138
136
  * @type Number
@@ -145,6 +143,18 @@ export class IText<
145
143
  */
146
144
  declare selectionEnd: number;
147
145
 
146
+ /**
147
+ * Cache for visual positions per line to ensure consistency
148
+ * during selection operations
149
+ * @private
150
+ */
151
+ private _visualPositionsCache: Map<number, Array<{
152
+ logicalIndex: number;
153
+ visualX: number;
154
+ width: number;
155
+ isRtl: boolean;
156
+ }>> = new Map();
157
+
148
158
  declare compositionStart: number;
149
159
 
150
160
  declare compositionEnd: number;
@@ -358,6 +368,8 @@ export class IText<
358
368
  // clear the cursorOffsetCache, so we ensure to calculate once per renderCursor
359
369
  // the correct position but not at every cursor animation.
360
370
  this.cursorOffsetCache = {};
371
+ // Clear visual positions cache on full render since dimensions may have changed
372
+ this._clearVisualPositionsCache();
361
373
  this.renderCursorOrSelection();
362
374
  }
363
375
 
@@ -385,6 +397,9 @@ export class IText<
385
397
  if (!ctx) {
386
398
  return;
387
399
  }
400
+ // Clear cache to ensure fresh cursor position calculation
401
+ // This is important during selection drag when positions change frequently
402
+ this.cursorOffsetCache = {};
388
403
  const boundaries = this._getCursorBoundaries();
389
404
 
390
405
  const ancestors = this.findAncestorsWithClipPath();
@@ -469,12 +484,8 @@ export class IText<
469
484
  index: number = this.selectionStart,
470
485
  skipCaching?: boolean,
471
486
  ): CursorBoundaries {
472
- // Use advanced cursor positioning if available
473
- if (this.enableAdvancedLayout) {
474
- return this._getCursorBoundariesAdvanced(index);
475
- }
476
-
477
- // Fall back to original method
487
+ // Always use original method which uses __charBounds directly
488
+ // and has proper RTL handling built-in
478
489
  return this._getCursorBoundariesOriginal(index, skipCaching);
479
490
  }
480
491
 
@@ -509,7 +520,7 @@ export class IText<
509
520
 
510
521
  const layout = (this as any)._layoutTextAdvanced();
511
522
  const cursorRect = getCursorRect(index, layout, (this as any)._getAdvancedLayoutOptions());
512
-
523
+
513
524
  return {
514
525
  left: this._getLeftOffset(),
515
526
  top: this._getTopOffset(),
@@ -519,23 +530,280 @@ export class IText<
519
530
  }
520
531
 
521
532
  /**
522
- * Enhanced selection start from pointer using BiDi-aware hit testing
523
- * @override
533
+ * Override selection to use measureText-based visual positions
534
+ * This ensures hit testing matches actual browser BiDi rendering
524
535
  */
525
536
  getSelectionStartFromPointer(e: TPointerEvent): number {
526
- if (!this.enableAdvancedLayout || !(this as any)._layoutTextAdvanced) {
527
- return super.getSelectionStartFromPointer(e);
537
+ // Get mouse position in object-local coordinates (origin at center)
538
+ const scenePoint = this.canvas!.getScenePoint(e);
539
+ const localPoint = scenePoint.transform(invertTransform(this.calcTransformMatrix()));
540
+
541
+ // Convert to top-left origin coordinates
542
+ const mouseX = localPoint.x + this.width / 2;
543
+ const mouseY = localPoint.y + this.height / 2;
544
+
545
+ // Find the line based on Y position
546
+ let height = 0, lineIndex = 0;
547
+ for (let i = 0; i < this._textLines.length; i++) {
548
+ const lineHeight = this.getHeightOfLine(i);
549
+ if (mouseY >= height && mouseY < height + lineHeight) {
550
+ lineIndex = i;
551
+ break;
552
+ }
553
+ height += lineHeight;
554
+ if (i === this._textLines.length - 1) {
555
+ lineIndex = i;
556
+ }
528
557
  }
529
558
 
530
- const mouseOffset = this.canvas!.getScenePoint(e)
531
- .transform(invertTransform(this.calcTransformMatrix()))
532
- .add(new Point(-this._getLeftOffset(), -this._getTopOffset()));
559
+ // Calculate line start index using ORIGINAL line lengths (without tatweels)
560
+ // This ensures selection indices refer to the original text, not the display text
561
+ let lineStartIndex = 0;
562
+ for (let i = 0; i < lineIndex; i++) {
563
+ const origLen = this._getOriginalLineLength(i);
564
+ const newlineOffset = this.missingNewlineOffset(i);
565
+ console.log(`📍 Line ${i}: origLen=${origLen}, displayLen=${this._textLines[i].length}, tatweels=${this._getTatweelCountForLine(i)}, newlineOffset=${newlineOffset}`);
566
+ lineStartIndex += origLen + newlineOffset;
567
+ }
568
+ console.log(`📍 Click on line ${lineIndex}, lineStartIndex=${lineStartIndex}`);
533
569
 
534
- // Use BiDi-aware hit testing instead of naive RTL coordinate flipping
535
- const layout = (this as any)._layoutTextAdvanced();
536
- const hitResult = hitTest(mouseOffset.x, mouseOffset.y, layout, (this as any)._getAdvancedLayoutOptions());
537
-
538
- return Math.min(hitResult.charIndex, this._text.length);
570
+ const line = this._textLines[lineIndex];
571
+ const lineText = line.join('');
572
+ const displayCharLength = line.length;
573
+ const originalCharLength = this._getOriginalLineLength(lineIndex);
574
+
575
+ if (displayCharLength === 0) {
576
+ return lineStartIndex;
577
+ }
578
+
579
+ // Use measureText to get actual visual character positions
580
+ // This matches exactly how the canvas renders BiDi text
581
+ const visualPositions = this._measureVisualPositions(lineIndex, lineText);
582
+
583
+ // Calculate line offset based on alignment
584
+ const lineWidth = this.getLineWidth(lineIndex);
585
+ let lineStartX = 0;
586
+
587
+ if (this.textAlign === 'center' || this.textAlign === 'justify-center') {
588
+ lineStartX = (this.width - lineWidth) / 2;
589
+ } else if (this.textAlign === 'right' || this.textAlign === 'justify-right') {
590
+ lineStartX = this.width - lineWidth;
591
+ } else if (this.direction === 'rtl' && (this.textAlign === 'justify' || this.textAlign === 'left')) {
592
+ // For RTL with left/justify, text starts from right
593
+ lineStartX = this.width - lineWidth;
594
+ }
595
+
596
+ // Find which character was clicked based on visual position
597
+ const clickX = mouseX - lineStartX;
598
+
599
+ // Sort positions by visual X for hit testing
600
+ const sortedPositions = [...visualPositions].sort((a, b) => a.visualX - b.visualX);
601
+
602
+ // Handle click before first character
603
+ if (sortedPositions.length > 0 && clickX < sortedPositions[0].visualX) {
604
+ // Before first visual character - cursor at visual left edge
605
+ // For RTL base direction, this means logical end of line
606
+ return this.direction === 'rtl'
607
+ ? lineStartIndex + originalCharLength
608
+ : lineStartIndex;
609
+ }
610
+
611
+ // Handle click after last character
612
+ if (sortedPositions.length > 0) {
613
+ const lastPos = sortedPositions[sortedPositions.length - 1];
614
+ if (clickX > lastPos.visualX + lastPos.width) {
615
+ // After last visual character - cursor at visual right edge
616
+ // For RTL base direction, this means logical start of line
617
+ return this.direction === 'rtl'
618
+ ? lineStartIndex
619
+ : lineStartIndex + originalCharLength;
620
+ }
621
+ }
622
+
623
+ // Find the character at click position
624
+ for (let i = 0; i < sortedPositions.length; i++) {
625
+ const pos = sortedPositions[i];
626
+ const charEnd = pos.visualX + pos.width;
627
+
628
+ if (clickX >= pos.visualX && clickX <= charEnd) {
629
+ // Convert display index to original index
630
+ // This also handles tatweels - they map to the character they extend
631
+ const originalCharIndex = this._displayToOriginalIndex(lineIndex, pos.logicalIndex);
632
+
633
+ // Check if this is a tatweel - if so, treat click as clicking on the extended character
634
+ const isTatweel = this._isTatweelAtDisplayIndex(lineIndex, pos.logicalIndex);
635
+
636
+ console.log(`📍 Hit char: displayIdx=${pos.logicalIndex}, origIdx=${originalCharIndex}, isTatweel=${isTatweel}, char="${this._textLines[lineIndex][pos.logicalIndex]}"`);
637
+
638
+ const charMiddle = pos.visualX + pos.width / 2;
639
+ const clickedLeftHalf = clickX <= charMiddle;
640
+
641
+ // For tatweels, clicking anywhere on it should place cursor after the extended character
642
+ if (isTatweel) {
643
+ // Tatweel extends the character before it, so cursor goes after that character
644
+ // originalCharIndex from _displayToOriginalIndex already maps tatweel to char+1
645
+ const result = lineStartIndex + originalCharIndex;
646
+ console.log(`📍 Tatweel click result: ${result}`);
647
+ return result;
648
+ }
649
+
650
+ // For RTL characters: left visual half means cursor AFTER (higher logical index)
651
+ // For LTR characters: left visual half means cursor BEFORE (lower logical index)
652
+ if (pos.isRtl) {
653
+ // RTL character
654
+ const result = lineStartIndex + (clickedLeftHalf ? originalCharIndex + 1 : originalCharIndex);
655
+ console.log(`📍 RTL char result: ${result} (clickedLeftHalf=${clickedLeftHalf})`);
656
+ return result;
657
+ } else {
658
+ // LTR character
659
+ const result = lineStartIndex + (clickedLeftHalf ? originalCharIndex : originalCharIndex + 1);
660
+ console.log(`📍 LTR char result: ${result} (clickedLeftHalf=${clickedLeftHalf})`);
661
+ return result;
662
+ }
663
+ }
664
+ }
665
+
666
+ // console.log(`📍 No match, returning end: ${lineStartIndex + originalCharLength}`);
667
+ return lineStartIndex + originalCharLength;
668
+ }
669
+
670
+ /**
671
+ * Clear the visual positions cache
672
+ * Should be called when text content or dimensions change
673
+ */
674
+ _clearVisualPositionsCache() {
675
+ this._visualPositionsCache.clear();
676
+ }
677
+
678
+ /**
679
+ * Measure visual character positions for hit testing using BiDi analysis
680
+ * This properly handles mixed RTL/LTR text by analyzing BiDi runs
681
+ * Results are cached per line for consistency during selection operations
682
+ */
683
+ _measureVisualPositions(lineIndex: number, lineText: string): Array<{
684
+ logicalIndex: number;
685
+ visualX: number;
686
+ width: number;
687
+ isRtl: boolean; // Direction of this character's run
688
+ }> {
689
+ // Check cache first
690
+ if (this._visualPositionsCache.has(lineIndex)) {
691
+ return this._visualPositionsCache.get(lineIndex)!;
692
+ }
693
+
694
+ const line = this._textLines[lineIndex];
695
+ const positions: Array<{logicalIndex: number; visualX: number; width: number; isRtl: boolean}> = [];
696
+
697
+ const chars = this.__charBounds[lineIndex];
698
+ if (!chars || chars.length === 0) {
699
+ this._visualPositionsCache.set(lineIndex, positions);
700
+ return positions;
701
+ }
702
+
703
+ // For LTR direction, use logical positions directly
704
+ if (this.direction !== 'rtl') {
705
+ for (let i = 0; i < line.length; i++) {
706
+ positions.push({
707
+ logicalIndex: i,
708
+ visualX: chars[i]?.left || 0,
709
+ width: chars[i]?.kernedWidth || 0,
710
+ isRtl: false,
711
+ });
712
+ }
713
+ this._visualPositionsCache.set(lineIndex, positions);
714
+ return positions;
715
+ }
716
+
717
+ // For RTL, use BiDi analysis to determine visual positions
718
+ const runs = analyzeBiDi(lineText, 'rtl');
719
+
720
+ // Build mapping from string position to grapheme index
721
+ // This is needed because analyzeBiDi works on string positions (code points)
722
+ // but we need grapheme indices for charBounds
723
+ const stringPosToGrapheme: number[] = [];
724
+ let strPos = 0;
725
+ for (let gi = 0; gi < line.length; gi++) {
726
+ const grapheme = line[gi];
727
+ for (let j = 0; j < grapheme.length; j++) {
728
+ stringPosToGrapheme[strPos + j] = gi;
729
+ }
730
+ strPos += grapheme.length;
731
+ }
732
+
733
+ // Calculate width for each run
734
+ interface RunInfo {
735
+ run: typeof runs[0];
736
+ width: number;
737
+ charIndices: number[];
738
+ }
739
+
740
+ const runInfos: RunInfo[] = [];
741
+
742
+ for (const run of runs) {
743
+ const runChars: number[] = [];
744
+ let runWidth = 0;
745
+ const seenGraphemes = new Set<number>();
746
+
747
+ // Map string positions in this run to grapheme indices
748
+ for (let sp = run.start; sp < run.end; sp++) {
749
+ const gi = stringPosToGrapheme[sp];
750
+ if (gi !== undefined && !seenGraphemes.has(gi)) {
751
+ seenGraphemes.add(gi);
752
+ runChars.push(gi);
753
+ runWidth += chars[gi]?.kernedWidth || 0;
754
+ }
755
+ }
756
+
757
+ runInfos.push({
758
+ run,
759
+ width: runWidth,
760
+ charIndices: runChars,
761
+ });
762
+ }
763
+
764
+ // For RTL base direction, runs are displayed right-to-left
765
+ // So first run appears on the right, last run on the left
766
+ const totalWidth = this.getLineWidth(lineIndex);
767
+ let visualX = totalWidth; // Start from right edge
768
+
769
+ for (const runInfo of runInfos) {
770
+ visualX -= runInfo.width; // Move left by run width
771
+
772
+ const isRtlRun = runInfo.run.direction === 'rtl';
773
+ if (isRtlRun) {
774
+ // RTL run: characters displayed right-to-left within run
775
+ // First char of run at visual right of run, last at visual left
776
+ let charX = visualX + runInfo.width;
777
+ for (const idx of runInfo.charIndices) {
778
+ const charWidth = chars[idx]?.kernedWidth || 0;
779
+ charX -= charWidth;
780
+ positions.push({
781
+ logicalIndex: idx,
782
+ visualX: charX,
783
+ width: charWidth,
784
+ isRtl: true,
785
+ });
786
+ }
787
+ } else {
788
+ // LTR run: characters displayed left-to-right within run
789
+ // First char of run at visual left of run, last at visual right
790
+ let charX = visualX;
791
+ for (const idx of runInfo.charIndices) {
792
+ const charWidth = chars[idx]?.kernedWidth || 0;
793
+ positions.push({
794
+ logicalIndex: idx,
795
+ visualX: charX,
796
+ width: charWidth,
797
+ isRtl: false,
798
+ });
799
+ charX += charWidth;
800
+ }
801
+ }
802
+ }
803
+
804
+ // Cache the result
805
+ this._visualPositionsCache.set(lineIndex, positions);
806
+ return positions;
539
807
  }
540
808
 
541
809
  /**
@@ -555,48 +823,142 @@ export class IText<
555
823
  }
556
824
 
557
825
  /**
558
- * Calculates cursor left/top offset relative to instance's center point
826
+ * Calculates cursor left/top offset relative to _getLeftOffset()
827
+ * Uses visual positions for BiDi text support
828
+ * Handles kashida by converting original indices to display indices
559
829
  * @private
560
- * @param {number} index index from start
830
+ * @param {number} index index from start (in original text space, without tatweels)
561
831
  */
562
832
  __getCursorBoundariesOffsets(index: number) {
563
- let topOffset = 0,
564
- leftOffset = 0;
565
- const { charIndex, lineIndex } = this.get2DCursorLocation(index);
833
+ let topOffset = 0;
834
+
835
+ // Find line index and original char index using original line lengths
836
+ let lineIndex = 0;
837
+ let originalCharIndex = index;
838
+
839
+ for (let i = 0; i < this._textLines.length; i++) {
840
+ const originalLineLength = this._getOriginalLineLength(i);
841
+ if (originalCharIndex <= originalLineLength) {
842
+ lineIndex = i;
843
+ break;
844
+ }
845
+ originalCharIndex -= originalLineLength + this.missingNewlineOffset(i);
846
+ lineIndex = i + 1;
847
+ }
848
+
849
+ // Clamp lineIndex to valid range
850
+ if (lineIndex >= this._textLines.length) {
851
+ lineIndex = this._textLines.length - 1;
852
+ originalCharIndex = this._getOriginalLineLength(lineIndex);
853
+ }
566
854
 
567
855
  for (let i = 0; i < lineIndex; i++) {
568
856
  topOffset += this.getHeightOfLine(i);
569
857
  }
570
- const lineLeftOffset = this._getLineLeftOffset(lineIndex);
571
- const bound = this.__charBounds[lineIndex][charIndex];
572
- bound && (leftOffset = bound.left);
573
- if (
574
- this.charSpacing !== 0 &&
575
- charIndex === this._textLines[lineIndex].length
576
- ) {
577
- leftOffset -= this._getWidthOfCharSpacing();
858
+
859
+ // Convert original char index to display char index for visual lookup
860
+ const displayCharIndex = this._originalToDisplayIndex(lineIndex, originalCharIndex);
861
+
862
+ // Get visual positions for cursor placement
863
+ const lineText = this._textLines[lineIndex].join('');
864
+ const visualPositions = this._measureVisualPositions(lineIndex, lineText);
865
+ const lineWidth = this.getLineWidth(lineIndex);
866
+ const displayLineLength = this._textLines[lineIndex].length;
867
+ const originalLineLength = this._getOriginalLineLength(lineIndex);
868
+
869
+ // Find visual X position for cursor (0 to lineWidth, from visual left)
870
+ let visualX = 0;
871
+
872
+ if (visualPositions.length === 0) {
873
+ // Fallback for empty line
874
+ return { top: topOffset, left: 0 };
578
875
  }
579
- const boundaries = {
580
- top: topOffset,
581
- left: lineLeftOffset + (leftOffset > 0 ? leftOffset : 0),
582
- };
583
- if (this.direction === 'rtl') {
584
- if (
585
- this.textAlign === RIGHT ||
586
- this.textAlign === JUSTIFY ||
587
- this.textAlign === JUSTIFY_RIGHT
588
- ) {
589
- boundaries.left *= -1;
590
- } else if (this.textAlign === LEFT || this.textAlign === JUSTIFY_LEFT) {
591
- boundaries.left = lineLeftOffset - (leftOffset > 0 ? leftOffset : 0);
592
- } else if (
593
- this.textAlign === CENTER ||
594
- this.textAlign === JUSTIFY_CENTER
595
- ) {
596
- boundaries.left = lineLeftOffset - (leftOffset > 0 ? leftOffset : 0);
876
+
877
+ if (originalCharIndex === 0) {
878
+ // Cursor at logical start
879
+ // For RTL base direction, logical start is at visual right
880
+ if (this.direction === 'rtl') {
881
+ visualX = lineWidth; // Right edge
882
+ } else {
883
+ visualX = 0; // Left edge
884
+ }
885
+ } else if (originalCharIndex >= originalLineLength) {
886
+ // Cursor at logical end
887
+ // For RTL base direction, logical end is at visual left
888
+ if (this.direction === 'rtl') {
889
+ visualX = 0; // Left edge
890
+ } else {
891
+ visualX = lineWidth; // Right edge
892
+ }
893
+ } else {
894
+ // Cursor between characters - find visual position of character at displayCharIndex
895
+ const charPos = visualPositions.find(p => p.logicalIndex === displayCharIndex);
896
+ if (charPos) {
897
+ // Use character's direction to determine cursor position
898
+ // For RTL char: cursor "before" it appears at its right visual edge
899
+ // For LTR char: cursor "before" it appears at its left visual edge
900
+ if (charPos.isRtl) {
901
+ visualX = charPos.visualX + charPos.width;
902
+ } else {
903
+ visualX = charPos.visualX;
904
+ }
905
+ } else {
906
+ // Fallback - try the previous character in display space
907
+ const prevDisplayIndex = displayCharIndex > 0 ? displayCharIndex - 1 : 0;
908
+ const prevCharPos = visualPositions.find(p => p.logicalIndex === prevDisplayIndex);
909
+ if (prevCharPos) {
910
+ // Cursor after previous character
911
+ if (prevCharPos.isRtl) {
912
+ visualX = prevCharPos.visualX;
913
+ } else {
914
+ visualX = prevCharPos.visualX + prevCharPos.width;
915
+ }
916
+ } else {
917
+ // Ultimate fallback
918
+ const bound = this.__charBounds[lineIndex][displayCharIndex];
919
+ visualX = bound?.left || 0;
920
+ }
597
921
  }
598
922
  }
599
- return boundaries;
923
+
924
+ // Calculate alignment offset (how much line is shifted from left edge)
925
+ let alignOffset = 0;
926
+ if (this.textAlign === 'center' || this.textAlign === 'justify-center') {
927
+ alignOffset = (this.width - lineWidth) / 2;
928
+ } else if (this.textAlign === 'right' || this.textAlign === 'justify-right') {
929
+ alignOffset = this.width - lineWidth;
930
+ } else if (this.direction === 'rtl' && (this.textAlign === 'justify' || this.textAlign === 'left')) {
931
+ alignOffset = this.width - lineWidth;
932
+ }
933
+
934
+ // The returned left value is added to _getLeftOffset() in _getCursorBoundaries
935
+ // _getLeftOffset() returns -width/2 for LTR, +width/2 for RTL
936
+ // Final cursor X = _getLeftOffset() + leftOffset
937
+ //
938
+ // For LTR: cursor X = -width/2 + (alignOffset + visualX)
939
+ // For RTL: cursor X = +width/2 + leftOffset
940
+ // We want cursor at: -width/2 + alignOffset + visualX
941
+ // So leftOffset = -width/2 + alignOffset + visualX - width/2 = alignOffset + visualX - width
942
+
943
+ let leftOffset: number;
944
+ if (this.direction === 'rtl') {
945
+ // For RTL, _getLeftOffset() = +width/2
946
+ // We want final X = -width/2 + alignOffset + visualX
947
+ // So: +width/2 + leftOffset = -width/2 + alignOffset + visualX
948
+ // leftOffset = -width + alignOffset + visualX
949
+ leftOffset = -this.width + alignOffset + visualX;
950
+ } else {
951
+ // For LTR, _getLeftOffset() = -width/2
952
+ // We want final X = -width/2 + alignOffset + visualX
953
+ // So: -width/2 + leftOffset = -width/2 + alignOffset + visualX
954
+ // leftOffset = alignOffset + visualX
955
+ leftOffset = alignOffset + visualX;
956
+ }
957
+
958
+ return {
959
+ top: topOffset,
960
+ left: leftOffset,
961
+ };
600
962
  }
601
963
 
602
964
  /**
@@ -642,7 +1004,7 @@ export class IText<
642
1004
  topOffset =
643
1005
  boundaries.topOffset +
644
1006
  ((1 - this._fontSizeFraction) * this.getHeightOfLine(lineIndex)) /
645
- this.lineHeight -
1007
+ this.lineHeight -
646
1008
  charHeight * (1 - this._fontSizeFraction);
647
1009
 
648
1010
  return {
@@ -709,9 +1071,10 @@ export class IText<
709
1071
  }
710
1072
 
711
1073
  /**
712
- * Renders text selection
1074
+ * Renders text selection using visual positions for BiDi support
1075
+ * Handles kashida by converting original indices to display indices
713
1076
  * @private
714
- * @param {{ selectionStart: number, selectionEnd: number }} selection
1077
+ * @param {{ selectionStart: number, selectionEnd: number }} selection (in original text space)
715
1078
  * @param {Object} boundaries Object with left/top/leftOffset/topOffset
716
1079
  * @param {CanvasRenderingContext2D} ctx transformed context to draw on
717
1080
  */
@@ -722,49 +1085,115 @@ export class IText<
722
1085
  ) {
723
1086
  const selectionStart = selection.selectionStart,
724
1087
  selectionEnd = selection.selectionEnd,
725
- isJustify = this.textAlign.includes(JUSTIFY),
726
- start = this.get2DCursorLocation(selectionStart),
727
- end = this.get2DCursorLocation(selectionEnd),
728
- startLine = start.lineIndex,
729
- endLine = end.lineIndex,
730
- startChar = start.charIndex < 0 ? 0 : start.charIndex,
731
- endChar = end.charIndex < 0 ? 0 : end.charIndex;
1088
+ isJustify = this.textAlign.includes(JUSTIFY);
1089
+
1090
+ // Convert selection indices to line/char using original text space
1091
+ // This handles kashida properly since selection indices don't include tatweels
1092
+ let startLine = 0, endLine = 0;
1093
+ let originalStartChar = selectionStart, originalEndChar = selectionEnd;
1094
+
1095
+ // Find start line and char
1096
+ let charCount = 0;
1097
+ for (let i = 0; i < this._textLines.length; i++) {
1098
+ const originalLineLength = this._getOriginalLineLength(i);
1099
+ if (charCount + originalLineLength >= selectionStart) {
1100
+ startLine = i;
1101
+ originalStartChar = selectionStart - charCount;
1102
+ break;
1103
+ }
1104
+ charCount += originalLineLength + this.missingNewlineOffset(i);
1105
+ }
1106
+
1107
+ // Find end line and char
1108
+ charCount = 0;
1109
+ for (let i = 0; i < this._textLines.length; i++) {
1110
+ const originalLineLength = this._getOriginalLineLength(i);
1111
+ if (charCount + originalLineLength >= selectionEnd) {
1112
+ endLine = i;
1113
+ originalEndChar = selectionEnd - charCount;
1114
+ break;
1115
+ }
1116
+ charCount += originalLineLength + this.missingNewlineOffset(i);
1117
+ if (i === this._textLines.length - 1) {
1118
+ endLine = i;
1119
+ originalEndChar = originalLineLength;
1120
+ }
1121
+ }
732
1122
 
733
1123
  for (let i = startLine; i <= endLine; i++) {
734
- const lineOffset = this._getLineLeftOffset(i) || 0;
735
1124
  let lineHeight = this.getHeightOfLine(i),
736
- realLineHeight = 0,
737
- boxStart = 0,
738
- boxEnd = 0;
739
-
740
- // Simplified selection rendering that works for both LTR and RTL
1125
+ realLineHeight = 0;
1126
+
1127
+ // Get visual positions for this line
1128
+ const lineText = this._textLines[i].join('');
1129
+ const visualPositions = this._measureVisualPositions(i, lineText);
1130
+ const displayLineLength = this._textLines[i].length;
1131
+ const originalLineLength = this._getOriginalLineLength(i);
1132
+
1133
+ // Calculate selection bounds in original space, then convert to display
1134
+ let originalLineStartChar = 0;
1135
+ let originalLineEndChar = originalLineLength;
1136
+
741
1137
  if (i === startLine) {
742
- boxStart = this.__charBounds[startLine][startChar].left;
1138
+ originalLineStartChar = originalStartChar;
743
1139
  }
744
- if (i >= startLine && i < endLine) {
745
- boxEnd =
746
- isJustify && !this.isEndOfWrapping(i)
1140
+ if (i === endLine) {
1141
+ originalLineEndChar = originalEndChar;
1142
+ }
1143
+
1144
+ // Convert original char indices to display indices for visual lookup
1145
+ const displayLineStartChar = this._originalToDisplayIndex(i, originalLineStartChar);
1146
+ const displayLineEndChar = this._originalToDisplayIndex(i, originalLineEndChar);
1147
+
1148
+ // Get visual X positions for selection range
1149
+ let minVisualX = Infinity;
1150
+ let maxVisualX = -Infinity;
1151
+
1152
+ for (const pos of visualPositions) {
1153
+ if (pos.logicalIndex >= displayLineStartChar && pos.logicalIndex < displayLineEndChar) {
1154
+ minVisualX = Math.min(minVisualX, pos.visualX);
1155
+ maxVisualX = Math.max(maxVisualX, pos.visualX + pos.width);
1156
+ }
1157
+ }
1158
+
1159
+ // Handle edge cases
1160
+ if (minVisualX === Infinity || maxVisualX === -Infinity) {
1161
+ if (i >= startLine && i < endLine) {
1162
+ // Full line selection
1163
+ minVisualX = 0;
1164
+ maxVisualX = isJustify && !this.isEndOfWrapping(i)
747
1165
  ? this.width
748
1166
  : this.getLineWidth(i) || 5;
749
- } else if (i === endLine) {
750
- if (endChar === 0) {
751
- boxEnd = this.__charBounds[endLine][endChar].left;
752
1167
  } else {
753
- const charSpacing = this._getWidthOfCharSpacing();
754
- boxEnd =
755
- this.__charBounds[endLine][endChar - 1].left +
756
- this.__charBounds[endLine][endChar - 1].width -
757
- charSpacing;
1168
+ continue; // No selection on this line
758
1169
  }
759
1170
  }
1171
+
760
1172
  realLineHeight = lineHeight;
761
1173
  if (this.lineHeight < 1 || (i === endLine && this.lineHeight > 1)) {
762
1174
  lineHeight /= this.lineHeight;
763
1175
  }
764
- let drawStart = boundaries.left + lineOffset + boxStart,
765
- drawHeight = lineHeight,
766
- extraTop = 0;
767
- const drawWidth = boxEnd - boxStart;
1176
+
1177
+ // Calculate draw position
1178
+ // Visual positions are relative to line start (0 to lineWidth)
1179
+ // Need to add alignment offset
1180
+ const lineWidth = this.getLineWidth(i);
1181
+ let alignOffset = 0;
1182
+
1183
+ if (this.textAlign === 'center' || this.textAlign === 'justify-center') {
1184
+ alignOffset = (this.width - lineWidth) / 2;
1185
+ } else if (this.textAlign === 'right' || this.textAlign === 'justify-right') {
1186
+ alignOffset = this.width - lineWidth;
1187
+ } else if (this.direction === 'rtl' && (this.textAlign === 'justify' || this.textAlign === 'left')) {
1188
+ alignOffset = this.width - lineWidth;
1189
+ }
1190
+
1191
+ // Draw from center origin (-width/2 to width/2)
1192
+ const drawStart = -this.width / 2 + alignOffset + minVisualX;
1193
+ const drawWidth = maxVisualX - minVisualX;
1194
+ let drawHeight = lineHeight;
1195
+ let extraTop = 0;
1196
+
768
1197
  if (this.inCompositionMode) {
769
1198
  ctx.fillStyle = this.compositionColor || 'black';
770
1199
  drawHeight = 1;
@@ -772,22 +1201,7 @@ export class IText<
772
1201
  } else {
773
1202
  ctx.fillStyle = this.selectionColor;
774
1203
  }
775
- if (this.direction === 'rtl') {
776
- if (
777
- this.textAlign === RIGHT ||
778
- this.textAlign === JUSTIFY ||
779
- this.textAlign === JUSTIFY_RIGHT
780
- ) {
781
- drawStart = this.width - drawStart - drawWidth;
782
- } else if (this.textAlign === LEFT || this.textAlign === JUSTIFY_LEFT) {
783
- drawStart = boundaries.left + lineOffset - boxEnd;
784
- } else if (
785
- this.textAlign === CENTER ||
786
- this.textAlign === JUSTIFY_CENTER
787
- ) {
788
- drawStart = boundaries.left + lineOffset - boxEnd;
789
- }
790
- }
1204
+
791
1205
  ctx.fillRect(
792
1206
  drawStart,
793
1207
  boundaries.top + boundaries.topOffset + extraTop,