@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
package/dist/index.js CHANGED
@@ -360,7 +360,7 @@
360
360
  }
361
361
  const cache = new Cache();
362
362
 
363
- var version = "7.0.1-beta15";
363
+ var version = "7.0.1-beta16";
364
364
 
365
365
  // use this syntax so babel plugin see this import here
366
366
  const VERSION = version;
@@ -4806,7 +4806,7 @@
4806
4806
  const TEXT_DECORATION_THICKNESS = 'textDecorationThickness';
4807
4807
  const fontProperties = ['fontSize', 'fontWeight', 'fontFamily', 'fontStyle'];
4808
4808
  const textDecorationProperties = ['underline', 'overline', 'linethrough'];
4809
- const textLayoutProperties = [...fontProperties, 'lineHeight', 'text', 'charSpacing', 'textAlign', 'styles', 'path', 'pathStartOffset', 'pathSide', 'pathAlign', 'wrap', 'ellipsis', 'letterSpacing', 'enableAdvancedLayout', 'verticalAlign'];
4809
+ const textLayoutProperties = [...fontProperties, 'lineHeight', 'text', 'charSpacing', 'textAlign', 'styles', 'path', 'pathStartOffset', 'pathSide', 'pathAlign', 'wrap', 'ellipsis', 'letterSpacing', 'enableAdvancedLayout', 'verticalAlign', 'kashida'];
4810
4810
  const additionalProps = [...textLayoutProperties, ...textDecorationProperties, 'textBackgroundColor', 'direction', TEXT_DECORATION_THICKNESS, 'useOverlayEditing'];
4811
4811
  const styleProperties = [...fontProperties, ...textDecorationProperties, STROKE, 'strokeWidth', FILL, 'deltaY', 'textBackgroundColor', TEXT_DECORATION_THICKNESS];
4812
4812
 
@@ -4843,6 +4843,7 @@
4843
4843
  letterSpacing: 0,
4844
4844
  enableAdvancedLayout: false,
4845
4845
  verticalAlign: 'top',
4846
+ kashida: 'none',
4846
4847
  // Overlay editor properties
4847
4848
  useOverlayEditing: false,
4848
4849
  CACHE_FONT_SIZE: 400,
@@ -19675,6 +19676,14 @@
19675
19676
  * and grapheme cluster boundary detection.
19676
19677
  */
19677
19678
 
19679
+ // Unicode character categories for text processing
19680
+ const UNICODE_CATEGORIES = {
19681
+ // Bidirectional types
19682
+ L: /[\u0041-\u005A\u0061-\u007A\u00AA\u00B5\u00BA\u00C0-\u00D6\u00D8-\u00F6]/,
19683
+ R: /[\u05BE\u05C0\u05C3\u05C6\u05D0-\u05EA\u05F0-\u05F4\u0608\u060B\u060D]/,
19684
+ AL: /[\u0627\u0629-\u063A\u0641-\u064A\u066D-\u066F\u0671-\u06D3\u06D5]/,
19685
+ EN: /[\u0030-\u0039\u00B2\u00B3\u00B9\u06F0-\u06F9]/,
19686
+ AN: /[\u0660-\u0669\u066B\u066C]/};
19678
19687
 
19679
19688
  /**
19680
19689
  * Enhanced grapheme segmentation using Intl.Segmenter when available
@@ -19698,6 +19707,291 @@
19698
19707
  return graphemeSplit(text);
19699
19708
  }
19700
19709
 
19710
+ /**
19711
+ * Analyze text for bidirectional runs using Unicode BiDi algorithm (simplified)
19712
+ */
19713
+ function analyzeBiDi(text) {
19714
+ let baseDirection = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 'ltr';
19715
+ if (!text) return [];
19716
+ const runs = [];
19717
+ const chars = Array.from(text);
19718
+ let currentRun = null;
19719
+ for (let i = 0; i < chars.length; i++) {
19720
+ const char = chars[i];
19721
+ const charDirection = getBidiDirection(char, baseDirection);
19722
+
19723
+ // Start new run if direction changes
19724
+ if (!currentRun || currentRun.direction !== charDirection) {
19725
+ if (currentRun) {
19726
+ runs.push(currentRun);
19727
+ }
19728
+ currentRun = {
19729
+ text: char,
19730
+ direction: charDirection,
19731
+ level: charDirection === 'rtl' ? 1 : 0,
19732
+ start: i,
19733
+ end: i + 1
19734
+ };
19735
+ } else {
19736
+ // Continue current run
19737
+ currentRun.text += char;
19738
+ currentRun.end = i + 1;
19739
+ }
19740
+ }
19741
+
19742
+ // Add final run
19743
+ if (currentRun) {
19744
+ runs.push(currentRun);
19745
+ }
19746
+ return runs.length > 0 ? runs : [{
19747
+ text,
19748
+ direction: baseDirection,
19749
+ level: baseDirection === 'rtl' ? 1 : 0,
19750
+ start: 0,
19751
+ end: text.length
19752
+ }];
19753
+ }
19754
+
19755
+ /**
19756
+ * Character classification functions
19757
+ */
19758
+ function isWhitespace(grapheme) {
19759
+ return /\s/.test(grapheme);
19760
+ }
19761
+
19762
+ /**
19763
+ * Get bidirectional character type
19764
+ */
19765
+ function getBidiDirection(char, baseDirection) {
19766
+ // Strong RTL characters
19767
+ if (UNICODE_CATEGORIES.R.test(char) || UNICODE_CATEGORIES.AL.test(char)) {
19768
+ return 'rtl';
19769
+ }
19770
+
19771
+ // Strong LTR characters
19772
+ if (UNICODE_CATEGORIES.L.test(char)) {
19773
+ return 'ltr';
19774
+ }
19775
+
19776
+ // Numbers follow base direction in simplified algorithm
19777
+ if (UNICODE_CATEGORIES.EN.test(char) || UNICODE_CATEGORIES.AN.test(char)) {
19778
+ return baseDirection;
19779
+ }
19780
+
19781
+ // Neutral characters follow context
19782
+ return baseDirection;
19783
+ }
19784
+
19785
+ // ============================================================================
19786
+ // Arabic Kashida (Tatweel) Support
19787
+ // ============================================================================
19788
+
19789
+ /**
19790
+ * Arabic Tatweel (kashida) character used for justification
19791
+ */
19792
+ const ARABIC_TATWEEL = '\u0640';
19793
+
19794
+ /**
19795
+ * Arabic letters that do NOT connect to the following letter (non-connecting on left).
19796
+ * These letters cannot have kashida inserted after them.
19797
+ * ا (Alef), د (Dal), ذ (Thal), ر (Ra), ز (Zay), و (Waw), ة (Teh Marbuta), ء (Hamza)
19798
+ */
19799
+ const ARABIC_NON_CONNECTING = new Set(['\u0627',
19800
+ // Alef
19801
+ '\u062F',
19802
+ // Dal
19803
+ '\u0630',
19804
+ // Thal
19805
+ '\u0631',
19806
+ // Ra
19807
+ '\u0632',
19808
+ // Zay
19809
+ '\u0648',
19810
+ // Waw
19811
+ '\u0629',
19812
+ // Teh Marbuta
19813
+ '\u0621',
19814
+ // Hamza
19815
+ '\u0622',
19816
+ // Alef with Madda
19817
+ '\u0623',
19818
+ // Alef with Hamza Above
19819
+ '\u0625',
19820
+ // Alef with Hamza Below
19821
+ '\u0672',
19822
+ // Alef with Wavy Hamza Above
19823
+ '\u0673',
19824
+ // Alef with Wavy Hamza Below
19825
+ '\u0675',
19826
+ // High Hamza Alef
19827
+ '\u0688',
19828
+ // Dal with Small Tah
19829
+ '\u0689',
19830
+ // Dal with Ring
19831
+ '\u068A',
19832
+ // Dal with Dot Below
19833
+ '\u068B',
19834
+ // Dal with Dot Below and Small Tah
19835
+ '\u068C',
19836
+ // Dahal
19837
+ '\u068D',
19838
+ // Ddahal
19839
+ '\u068E',
19840
+ // Dul
19841
+ '\u068F',
19842
+ // Dal with Three Dots Above Downwards
19843
+ '\u0690',
19844
+ // Dal with Four Dots Above
19845
+ '\u0691',
19846
+ // Rreh
19847
+ '\u0692',
19848
+ // Reh with Small V
19849
+ '\u0693',
19850
+ // Reh with Ring
19851
+ '\u0694',
19852
+ // Reh with Dot Below
19853
+ '\u0695',
19854
+ // Reh with Small V Below
19855
+ '\u0696',
19856
+ // Reh with Dot Below and Dot Above
19857
+ '\u0697',
19858
+ // Reh with Two Dots Above
19859
+ '\u0698',
19860
+ // Jeh
19861
+ '\u0699',
19862
+ // Reh with Four Dots Above
19863
+ '\u06C4',
19864
+ // Waw with Ring
19865
+ '\u06C5',
19866
+ // Kirghiz Oe
19867
+ '\u06C6',
19868
+ // Oe
19869
+ '\u06C7',
19870
+ // U
19871
+ '\u06C8',
19872
+ // Yu
19873
+ '\u06C9',
19874
+ // Kirghiz Yu
19875
+ '\u06CA',
19876
+ // Waw with Two Dots Above
19877
+ '\u06CB',
19878
+ // Ve
19879
+ '\u06CD',
19880
+ // Yeh with Tail
19881
+ '\u06CF' // Waw with Dot Above
19882
+ ]);
19883
+
19884
+ /**
19885
+ * Check if a character is an Arabic letter (main Arabic block + extended)
19886
+ */
19887
+ function isArabicLetter(char) {
19888
+ if (!char) return false;
19889
+ const code = char.charCodeAt(0);
19890
+ // Arabic: U+0600-U+06FF (main block)
19891
+ // Arabic Supplement: U+0750-U+077F
19892
+ // Arabic Extended-A: U+08A0-U+08FF
19893
+ return code >= 0x0620 && code <= 0x064A ||
19894
+ // Main letters
19895
+ code >= 0x066E && code <= 0x06D3 ||
19896
+ // Extended letters
19897
+ code >= 0x0750 && code <= 0x077F ||
19898
+ // Arabic Supplement
19899
+ code >= 0x08A0 && code <= 0x08FF // Arabic Extended-A
19900
+ ;
19901
+ }
19902
+
19903
+ /**
19904
+ * Check if kashida can be inserted between two characters.
19905
+ * Kashida can only be inserted:
19906
+ * - Between two Arabic letters
19907
+ * - After a letter that connects to the next (not in ARABIC_NON_CONNECTING)
19908
+ * - Not at word boundaries (no whitespace before/after)
19909
+ */
19910
+ // Alef variants that form ligatures with lam
19911
+ const ARABIC_ALEF_VARIANTS = new Set(['\u0627',
19912
+ // ا ALEF
19913
+ '\u0623',
19914
+ // أ ALEF WITH HAMZA ABOVE
19915
+ '\u0625',
19916
+ // إ ALEF WITH HAMZA BELOW
19917
+ '\u0622',
19918
+ // آ ALEF WITH MADDA ABOVE
19919
+ '\u0671' // ٱ ALEF WASLA
19920
+ ]);
19921
+
19922
+ // Lam character
19923
+ const ARABIC_LAM = '\u0644'; // ل
19924
+
19925
+ function canInsertKashida(prevChar, nextChar) {
19926
+ if (!prevChar || !nextChar) return false;
19927
+
19928
+ // Can't insert at whitespace boundaries
19929
+ if (/\s/.test(prevChar) || /\s/.test(nextChar)) return false;
19930
+
19931
+ // Both must be Arabic letters
19932
+ if (!isArabicLetter(prevChar) || !isArabicLetter(nextChar)) return false;
19933
+
19934
+ // Previous char must connect to the next (not be non-connecting)
19935
+ if (ARABIC_NON_CONNECTING.has(prevChar)) return false;
19936
+
19937
+ // NEVER insert kashida between lam and alef - they form a ligature (لا)
19938
+ if (prevChar === ARABIC_LAM && ARABIC_ALEF_VARIANTS.has(nextChar)) return false;
19939
+ return true;
19940
+ }
19941
+
19942
+ /**
19943
+ * Represents a valid kashida insertion point
19944
+ */
19945
+
19946
+ /**
19947
+ * Find all valid kashida insertion points in a line of text.
19948
+ * Returns points sorted by priority (highest first).
19949
+ *
19950
+ * Priority rules (similar to Adobe Illustrator):
19951
+ * 1. Between connected letters (ب + ب = highest)
19952
+ * 2. Prefer middle of words over edges
19953
+ * 3. Avoid inserting right before/after spaces
19954
+ */
19955
+ function findKashidaPoints(graphemes) {
19956
+ const points = [];
19957
+ for (let i = 0; i < graphemes.length - 1; i++) {
19958
+ const prev = graphemes[i];
19959
+ const next = graphemes[i + 1];
19960
+ if (canInsertKashida(prev, next)) {
19961
+ // Calculate priority based on position in word
19962
+ let priority = 1;
19963
+
19964
+ // Find word boundaries
19965
+ let wordStart = i;
19966
+ let wordEnd = i + 1;
19967
+ while (wordStart > 0 && !isWhitespace(graphemes[wordStart - 1])) {
19968
+ wordStart--;
19969
+ }
19970
+ while (wordEnd < graphemes.length && !isWhitespace(graphemes[wordEnd])) {
19971
+ wordEnd++;
19972
+ }
19973
+ const wordLength = wordEnd - wordStart;
19974
+ const posInWord = i - wordStart;
19975
+
19976
+ // Higher priority for middle positions
19977
+ const distFromEdge = Math.min(posInWord, wordLength - 1 - posInWord);
19978
+ priority = distFromEdge + 1;
19979
+
19980
+ // Boost priority for longer words
19981
+ if (wordLength > 4) priority += 1;
19982
+ if (wordLength > 6) priority += 1;
19983
+ points.push({
19984
+ charIndex: i,
19985
+ priority
19986
+ });
19987
+ }
19988
+ }
19989
+
19990
+ // Sort by priority descending
19991
+ points.sort((a, b) => b.priority - a.priority);
19992
+ return points;
19993
+ }
19994
+
19701
19995
  /**
19702
19996
  * Ellipsis Text Truncation System
19703
19997
  *
@@ -20056,7 +20350,7 @@
20056
20350
  let lineHeight = 0;
20057
20351
  let charIndex = textOffset; // Track character position in original text
20058
20352
 
20059
- // Measure each grapheme
20353
+ // Measure each grapheme in logical order
20060
20354
  for (let i = 0; i < graphemes.length; i++) {
20061
20355
  const grapheme = graphemes[i];
20062
20356
  const prevGrapheme = i > 0 ? graphemes[i - 1] : undefined;
@@ -20072,12 +20366,14 @@
20072
20366
  bounds.push({
20073
20367
  grapheme,
20074
20368
  x,
20369
+ // Will be updated by BiDi reordering
20075
20370
  y: 0,
20076
20371
  // Will be adjusted later
20077
20372
  width: measurement.width,
20078
20373
  height: measurement.height,
20079
20374
  kernedWidth: measurement.kernedWidth,
20080
20375
  left: x,
20376
+ // Logical position (cumulative)
20081
20377
  baseline: measurement.baseline,
20082
20378
  charIndex: charIndex,
20083
20379
  // Character position in original text
@@ -20091,6 +20387,9 @@
20091
20387
  lineHeight = Math.max(lineHeight, measurement.height);
20092
20388
  }
20093
20389
 
20390
+ // Note: BiDi visual reordering is handled by the browser's canvas fillText
20391
+ // The layout stores positions in logical order; hit testing handles the visual mapping
20392
+
20094
20393
  // Remove trailing spacing from total width (but keep in bounds for rendering)
20095
20394
  if (bounds.length > 0) {
20096
20395
  options.letterSpacing || 0;
@@ -20101,7 +20400,9 @@
20101
20400
  }
20102
20401
 
20103
20402
  // Apply line height
20104
- const finalHeight = lineHeight * options.lineHeight;
20403
+ // Note: Fabric.js uses _fontSizeMult = 1.13 for line height calculation
20404
+ const fontSizeMult = 1.13;
20405
+ const finalHeight = lineHeight * options.lineHeight * fontSizeMult;
20105
20406
  return {
20106
20407
  text,
20107
20408
  graphemes,
@@ -20171,11 +20472,119 @@
20171
20472
  return lines.length > 0 ? lines : [''];
20172
20473
  }
20173
20474
 
20475
+ /**
20476
+ * Apply BiDi visual reordering to calculate correct visual X positions
20477
+ * This implements the Unicode Bidirectional Algorithm for character placement
20478
+ */
20479
+ function applyBiDiVisualReordering(line, options) {
20480
+ const baseDirection = options.direction === 'inherit' ? 'ltr' : options.direction;
20481
+
20482
+ // Quick check: if all characters are same direction as base, no reordering needed
20483
+ const runs = analyzeBiDi(line.text, baseDirection);
20484
+ const hasMixedBiDi = runs.length > 1 || runs.length === 1 && runs[0].direction !== baseDirection;
20485
+ if (!hasMixedBiDi) {
20486
+ // For pure LTR or pure RTL, just set visual x = logical left
20487
+ // For RTL base direction, we need to flip positions
20488
+ if (baseDirection === 'rtl') {
20489
+ // RTL: rightmost character should be at x=0, leftmost at x=lineWidth
20490
+ line.bounds.forEach(bound => {
20491
+ bound.x = line.width - bound.left - bound.kernedWidth;
20492
+ });
20493
+ }
20494
+ // For LTR, x is already correct (same as left)
20495
+ return line;
20496
+ }
20497
+
20498
+ // Mixed BiDi text - need to reorder runs visually
20499
+ // 1. Build mapping from grapheme index to run
20500
+ const graphemeToRun = [];
20501
+ let runGraphemeStart = 0;
20502
+ for (let runIdx = 0; runIdx < runs.length; runIdx++) {
20503
+ const run = runs[runIdx];
20504
+ const runGraphemes = segmentGraphemes(run.text);
20505
+ for (let i = 0; i < runGraphemes.length; i++) {
20506
+ graphemeToRun.push(runIdx);
20507
+ }
20508
+ runGraphemeStart += runGraphemes.length;
20509
+ }
20510
+
20511
+ // 2. Calculate run widths and positions
20512
+ const runWidths = [];
20513
+ const runStartIndices = [];
20514
+ let currentIdx = 0;
20515
+ for (const run of runs) {
20516
+ runStartIndices.push(currentIdx);
20517
+ const runGraphemes = segmentGraphemes(run.text);
20518
+ let runWidth = 0;
20519
+ for (let i = 0; i < runGraphemes.length; i++) {
20520
+ if (currentIdx + i < line.bounds.length) {
20521
+ const letterSpacing = options.letterSpacing || 0;
20522
+ const charSpacing = options.charSpacing ? options.fontSize * options.charSpacing / 1000 : 0;
20523
+ runWidth += line.bounds[currentIdx + i].kernedWidth + letterSpacing + charSpacing;
20524
+ }
20525
+ }
20526
+ runWidths.push(runWidth);
20527
+ currentIdx += runGraphemes.length;
20528
+ }
20529
+
20530
+ // 3. Determine visual order of runs based on base direction
20531
+ // RTL base: runs display right-to-left (first run on right)
20532
+ // LTR base: runs display left-to-right (first run on left)
20533
+ const visualRunOrder = runs.map((_, i) => i);
20534
+ if (baseDirection === 'rtl') {
20535
+ visualRunOrder.reverse();
20536
+ }
20537
+
20538
+ // 4. Calculate visual X position for each run
20539
+ const runVisualX = new Array(runs.length);
20540
+ let currentX = 0;
20541
+ for (const runIdx of visualRunOrder) {
20542
+ runVisualX[runIdx] = currentX;
20543
+ currentX += runWidths[runIdx];
20544
+ }
20545
+
20546
+ // 5. Assign visual X positions to each grapheme
20547
+ for (let i = 0; i < line.bounds.length; i++) {
20548
+ const runIdx = graphemeToRun[i];
20549
+ if (runIdx === undefined) continue;
20550
+ const run = runs[runIdx];
20551
+ const runStart = runStartIndices[runIdx];
20552
+
20553
+ // Calculate spacing once
20554
+ const letterSpacing = options.letterSpacing || 0;
20555
+ const charSpacing = options.charSpacing ? options.fontSize * options.charSpacing / 1000 : 0;
20556
+ const totalSpacing = letterSpacing + charSpacing;
20557
+
20558
+ // Calculate offset within run (sum of widths of chars before this one)
20559
+ let offsetInRun = 0;
20560
+ for (let j = runStart; j < i; j++) {
20561
+ offsetInRun += line.bounds[j].kernedWidth + totalSpacing;
20562
+ }
20563
+
20564
+ // Character width including spacing
20565
+ const charWidth = line.bounds[i].kernedWidth + totalSpacing;
20566
+
20567
+ // For RTL runs, characters within the run are reversed visually
20568
+ // First logical char appears on the right, last on the left
20569
+ if (run.direction === 'rtl') {
20570
+ // Visual X = run right edge - cumulative width including this char
20571
+ // This places first char at right side of run, last char at left side
20572
+ line.bounds[i].x = runVisualX[runIdx] + runWidths[runIdx] - offsetInRun - charWidth;
20573
+ } else {
20574
+ // LTR run: visual position is run start + offset within run
20575
+ line.bounds[i].x = runVisualX[runIdx] + offsetInRun;
20576
+ }
20577
+ }
20578
+ return line;
20579
+ }
20580
+
20174
20581
  /**
20175
20582
  * Apply text alignment to lines
20176
20583
  */
20177
20584
  function applyAlignment(lines, align, containerWidth, options) {
20178
20585
  return lines.map(line => {
20586
+ // First apply BiDi reordering to get correct visual X positions
20587
+ applyBiDiVisualReordering(line, options);
20179
20588
  let offsetX = 0;
20180
20589
  switch (align) {
20181
20590
  case 'center':
@@ -20195,7 +20604,7 @@
20195
20604
  break;
20196
20605
  }
20197
20606
 
20198
- // Apply offset to all bounds
20607
+ // Apply offset to all bounds (both visual x and logical left for alignment)
20199
20608
  if (offsetX !== 0) {
20200
20609
  line.bounds.forEach(bound => {
20201
20610
  bound.x += offsetX;
@@ -20279,7 +20688,9 @@
20279
20688
  * Create empty line for empty paragraphs
20280
20689
  */
20281
20690
  function createEmptyLine(options) {
20282
- const height = options.fontSize * options.lineHeight;
20691
+ // Fabric.js uses _fontSizeMult = 1.13 for line height calculation
20692
+ const fontSizeMult = 1.13;
20693
+ const height = options.fontSize * options.lineHeight * fontSizeMult;
20283
20694
  return {
20284
20695
  text: '',
20285
20696
  graphemes: [],
@@ -20765,6 +21176,12 @@
20765
21176
  * @protected
20766
21177
  */
20767
21178
  _defineProperty(this, "__charBounds", []);
21179
+ /**
21180
+ * contains kashida extension info for each line.
21181
+ * Each entry contains { charIndex, width } for characters that have kashida extensions.
21182
+ * @protected
21183
+ */
21184
+ _defineProperty(this, "__kashidaInfo", []);
20768
21185
  Object.assign(this, FabricText.ownDefaults);
20769
21186
  this.setOptions(options);
20770
21187
  if (!this.styles) {
@@ -20883,11 +21300,31 @@
20883
21300
  }
20884
21301
 
20885
21302
  /**
20886
- * Enlarge space boxes and shift the others for justify alignment
21303
+ * Enlarge space boxes and shift the others for justify alignment.
21304
+ * Supports Arabic kashida (tatweel) justification when kashida property is set.
21305
+ * When kashida is enabled, actual tatweel characters are inserted into the text.
20887
21306
  */
20888
21307
  enlargeSpaces() {
20889
- let diffSpace, currentLineWidth, numberOfSpaces, accumulatedSpace, line, charBound, spaces;
21308
+ // console.log('=== enlargeSpaces START ===');
21309
+ // console.log('this.kashida:', this.kashida);
21310
+
21311
+ // Kashida ratios: proportion of extra space distributed via kashida vs space expansion
21312
+ const kashidaRatios = {
21313
+ none: 0,
21314
+ short: 0.25,
21315
+ medium: 0.5,
21316
+ long: 0.75,
21317
+ stylistic: 1.0
21318
+ };
21319
+ const kashidaRatio = kashidaRatios[this.kashida] || 0;
21320
+ // console.log('kashidaRatio:', kashidaRatio);
21321
+
21322
+ // Reset kashida info
21323
+ this.__kashidaInfo = [];
20890
21324
  for (let i = 0, len = this._textLines.length; i < len; i++) {
21325
+ // Initialize kashida info for this line
21326
+ this.__kashidaInfo[i] = [];
21327
+
20891
21328
  // Check if this line should be justified
20892
21329
  const hasTextAfter = this._textLines.slice(i + 1).some(line => {
20893
21330
  const lineText = Array.isArray(line) ? line.join('') : line;
@@ -20897,33 +21334,121 @@
20897
21334
  const isLastLine = i === len - 1 || this.isEndOfWrapping(i) || isVisualLastLine;
20898
21335
  const shouldJustifyLine = this.textAlign.includes('justify') && !isLastLine;
20899
21336
  if (!shouldJustifyLine) {
21337
+ // console.log(` Line ${i}: skipped (not justified)`);
20900
21338
  continue;
20901
21339
  }
20902
- accumulatedSpace = 0;
20903
- line = this._textLines[i];
20904
- currentLineWidth = this.getLineWidth(i);
20905
- if (currentLineWidth < this.width && (spaces = this.textLines[i].match(this._reSpacesAndTabs))) {
20906
- numberOfSpaces = spaces.length;
20907
- diffSpace = (this.width - currentLineWidth) / numberOfSpaces;
20908
-
20909
- // Same logic for both LTR and RTL:
20910
- // Expand space widths and shift subsequent characters
20911
- // The rendering handles direction via ctx.direction
20912
- for (let j = 0; j <= line.length; j++) {
20913
- charBound = this.__charBounds[i][j];
20914
- if (charBound) {
20915
- if (this._reSpaceAndTab.test(line[j])) {
20916
- charBound.width += diffSpace;
20917
- charBound.kernedWidth += diffSpace;
20918
- charBound.left += accumulatedSpace;
20919
- accumulatedSpace += diffSpace;
20920
- } else {
20921
- charBound.left += accumulatedSpace;
21340
+ const line = this._textLines[i];
21341
+ const currentLineWidth = this.getLineWidth(i);
21342
+ const totalExtraSpace = this.width - currentLineWidth;
21343
+ // console.log(` Line ${i}: width=${this.width}, lineWidth=${currentLineWidth}, extraSpace=${totalExtraSpace}`);
21344
+
21345
+ if (totalExtraSpace <= 0) {
21346
+ // console.log(` Line ${i}: skipped (no extra space)`);
21347
+ continue;
21348
+ }
21349
+
21350
+ // Find spaces for space expansion
21351
+ const spaces = this.textLines[i].match(this._reSpacesAndTabs);
21352
+ const numberOfSpaces = spaces ? spaces.length : 0;
21353
+
21354
+ // Find kashida points if enabled
21355
+ const kashidaPoints = kashidaRatio > 0 ? findKashidaPoints(line) : [];
21356
+ const hasKashidaPoints = kashidaPoints.length > 0;
21357
+
21358
+ // Calculate space distribution
21359
+ let kashidaSpace = 0;
21360
+ if (hasKashidaPoints && kashidaRatio > 0) {
21361
+ // Distribute between kashida and spaces
21362
+ kashidaSpace = totalExtraSpace * kashidaRatio;
21363
+ }
21364
+
21365
+ // Calculate per-kashida and per-space widths
21366
+ const perKashidaWidth = hasKashidaPoints ? kashidaSpace / kashidaPoints.length : 0;
21367
+
21368
+ // If kashida is enabled, insert tatweel characters into the text
21369
+ if (hasKashidaPoints && perKashidaWidth > 0) {
21370
+ // console.log(`=== Inserting kashida for line ${i} ===`);
21371
+ // console.log(` kashidaPoints: ${kashidaPoints.length}, perKashidaWidth: ${perKashidaWidth}`);
21372
+
21373
+ // Sort by charIndex descending to insert from end (so indices stay valid)
21374
+ const sortedPoints = [...kashidaPoints].sort((a, b) => b.charIndex - a.charIndex);
21375
+
21376
+ // Calculate how many tatweels to insert per point
21377
+ // Measure tatweel width to determine count
21378
+ const ctx = getMeasuringContext();
21379
+ // console.log(` getMeasuringContext: ${ctx ? 'OK' : 'NULL'}`);
21380
+
21381
+ if (ctx) {
21382
+ ctx.font = this._getFontDeclaration();
21383
+ const tatweelWidth = ctx.measureText(ARABIC_TATWEEL).width;
21384
+ // console.log(` tatweelWidth: ${tatweelWidth}`);
21385
+
21386
+ if (tatweelWidth > 0) {
21387
+ const newLine = [...line];
21388
+ for (const point of sortedPoints) {
21389
+ const tatweelCount = Math.max(1, Math.round(perKashidaWidth / tatweelWidth));
21390
+ // console.log(` Point ${point.charIndex}: inserting ${tatweelCount} tatweels`);
21391
+
21392
+ // Insert tatweels after the character
21393
+ for (let t = 0; t < tatweelCount; t++) {
21394
+ newLine.splice(point.charIndex + 1, 0, ARABIC_TATWEEL);
21395
+ }
21396
+
21397
+ // Store kashida info with updated indices and tatweel count
21398
+ this.__kashidaInfo[i].push({
21399
+ charIndex: point.charIndex,
21400
+ width: perKashidaWidth,
21401
+ tatweelCount: tatweelCount
21402
+ });
21403
+ }
21404
+
21405
+ // console.log(` Total inserted: ${insertedCount} tatweels`);
21406
+ // console.log(` Original line length: ${line.length}, new line length: ${newLine.length}`);
21407
+ // console.log(` New line: ${newLine.join('')}`);
21408
+
21409
+ // Update _textLines with the new line containing tatweels
21410
+ this._textLines[i] = newLine;
21411
+
21412
+ // Update textLines string version
21413
+ if (this.textLines && this.textLines[i] !== undefined) {
21414
+ this.textLines[i] = newLine.join('');
20922
21415
  }
21416
+
21417
+ // Recalculate charBounds for this line since text changed
21418
+ this.__charBounds[i] = [];
21419
+ this.__lineWidths[i] = undefined;
21420
+ this._measureLine(i);
21421
+
21422
+ // console.log(` After remeasure, lineWidth: ${this.__lineWidths[i]}`);
21423
+ }
21424
+ }
21425
+ }
21426
+
21427
+ // Now apply space expansion to remaining extra space
21428
+ const newLineWidth = this.getLineWidth(i);
21429
+ const remainingSpace = this.width - newLineWidth;
21430
+ if (remainingSpace > 0 && numberOfSpaces > 0) {
21431
+ const extraPerSpace = remainingSpace / numberOfSpaces;
21432
+ let accumulatedOffset = 0;
21433
+ for (let j = 0; j < this._textLines[i].length; j++) {
21434
+ const charBound = this.__charBounds[i][j];
21435
+ if (!charBound) continue;
21436
+ charBound.left += accumulatedOffset;
21437
+ if (this._reSpaceAndTab.test(this._textLines[i][j])) {
21438
+ charBound.width += extraPerSpace;
21439
+ charBound.kernedWidth += extraPerSpace;
21440
+ accumulatedOffset += extraPerSpace;
20923
21441
  }
20924
21442
  }
20925
21443
  }
20926
21444
  }
21445
+
21446
+ // Final debug log showing kashida state
21447
+ // console.log('=== enlargeSpaces END ===');
21448
+ // console.log('Final __kashidaInfo:', JSON.stringify(this.__kashidaInfo.map((lineInfo, i) => ({
21449
+ // line: i,
21450
+ // entries: lineInfo.map(k => ({ charIndex: k.charIndex, tatweelCount: k.tatweelCount }))
21451
+ // }))));
20927
21452
  }
20928
21453
 
20929
21454
  /**
@@ -20943,7 +21468,9 @@
20943
21468
  return {
20944
21469
  text: this.text,
20945
21470
  width: this.width,
20946
- height: this.height,
21471
+ // Don't pass height constraint to allow vertical auto-expansion
21472
+ // Only pass height if ellipsis is enabled (need to truncate)
21473
+ height: this.ellipsis ? this.height : undefined,
20947
21474
  wrap: this.wrap || 'word',
20948
21475
  align: this._mapTextAlignToAlign(this.textAlign),
20949
21476
  ellipsis: this.ellipsis || false,
@@ -20998,9 +21525,13 @@
20998
21525
  // Convert layout to legacy format for compatibility
20999
21526
  this._convertLayoutToLegacyFormat(layout);
21000
21527
 
21001
- // Ensure justify alignment is properly applied for compatibility with legacy rendering
21002
- // Skip legacy enlargeSpaces when using advanced layout; Konva layout already distributes spaces.
21003
-
21528
+ // Apply kashida if enabled for justify alignment
21529
+ // This must be called after _convertLayoutToLegacyFormat to ensure __charBounds exists
21530
+ if (this.textAlign.includes(JUSTIFY) && this.kashida && this.kashida !== 'none') {
21531
+ if (this.__charBounds && this.__charBounds.length > 0) {
21532
+ this.enlargeSpaces();
21533
+ }
21534
+ }
21004
21535
  this.dirty = true;
21005
21536
  }
21006
21537
 
@@ -21012,16 +21543,27 @@
21012
21543
  this._textLines = layout.lines.map(line => line.graphemes);
21013
21544
  this.textLines = layout.lines.map(line => line.text);
21014
21545
 
21546
+ // Set _text as flat array of all graphemes (required for editing)
21547
+ this._text = layout.lines.flatMap(line => line.graphemes);
21548
+
21015
21549
  // Convert bounds to legacy format
21550
+ // IMPORTANT: Preserve both logical (left) and visual (renderLeft) positions
21551
+ // - left: cumulative logical offset (for text editing operations)
21552
+ // - renderLeft: actual visual X position after BiDi reordering and alignment
21553
+ // The renderLeft is critical for correct cursor/selection hit testing in mixed RTL/LTR text
21016
21554
  this.__charBounds = layout.lines.map(line => line.bounds.map(bound => ({
21017
21555
  left: bound.left,
21018
21556
  top: bound.y,
21019
21557
  width: bound.width,
21020
21558
  height: bound.height,
21021
21559
  kernedWidth: bound.kernedWidth,
21022
- deltaY: bound.deltaY || 0
21560
+ deltaY: bound.deltaY || 0,
21561
+ renderLeft: bound.x // Visual X position for hit testing
21023
21562
  })));
21024
21563
 
21564
+ // Populate line widths cache to prevent getLineWidth from triggering legacy measurement
21565
+ this.__lineWidths = layout.lines.map(line => line.width);
21566
+
21025
21567
  // Update grapheme info for compatibility
21026
21568
  if (layout.lines.length > 0) {
21027
21569
  this._unwrappedTextLines = layout.lines.map(line => line.graphemes);
@@ -21189,6 +21731,48 @@
21189
21731
  this._renderChars(method, ctx, line, left, top, lineIndex);
21190
21732
  }
21191
21733
 
21734
+ /**
21735
+ * Build display text lines with kashida characters inserted.
21736
+ * This creates a version of _textLines with tatweel characters added at kashida points.
21737
+ * @private
21738
+ */
21739
+ _buildKashidaDisplayLines() {
21740
+ if (this.kashida === 'none' || !this.__kashidaInfo) {
21741
+ return this._textLines;
21742
+ }
21743
+ const displayLines = [];
21744
+ for (let lineIndex = 0; lineIndex < this._textLines.length; lineIndex++) {
21745
+ const line = this._textLines[lineIndex];
21746
+ const kashidaInfo = this.__kashidaInfo[lineIndex];
21747
+ if (!kashidaInfo || kashidaInfo.length === 0) {
21748
+ displayLines.push([...line]);
21749
+ continue;
21750
+ }
21751
+
21752
+ // Sort kashida points by charIndex descending so we can insert from the end
21753
+ const sortedKashida = [...kashidaInfo].sort((a, b) => b.charIndex - a.charIndex);
21754
+
21755
+ // Calculate how many tatweels to insert based on width
21756
+ const newLine = [...line];
21757
+ for (const {
21758
+ charIndex,
21759
+ width
21760
+ } of sortedKashida) {
21761
+ if (width <= 0 || charIndex >= newLine.length) continue;
21762
+
21763
+ // Calculate number of tatweel characters based on width
21764
+ // Each tatweel is approximately 5px at font size 24
21765
+ const tatweelCount = Math.max(1, Math.round(width / 3));
21766
+ const tatweels = ARABIC_TATWEEL.repeat(tatweelCount);
21767
+
21768
+ // Insert tatweels after the character at charIndex
21769
+ newLine.splice(charIndex + 1, 0, tatweels);
21770
+ }
21771
+ displayLines.push(newLine);
21772
+ }
21773
+ return displayLines;
21774
+ }
21775
+
21192
21776
  /**
21193
21777
  * Renders the text background for lines, taking care of style
21194
21778
  * @private
@@ -21269,10 +21853,13 @@
21269
21853
  const fontCache = cache.getFontCache(charStyle),
21270
21854
  fontDeclaration = this._getFontDeclaration(charStyle),
21271
21855
  couple = previousChar + _char,
21272
- stylesAreEqual = previousChar && fontDeclaration === this._getFontDeclaration(prevCharStyle),
21856
+ // Skip kerning for tatweel (kashida) characters - they extend connections
21857
+ // and kerning would make the following character appear too narrow
21858
+ isTatweel = previousChar === '\u0640',
21859
+ stylesAreEqual = previousChar && !isTatweel && fontDeclaration === this._getFontDeclaration(prevCharStyle),
21273
21860
  fontMultiplier = charStyle.fontSize / this.CACHE_FONT_SIZE;
21274
21861
  let width, coupleWidth, previousWidth, kernedWidth;
21275
- if (previousChar && fontCache[previousChar] !== undefined) {
21862
+ if (previousChar && !isTatweel && fontCache[previousChar] !== undefined) {
21276
21863
  previousWidth = fontCache[previousChar];
21277
21864
  }
21278
21865
  if (fontCache[_char] !== undefined) {
@@ -21290,11 +21877,11 @@
21290
21877
  kernedWidth = width = ctx.measureText(_char).width;
21291
21878
  fontCache[_char] = width;
21292
21879
  }
21293
- if (previousWidth === undefined && stylesAreEqual && previousChar) {
21880
+ if (previousWidth === undefined && stylesAreEqual && previousChar && !isTatweel) {
21294
21881
  previousWidth = ctx.measureText(previousChar).width;
21295
21882
  fontCache[previousChar] = previousWidth;
21296
21883
  }
21297
- if (stylesAreEqual && coupleWidth === undefined) {
21884
+ if (stylesAreEqual && coupleWidth === undefined && !isTatweel) {
21298
21885
  // we can measure the kerning couple and subtract the width of the previous character
21299
21886
  coupleWidth = ctx.measureText(couple).width;
21300
21887
  fontCache[couple] = coupleWidth;
@@ -21341,10 +21928,7 @@
21341
21928
  */
21342
21929
  _measureLine(lineIndex) {
21343
21930
  // Debug: detect if measureLine is called after justify was applied
21344
- if (this._justifyApplied) {
21345
- console.warn(`WARNING: _measureLine called for line ${lineIndex} AFTER justify was applied! This will overwrite justified charBounds.`);
21346
- console.trace('Stack trace:');
21347
- }
21931
+ if (this._justifyApplied) ;
21348
21932
  let width = 0,
21349
21933
  prevGrapheme,
21350
21934
  graphemeInfo;
@@ -21519,13 +22103,7 @@
21519
22103
  top = this._getTopOffset();
21520
22104
 
21521
22105
  // Debug: log once per render
21522
- if (method === 'fillText' && (_this$textAlign = this.textAlign) !== null && _this$textAlign !== void 0 && _this$textAlign.includes('justify')) {
21523
- console.log('=== RENDER DEBUG ===');
21524
- console.log('direction:', this.direction);
21525
- console.log('textAlign:', this.textAlign);
21526
- console.log('width:', this.width);
21527
- console.log('_getLeftOffset:', left);
21528
- }
22106
+ if (method === 'fillText' && (_this$textAlign = this.textAlign) !== null && _this$textAlign !== void 0 && _this$textAlign.includes('justify')) ;
21529
22107
  for (let i = 0, len = this._textLines.length; i < len; i++) {
21530
22108
  var _this$textAlign2;
21531
22109
  const heightOfLine = this.getHeightOfLine(i),
@@ -21534,8 +22112,8 @@
21534
22112
 
21535
22113
  // Debug: log line offsets for justify
21536
22114
  if (method === 'fillText' && (_this$textAlign2 = this.textAlign) !== null && _this$textAlign2 !== void 0 && _this$textAlign2.includes('justify')) {
21537
- const lineWidth = this.getLineWidth(i);
21538
- console.log(`Line ${i}: leftOffset=${leftOffset.toFixed(2)}, lineWidth=${lineWidth.toFixed(2)}, renderAt=${(left + leftOffset).toFixed(2)}`);
22115
+ this.getLineWidth(i);
22116
+ // console.log(`Line ${i}: leftOffset=${leftOffset.toFixed(2)}, lineWidth=${lineWidth.toFixed(2)}, renderAt=${(left + leftOffset).toFixed(2)}`);
21539
22117
  }
21540
22118
  this._renderTextLine(method, ctx, this._textLines[i], left + leftOffset, top + lineHeights + maxHeight, i);
21541
22119
  lineHeights += heightOfLine;
@@ -21586,12 +22164,18 @@
21586
22164
  const lineHeight = this.getHeightOfLine(lineIndex),
21587
22165
  isJustify = this.textAlign.includes(JUSTIFY),
21588
22166
  path = this.path,
21589
- shortCut = !isJustify && this.charSpacing === 0 && this.isEmptyStyles(lineIndex) && !path,
21590
22167
  isLtr = this.direction === 'ltr',
21591
22168
  sign = this.direction === 'ltr' ? 1 : -1,
21592
22169
  // this was changed in the PR #7674
21593
22170
  // currentDirection = ctx.canvas.getAttribute('dir');
21594
22171
  currentDirection = ctx.direction;
22172
+
22173
+ // Check if we should use BiDi-aware rendering with pre-calculated positions
22174
+ // This is needed for advanced layout with RTL or mixed BiDi text
22175
+ const chars = this.__charBounds[lineIndex];
22176
+ this.enableAdvancedLayout && (chars === null || chars === void 0 ? void 0 : chars.length) > 0 && chars[0].renderLeft !== undefined;
22177
+
22178
+ const shortCut = !isJustify && this.charSpacing === 0 && this.isEmptyStyles(lineIndex) && !path;
21595
22179
  let actualStyle,
21596
22180
  nextStyle,
21597
22181
  charsToRender = '',
@@ -21600,6 +22184,9 @@
21600
22184
  timeToRender,
21601
22185
  drawingLeft;
21602
22186
  ctx.save();
22187
+
22188
+ // For BiDi rendering with pre-calculated positions, disable browser BiDi
22189
+ // and render each character at its calculated visual position
21603
22190
  if (currentDirection !== this.direction) {
21604
22191
  ctx.canvas.setAttribute('dir', isLtr ? 'ltr' : 'rtl');
21605
22192
  ctx.direction = isLtr ? 'ltr' : 'rtl';
@@ -21619,12 +22206,12 @@
21619
22206
  }
21620
22207
  // Debug: Log charBounds being used for first line only during justify
21621
22208
  if (isJustify && lineIndex === 0 && method === 'fillText') {
21622
- console.log(`\n=== RENDER _renderChars line ${lineIndex} ===`);
21623
- console.log('Initial left:', left.toFixed(2), 'sign:', sign);
21624
- console.log('_justifyApplied flag:', this._justifyApplied);
22209
+ // console.log(`\n=== RENDER _renderChars line ${lineIndex} ===`);
22210
+ // console.log('Initial left:', left.toFixed(2), 'sign:', sign);
22211
+ // console.log('_justifyApplied flag:', (this as any)._justifyApplied);
21625
22212
  const lineBounds = this.__charBounds[lineIndex];
21626
- const totalKW = (lineBounds === null || lineBounds === void 0 ? void 0 : lineBounds.reduce((s, b) => s + ((b === null || b === void 0 ? void 0 : b.kernedWidth) || 0), 0)) || 0;
21627
- console.log('Total kernedWidth in charBounds:', totalKW.toFixed(2), '(should be ~300 if justify was applied)');
22213
+ (lineBounds === null || lineBounds === void 0 ? void 0 : lineBounds.reduce((s, b) => s + ((b === null || b === void 0 ? void 0 : b.kernedWidth) || 0), 0)) || 0;
22214
+ // console.log('Total kernedWidth in charBounds:', totalKW.toFixed(2), '(should be ~300 if justify was applied)');
21628
22215
  // Log first few space widths to verify expansion
21629
22216
  const spaceIndices = [3, 9, 15, 23, 31];
21630
22217
  spaceIndices.forEach(idx => {
@@ -21673,10 +22260,6 @@
21673
22260
  // For RTL with textAlign='right': x is the right edge, so drawingLeft = left
21674
22261
  // Both cases: drawingLeft = left (the text alignment handles the edge correctly)
21675
22262
  drawingLeft = left;
21676
- // Debug: log first chunk positioning for justify
21677
- if (isJustify && lineIndex === 0 && method === 'fillText' && i < 5) {
21678
- console.log(` Chunk ending at char ${i}: left=${left.toFixed(2)}, boxWidth=${boxWidth.toFixed(2)}, drawingLeft=${drawingLeft.toFixed(2)}, textAlign=${isLtr ? 'left' : 'right'}`);
21679
- }
21680
22263
  this._renderChar(method, ctx, lineIndex, i, charsToRender, drawingLeft, top);
21681
22264
  }
21682
22265
  charsToRender = '';
@@ -21685,11 +22268,6 @@
21685
22268
  boxWidth = 0;
21686
22269
  }
21687
22270
  }
21688
- // Debug: log final position for justify
21689
- if (isJustify && lineIndex === 0 && method === 'fillText') {
21690
- console.log('Final left position after rendering:', left.toFixed(2));
21691
- console.log('Expected final position:', (sign > 0 ? this.width / 2 : -this.width / 2).toFixed(2));
21692
- }
21693
22271
  ctx.restore();
21694
22272
  }
21695
22273
 
@@ -21921,12 +22499,159 @@
21921
22499
  * @private
21922
22500
  */
21923
22501
  _clearCache() {
22502
+ // console.log('🗑️ _clearCache called');
22503
+ // console.trace('🗑️ _clearCache stack trace');
21924
22504
  this._forceClearCache = false;
21925
22505
  this.__lineWidths = [];
21926
22506
  this.__lineHeights = [];
21927
22507
  this.__charBounds = [];
22508
+ this.__kashidaInfo = [];
21928
22509
  // Reset justify applied flag
21929
22510
  this._justifyApplied = false;
22511
+ // Reset dimension state to force recalculation
22512
+ this._lastDimensionState = null;
22513
+ }
22514
+
22515
+ /**
22516
+ * Convert a display character index (in _textLines with tatweels) to original text index.
22517
+ * When kashida is applied, _textLines contains extra tatweel characters that don't exist
22518
+ * in the original text. This method maps back to the original index.
22519
+ * @param lineIndex - The line index
22520
+ * @param displayCharIndex - Character index in the display text (with tatweels)
22521
+ * @returns Original character index (without tatweels)
22522
+ */
22523
+ _displayToOriginalIndex(lineIndex, displayCharIndex) {
22524
+ var _this$__kashidaInfo;
22525
+ // console.log(`🔄 _displayToOriginalIndex called: line=${lineIndex}, displayIdx=${displayCharIndex}`);
22526
+ // console.log(`🔄 __kashidaInfo exists: ${!!this.__kashidaInfo}, length: ${this.__kashidaInfo?.length}`);
22527
+ // console.log(`🔄 __kashidaInfo raw:`, JSON.stringify(this.__kashidaInfo));
22528
+
22529
+ const kashidaInfo = (_this$__kashidaInfo = this.__kashidaInfo) === null || _this$__kashidaInfo === void 0 ? void 0 : _this$__kashidaInfo[lineIndex];
22530
+ if (!kashidaInfo || kashidaInfo.length === 0) {
22531
+ // No kashida on this line, indices are the same
22532
+ // console.log(`🔄 No kashida info for line ${lineIndex}, returning same index`);
22533
+ return displayCharIndex;
22534
+ }
22535
+
22536
+ // Sort kashida info by charIndex ascending for proper traversal
22537
+ const sortedKashida = [...kashidaInfo].sort((a, b) => a.charIndex - b.charIndex);
22538
+
22539
+ // console.log(`🔄 _displayToOriginalIndex: line=${lineIndex}, displayIdx=${displayCharIndex}`);
22540
+ // console.log(`🔄 kashidaInfo:`, sortedKashida.map(k => `{charIdx:${k.charIndex}, cnt:${k.tatweelCount}}`).join(', '));
22541
+
22542
+ let tatweelsBeforeIndex = 0;
22543
+ for (const k of sortedKashida) {
22544
+ const tatweelCount = k.tatweelCount || 0;
22545
+ // Position where tatweels start (after the original character)
22546
+ const tatweelStartPos = k.charIndex + 1 + tatweelsBeforeIndex;
22547
+ const tatweelEndPos = tatweelStartPos + tatweelCount;
22548
+
22549
+ // console.log(`🔄 k.charIndex=${k.charIndex}, tatweelStartPos=${tatweelStartPos}, tatweelEndPos=${tatweelEndPos}, tatweelsBeforeIndex=${tatweelsBeforeIndex}`);
22550
+
22551
+ if (displayCharIndex < tatweelStartPos) {
22552
+ // Before this kashida point
22553
+ // console.log(`🔄 displayIdx < tatweelStartPos, break`);
22554
+ break;
22555
+ } else if (displayCharIndex < tatweelEndPos) {
22556
+ // Within tatweel characters - map to the character before tatweels
22557
+ // console.log(`🔄 Within tatweel, return ${k.charIndex + 1}`);
22558
+ return k.charIndex + 1;
22559
+ } else {
22560
+ // After this kashida point
22561
+ tatweelsBeforeIndex += tatweelCount;
22562
+ // console.log(`🔄 After this kashida, tatweelsBeforeIndex now=${tatweelsBeforeIndex}`);
22563
+ }
22564
+ }
22565
+
22566
+ // Subtract all tatweels that come before this position
22567
+ const result = displayCharIndex - tatweelsBeforeIndex;
22568
+ // console.log(`🔄 Final result: ${displayCharIndex} - ${tatweelsBeforeIndex} = ${result}`);
22569
+ return result;
22570
+ }
22571
+
22572
+ /**
22573
+ * Convert an original text character index to display index (in _textLines with tatweels).
22574
+ * @param lineIndex - The line index
22575
+ * @param originalCharIndex - Character index in the original text (without tatweels)
22576
+ * @returns Display character index (with tatweels)
22577
+ */
22578
+ _originalToDisplayIndex(lineIndex, originalCharIndex) {
22579
+ var _this$__kashidaInfo2;
22580
+ const kashidaInfo = (_this$__kashidaInfo2 = this.__kashidaInfo) === null || _this$__kashidaInfo2 === void 0 ? void 0 : _this$__kashidaInfo2[lineIndex];
22581
+ if (!kashidaInfo || kashidaInfo.length === 0) {
22582
+ // No kashida on this line, indices are the same
22583
+ return originalCharIndex;
22584
+ }
22585
+
22586
+ // Sort kashida info by charIndex ascending
22587
+ const sortedKashida = [...kashidaInfo].sort((a, b) => a.charIndex - b.charIndex);
22588
+ let tatweelsBeforeIndex = 0;
22589
+ for (const k of sortedKashida) {
22590
+ const tatweelCount = k.tatweelCount || 0;
22591
+ // If the original char index is after this kashida insertion point,
22592
+ // add the tatweels to the offset
22593
+ if (originalCharIndex > k.charIndex) {
22594
+ tatweelsBeforeIndex += tatweelCount;
22595
+ } else {
22596
+ break;
22597
+ }
22598
+ }
22599
+ return originalCharIndex + tatweelsBeforeIndex;
22600
+ }
22601
+
22602
+ /**
22603
+ * Check if a display character index points to a tatweel character.
22604
+ * @param lineIndex - The line index
22605
+ * @param displayCharIndex - Character index in the display text
22606
+ * @returns True if the character at this index is a tatweel
22607
+ */
22608
+ _isTatweelAtDisplayIndex(lineIndex, displayCharIndex) {
22609
+ var _this$__kashidaInfo3;
22610
+ const kashidaInfo = (_this$__kashidaInfo3 = this.__kashidaInfo) === null || _this$__kashidaInfo3 === void 0 ? void 0 : _this$__kashidaInfo3[lineIndex];
22611
+ if (!kashidaInfo || kashidaInfo.length === 0) {
22612
+ return false;
22613
+ }
22614
+
22615
+ // Sort kashida info by charIndex ascending
22616
+ const sortedKashida = [...kashidaInfo].sort((a, b) => a.charIndex - b.charIndex);
22617
+ let tatweelsBeforeIndex = 0;
22618
+ for (const k of sortedKashida) {
22619
+ const tatweelCount = k.tatweelCount || 0;
22620
+ const tatweelStartPos = k.charIndex + 1 + tatweelsBeforeIndex;
22621
+ const tatweelEndPos = tatweelStartPos + tatweelCount;
22622
+ if (displayCharIndex >= tatweelStartPos && displayCharIndex < tatweelEndPos) {
22623
+ return true;
22624
+ }
22625
+ tatweelsBeforeIndex += tatweelCount;
22626
+ }
22627
+ return false;
22628
+ }
22629
+
22630
+ /**
22631
+ * Get the total number of tatweel characters inserted in a line.
22632
+ * @param lineIndex - The line index
22633
+ * @returns Total number of tatweels in this line
22634
+ */
22635
+ _getTatweelCountForLine(lineIndex) {
22636
+ var _this$__kashidaInfo4;
22637
+ const kashidaInfo = (_this$__kashidaInfo4 = this.__kashidaInfo) === null || _this$__kashidaInfo4 === void 0 ? void 0 : _this$__kashidaInfo4[lineIndex];
22638
+ if (!kashidaInfo || kashidaInfo.length === 0) {
22639
+ return 0;
22640
+ }
22641
+ return kashidaInfo.reduce((sum, k) => sum + (k.tatweelCount || 0), 0);
22642
+ }
22643
+
22644
+ /**
22645
+ * Get the original line length (without tatweels).
22646
+ * When kashida is applied, _textLines contains extra tatweel characters.
22647
+ * This returns the length as it would be in the original text.
22648
+ * @param lineIndex - The line index
22649
+ * @returns Original line length without tatweels
22650
+ */
22651
+ _getOriginalLineLength(lineIndex) {
22652
+ var _this$_textLines$line;
22653
+ const displayLength = ((_this$_textLines$line = this._textLines[lineIndex]) === null || _this$_textLines$line === void 0 ? void 0 : _this$_textLines$line.length) || 0;
22654
+ return displayLength - this._getTatweelCountForLine(lineIndex);
21930
22655
  }
21931
22656
 
21932
22657
  /**
@@ -22970,7 +23695,9 @@
22970
23695
  requestAnimationFrame(() => {
22971
23696
  if (!this.isDestroyed) {
22972
23697
  this.applyOverlayStyle();
22973
- console.log('📐 Height changed - rechecking alignment after repositioning:');
23698
+ // console.log(
23699
+ // '📐 Height changed - rechecking alignment after repositioning:',
23700
+ // );
22974
23701
  }
22975
23702
  });
22976
23703
  }
@@ -23066,7 +23793,7 @@
23066
23793
 
23067
23794
  // Special handling for text objects loaded from JSON - ensure they're properly initialized
23068
23795
  if (target.dirty !== false && target.initDimensions) {
23069
- console.log('🔧 Ensuring text object is properly initialized before overlay editing');
23796
+ // console.log('🔧 Ensuring text object is properly initialized before overlay editing');
23070
23797
  // Force re-initialization if the text object seems to be in a dirty state
23071
23798
  target.initDimensions();
23072
23799
  }
@@ -23083,11 +23810,11 @@
23083
23810
  const autoDetectedDirection = this.firstStrongDir(this.textarea.value || '');
23084
23811
 
23085
23812
  // DEBUG: Log alignment details
23086
- console.log('🔍 ALIGNMENT DEBUG:');
23087
- console.log(' Fabric textAlign:', textAlign);
23088
- console.log(' Fabric direction:', target.direction);
23089
- console.log(' Text content:', JSON.stringify(target.text));
23090
- console.log(' Detected direction:', autoDetectedDirection);
23813
+ // console.log('🔍 ALIGNMENT DEBUG:');
23814
+ // console.log(' Fabric textAlign:', textAlign);
23815
+ // console.log(' Fabric direction:', (target as any).direction);
23816
+ // console.log(' Text content:', JSON.stringify(target.text));
23817
+ // console.log(' Detected direction:', autoDetectedDirection);
23091
23818
 
23092
23819
  // Map fabric.js justify to CSS
23093
23820
  if (textAlign.includes('justify')) {
@@ -23105,7 +23832,7 @@
23105
23832
  // If text is RTL but fabric says justify-left, override to justify-right for better UX
23106
23833
  if (autoDetectedDirection === 'rtl') {
23107
23834
  this.textarea.style.textAlignLast = 'right';
23108
- console.log(' → Overrode justify-left to justify-right for RTL text');
23835
+ // console.log(' → Overrode justify-left to justify-right for RTL text');
23109
23836
  } else {
23110
23837
  this.textarea.style.textAlignLast = 'left';
23111
23838
  }
@@ -23113,7 +23840,7 @@
23113
23840
  // If text is LTR but fabric says justify-right, override to justify-left for better UX
23114
23841
  if (autoDetectedDirection === 'ltr') {
23115
23842
  this.textarea.style.textAlignLast = 'left';
23116
- console.log(' → Overrode justify-right to justify-left for LTR text');
23843
+ // console.log(' → Overrode justify-right to justify-left for LTR text');
23117
23844
  } else {
23118
23845
  this.textarea.style.textAlignLast = 'right';
23119
23846
  }
@@ -23132,16 +23859,17 @@
23132
23859
  // Try to force better justify behavior
23133
23860
  this.textarea.style.textJustifyTrim = 'none';
23134
23861
  this.textarea.style.textAutospace = 'none';
23135
- console.log(' → Applied justify alignment:', textAlign, 'with last-line:', this.textarea.style.textAlignLast);
23862
+
23863
+ // console.log(' → Applied justify alignment:', textAlign, 'with last-line:', this.textarea.style.textAlignLast);
23136
23864
  } catch (error) {
23137
- console.warn(' → Justify setup failed, falling back to standard alignment:', error);
23865
+ // console.warn(' → Justify setup failed, falling back to standard alignment:', error);
23138
23866
  cssTextAlign = textAlign.replace('justify-', '').replace('justify', 'left');
23139
23867
  }
23140
23868
  } else {
23141
23869
  this.textarea.style.textAlignLast = 'auto';
23142
23870
  this.textarea.style.textJustify = 'auto';
23143
23871
  this.textarea.style.wordSpacing = 'normal';
23144
- console.log(' → Applied standard alignment:', cssTextAlign);
23872
+ // console.log(' → Applied standard alignment:', cssTextAlign);
23145
23873
  }
23146
23874
  this.textarea.style.textAlign = cssTextAlign;
23147
23875
  this.textarea.style.color = ((_target$fill = target.fill) === null || _target$fill === void 0 ? void 0 : _target$fill.toString()) || '#000';
@@ -23167,37 +23895,31 @@
23167
23895
  this.textarea.style.hyphens = 'none';
23168
23896
 
23169
23897
  // DEBUG: Log final CSS properties
23170
- console.log('🎨 FINAL TEXTAREA CSS:');
23171
- console.log(' textAlign:', this.textarea.style.textAlign);
23172
- console.log(' textAlignLast:', this.textarea.style.textAlignLast);
23173
- console.log(' direction:', this.textarea.style.direction);
23174
- console.log(' unicodeBidi:', this.textarea.style.unicodeBidi);
23175
- console.log(' width:', this.textarea.style.width);
23176
- console.log(' textJustify:', this.textarea.style.textJustify);
23177
- console.log(' wordSpacing:', this.textarea.style.wordSpacing);
23178
- console.log(' whiteSpace:', this.textarea.style.whiteSpace);
23898
+ // console.log('🎨 FINAL TEXTAREA CSS:');
23899
+ // console.log(' textAlign:', this.textarea.style.textAlign);
23900
+ // console.log(' textAlignLast:', this.textarea.style.textAlignLast);
23901
+ // console.log(' direction:', this.textarea.style.direction);
23902
+ // console.log(' unicodeBidi:', this.textarea.style.unicodeBidi);
23903
+ // console.log(' width:', this.textarea.style.width);
23904
+ // console.log(' textJustify:', (this.textarea.style as any).textJustify);
23905
+ // console.log(' wordSpacing:', (this.textarea.style as any).wordSpacing);
23906
+ // console.log(' whiteSpace:', this.textarea.style.whiteSpace);
23179
23907
 
23180
23908
  // If justify, log Fabric object dimensions for comparison
23181
- if (textAlign.includes('justify')) {
23182
- var _calcTextWidth, _ref;
23183
- console.log('🔧 FABRIC OBJECT JUSTIFY INFO:');
23184
- console.log(' Fabric width:', target.width);
23185
- console.log(' Fabric calcTextWidth:', (_calcTextWidth = (_ref = target).calcTextWidth) === null || _calcTextWidth === void 0 ? void 0 : _calcTextWidth.call(_ref));
23186
- console.log(' Fabric textAlign:', target.textAlign);
23187
- console.log(' Text lines:', target.textLines);
23188
- }
23909
+ if (textAlign.includes('justify')) ;
23189
23910
 
23190
23911
  // Debug font properties matching
23191
- console.log('🔤 FONT PROPERTIES COMPARISON:');
23192
- console.log(' Fabric fontFamily:', target.fontFamily);
23193
- console.log(' Fabric fontWeight:', target.fontWeight);
23194
- console.log(' Fabric fontStyle:', target.fontStyle);
23195
- console.log(' Fabric fontSize:', target.fontSize);
23196
- console.log(' → Textarea fontFamily:', this.textarea.style.fontFamily);
23197
- console.log(' → Textarea fontWeight:', this.textarea.style.fontWeight);
23198
- console.log(' → Textarea fontStyle:', this.textarea.style.fontStyle);
23199
- console.log(' → Textarea fontSize:', this.textarea.style.fontSize);
23200
- console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
23912
+ // console.log('🔤 FONT PROPERTIES COMPARISON:');
23913
+ // console.log(' Fabric fontFamily:', target.fontFamily);
23914
+ // console.log(' Fabric fontWeight:', target.fontWeight);
23915
+ // console.log(' Fabric fontStyle:', target.fontStyle);
23916
+ // console.log(' Fabric fontSize:', target.fontSize);
23917
+ // console.log(' → Textarea fontFamily:', this.textarea.style.fontFamily);
23918
+ // console.log(' → Textarea fontWeight:', this.textarea.style.fontWeight);
23919
+ // console.log(' → Textarea fontStyle:', this.textarea.style.fontStyle);
23920
+ // console.log(' → Textarea fontSize:', this.textarea.style.fontSize);
23921
+
23922
+ // console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
23201
23923
 
23202
23924
  // Enhanced font rendering to better match fabric.js canvas rendering
23203
23925
  // Default to auto for more natural rendering
@@ -23212,11 +23934,12 @@
23212
23934
  if (isBold) {
23213
23935
  this.textarea.style.webkitFontSmoothing = 'subpixel-antialiased';
23214
23936
  this.textarea.style.mozOsxFontSmoothing = 'unset';
23215
- console.log('🔤 Applied enhanced bold rendering for better thickness matching');
23937
+ // console.log('🔤 Applied enhanced bold rendering for better thickness matching');
23216
23938
  }
23217
- console.log('🎨 FONT SMOOTHING APPLIED:');
23218
- console.log(' webkitFontSmoothing:', this.textarea.style.webkitFontSmoothing);
23219
- console.log(' mozOsxFontSmoothing:', this.textarea.style.mozOsxFontSmoothing);
23939
+
23940
+ // console.log('🎨 FONT SMOOTHING APPLIED:');
23941
+ // console.log(' webkitFontSmoothing:', (this.textarea.style as any).webkitFontSmoothing);
23942
+ // console.log(' mozOsxFontSmoothing:', (this.textarea.style as any).mozOsxFontSmoothing);
23220
23943
 
23221
23944
  // Initial bounds are set correctly by Fabric.js - don't force update here
23222
23945
  }
@@ -23245,137 +23968,130 @@
23245
23968
  width: canvasBounds.width * zoom,
23246
23969
  height: canvasBounds.height * zoom
23247
23970
  };
23248
- console.log('🔍 BOUNDING BOX COMPARISON:');
23249
- console.log('📦 Textarea Rect:', {
23250
- left: Math.round(textareaRect.left * 100) / 100,
23251
- top: Math.round(textareaRect.top * 100) / 100,
23252
- width: Math.round(textareaRect.width * 100) / 100,
23253
- height: Math.round(textareaRect.height * 100) / 100
23254
- });
23255
- console.log('📦 Host Div Rect:', {
23256
- left: Math.round(hostRect.left * 100) / 100,
23257
- top: Math.round(hostRect.top * 100) / 100,
23258
- width: Math.round(hostRect.width * 100) / 100,
23259
- height: Math.round(hostRect.height * 100) / 100
23260
- });
23261
- console.log('📦 Canvas Object Bounds (screen):', {
23262
- left: Math.round(screenObjectBounds.left * 100) / 100,
23263
- top: Math.round(screenObjectBounds.top * 100) / 100,
23264
- width: Math.round(screenObjectBounds.width * 100) / 100,
23265
- height: Math.round(screenObjectBounds.height * 100) / 100
23266
- });
23267
- console.log('📦 Canvas Object Bounds (canvas):', canvasBounds);
23971
+
23972
+ // console.log('🔍 BOUNDING BOX COMPARISON:');
23973
+ // console.log('📦 Textarea Rect:', {
23974
+ // left: Math.round(textareaRect.left * 100) / 100,
23975
+ // top: Math.round(textareaRect.top * 100) / 100,
23976
+ // width: Math.round(textareaRect.width * 100) / 100,
23977
+ // height: Math.round(textareaRect.height * 100) / 100,
23978
+ // });
23979
+ // console.log('📦 Host Div Rect:', {
23980
+ // left: Math.round(hostRect.left * 100) / 100,
23981
+ // top: Math.round(hostRect.top * 100) / 100,
23982
+ // width: Math.round(hostRect.width * 100) / 100,
23983
+ // height: Math.round(hostRect.height * 100) / 100,
23984
+ // });
23985
+ // console.log('📦 Canvas Object Bounds (screen):', {
23986
+ // left: Math.round(screenObjectBounds.left * 100) / 100,
23987
+ // top: Math.round(screenObjectBounds.top * 100) / 100,
23988
+ // width: Math.round(screenObjectBounds.width * 100) / 100,
23989
+ // height: Math.round(screenObjectBounds.height * 100) / 100,
23990
+ // });
23991
+ // console.log('📦 Canvas Object Bounds (canvas):', canvasBounds);
23268
23992
 
23269
23993
  // Calculate differences
23270
- const hostVsObject = {
23994
+ ({
23271
23995
  leftDiff: Math.round((hostRect.left - screenObjectBounds.left) * 100) / 100,
23272
23996
  topDiff: Math.round((hostRect.top - screenObjectBounds.top) * 100) / 100,
23273
23997
  widthDiff: Math.round((hostRect.width - screenObjectBounds.width) * 100) / 100,
23274
23998
  heightDiff: Math.round((hostRect.height - screenObjectBounds.height) * 100) / 100
23275
- };
23276
- const textareaVsObject = {
23999
+ });
24000
+ ({
23277
24001
  leftDiff: Math.round((textareaRect.left - screenObjectBounds.left) * 100) / 100,
23278
24002
  topDiff: Math.round((textareaRect.top - screenObjectBounds.top) * 100) / 100,
23279
24003
  widthDiff: Math.round((textareaRect.width - screenObjectBounds.width) * 100) / 100,
23280
24004
  heightDiff: Math.round((textareaRect.height - screenObjectBounds.height) * 100) / 100
23281
- };
23282
- console.log('📏 Host Div vs Canvas Object Diff:', hostVsObject);
23283
- console.log('📏 Textarea vs Canvas Object Diff:', textareaVsObject);
24005
+ });
23284
24006
 
23285
- // Check if they're aligned (within 2px tolerance)
23286
- const tolerance = 2;
23287
- const hostAligned = Math.abs(hostVsObject.leftDiff) < tolerance && Math.abs(hostVsObject.topDiff) < tolerance && Math.abs(hostVsObject.widthDiff) < tolerance && Math.abs(hostVsObject.heightDiff) < tolerance;
23288
- const textareaAligned = Math.abs(textareaVsObject.leftDiff) < tolerance && Math.abs(textareaVsObject.topDiff) < tolerance && Math.abs(textareaVsObject.widthDiff) < tolerance && Math.abs(textareaVsObject.heightDiff) < tolerance;
23289
- console.log(hostAligned ? '✅ Host Div ALIGNED with canvas object' : '❌ Host Div MISALIGNED with canvas object');
23290
- console.log(textareaAligned ? '✅ Textarea ALIGNED with canvas object' : '❌ Textarea MISALIGNED with canvas object');
23291
- console.log('🔍 Zoom:', zoom, 'Viewport Transform:', vpt);
23292
- console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
24007
+ // console.log(
24008
+ // hostAligned
24009
+ // ? '✅ Host Div ALIGNED with canvas object'
24010
+ // : '❌ Host Div MISALIGNED with canvas object',
24011
+ // );
24012
+ // console.log(
24013
+ // textareaAligned
24014
+ // ? '✅ Textarea ALIGNED with canvas object'
24015
+ // : '❌ Textarea MISALIGNED with canvas object',
24016
+ // );
24017
+ // console.log('🔍 Zoom:', zoom, 'Viewport Transform:', vpt);
24018
+ // console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
23293
24019
  }
23294
24020
 
23295
24021
  /**
23296
24022
  * Debug method to compare text wrapping between textarea and Fabric text object
23297
24023
  */
23298
24024
  debugTextWrapping() {
23299
- const target = this.target;
24025
+ this.target;
23300
24026
  const text = this.textarea.value;
23301
- console.log('📝 TEXT WRAPPING COMPARISON:');
23302
- console.log('📄 Text Content:', `"${text}"`);
23303
- console.log('📄 Text Length:', text.length);
24027
+
24028
+ // console.log('📝 TEXT WRAPPING COMPARISON:');
24029
+ // console.log('📄 Text Content:', `"${text}"`);
24030
+ // console.log('📄 Text Length:', text.length);
23304
24031
 
23305
24032
  // Analyze line breaks
23306
24033
  const explicitLines = text.split('\n');
23307
- console.log('📄 Explicit Lines (\\n):', explicitLines.length);
24034
+ // console.log('📄 Explicit Lines (\\n):', explicitLines.length);
23308
24035
  explicitLines.forEach((line, i) => {
23309
- console.log(` Line ${i + 1}: "${line}" (${line.length} chars)`);
24036
+ // console.log(` Line ${i + 1}: "${line}" (${line.length} chars)`);
23310
24037
  });
23311
24038
 
23312
24039
  // Get textarea computed styles for wrapping analysis
23313
24040
  const textareaStyles = window.getComputedStyle(this.textarea);
23314
- console.log('📐 Textarea Wrapping Styles:');
23315
- console.log(' width:', textareaStyles.width);
23316
- console.log(' fontSize:', textareaStyles.fontSize);
23317
- console.log(' fontFamily:', textareaStyles.fontFamily);
23318
- console.log(' fontWeight:', textareaStyles.fontWeight);
23319
- console.log(' letterSpacing:', textareaStyles.letterSpacing);
23320
- console.log(' lineHeight:', textareaStyles.lineHeight);
23321
- console.log(' whiteSpace:', textareaStyles.whiteSpace);
23322
- console.log(' wordWrap:', textareaStyles.wordWrap);
23323
- console.log(' overflowWrap:', textareaStyles.overflowWrap);
23324
- console.log(' direction:', textareaStyles.direction);
23325
- console.log(' textAlign:', textareaStyles.textAlign);
24041
+ // console.log('📐 Textarea Wrapping Styles:');
24042
+ // console.log(' width:', textareaStyles.width);
24043
+ // console.log(' fontSize:', textareaStyles.fontSize);
24044
+ // console.log(' fontFamily:', textareaStyles.fontFamily);
24045
+ // console.log(' fontWeight:', textareaStyles.fontWeight);
24046
+ // console.log(' letterSpacing:', textareaStyles.letterSpacing);
24047
+ // console.log(' lineHeight:', textareaStyles.lineHeight);
24048
+ // console.log(' whiteSpace:', textareaStyles.whiteSpace);
24049
+ // console.log(' wordWrap:', textareaStyles.wordWrap);
24050
+ // console.log(' overflowWrap:', textareaStyles.overflowWrap);
24051
+ // console.log(' direction:', textareaStyles.direction);
24052
+ // console.log(' textAlign:', textareaStyles.textAlign);
23326
24053
 
23327
24054
  // Get Fabric text object properties for comparison
23328
- console.log('📐 Fabric Text Object Properties:');
23329
- console.log(' width:', target.width);
23330
- console.log(' fontSize:', target.fontSize);
23331
- console.log(' fontFamily:', target.fontFamily);
23332
- console.log(' fontWeight:', target.fontWeight);
23333
- console.log(' charSpacing:', target.charSpacing);
23334
- console.log(' lineHeight:', target.lineHeight);
23335
- console.log(' direction:', target.direction);
23336
- console.log(' textAlign:', target.textAlign);
23337
- console.log(' scaleX:', target.scaleX);
23338
- console.log(' scaleY:', target.scaleY);
24055
+ // console.log('📐 Fabric Text Object Properties:');
24056
+ // console.log(' width:', (target as any).width);
24057
+ // console.log(' fontSize:', target.fontSize);
24058
+ // console.log(' fontFamily:', target.fontFamily);
24059
+ // console.log(' fontWeight:', target.fontWeight);
24060
+ // console.log(' charSpacing:', target.charSpacing);
24061
+ // console.log(' lineHeight:', target.lineHeight);
24062
+ // console.log(' direction:', (target as any).direction);
24063
+ // console.log(' textAlign:', (target as any).textAlign);
24064
+ // console.log(' scaleX:', target.scaleX);
24065
+ // console.log(' scaleY:', target.scaleY);
23339
24066
 
23340
24067
  // Calculate effective dimensions for comparison - use actual rendered width
23341
24068
  // **THE FIX:** Use getBoundingRect to get the *actual rendered width* of the Fabric object.
23342
- const fabricEffectiveWidth = this.target.getBoundingRect().width;
24069
+ this.target.getBoundingRect().width;
23343
24070
  // Use the exact width set on textarea for comparison
23344
24071
  const textareaComputedWidth = parseFloat(window.getComputedStyle(this.textarea).width);
23345
- const textareaEffectiveWidth = textareaComputedWidth / this.canvas.getZoom();
23346
- const widthDiff = Math.abs(textareaEffectiveWidth - fabricEffectiveWidth);
23347
- console.log('📏 Effective Width Comparison:');
23348
- console.log(' Textarea Effective Width:', textareaEffectiveWidth);
23349
- console.log(' Fabric Effective Width:', fabricEffectiveWidth);
23350
- console.log(' Width Difference:', widthDiff.toFixed(2) + 'px');
23351
- console.log(widthDiff < 1 ? ' Widths MATCH for wrapping' : ' Width MISMATCH may cause different wrapping');
23352
-
23353
- // Check text direction and bidi handling
23354
- const hasRTLText = /[\u0590-\u05FF\u0600-\u06FF\u0750-\u077F\uFB50-\uFDFF\uFE70-\uFEFF]/.test(text);
23355
- const hasBidiText = /[\u0590-\u06FF]/.test(text) && /[a-zA-Z]/.test(text);
23356
- console.log('🌍 Text Direction Analysis:');
23357
- console.log(' Has RTL characters:', hasRTLText);
23358
- console.log(' Has mixed Bidi text:', hasBidiText);
23359
- console.log(' Textarea direction:', textareaStyles.direction);
23360
- console.log(' Fabric direction:', target.direction || 'auto');
23361
- console.log(' Textarea unicodeBidi:', textareaStyles.unicodeBidi);
24072
+ textareaComputedWidth / this.canvas.getZoom();
24073
+
24074
+ // console.log('🌍 Text Direction Analysis:');
24075
+ // console.log(' Has RTL characters:', hasRTLText);
24076
+ // console.log(' Has mixed Bidi text:', hasBidiText);
24077
+ // console.log(' Textarea direction:', textareaStyles.direction);
24078
+ // console.log(' Fabric direction:', (target as any).direction || 'auto');
24079
+ // console.log(' Textarea unicodeBidi:', textareaStyles.unicodeBidi);
23362
24080
 
23363
24081
  // Measure actual rendered line count
23364
24082
  const textareaScrollHeight = this.textarea.scrollHeight;
23365
24083
  const textareaLineHeight = parseFloat(textareaStyles.lineHeight) || parseFloat(textareaStyles.fontSize) * 1.2;
23366
24084
  const estimatedTextareaLines = Math.round(textareaScrollHeight / textareaLineHeight);
23367
- console.log('📊 Line Count Analysis:');
23368
- console.log(' Textarea scrollHeight:', textareaScrollHeight);
23369
- console.log(' Textarea lineHeight:', textareaLineHeight);
23370
- console.log(' Estimated rendered lines:', estimatedTextareaLines);
23371
- console.log(' Explicit line breaks:', explicitLines.length);
23372
- if (estimatedTextareaLines > explicitLines.length) {
23373
- console.log('🔄 Text wrapping detected in textarea');
23374
- console.log(' Wrapped lines:', estimatedTextareaLines - explicitLines.length);
23375
- } else {
23376
- console.log('📏 No text wrapping in textarea');
23377
- }
23378
- console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
24085
+
24086
+ // console.log('📊 Line Count Analysis:');
24087
+ // console.log(' Textarea scrollHeight:', textareaScrollHeight);
24088
+ // console.log(' Textarea lineHeight:', textareaLineHeight);
24089
+ // console.log(' Estimated rendered lines:', estimatedTextareaLines);
24090
+ // console.log(' Explicit line breaks:', explicitLines.length);
24091
+
24092
+ if (estimatedTextareaLines > explicitLines.length) ;
24093
+
24094
+ // console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
23379
24095
  }
23380
24096
 
23381
24097
  /**
@@ -23464,17 +24180,16 @@
23464
24180
 
23465
24181
  // Only update direction when not explicitly set on the object
23466
24182
  if (!hasExplicitDirection && detectedDirection && detectedDirection !== currentDirection) {
23467
- console.log(`🔄 Overlay Exit: Auto-detected direction change from "${currentDirection}" to "${detectedDirection}"`);
23468
- console.log(` Text content: "${finalText.substring(0, 50)}..."`);
24183
+ // console.log(`🔄 Overlay Exit: Auto-detected direction change from "${currentDirection}" to "${detectedDirection}"`);
24184
+ // console.log(` Text content: "${finalText.substring(0, 50)}..."`);
23469
24185
 
23470
24186
  // Update the fabric object's direction
23471
24187
  this.target.set('direction', detectedDirection);
23472
24188
 
23473
24189
  // Force a re-render to apply the direction change
23474
24190
  this.canvas.requestRenderAll();
23475
- console.log(`✅ Fabric object direction updated to: ${detectedDirection}`);
23476
- } else {
23477
- console.log(`📝 Overlay Exit: Direction unchanged (${currentDirection}), text: "${finalText.substring(0, 30)}..."`);
24191
+
24192
+ // console.log(`✅ Fabric object direction updated to: ${detectedDirection}`);
23478
24193
  }
23479
24194
  if (this.onCommit) {
23480
24195
  this.onCommit(finalText);
@@ -23684,7 +24399,11 @@
23684
24399
  * - `\-` Matches a "-" character (char code 45).
23685
24400
  */
23686
24401
  // eslint-disable-next-line no-useless-escape
23687
- const reNonWord = /[ \n\.,;!\?\-]/;
24402
+ // Word boundary characters for Latin, Arabic, and Hebrew
24403
+ // Latin: space, newline, punctuation
24404
+ // Arabic: ، (comma U+060C), ؛ (semicolon U+061B), ؟ (question U+061F), ۔ (full stop U+06D4), ـ (tatweel U+0640)
24405
+ // Hebrew: ׃ (sof pasuq U+05C3), ״ (gershayim U+05F4)
24406
+ const reNonWord = /[ \n\.,;!\?\-\u060C\u061B\u061F\u06D4\u0640\u05C3\u05F4\u2000-\u206F]/;
23688
24407
  class ITextBehavior extends FabricText {
23689
24408
  constructor() {
23690
24409
  super(...arguments);
@@ -23905,12 +24624,99 @@
23905
24624
 
23906
24625
  /**
23907
24626
  * Finds index corresponding to beginning or end of a word
24627
+ * Uses Intl.Segmenter for proper Unicode word segmentation when available,
24628
+ * falls back to regex-based detection for older browsers.
23908
24629
  * @param {Number} selectionStart Index of a character
23909
24630
  * @param {Number} direction 1 or -1
23910
24631
  * @return {Number} Index of the beginning or end of a word
23911
24632
  */
23912
24633
  searchWordBoundary(selectionStart, direction) {
23913
- const text = this._text;
24634
+ // Try to use Intl.Segmenter for proper Unicode word segmentation
24635
+ if (typeof Intl !== 'undefined' && Intl.Segmenter) {
24636
+ return this._searchWordBoundaryWithSegmenter(selectionStart, direction);
24637
+ }
24638
+ // Fallback to regex-based detection
24639
+ return this._searchWordBoundaryWithRegex(selectionStart, direction);
24640
+ }
24641
+
24642
+ /**
24643
+ * Word boundary search using Intl.Segmenter (proper Unicode support)
24644
+ * Works on original text (this.text) since selectionStart is in original text space
24645
+ */
24646
+ _searchWordBoundaryWithSegmenter(selectionStart, direction) {
24647
+ // Use original text (without kashida) since indices are in original text space
24648
+ const originalText = this.text;
24649
+ const SegmenterClass = Intl.Segmenter;
24650
+ const segmenter = new SegmenterClass(undefined, {
24651
+ granularity: 'word'
24652
+ });
24653
+ const segments = Array.from(segmenter.segment(originalText));
24654
+ if (segments.length === 0) {
24655
+ return direction === -1 ? 0 : originalText.length;
24656
+ }
24657
+
24658
+ // Find the segment containing the cursor position
24659
+ let currentSegmentIdx = 0;
24660
+ for (let i = 0; i < segments.length; i++) {
24661
+ const seg = segments[i];
24662
+ if (selectionStart >= seg.index && selectionStart < seg.index + seg.segment.length) {
24663
+ currentSegmentIdx = i;
24664
+ break;
24665
+ }
24666
+ if (selectionStart >= seg.index + seg.segment.length) {
24667
+ currentSegmentIdx = i;
24668
+ }
24669
+ }
24670
+
24671
+ // Find word boundaries
24672
+ if (direction === -1) {
24673
+ // Search backwards for word start
24674
+ let targetIdx = currentSegmentIdx;
24675
+
24676
+ // If cursor is at the start of a segment, look at previous segment
24677
+ if (selectionStart === segments[targetIdx].index && targetIdx > 0) {
24678
+ targetIdx--;
24679
+ }
24680
+
24681
+ // Skip non-word segments
24682
+ while (targetIdx > 0 && !segments[targetIdx].isWordLike) {
24683
+ targetIdx--;
24684
+ }
24685
+
24686
+ // Return the start of the word segment
24687
+ if (segments[targetIdx].isWordLike) {
24688
+ return segments[targetIdx].index;
24689
+ }
24690
+ return 0;
24691
+ } else {
24692
+ // Search forwards for word end
24693
+ let targetIdx = currentSegmentIdx;
24694
+
24695
+ // If we're in a word, find its end
24696
+ if (segments[targetIdx].isWordLike) {
24697
+ return segments[targetIdx].index + segments[targetIdx].segment.length;
24698
+ }
24699
+
24700
+ // Skip non-word segments to find next word
24701
+ while (targetIdx < segments.length && !segments[targetIdx].isWordLike) {
24702
+ targetIdx++;
24703
+ }
24704
+
24705
+ // Return the end of the next word segment
24706
+ if (targetIdx < segments.length && segments[targetIdx].isWordLike) {
24707
+ return segments[targetIdx].index + segments[targetIdx].segment.length;
24708
+ }
24709
+ return originalText.length;
24710
+ }
24711
+ }
24712
+
24713
+ /**
24714
+ * Word boundary search using regex (fallback for older browsers)
24715
+ * Works on original text (this.text) since selectionStart is in original text space
24716
+ */
24717
+ _searchWordBoundaryWithRegex(selectionStart, direction) {
24718
+ // Use original text as an array of characters
24719
+ const text = Array.from(this.text);
23914
24720
  // if we land on a space we move the cursor backwards
23915
24721
  // if we are searching boundary end we move the cursor backwards ONLY if we don't land on a line break
23916
24722
  let index = selectionStart > 0 && this._reSpace.test(text[selectionStart]) && (direction === -1 || !reNewline.test(text[selectionStart - 1])) ? selectionStart - 1 : selectionStart,
@@ -24025,8 +24831,8 @@
24025
24831
  });
24026
24832
  }
24027
24833
 
24028
- /**
24029
- * Commit overlay editing changes
24834
+ /**
24835
+ * Commit overlay editing changes
24030
24836
  */
24031
24837
  commitOverlayEdit(text) {
24032
24838
  // Preserve geometry to avoid nudge when layout recalculates
@@ -24037,17 +24843,17 @@
24037
24843
  const prevUsingBrowserWrap = this._usingBrowserWrapping;
24038
24844
  const hadLock = this.lockDynamicMinWidth;
24039
24845
  this.lockDynamicMinWidth = true;
24040
- const countKashida = val => val ? (val.match(/\u0640/g) || []).length : 0;
24041
- console.log('[OverlayCommit] pre-layout', {
24042
- textLength: text === null || text === void 0 ? void 0 : text.length,
24043
- kashidas: countKashida(text),
24044
- prevWidth,
24045
- prevMinWidth,
24046
- prevUsingBrowserWrap,
24047
- hadLock,
24048
- dir: this.direction,
24049
- align: this.textAlign
24050
- });
24846
+ // console.log('[OverlayCommit] pre-layout', {
24847
+ // textLength: text?.length,
24848
+ // kashidas: countKashida(text),
24849
+ // prevWidth,
24850
+ // prevMinWidth,
24851
+ // prevUsingBrowserWrap,
24852
+ // hadLock,
24853
+ // dir: (this as any).direction,
24854
+ // align: (this as any).textAlign,
24855
+ // });
24856
+
24051
24857
  const overlayEditor = this.__overlayEditor;
24052
24858
  if (overlayEditor) {
24053
24859
  // Extract browser lines for pixel-perfect rendering
@@ -24055,7 +24861,7 @@
24055
24861
  const result = extractLinesFromDOM(overlayEditor.textareaElement);
24056
24862
  storeBrowserLines(this, result.lines);
24057
24863
  } catch (error) {
24058
- console.warn('Failed to extract browser lines:', error);
24864
+ // console.warn('Failed to extract browser lines:', error);
24059
24865
  }
24060
24866
  }
24061
24867
 
@@ -24071,15 +24877,15 @@
24071
24877
  }
24072
24878
  this.dirty = true;
24073
24879
  this.initDimensions();
24074
- console.log('[OverlayCommit] post-layout', {
24075
- width: this.get('width'),
24076
- dynMinWidth: this.dynamicMinWidth,
24077
- usingBrowserWrap: this._usingBrowserWrapping,
24078
- lockDynamicMinWidth: this.lockDynamicMinWidth,
24079
- kashidas: countKashida(this.text),
24080
- left: this.left,
24081
- top: this.top
24082
- });
24880
+ // console.log('[OverlayCommit] post-layout', {
24881
+ // width: this.get('width'),
24882
+ // dynMinWidth: (this as any).dynamicMinWidth,
24883
+ // usingBrowserWrap: (this as any)._usingBrowserWrapping,
24884
+ // lockDynamicMinWidth: (this as any).lockDynamicMinWidth,
24885
+ // kashidas: countKashida(this.text),
24886
+ // left: this.left,
24887
+ // top: this.top,
24888
+ // });
24083
24889
  // Restore geometry after layout so the object doesn't drift
24084
24890
  this.set({
24085
24891
  left: prevLeft,
@@ -24089,13 +24895,13 @@
24089
24895
  this.setCoords();
24090
24896
  this.exitEditing();
24091
24897
  this.lockDynamicMinWidth = hadLock;
24092
- console.log('[OverlayCommit] final', {
24093
- width: this.get('width'),
24094
- dynMinWidth: this.dynamicMinWidth,
24095
- lockRestored: hadLock,
24096
- left: this.left,
24097
- top: this.top
24098
- });
24898
+ // console.log('[OverlayCommit] final', {
24899
+ // width: this.get('width'),
24900
+ // dynMinWidth: (this as any).dynamicMinWidth,
24901
+ // lockRestored: hadLock,
24902
+ // left: this.left,
24903
+ // top: this.top,
24904
+ // });
24099
24905
  this.fire('changed');
24100
24906
  this.canvas && this.canvas.requestRenderAll();
24101
24907
  }
@@ -24195,7 +25001,7 @@
24195
25001
  * @private
24196
25002
  */
24197
25003
  _updateTextarea() {
24198
- console.log('🔤 _updateTextarea called with fabric text:', this.text);
25004
+ // console.log('🔤 _updateTextarea called with fabric text:', this.text);
24199
25005
  this.cursorOffsetCache = {};
24200
25006
  if (!this.hiddenTextarea) {
24201
25007
  return;
@@ -24204,9 +25010,9 @@
24204
25010
  // Sync textarea content with fabric text to prevent double-keypress issues
24205
25011
  const currentFabricText = this.text;
24206
25012
  if (this.hiddenTextarea.value !== currentFabricText) {
24207
- console.log('🔤 _updateTextarea: syncing textarea to fabric text');
24208
- console.log('🔤 _updateTextarea: textarea was:', this.hiddenTextarea.value);
24209
- console.log('🔤 _updateTextarea: fabric is:', currentFabricText);
25013
+ // console.log('🔤 _updateTextarea: syncing textarea to fabric text');
25014
+ // console.log('🔤 _updateTextarea: textarea was:', this.hiddenTextarea.value);
25015
+ // console.log('🔤 _updateTextarea: fabric is:', currentFabricText);
24210
25016
  this.hiddenTextarea.value = currentFabricText;
24211
25017
  }
24212
25018
  if (!this.inCompositionMode) {
@@ -24857,34 +25663,29 @@
24857
25663
  }
24858
25664
 
24859
25665
  // Debug log to track the double keypress issue
24860
- console.log('🔤 onInput debug:', {
24861
- fabricText: this.text,
24862
- textareaValue: value,
24863
- fabricSelection: {
24864
- start: this.selectionStart,
24865
- end: this.selectionEnd
24866
- },
24867
- textareaSelection: {
24868
- start: selectionStart,
24869
- end: selectionEnd
24870
- },
24871
- fromPaste,
24872
- inComposition: this.inCompositionMode
24873
- });
25666
+ // console.log('🔤 onInput debug:', {
25667
+ // fabricText: this.text,
25668
+ // textareaValue: value,
25669
+ // fabricSelection: { start: this.selectionStart, end: this.selectionEnd },
25670
+ // textareaSelection: { start: selectionStart, end: selectionEnd },
25671
+ // fromPaste,
25672
+ // inComposition: this.inCompositionMode
25673
+ // });
24874
25674
 
24875
25675
  // Immediate sync for simple character replacement - fix for double keypress issue
24876
25676
  if (this.text !== value && !this.inCompositionMode) {
24877
- console.log('🔤 Immediate sync: fabric text differs from textarea, syncing immediately');
24878
- console.log('🔤 Before sync - fabric text:', this.text);
24879
- console.log('🔤 Before sync - textarea value:', value);
24880
- console.log('🔤 fromPaste:', fromPaste);
25677
+ // console.log('🔤 Immediate sync: fabric text differs from textarea, syncing immediately');
25678
+ // console.log('🔤 Before sync - fabric text:', this.text);
25679
+ // console.log('🔤 Before sync - textarea value:', value);
25680
+ // console.log('🔤 fromPaste:', fromPaste);
24881
25681
 
24882
25682
  // Clear all relevant caches that might prevent visual updates
24883
25683
  this.cursorOffsetCache = {};
24884
25684
  this._browserWrapCache = null;
24885
25685
  this._lastDimensionState = null;
24886
25686
  this._forceClearCache = true;
24887
- console.log('🔤 Cleared all caches');
25687
+
25688
+ // console.log('🔤 Cleared all caches');
24888
25689
 
24889
25690
  // Use the same logic as updateAndFire but immediately
24890
25691
  this.updateFromTextArea();
@@ -24897,8 +25698,9 @@
24897
25698
  // Remove requestRenderAll() which queues for next animation frame
24898
25699
  this.canvas.renderAll();
24899
25700
  }
24900
- console.log('🔤 After updateFromTextArea - fabric text:', this.text);
24901
- console.log('🔤 Sync complete, caches cleared, synchronous render only');
25701
+
25702
+ // console.log('🔤 After updateFromTextArea - fabric text:', this.text);
25703
+ // console.log('🔤 Sync complete, caches cleared, synchronous render only');
24902
25704
  return;
24903
25705
  }
24904
25706
  const updateAndFire = () => {
@@ -25542,8 +26344,10 @@
25542
26344
  // use character left positions which are always increasing even with RTL segments
25543
26345
 
25544
26346
  for (let j = 0; j < charLength; j++) {
26347
+ var _chars$left, _chars;
25545
26348
  const charStart = lineLeftOffset + chars[j].left;
25546
- const charEnd = lineLeftOffset + chars[j + 1].left;
26349
+ // For last character, use its width to calculate end position
26350
+ const charEnd = lineLeftOffset + ((_chars$left = (_chars = chars[j + 1]) === null || _chars === void 0 ? void 0 : _chars.left) !== null && _chars$left !== void 0 ? _chars$left : chars[j].left + chars[j].kernedWidth);
25547
26351
  const charMiddle = (charStart + charEnd) / 2;
25548
26352
  if (mouseOffset.x <= charMiddle) {
25549
26353
  charIndex = lineStartIndex + j;
@@ -25554,9 +26358,24 @@
25554
26358
  }
25555
26359
  charIndex = lineStartIndex + charLength;
25556
26360
  }
25557
- const lineCharIndex = charIndex - lineStartIndex;
25558
- const result = this.flipX ? lineStartIndex + (charLength - lineCharIndex) : charIndex;
25559
- return Math.min(result, this._text.length);
26361
+ let lineCharIndex = charIndex - lineStartIndex;
26362
+
26363
+ // Handle flipX
26364
+ if (this.flipX) {
26365
+ lineCharIndex = charLength - lineCharIndex;
26366
+ }
26367
+
26368
+ // Convert display index to original index (handles kashida)
26369
+ const originalLineCharIndex = this._displayToOriginalIndex(lineIndex, lineCharIndex);
26370
+
26371
+ // Calculate original line start (sum of original line lengths before this line)
26372
+ let originalLineStart = 0;
26373
+ for (let i = 0; i < lineIndex; i++) {
26374
+ const originalLineLength = this._getOriginalLineLength(i);
26375
+ originalLineStart += originalLineLength + this.missingNewlineOffset(i);
26376
+ }
26377
+ const originalIndex = originalLineStart + originalLineCharIndex;
26378
+ return Math.min(originalIndex, this.text.length);
25560
26379
  }
25561
26380
  }
25562
26381
 
@@ -25567,50 +26386,6 @@
25567
26386
  * for interactive text editing with grapheme-aware boundaries.
25568
26387
  */
25569
26388
 
25570
- /**
25571
- * Hit test a point against laid out text to find insertion position
25572
- */
25573
- function hitTest(x, y, layout, options) {
25574
- if (layout.lines.length === 0) {
25575
- return {
25576
- lineIndex: 0,
25577
- charIndex: 0,
25578
- graphemeIndex: 0,
25579
- isAtLineEnd: true,
25580
- isAtTextEnd: true,
25581
- insertionIndex: 0
25582
- };
25583
- }
25584
-
25585
- // Find the line containing the y coordinate
25586
- const lineResult = findLineAtY(y, layout.lines);
25587
- const line = layout.lines[lineResult.lineIndex];
25588
- if (!line || line.bounds.length === 0) {
25589
- return {
25590
- lineIndex: lineResult.lineIndex,
25591
- charIndex: 0,
25592
- graphemeIndex: 0,
25593
- isAtLineEnd: true,
25594
- isAtTextEnd: lineResult.lineIndex >= layout.lines.length - 1,
25595
- insertionIndex: calculateInsertionIndex(lineResult.lineIndex, 0, layout)
25596
- };
25597
- }
25598
-
25599
- // Find the character position within the line
25600
- const charResult = findCharAtX(x, line);
25601
-
25602
- // Calculate total insertion index
25603
- const insertionIndex = calculateInsertionIndex(lineResult.lineIndex, charResult.graphemeIndex, layout);
25604
- return {
25605
- lineIndex: lineResult.lineIndex,
25606
- charIndex: charResult.charIndex,
25607
- graphemeIndex: charResult.graphemeIndex,
25608
- isAtLineEnd: charResult.isAtLineEnd,
25609
- isAtTextEnd: lineResult.lineIndex >= layout.lines.length - 1 && charResult.isAtLineEnd,
25610
- insertionIndex,
25611
- closestBound: charResult.closestBound
25612
- };
25613
- }
25614
26389
 
25615
26390
  /**
25616
26391
  * Get cursor rectangle for a given insertion index
@@ -25658,159 +26433,6 @@
25658
26433
  };
25659
26434
  }
25660
26435
 
25661
- // Private helper functions
25662
-
25663
- /**
25664
- * Find which line contains the given Y coordinate
25665
- */
25666
- function findLineAtY(y, lines) {
25667
- var _lines;
25668
- let currentY = 0;
25669
- for (let i = 0; i < lines.length; i++) {
25670
- const line = lines[i];
25671
- if (y >= currentY && y < currentY + line.height) {
25672
- return {
25673
- lineIndex: i,
25674
- offsetY: y - currentY
25675
- };
25676
- }
25677
- currentY += line.height;
25678
- }
25679
-
25680
- // Y is past all lines - return last line
25681
- return {
25682
- lineIndex: lines.length - 1,
25683
- offsetY: ((_lines = lines[lines.length - 1]) === null || _lines === void 0 ? void 0 : _lines.height) || 0
25684
- };
25685
- }
25686
-
25687
- /**
25688
- * Find character position within a line at given X coordinate
25689
- */
25690
- function findCharAtX(x, line, options) {
25691
- if (line.bounds.length === 0) {
25692
- return {
25693
- charIndex: 0,
25694
- graphemeIndex: 0,
25695
- isAtLineEnd: true
25696
- };
25697
- }
25698
-
25699
- // Create visual ordering: sort bounds by visual X position (left-to-right)
25700
- // This handles mixed LTR/RTL content where visual order != logical order
25701
- const visualBounds = line.bounds.map((bound, logicalIndex) => ({
25702
- bound,
25703
- logicalIndex,
25704
- visualX: bound.x,
25705
- visualXEnd: bound.x + bound.kernedWidth
25706
- })).sort((a, b) => a.visualX - b.visualX);
25707
-
25708
- // Find leftmost and rightmost visual positions
25709
- const leftmostX = visualBounds[0].visualX;
25710
- const rightmostX = visualBounds[visualBounds.length - 1].visualXEnd;
25711
-
25712
- // Handle clicks before the line starts
25713
- if (x < leftmostX) {
25714
- // Find the character that appears visually first
25715
- const firstVisualBound = visualBounds[0];
25716
- return {
25717
- charIndex: firstVisualBound.bound.charIndex,
25718
- graphemeIndex: firstVisualBound.bound.graphemeIndex,
25719
- isAtLineEnd: false,
25720
- closestBound: firstVisualBound.bound
25721
- };
25722
- }
25723
-
25724
- // Handle clicks after the line ends
25725
- if (x >= rightmostX) {
25726
- // Find the character that appears visually last
25727
- const lastVisualBound = visualBounds[visualBounds.length - 1];
25728
- return {
25729
- charIndex: lastVisualBound.bound.charIndex + 1,
25730
- graphemeIndex: lastVisualBound.bound.graphemeIndex + 1,
25731
- isAtLineEnd: true,
25732
- closestBound: lastVisualBound.bound
25733
- };
25734
- }
25735
-
25736
- // Find the character containing the X coordinate
25737
- for (let i = 0; i < visualBounds.length; i++) {
25738
- const {
25739
- bound,
25740
- visualX,
25741
- visualXEnd
25742
- } = visualBounds[i];
25743
- if (x >= visualX && x < visualXEnd) {
25744
- // Determine if closer to start or end of character
25745
- const midpoint = visualX + (visualXEnd - visualX) / 2;
25746
- const insertBeforeChar = x < midpoint;
25747
- if (insertBeforeChar) {
25748
- return {
25749
- charIndex: bound.charIndex,
25750
- graphemeIndex: bound.graphemeIndex,
25751
- isAtLineEnd: false,
25752
- closestBound: bound
25753
- };
25754
- } else {
25755
- // Insert after this character
25756
- return {
25757
- charIndex: bound.charIndex + 1,
25758
- graphemeIndex: bound.graphemeIndex + 1,
25759
- isAtLineEnd: false,
25760
- closestBound: bound
25761
- };
25762
- }
25763
- }
25764
-
25765
- // Check if x is in the gap between this character and the next
25766
- if (i < visualBounds.length - 1) {
25767
- const nextVisual = visualBounds[i + 1];
25768
- if (x >= visualXEnd && x < nextVisual.visualX) {
25769
- // Click in gap - place cursor after current character
25770
- return {
25771
- charIndex: bound.charIndex + 1,
25772
- graphemeIndex: bound.graphemeIndex + 1,
25773
- isAtLineEnd: false,
25774
- closestBound: bound
25775
- };
25776
- }
25777
- }
25778
- }
25779
-
25780
- // Fallback - find closest character
25781
- const closestBound = visualBounds.reduce((closest, current) => {
25782
- const closestDistance = Math.abs((closest.visualX + closest.visualXEnd) / 2 - x);
25783
- const currentDistance = Math.abs((current.visualX + current.visualXEnd) / 2 - x);
25784
- return currentDistance < closestDistance ? current : closest;
25785
- });
25786
- return {
25787
- charIndex: closestBound.bound.charIndex,
25788
- graphemeIndex: closestBound.bound.graphemeIndex,
25789
- isAtLineEnd: false,
25790
- closestBound: closestBound.bound
25791
- };
25792
- }
25793
-
25794
- /**
25795
- * Calculate total insertion index from line and character indices
25796
- */
25797
- function calculateInsertionIndex(lineIndex, graphemeIndex, layout) {
25798
- let insertionIndex = 0;
25799
-
25800
- // Add characters from all previous lines
25801
- for (let i = 0; i < lineIndex && i < layout.lines.length; i++) {
25802
- insertionIndex += layout.lines[i].graphemes.length;
25803
- // Add newline character (except for last line)
25804
- if (i < layout.lines.length - 1) {
25805
- insertionIndex += 1; // \n character
25806
- }
25807
- }
25808
-
25809
- // Add characters within current line
25810
- insertionIndex += graphemeIndex;
25811
- return insertionIndex;
25812
- }
25813
-
25814
26436
  /**
25815
26437
  * Find line and grapheme position from insertion index
25816
26438
  */
@@ -26024,6 +26646,20 @@
26024
26646
  ...IText.ownDefaults,
26025
26647
  ...options
26026
26648
  });
26649
+ /**
26650
+ * Index where text selection starts (or where cursor is when there is no selection)
26651
+ * @type Number
26652
+ */
26653
+ /**
26654
+ * Index where text selection ends
26655
+ * @type Number
26656
+ */
26657
+ /**
26658
+ * Cache for visual positions per line to ensure consistency
26659
+ * during selection operations
26660
+ * @private
26661
+ */
26662
+ _defineProperty(this, "_visualPositionsCache", new Map());
26027
26663
  this.initBehavior();
26028
26664
  }
26029
26665
 
@@ -26147,6 +26783,8 @@
26147
26783
  // clear the cursorOffsetCache, so we ensure to calculate once per renderCursor
26148
26784
  // the correct position but not at every cursor animation.
26149
26785
  this.cursorOffsetCache = {};
26786
+ // Clear visual positions cache on full render since dimensions may have changed
26787
+ this._clearVisualPositionsCache();
26150
26788
  this.renderCursorOrSelection();
26151
26789
  }
26152
26790
 
@@ -26174,6 +26812,9 @@
26174
26812
  if (!ctx) {
26175
26813
  return;
26176
26814
  }
26815
+ // Clear cache to ensure fresh cursor position calculation
26816
+ // This is important during selection drag when positions change frequently
26817
+ this.cursorOffsetCache = {};
26177
26818
  const boundaries = this._getCursorBoundaries();
26178
26819
  const ancestors = this.findAncestorsWithClipPath();
26179
26820
  const hasAncestorsWithClipping = ancestors.length > 0;
@@ -26251,12 +26892,8 @@
26251
26892
  _getCursorBoundaries() {
26252
26893
  let index = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : this.selectionStart;
26253
26894
  let skipCaching = arguments.length > 1 ? arguments[1] : undefined;
26254
- // Use advanced cursor positioning if available
26255
- if (this.enableAdvancedLayout) {
26256
- return this._getCursorBoundariesAdvanced(index);
26257
- }
26258
-
26259
- // Fall back to original method
26895
+ // Always use original method which uses __charBounds directly
26896
+ // and has proper RTL handling built-in
26260
26897
  return this._getCursorBoundariesOriginal(index, skipCaching);
26261
26898
  }
26262
26899
 
@@ -26295,19 +26932,261 @@
26295
26932
  }
26296
26933
 
26297
26934
  /**
26298
- * Enhanced selection start from pointer using BiDi-aware hit testing
26299
- * @override
26935
+ * Override selection to use measureText-based visual positions
26936
+ * This ensures hit testing matches actual browser BiDi rendering
26300
26937
  */
26301
26938
  getSelectionStartFromPointer(e) {
26302
- if (!this.enableAdvancedLayout || !this._layoutTextAdvanced) {
26303
- return super.getSelectionStartFromPointer(e);
26939
+ // Get mouse position in object-local coordinates (origin at center)
26940
+ const scenePoint = this.canvas.getScenePoint(e);
26941
+ const localPoint = scenePoint.transform(invertTransform(this.calcTransformMatrix()));
26942
+
26943
+ // Convert to top-left origin coordinates
26944
+ const mouseX = localPoint.x + this.width / 2;
26945
+ const mouseY = localPoint.y + this.height / 2;
26946
+
26947
+ // Find the line based on Y position
26948
+ let height = 0,
26949
+ lineIndex = 0;
26950
+ for (let i = 0; i < this._textLines.length; i++) {
26951
+ const lineHeight = this.getHeightOfLine(i);
26952
+ if (mouseY >= height && mouseY < height + lineHeight) {
26953
+ lineIndex = i;
26954
+ break;
26955
+ }
26956
+ height += lineHeight;
26957
+ if (i === this._textLines.length - 1) {
26958
+ lineIndex = i;
26959
+ }
26304
26960
  }
26305
- const mouseOffset = this.canvas.getScenePoint(e).transform(invertTransform(this.calcTransformMatrix())).add(new Point(-this._getLeftOffset(), -this._getTopOffset()));
26306
26961
 
26307
- // Use BiDi-aware hit testing instead of naive RTL coordinate flipping
26308
- const layout = this._layoutTextAdvanced();
26309
- const hitResult = hitTest(mouseOffset.x, mouseOffset.y, layout, this._getAdvancedLayoutOptions());
26310
- return Math.min(hitResult.charIndex, this._text.length);
26962
+ // Calculate line start index using ORIGINAL line lengths (without tatweels)
26963
+ // This ensures selection indices refer to the original text, not the display text
26964
+ let lineStartIndex = 0;
26965
+ for (let i = 0; i < lineIndex; i++) {
26966
+ const origLen = this._getOriginalLineLength(i);
26967
+ const newlineOffset = this.missingNewlineOffset(i);
26968
+ console.log(`📍 Line ${i}: origLen=${origLen}, displayLen=${this._textLines[i].length}, tatweels=${this._getTatweelCountForLine(i)}, newlineOffset=${newlineOffset}`);
26969
+ lineStartIndex += origLen + newlineOffset;
26970
+ }
26971
+ console.log(`📍 Click on line ${lineIndex}, lineStartIndex=${lineStartIndex}`);
26972
+ const line = this._textLines[lineIndex];
26973
+ const lineText = line.join('');
26974
+ const displayCharLength = line.length;
26975
+ const originalCharLength = this._getOriginalLineLength(lineIndex);
26976
+ if (displayCharLength === 0) {
26977
+ return lineStartIndex;
26978
+ }
26979
+
26980
+ // Use measureText to get actual visual character positions
26981
+ // This matches exactly how the canvas renders BiDi text
26982
+ const visualPositions = this._measureVisualPositions(lineIndex, lineText);
26983
+
26984
+ // Calculate line offset based on alignment
26985
+ const lineWidth = this.getLineWidth(lineIndex);
26986
+ let lineStartX = 0;
26987
+ if (this.textAlign === 'center' || this.textAlign === 'justify-center') {
26988
+ lineStartX = (this.width - lineWidth) / 2;
26989
+ } else if (this.textAlign === 'right' || this.textAlign === 'justify-right') {
26990
+ lineStartX = this.width - lineWidth;
26991
+ } else if (this.direction === 'rtl' && (this.textAlign === 'justify' || this.textAlign === 'left')) {
26992
+ // For RTL with left/justify, text starts from right
26993
+ lineStartX = this.width - lineWidth;
26994
+ }
26995
+
26996
+ // Find which character was clicked based on visual position
26997
+ const clickX = mouseX - lineStartX;
26998
+
26999
+ // Sort positions by visual X for hit testing
27000
+ const sortedPositions = [...visualPositions].sort((a, b) => a.visualX - b.visualX);
27001
+
27002
+ // Handle click before first character
27003
+ if (sortedPositions.length > 0 && clickX < sortedPositions[0].visualX) {
27004
+ // Before first visual character - cursor at visual left edge
27005
+ // For RTL base direction, this means logical end of line
27006
+ return this.direction === 'rtl' ? lineStartIndex + originalCharLength : lineStartIndex;
27007
+ }
27008
+
27009
+ // Handle click after last character
27010
+ if (sortedPositions.length > 0) {
27011
+ const lastPos = sortedPositions[sortedPositions.length - 1];
27012
+ if (clickX > lastPos.visualX + lastPos.width) {
27013
+ // After last visual character - cursor at visual right edge
27014
+ // For RTL base direction, this means logical start of line
27015
+ return this.direction === 'rtl' ? lineStartIndex : lineStartIndex + originalCharLength;
27016
+ }
27017
+ }
27018
+
27019
+ // Find the character at click position
27020
+ for (let i = 0; i < sortedPositions.length; i++) {
27021
+ const pos = sortedPositions[i];
27022
+ const charEnd = pos.visualX + pos.width;
27023
+ if (clickX >= pos.visualX && clickX <= charEnd) {
27024
+ // Convert display index to original index
27025
+ // This also handles tatweels - they map to the character they extend
27026
+ const originalCharIndex = this._displayToOriginalIndex(lineIndex, pos.logicalIndex);
27027
+
27028
+ // Check if this is a tatweel - if so, treat click as clicking on the extended character
27029
+ const isTatweel = this._isTatweelAtDisplayIndex(lineIndex, pos.logicalIndex);
27030
+ console.log(`📍 Hit char: displayIdx=${pos.logicalIndex}, origIdx=${originalCharIndex}, isTatweel=${isTatweel}, char="${this._textLines[lineIndex][pos.logicalIndex]}"`);
27031
+ const charMiddle = pos.visualX + pos.width / 2;
27032
+ const clickedLeftHalf = clickX <= charMiddle;
27033
+
27034
+ // For tatweels, clicking anywhere on it should place cursor after the extended character
27035
+ if (isTatweel) {
27036
+ // Tatweel extends the character before it, so cursor goes after that character
27037
+ // originalCharIndex from _displayToOriginalIndex already maps tatweel to char+1
27038
+ const result = lineStartIndex + originalCharIndex;
27039
+ console.log(`📍 Tatweel click result: ${result}`);
27040
+ return result;
27041
+ }
27042
+
27043
+ // For RTL characters: left visual half means cursor AFTER (higher logical index)
27044
+ // For LTR characters: left visual half means cursor BEFORE (lower logical index)
27045
+ if (pos.isRtl) {
27046
+ // RTL character
27047
+ const result = lineStartIndex + (clickedLeftHalf ? originalCharIndex + 1 : originalCharIndex);
27048
+ console.log(`📍 RTL char result: ${result} (clickedLeftHalf=${clickedLeftHalf})`);
27049
+ return result;
27050
+ } else {
27051
+ // LTR character
27052
+ const result = lineStartIndex + (clickedLeftHalf ? originalCharIndex : originalCharIndex + 1);
27053
+ console.log(`📍 LTR char result: ${result} (clickedLeftHalf=${clickedLeftHalf})`);
27054
+ return result;
27055
+ }
27056
+ }
27057
+ }
27058
+
27059
+ // console.log(`📍 No match, returning end: ${lineStartIndex + originalCharLength}`);
27060
+ return lineStartIndex + originalCharLength;
27061
+ }
27062
+
27063
+ /**
27064
+ * Clear the visual positions cache
27065
+ * Should be called when text content or dimensions change
27066
+ */
27067
+ _clearVisualPositionsCache() {
27068
+ this._visualPositionsCache.clear();
27069
+ }
27070
+
27071
+ /**
27072
+ * Measure visual character positions for hit testing using BiDi analysis
27073
+ * This properly handles mixed RTL/LTR text by analyzing BiDi runs
27074
+ * Results are cached per line for consistency during selection operations
27075
+ */
27076
+ _measureVisualPositions(lineIndex, lineText) {
27077
+ // Check cache first
27078
+ if (this._visualPositionsCache.has(lineIndex)) {
27079
+ return this._visualPositionsCache.get(lineIndex);
27080
+ }
27081
+ const line = this._textLines[lineIndex];
27082
+ const positions = [];
27083
+ const chars = this.__charBounds[lineIndex];
27084
+ if (!chars || chars.length === 0) {
27085
+ this._visualPositionsCache.set(lineIndex, positions);
27086
+ return positions;
27087
+ }
27088
+
27089
+ // For LTR direction, use logical positions directly
27090
+ if (this.direction !== 'rtl') {
27091
+ for (let i = 0; i < line.length; i++) {
27092
+ var _chars$i, _chars$i2;
27093
+ positions.push({
27094
+ logicalIndex: i,
27095
+ visualX: ((_chars$i = chars[i]) === null || _chars$i === void 0 ? void 0 : _chars$i.left) || 0,
27096
+ width: ((_chars$i2 = chars[i]) === null || _chars$i2 === void 0 ? void 0 : _chars$i2.kernedWidth) || 0,
27097
+ isRtl: false
27098
+ });
27099
+ }
27100
+ this._visualPositionsCache.set(lineIndex, positions);
27101
+ return positions;
27102
+ }
27103
+
27104
+ // For RTL, use BiDi analysis to determine visual positions
27105
+ const runs = analyzeBiDi(lineText, 'rtl');
27106
+
27107
+ // Build mapping from string position to grapheme index
27108
+ // This is needed because analyzeBiDi works on string positions (code points)
27109
+ // but we need grapheme indices for charBounds
27110
+ const stringPosToGrapheme = [];
27111
+ let strPos = 0;
27112
+ for (let gi = 0; gi < line.length; gi++) {
27113
+ const grapheme = line[gi];
27114
+ for (let j = 0; j < grapheme.length; j++) {
27115
+ stringPosToGrapheme[strPos + j] = gi;
27116
+ }
27117
+ strPos += grapheme.length;
27118
+ }
27119
+
27120
+ // Calculate width for each run
27121
+
27122
+ const runInfos = [];
27123
+ for (const run of runs) {
27124
+ const runChars = [];
27125
+ let runWidth = 0;
27126
+ const seenGraphemes = new Set();
27127
+
27128
+ // Map string positions in this run to grapheme indices
27129
+ for (let sp = run.start; sp < run.end; sp++) {
27130
+ const gi = stringPosToGrapheme[sp];
27131
+ if (gi !== undefined && !seenGraphemes.has(gi)) {
27132
+ var _chars$gi;
27133
+ seenGraphemes.add(gi);
27134
+ runChars.push(gi);
27135
+ runWidth += ((_chars$gi = chars[gi]) === null || _chars$gi === void 0 ? void 0 : _chars$gi.kernedWidth) || 0;
27136
+ }
27137
+ }
27138
+ runInfos.push({
27139
+ run,
27140
+ width: runWidth,
27141
+ charIndices: runChars
27142
+ });
27143
+ }
27144
+
27145
+ // For RTL base direction, runs are displayed right-to-left
27146
+ // So first run appears on the right, last run on the left
27147
+ const totalWidth = this.getLineWidth(lineIndex);
27148
+ let visualX = totalWidth; // Start from right edge
27149
+
27150
+ for (const runInfo of runInfos) {
27151
+ visualX -= runInfo.width; // Move left by run width
27152
+
27153
+ const isRtlRun = runInfo.run.direction === 'rtl';
27154
+ if (isRtlRun) {
27155
+ // RTL run: characters displayed right-to-left within run
27156
+ // First char of run at visual right of run, last at visual left
27157
+ let charX = visualX + runInfo.width;
27158
+ for (const idx of runInfo.charIndices) {
27159
+ var _chars$idx;
27160
+ const charWidth = ((_chars$idx = chars[idx]) === null || _chars$idx === void 0 ? void 0 : _chars$idx.kernedWidth) || 0;
27161
+ charX -= charWidth;
27162
+ positions.push({
27163
+ logicalIndex: idx,
27164
+ visualX: charX,
27165
+ width: charWidth,
27166
+ isRtl: true
27167
+ });
27168
+ }
27169
+ } else {
27170
+ // LTR run: characters displayed left-to-right within run
27171
+ // First char of run at visual left of run, last at visual right
27172
+ let charX = visualX;
27173
+ for (const idx of runInfo.charIndices) {
27174
+ var _chars$idx2;
27175
+ const charWidth = ((_chars$idx2 = chars[idx]) === null || _chars$idx2 === void 0 ? void 0 : _chars$idx2.kernedWidth) || 0;
27176
+ positions.push({
27177
+ logicalIndex: idx,
27178
+ visualX: charX,
27179
+ width: charWidth,
27180
+ isRtl: false
27181
+ });
27182
+ charX += charWidth;
27183
+ }
27184
+ }
27185
+ }
27186
+
27187
+ // Cache the result
27188
+ this._visualPositionsCache.set(lineIndex, positions);
27189
+ return positions;
26311
27190
  }
26312
27191
 
26313
27192
  /**
@@ -26327,40 +27206,140 @@
26327
27206
  }
26328
27207
 
26329
27208
  /**
26330
- * Calculates cursor left/top offset relative to instance's center point
27209
+ * Calculates cursor left/top offset relative to _getLeftOffset()
27210
+ * Uses visual positions for BiDi text support
27211
+ * Handles kashida by converting original indices to display indices
26331
27212
  * @private
26332
- * @param {number} index index from start
27213
+ * @param {number} index index from start (in original text space, without tatweels)
26333
27214
  */
26334
27215
  __getCursorBoundariesOffsets(index) {
26335
- let topOffset = 0,
26336
- leftOffset = 0;
26337
- const {
26338
- charIndex,
26339
- lineIndex
26340
- } = this.get2DCursorLocation(index);
27216
+ let topOffset = 0;
27217
+
27218
+ // Find line index and original char index using original line lengths
27219
+ let lineIndex = 0;
27220
+ let originalCharIndex = index;
27221
+ for (let i = 0; i < this._textLines.length; i++) {
27222
+ const originalLineLength = this._getOriginalLineLength(i);
27223
+ if (originalCharIndex <= originalLineLength) {
27224
+ lineIndex = i;
27225
+ break;
27226
+ }
27227
+ originalCharIndex -= originalLineLength + this.missingNewlineOffset(i);
27228
+ lineIndex = i + 1;
27229
+ }
27230
+
27231
+ // Clamp lineIndex to valid range
27232
+ if (lineIndex >= this._textLines.length) {
27233
+ lineIndex = this._textLines.length - 1;
27234
+ originalCharIndex = this._getOriginalLineLength(lineIndex);
27235
+ }
26341
27236
  for (let i = 0; i < lineIndex; i++) {
26342
27237
  topOffset += this.getHeightOfLine(i);
26343
27238
  }
26344
- const lineLeftOffset = this._getLineLeftOffset(lineIndex);
26345
- const bound = this.__charBounds[lineIndex][charIndex];
26346
- bound && (leftOffset = bound.left);
26347
- if (this.charSpacing !== 0 && charIndex === this._textLines[lineIndex].length) {
26348
- leftOffset -= this._getWidthOfCharSpacing();
27239
+
27240
+ // Convert original char index to display char index for visual lookup
27241
+ const displayCharIndex = this._originalToDisplayIndex(lineIndex, originalCharIndex);
27242
+
27243
+ // Get visual positions for cursor placement
27244
+ const lineText = this._textLines[lineIndex].join('');
27245
+ const visualPositions = this._measureVisualPositions(lineIndex, lineText);
27246
+ const lineWidth = this.getLineWidth(lineIndex);
27247
+ this._textLines[lineIndex].length;
27248
+ const originalLineLength = this._getOriginalLineLength(lineIndex);
27249
+
27250
+ // Find visual X position for cursor (0 to lineWidth, from visual left)
27251
+ let visualX = 0;
27252
+ if (visualPositions.length === 0) {
27253
+ // Fallback for empty line
27254
+ return {
27255
+ top: topOffset,
27256
+ left: 0
27257
+ };
26349
27258
  }
26350
- const boundaries = {
26351
- top: topOffset,
26352
- left: lineLeftOffset + (leftOffset > 0 ? leftOffset : 0)
26353
- };
26354
- if (this.direction === 'rtl') {
26355
- if (this.textAlign === RIGHT || this.textAlign === JUSTIFY || this.textAlign === JUSTIFY_RIGHT) {
26356
- boundaries.left *= -1;
26357
- } else if (this.textAlign === LEFT || this.textAlign === JUSTIFY_LEFT) {
26358
- boundaries.left = lineLeftOffset - (leftOffset > 0 ? leftOffset : 0);
26359
- } else if (this.textAlign === CENTER || this.textAlign === JUSTIFY_CENTER) {
26360
- boundaries.left = lineLeftOffset - (leftOffset > 0 ? leftOffset : 0);
27259
+ if (originalCharIndex === 0) {
27260
+ // Cursor at logical start
27261
+ // For RTL base direction, logical start is at visual right
27262
+ if (this.direction === 'rtl') {
27263
+ visualX = lineWidth; // Right edge
27264
+ } else {
27265
+ visualX = 0; // Left edge
27266
+ }
27267
+ } else if (originalCharIndex >= originalLineLength) {
27268
+ // Cursor at logical end
27269
+ // For RTL base direction, logical end is at visual left
27270
+ if (this.direction === 'rtl') {
27271
+ visualX = 0; // Left edge
27272
+ } else {
27273
+ visualX = lineWidth; // Right edge
27274
+ }
27275
+ } else {
27276
+ // Cursor between characters - find visual position of character at displayCharIndex
27277
+ const charPos = visualPositions.find(p => p.logicalIndex === displayCharIndex);
27278
+ if (charPos) {
27279
+ // Use character's direction to determine cursor position
27280
+ // For RTL char: cursor "before" it appears at its right visual edge
27281
+ // For LTR char: cursor "before" it appears at its left visual edge
27282
+ if (charPos.isRtl) {
27283
+ visualX = charPos.visualX + charPos.width;
27284
+ } else {
27285
+ visualX = charPos.visualX;
27286
+ }
27287
+ } else {
27288
+ // Fallback - try the previous character in display space
27289
+ const prevDisplayIndex = displayCharIndex > 0 ? displayCharIndex - 1 : 0;
27290
+ const prevCharPos = visualPositions.find(p => p.logicalIndex === prevDisplayIndex);
27291
+ if (prevCharPos) {
27292
+ // Cursor after previous character
27293
+ if (prevCharPos.isRtl) {
27294
+ visualX = prevCharPos.visualX;
27295
+ } else {
27296
+ visualX = prevCharPos.visualX + prevCharPos.width;
27297
+ }
27298
+ } else {
27299
+ // Ultimate fallback
27300
+ const bound = this.__charBounds[lineIndex][displayCharIndex];
27301
+ visualX = (bound === null || bound === void 0 ? void 0 : bound.left) || 0;
27302
+ }
26361
27303
  }
26362
27304
  }
26363
- return boundaries;
27305
+
27306
+ // Calculate alignment offset (how much line is shifted from left edge)
27307
+ let alignOffset = 0;
27308
+ if (this.textAlign === 'center' || this.textAlign === 'justify-center') {
27309
+ alignOffset = (this.width - lineWidth) / 2;
27310
+ } else if (this.textAlign === 'right' || this.textAlign === 'justify-right') {
27311
+ alignOffset = this.width - lineWidth;
27312
+ } else if (this.direction === 'rtl' && (this.textAlign === 'justify' || this.textAlign === 'left')) {
27313
+ alignOffset = this.width - lineWidth;
27314
+ }
27315
+
27316
+ // The returned left value is added to _getLeftOffset() in _getCursorBoundaries
27317
+ // _getLeftOffset() returns -width/2 for LTR, +width/2 for RTL
27318
+ // Final cursor X = _getLeftOffset() + leftOffset
27319
+ //
27320
+ // For LTR: cursor X = -width/2 + (alignOffset + visualX)
27321
+ // For RTL: cursor X = +width/2 + leftOffset
27322
+ // We want cursor at: -width/2 + alignOffset + visualX
27323
+ // So leftOffset = -width/2 + alignOffset + visualX - width/2 = alignOffset + visualX - width
27324
+
27325
+ let leftOffset;
27326
+ if (this.direction === 'rtl') {
27327
+ // For RTL, _getLeftOffset() = +width/2
27328
+ // We want final X = -width/2 + alignOffset + visualX
27329
+ // So: +width/2 + leftOffset = -width/2 + alignOffset + visualX
27330
+ // leftOffset = -width + alignOffset + visualX
27331
+ leftOffset = -this.width + alignOffset + visualX;
27332
+ } else {
27333
+ // For LTR, _getLeftOffset() = -width/2
27334
+ // We want final X = -width/2 + alignOffset + visualX
27335
+ // So: -width/2 + leftOffset = -width/2 + alignOffset + visualX
27336
+ // leftOffset = alignOffset + visualX
27337
+ leftOffset = alignOffset + visualX;
27338
+ }
27339
+ return {
27340
+ top: topOffset,
27341
+ left: leftOffset
27342
+ };
26364
27343
  }
26365
27344
 
26366
27345
  /**
@@ -26452,51 +27431,119 @@
26452
27431
  }
26453
27432
 
26454
27433
  /**
26455
- * Renders text selection
27434
+ * Renders text selection using visual positions for BiDi support
27435
+ * Handles kashida by converting original indices to display indices
26456
27436
  * @private
26457
- * @param {{ selectionStart: number, selectionEnd: number }} selection
27437
+ * @param {{ selectionStart: number, selectionEnd: number }} selection (in original text space)
26458
27438
  * @param {Object} boundaries Object with left/top/leftOffset/topOffset
26459
27439
  * @param {CanvasRenderingContext2D} ctx transformed context to draw on
26460
27440
  */
26461
27441
  _renderSelection(ctx, selection, boundaries) {
26462
27442
  const selectionStart = selection.selectionStart,
26463
27443
  selectionEnd = selection.selectionEnd,
26464
- isJustify = this.textAlign.includes(JUSTIFY),
26465
- start = this.get2DCursorLocation(selectionStart),
26466
- end = this.get2DCursorLocation(selectionEnd),
26467
- startLine = start.lineIndex,
26468
- endLine = end.lineIndex,
26469
- startChar = start.charIndex < 0 ? 0 : start.charIndex,
26470
- endChar = end.charIndex < 0 ? 0 : end.charIndex;
27444
+ isJustify = this.textAlign.includes(JUSTIFY);
27445
+
27446
+ // Convert selection indices to line/char using original text space
27447
+ // This handles kashida properly since selection indices don't include tatweels
27448
+ let startLine = 0,
27449
+ endLine = 0;
27450
+ let originalStartChar = selectionStart,
27451
+ originalEndChar = selectionEnd;
27452
+
27453
+ // Find start line and char
27454
+ let charCount = 0;
27455
+ for (let i = 0; i < this._textLines.length; i++) {
27456
+ const originalLineLength = this._getOriginalLineLength(i);
27457
+ if (charCount + originalLineLength >= selectionStart) {
27458
+ startLine = i;
27459
+ originalStartChar = selectionStart - charCount;
27460
+ break;
27461
+ }
27462
+ charCount += originalLineLength + this.missingNewlineOffset(i);
27463
+ }
27464
+
27465
+ // Find end line and char
27466
+ charCount = 0;
27467
+ for (let i = 0; i < this._textLines.length; i++) {
27468
+ const originalLineLength = this._getOriginalLineLength(i);
27469
+ if (charCount + originalLineLength >= selectionEnd) {
27470
+ endLine = i;
27471
+ originalEndChar = selectionEnd - charCount;
27472
+ break;
27473
+ }
27474
+ charCount += originalLineLength + this.missingNewlineOffset(i);
27475
+ if (i === this._textLines.length - 1) {
27476
+ endLine = i;
27477
+ originalEndChar = originalLineLength;
27478
+ }
27479
+ }
26471
27480
  for (let i = startLine; i <= endLine; i++) {
26472
- const lineOffset = this._getLineLeftOffset(i) || 0;
26473
27481
  let lineHeight = this.getHeightOfLine(i),
26474
- realLineHeight = 0,
26475
- boxStart = 0,
26476
- boxEnd = 0;
27482
+ realLineHeight = 0;
26477
27483
 
26478
- // Simplified selection rendering that works for both LTR and RTL
27484
+ // Get visual positions for this line
27485
+ const lineText = this._textLines[i].join('');
27486
+ const visualPositions = this._measureVisualPositions(i, lineText);
27487
+ this._textLines[i].length;
27488
+ const originalLineLength = this._getOriginalLineLength(i);
27489
+
27490
+ // Calculate selection bounds in original space, then convert to display
27491
+ let originalLineStartChar = 0;
27492
+ let originalLineEndChar = originalLineLength;
26479
27493
  if (i === startLine) {
26480
- boxStart = this.__charBounds[startLine][startChar].left;
27494
+ originalLineStartChar = originalStartChar;
27495
+ }
27496
+ if (i === endLine) {
27497
+ originalLineEndChar = originalEndChar;
27498
+ }
27499
+
27500
+ // Convert original char indices to display indices for visual lookup
27501
+ const displayLineStartChar = this._originalToDisplayIndex(i, originalLineStartChar);
27502
+ const displayLineEndChar = this._originalToDisplayIndex(i, originalLineEndChar);
27503
+
27504
+ // Get visual X positions for selection range
27505
+ let minVisualX = Infinity;
27506
+ let maxVisualX = -Infinity;
27507
+ for (const pos of visualPositions) {
27508
+ if (pos.logicalIndex >= displayLineStartChar && pos.logicalIndex < displayLineEndChar) {
27509
+ minVisualX = Math.min(minVisualX, pos.visualX);
27510
+ maxVisualX = Math.max(maxVisualX, pos.visualX + pos.width);
27511
+ }
26481
27512
  }
26482
- if (i >= startLine && i < endLine) {
26483
- boxEnd = isJustify && !this.isEndOfWrapping(i) ? this.width : this.getLineWidth(i) || 5;
26484
- } else if (i === endLine) {
26485
- if (endChar === 0) {
26486
- boxEnd = this.__charBounds[endLine][endChar].left;
27513
+
27514
+ // Handle edge cases
27515
+ if (minVisualX === Infinity || maxVisualX === -Infinity) {
27516
+ if (i >= startLine && i < endLine) {
27517
+ // Full line selection
27518
+ minVisualX = 0;
27519
+ maxVisualX = isJustify && !this.isEndOfWrapping(i) ? this.width : this.getLineWidth(i) || 5;
26487
27520
  } else {
26488
- const charSpacing = this._getWidthOfCharSpacing();
26489
- boxEnd = this.__charBounds[endLine][endChar - 1].left + this.__charBounds[endLine][endChar - 1].width - charSpacing;
27521
+ continue; // No selection on this line
26490
27522
  }
26491
27523
  }
26492
27524
  realLineHeight = lineHeight;
26493
27525
  if (this.lineHeight < 1 || i === endLine && this.lineHeight > 1) {
26494
27526
  lineHeight /= this.lineHeight;
26495
27527
  }
26496
- let drawStart = boundaries.left + lineOffset + boxStart,
26497
- drawHeight = lineHeight,
26498
- extraTop = 0;
26499
- const drawWidth = boxEnd - boxStart;
27528
+
27529
+ // Calculate draw position
27530
+ // Visual positions are relative to line start (0 to lineWidth)
27531
+ // Need to add alignment offset
27532
+ const lineWidth = this.getLineWidth(i);
27533
+ let alignOffset = 0;
27534
+ if (this.textAlign === 'center' || this.textAlign === 'justify-center') {
27535
+ alignOffset = (this.width - lineWidth) / 2;
27536
+ } else if (this.textAlign === 'right' || this.textAlign === 'justify-right') {
27537
+ alignOffset = this.width - lineWidth;
27538
+ } else if (this.direction === 'rtl' && (this.textAlign === 'justify' || this.textAlign === 'left')) {
27539
+ alignOffset = this.width - lineWidth;
27540
+ }
27541
+
27542
+ // Draw from center origin (-width/2 to width/2)
27543
+ const drawStart = -this.width / 2 + alignOffset + minVisualX;
27544
+ const drawWidth = maxVisualX - minVisualX;
27545
+ let drawHeight = lineHeight;
27546
+ let extraTop = 0;
26500
27547
  if (this.inCompositionMode) {
26501
27548
  ctx.fillStyle = this.compositionColor || 'black';
26502
27549
  drawHeight = 1;
@@ -26504,15 +27551,6 @@
26504
27551
  } else {
26505
27552
  ctx.fillStyle = this.selectionColor;
26506
27553
  }
26507
- if (this.direction === 'rtl') {
26508
- if (this.textAlign === RIGHT || this.textAlign === JUSTIFY || this.textAlign === JUSTIFY_RIGHT) {
26509
- drawStart = this.width - drawStart - drawWidth;
26510
- } else if (this.textAlign === LEFT || this.textAlign === JUSTIFY_LEFT) {
26511
- drawStart = boundaries.left + lineOffset - boxEnd;
26512
- } else if (this.textAlign === CENTER || this.textAlign === JUSTIFY_CENTER) {
26513
- drawStart = boundaries.left + lineOffset - boxEnd;
26514
- }
26515
- }
26516
27554
  ctx.fillRect(drawStart, boundaries.top + boundaries.topOffset + extraTop, drawWidth, drawHeight);
26517
27555
  boundaries.topOffset += realLineHeight;
26518
27556
  }
@@ -26561,53 +27599,6 @@
26561
27599
  super.dispose();
26562
27600
  }
26563
27601
  }
26564
- /**
26565
- * Index where text selection starts (or where cursor is when there is no selection)
26566
- * @type Number
26567
- */
26568
- /**
26569
- * Index where text selection ends
26570
- * @type Number
26571
- */
26572
- /**
26573
- * Color of text selection
26574
- * @type String
26575
- */
26576
- /**
26577
- * Indicates whether text is in editing mode
26578
- * @type Boolean
26579
- */
26580
- /**
26581
- * Indicates whether a text can be edited
26582
- * @type Boolean
26583
- */
26584
- /**
26585
- * Border color of text object while it's in editing mode
26586
- * @type String
26587
- */
26588
- /**
26589
- * Width of cursor (in px)
26590
- * @type Number
26591
- */
26592
- /**
26593
- * Color of text cursor color in editing mode.
26594
- * if not set (default) will take color from the text.
26595
- * if set to a color value that fabric can understand, it will
26596
- * be used instead of the color of the text at the current position.
26597
- * @type String
26598
- */
26599
- /**
26600
- * Delay between cursor blink (in ms)
26601
- * @type Number
26602
- */
26603
- /**
26604
- * Duration of cursor fade in (in ms)
26605
- * @type Number
26606
- */
26607
- /**
26608
- * Indicates whether internal text char widths can be cached
26609
- * @type Boolean
26610
- */
26611
27602
  _defineProperty(IText, "ownDefaults", iTextDefaultValues);
26612
27603
  _defineProperty(IText, "type", 'IText');
26613
27604
  classRegistry.setClass(IText);
@@ -26678,7 +27669,7 @@
26678
27669
  }
26679
27670
 
26680
27671
  // Skip if nothing changed
26681
- const currentState = `${this.text}|${this.width}|${this.fontSize}|${this.fontFamily}|${this.textAlign}`;
27672
+ const currentState = `${this.text}|${this.width}|${this.fontSize}|${this.fontFamily}|${this.textAlign}|${this.kashida}`;
26682
27673
  if (this._lastDimensionState === currentState && this._textLines && this._textLines.length > 0) {
26683
27674
  return;
26684
27675
  }
@@ -26777,12 +27768,18 @@
26777
27768
  }
26778
27769
 
26779
27770
  // Use new layout engine
27771
+ // When kashida is enabled, don't let layout engine apply justify - we'll handle it with kashida
27772
+ const useKashidaJustify = this.kashida !== 'none' && this.textAlign.includes(JUSTIFY);
27773
+ const effectiveAlign = useKashidaJustify ? this.direction === 'rtl' ? 'right' : 'left' // Natural alignment, kashida will justify
27774
+ : this._mapTextAlignToAlign(this.textAlign);
26780
27775
  const layout = layoutText({
26781
27776
  text: this.text,
26782
27777
  width: this.width,
26783
- height: this.height,
27778
+ // Don't pass height constraint to allow vertical auto-expansion
27779
+ // Only pass height if explicitly set to constrain (e.g., for ellipsis)
27780
+ height: this.ellipsis ? this.height : undefined,
26784
27781
  wrap: this.wrap || 'word',
26785
- align: this._mapTextAlignToAlign(this.textAlign),
27782
+ align: effectiveAlign,
26786
27783
  ellipsis: this.ellipsis || false,
26787
27784
  fontSize: this.fontSize,
26788
27785
  lineHeight: this.lineHeight,
@@ -26820,9 +27817,264 @@
26820
27817
 
26821
27818
  // Generate style map for compatibility
26822
27819
  this._styleMap = this._generateStyleMapFromLayout(layout);
27820
+
27821
+ // Apply kashida for justified text in advanced layout mode
27822
+ if (this.textAlign.includes(JUSTIFY) && this.kashida !== 'none') {
27823
+ this._applyKashidaToLayout();
27824
+ }
26823
27825
  this.dirty = true;
26824
27826
  }
26825
27827
 
27828
+ /**
27829
+ * Apply kashida (tatweel) characters to layout for Arabic text justification.
27830
+ * This method INSERTS actual tatweel characters into the text lines.
27831
+ * @private
27832
+ */
27833
+ _applyKashidaToLayout() {
27834
+ if (!this._textLines || !this.__charBounds) {
27835
+ return;
27836
+ }
27837
+
27838
+ // Clear visual positions cache - it becomes stale when kashida is applied
27839
+ // Check if cache exists (it's initialized in IText constructor which runs after this during construction)
27840
+ if (this._visualPositionsCache) {
27841
+ this._clearVisualPositionsCache();
27842
+ }
27843
+ const kashidaRatios = {
27844
+ none: 0,
27845
+ short: 0.25,
27846
+ medium: 0.5,
27847
+ long: 0.75,
27848
+ stylistic: 1.0
27849
+ };
27850
+ const kashidaRatio = kashidaRatios[this.kashida] || 0;
27851
+ if (kashidaRatio === 0) {
27852
+ return;
27853
+ }
27854
+
27855
+ // Calculate tatweel width once
27856
+ const canvas = document.createElement('canvas');
27857
+ const ctx = canvas.getContext('2d');
27858
+ if (!ctx) {
27859
+ return;
27860
+ }
27861
+ ctx.font = this._getFontDeclaration();
27862
+ const tatweelWidth = ctx.measureText(ARABIC_TATWEEL).width;
27863
+ if (tatweelWidth <= 0) {
27864
+ return;
27865
+ }
27866
+
27867
+ // Reset kashida info
27868
+ this.__kashidaInfo = [];
27869
+ const totalLines = this._textLines.length;
27870
+ for (let lineIndex = 0; lineIndex < totalLines; lineIndex++) {
27871
+ this.__kashidaInfo[lineIndex] = [];
27872
+ const line = this._textLines[lineIndex];
27873
+ if (!this.__charBounds || !this.__charBounds[lineIndex]) {
27874
+ continue;
27875
+ }
27876
+
27877
+ // Don't apply kashida to the last line
27878
+ const isLastLine = lineIndex === totalLines - 1;
27879
+ if (isLastLine) {
27880
+ continue;
27881
+ }
27882
+ const lineBounds = this.__charBounds[lineIndex];
27883
+ const lastBound = lineBounds[lineBounds.length - 1];
27884
+
27885
+ // Calculate current line width
27886
+ const currentLineWidth = lastBound ? lastBound.left + lastBound.kernedWidth : 0;
27887
+ const totalExtraSpace = this.width - currentLineWidth;
27888
+
27889
+ // Only apply kashida if there's significant extra space to fill
27890
+ if (totalExtraSpace <= 2) {
27891
+ continue;
27892
+ }
27893
+
27894
+ // Find kashida points
27895
+ const kashidaPoints = findKashidaPoints(line);
27896
+ if (kashidaPoints.length === 0) {
27897
+ continue;
27898
+ }
27899
+
27900
+ // Calculate kashida space
27901
+ const kashidaSpace = totalExtraSpace * kashidaRatio;
27902
+
27903
+ // Calculate how many tatweels can fit
27904
+ const totalTatweels = Math.floor(kashidaSpace / tatweelWidth);
27905
+ if (totalTatweels === 0) {
27906
+ continue;
27907
+ }
27908
+
27909
+ // Limit kashida points
27910
+ const maxKashidaPoints = Math.min(kashidaPoints.length, totalTatweels);
27911
+ const usedKashidaPoints = kashidaPoints.slice(0, maxKashidaPoints);
27912
+
27913
+ // Distribute tatweels evenly
27914
+ const tatweelsPerPoint = Math.floor(totalTatweels / maxKashidaPoints);
27915
+ const extraTatweels = totalTatweels % maxKashidaPoints;
27916
+
27917
+ // console.log(`=== Inserting Kashida into line ${lineIndex} ===`);
27918
+ // console.log(` totalTatweels: ${totalTatweels}, usedPoints: ${usedKashidaPoints.length}`);
27919
+
27920
+ // Sort by charIndex descending so we insert from the end (prevents index shifting issues)
27921
+ const sortedPoints = [...usedKashidaPoints].sort((a, b) => b.charIndex - a.charIndex);
27922
+
27923
+ // Create new line with tatweels inserted
27924
+ const newLine = [...line];
27925
+ for (let i = 0; i < sortedPoints.length; i++) {
27926
+ const point = sortedPoints[i];
27927
+ const originalIndex = usedKashidaPoints.indexOf(point);
27928
+ const count = tatweelsPerPoint + (originalIndex < extraTatweels ? 1 : 0);
27929
+ if (count > 0) {
27930
+ // Insert tatweels AFTER the character at charIndex
27931
+ const tatweels = Array(count).fill(ARABIC_TATWEEL);
27932
+ newLine.splice(point.charIndex + 1, 0, ...tatweels);
27933
+ // console.log(` Inserted ${count} tatweels after char ${point.charIndex}`);
27934
+
27935
+ // Store kashida info for index conversion
27936
+ this.__kashidaInfo[lineIndex].push({
27937
+ charIndex: point.charIndex,
27938
+ width: count * tatweelWidth,
27939
+ tatweelCount: count
27940
+ });
27941
+ }
27942
+ }
27943
+
27944
+ // Update _textLines with the new line containing tatweels
27945
+ this._textLines[lineIndex] = newLine;
27946
+
27947
+ // Update textLines (string version)
27948
+ if (this.textLines) {
27949
+ this.textLines[lineIndex] = newLine.join('');
27950
+ }
27951
+
27952
+ // Clear and recalculate charBounds for this line
27953
+ this.__charBounds[lineIndex] = [];
27954
+ this.__lineWidths[lineIndex] = undefined;
27955
+ this._measureLine(lineIndex);
27956
+
27957
+ // Now expand spaces to fill any remaining gap
27958
+ let newLineBounds = this.__charBounds[lineIndex];
27959
+ if (newLineBounds && newLineBounds.length > 0) {
27960
+ let newLastBound = newLineBounds[newLineBounds.length - 1];
27961
+ let newLineWidth = newLastBound ? newLastBound.left + newLastBound.kernedWidth : 0;
27962
+ let remainingGap = this.width - newLineWidth;
27963
+ if (remainingGap > 0.5) {
27964
+ // Count spaces in the new line
27965
+ let spaceCount = 0;
27966
+ for (let i = 0; i < newLine.length; i++) {
27967
+ if (/\s/.test(newLine[i])) {
27968
+ spaceCount++;
27969
+ }
27970
+ }
27971
+ if (spaceCount > 0) {
27972
+ const extraPerSpace = remainingGap / spaceCount;
27973
+ let accumulatedExtra = 0;
27974
+
27975
+ // Expand space widths AND update left positions for subsequent chars
27976
+ for (let i = 0; i < newLineBounds.length; i++) {
27977
+ const bound = newLineBounds[i];
27978
+ if (!bound) continue;
27979
+
27980
+ // Update left position to account for previous space expansions
27981
+ bound.left += accumulatedExtra;
27982
+
27983
+ // If this is a space, expand it
27984
+ if (/\s/.test(newLine[i])) {
27985
+ bound.width += extraPerSpace;
27986
+ bound.kernedWidth += extraPerSpace;
27987
+ accumulatedExtra += extraPerSpace;
27988
+ }
27989
+ }
27990
+ // Update the extra entry at the end (cursor position)
27991
+ if (newLineBounds[newLine.length]) {
27992
+ newLineBounds[newLine.length].left += accumulatedExtra;
27993
+ }
27994
+
27995
+ // Recalculate remaining gap after space expansion
27996
+ newLastBound = newLineBounds[newLineBounds.length - 1];
27997
+ newLineWidth = newLastBound ? newLastBound.left + newLastBound.kernedWidth : 0;
27998
+ remainingGap = this.width - newLineWidth;
27999
+ }
28000
+ }
28001
+
28002
+ // If there's still a gap after space expansion, distribute it across all kashida points
28003
+ if (remainingGap > 0.5 && this.__kashidaInfo[lineIndex].length > 0) {
28004
+ const kashidaPointCount = this.__kashidaInfo[lineIndex].length;
28005
+ const extraPerKashida = remainingGap / kashidaPointCount;
28006
+
28007
+ // Find kashida positions in newLine and expand their widths
28008
+ let kashidaIndex = 0;
28009
+ let accumulatedExtra = 0;
28010
+ for (let i = 0; i < newLineBounds.length; i++) {
28011
+ const bound = newLineBounds[i];
28012
+ if (!bound) continue;
28013
+
28014
+ // Update left position for accumulated expansion
28015
+ bound.left += accumulatedExtra;
28016
+
28017
+ // Check if this is a tatweel character
28018
+ if (newLine[i] === ARABIC_TATWEEL) {
28019
+ var _this$__kashidaInfo$l;
28020
+ // Distribute extra width among tatweels
28021
+ const extraForThis = extraPerKashida / (((_this$__kashidaInfo$l = this.__kashidaInfo[lineIndex][kashidaIndex]) === null || _this$__kashidaInfo$l === void 0 ? void 0 : _this$__kashidaInfo$l.tatweelCount) || 1);
28022
+ bound.width += extraForThis;
28023
+ bound.kernedWidth += extraForThis;
28024
+ accumulatedExtra += extraForThis;
28025
+
28026
+ // Move to next kashida info when we've passed this group
28027
+ const currentKashidaInfo = this.__kashidaInfo[lineIndex][kashidaIndex];
28028
+ if (currentKashidaInfo && i > 0) {
28029
+ // Check if next char is not tatweel - means we're done with this group
28030
+ if (i + 1 >= newLine.length || newLine[i + 1] !== ARABIC_TATWEEL) {
28031
+ kashidaIndex++;
28032
+ }
28033
+ }
28034
+ }
28035
+ }
28036
+
28037
+ // Update the extra entry at the end
28038
+ if (newLineBounds[newLine.length]) {
28039
+ newLineBounds[newLine.length].left += accumulatedExtra;
28040
+ }
28041
+ }
28042
+ }
28043
+
28044
+ // Set line width to textbox width (for justified lines)
28045
+ this.__lineWidths[lineIndex] = this.width;
28046
+
28047
+ // console.log(` New line length: ${newLine.length}, text: ${newLine.join('')}`);
28048
+ }
28049
+
28050
+ // For justified lines with kashida, line width should equal textbox width
28051
+ // Only set undefined widths (non-justified lines without kashida)
28052
+ for (let i = 0; i < this._textLines.length; i++) {
28053
+ if (this.__lineWidths[i] === undefined && this.__charBounds[i]) {
28054
+ const bounds = this.__charBounds[i];
28055
+ const lastBound = bounds[bounds.length - 1];
28056
+ if (lastBound) {
28057
+ this.__lineWidths[i] = lastBound.left + lastBound.kernedWidth;
28058
+ }
28059
+ }
28060
+ }
28061
+
28062
+ // Update _text to match the new _textLines (required for editing)
28063
+ this._text = this._textLines.flat();
28064
+
28065
+ // DON'T update this.text - keep the original text intact
28066
+ // The tatweels are in _textLines and _text for rendering purposes only
28067
+
28068
+ this._justifyApplied = true;
28069
+
28070
+ // Debug log final kashida state
28071
+ // console.log('=== _applyKashidaToLayout END ===');
28072
+ // console.log('Final __kashidaInfo:', JSON.stringify(this.__kashidaInfo.map((lineInfo, i) => ({
28073
+ // line: i,
28074
+ // entries: lineInfo.map(k => ({ charIndex: k.charIndex, tatweelCount: k.tatweelCount }))
28075
+ // }))));
28076
+ }
28077
+
26826
28078
  /**
26827
28079
  * Generate style map from new layout format
26828
28080
  * @private
@@ -27345,84 +28597,100 @@
27345
28597
  * @private
27346
28598
  */
27347
28599
  _extractJustifySpaceMeasurements(element, lines) {
27348
- console.log('=== _extractJustifySpaceMeasurements START ===');
27349
- console.log('Textbox width:', this.width);
27350
- console.log('Lines count:', lines.length);
28600
+ // console.log('=== _extractJustifySpaceMeasurements START ===');
28601
+ // console.log('Textbox width:', this.width);
28602
+ // console.log('Lines count:', lines.length);
28603
+
27351
28604
  const measureCtx = this._browserMeasureCtx || (this._browserMeasureCtx = document.createElement('canvas').getContext('2d'));
27352
28605
  if (!measureCtx) {
27353
- console.log('ERROR: No measure context');
28606
+ // console.log('ERROR: No measure context');
27354
28607
  return [];
27355
28608
  }
27356
28609
  measureCtx.font = `${this.fontStyle || 'normal'} ${this.fontWeight || 'normal'} ${this.fontSize}px "${this.fontFamily}"`;
27357
28610
  const normalSpaceWidth = measureCtx.measureText(' ').width || 6;
27358
- console.log('Font:', measureCtx.font);
27359
- console.log('Normal space width:', normalSpaceWidth);
28611
+ // console.log('Font:', measureCtx.font);
28612
+ // console.log('Normal space width:', normalSpaceWidth);
28613
+
27360
28614
  const spaceWidths = [];
27361
28615
  lines.forEach((line, lineIndex) => {
27362
28616
  const lineSpaces = [];
27363
28617
  const spaceCount = (line.match(/\s/g) || []).length;
27364
28618
  const isLastLine = lineIndex === lines.length - 1;
27365
- console.log(`\nLine ${lineIndex}: "${line.substring(0, 50)}..." spaces: ${spaceCount}, isLast: ${isLastLine}`);
28619
+
28620
+ // console.log(`\nLine ${lineIndex}: "${line.substring(0, 50)}..." spaces: ${spaceCount}, isLast: ${isLastLine}`);
28621
+
27366
28622
  if (spaceCount > 0 && !isLastLine) {
27367
28623
  // Don't justify last line
27368
28624
  const naturalWidth = measureCtx.measureText(line).width;
27369
28625
  const remainingSpace = this.width - naturalWidth;
27370
28626
  const extraPerSpace = remainingSpace > 0 ? remainingSpace / spaceCount : 0;
27371
28627
  const expandedSpaceWidth = normalSpaceWidth + extraPerSpace;
27372
- console.log(` Natural width: ${naturalWidth.toFixed(2)}, Remaining: ${remainingSpace.toFixed(2)}`);
27373
- console.log(` Extra per space: ${extraPerSpace.toFixed(2)}, Expanded space: ${expandedSpaceWidth.toFixed(2)}`);
28628
+
28629
+ // console.log(` Natural width: ${naturalWidth.toFixed(2)}, Remaining: ${remainingSpace.toFixed(2)}`);
28630
+ // console.log(` Extra per space: ${extraPerSpace.toFixed(2)}, Expanded space: ${expandedSpaceWidth.toFixed(2)}`);
28631
+
27374
28632
  const safeWidth = Math.max(normalSpaceWidth, expandedSpaceWidth);
27375
28633
  for (let i = 0; i < spaceCount; i++) {
27376
28634
  lineSpaces.push(safeWidth);
27377
28635
  }
27378
28636
  } else if (spaceCount > 0) {
27379
28637
  // Last line: keep natural space width
27380
- console.log(` Last line - using normal space width: ${normalSpaceWidth}`);
28638
+ // console.log(` Last line - using normal space width: ${normalSpaceWidth}`);
27381
28639
  for (let i = 0; i < spaceCount; i++) {
27382
28640
  lineSpaces.push(normalSpaceWidth);
27383
28641
  }
27384
28642
  }
27385
28643
  spaceWidths.push(lineSpaces);
27386
28644
  });
27387
- console.log('\nFinal spaceWidths:', spaceWidths);
27388
- console.log('=== _extractJustifySpaceMeasurements END ===\n');
28645
+
28646
+ // console.log('\nFinal spaceWidths:', spaceWidths);
28647
+ // console.log('=== _extractJustifySpaceMeasurements END ===\n');
27389
28648
  return spaceWidths;
27390
28649
  }
27391
28650
 
27392
28651
  /**
27393
- * Apply justify space expansion using actual charBounds measurements
28652
+ * Apply justify space expansion using actual charBounds measurements.
28653
+ * Supports Arabic kashida (tatweel) justification when kashida property is set.
27394
28654
  * @private
27395
28655
  */
27396
28656
  _applyBrowserJustifySpaces() {
27397
- var _this$_textLines, _this$__charBounds;
27398
- console.log('=== _applyBrowserJustifySpaces START ===');
27399
- console.log('_textLines:', (_this$_textLines = this._textLines) === null || _this$_textLines === void 0 ? void 0 : _this$_textLines.length, 'lines');
27400
- console.log('__charBounds:', (_this$__charBounds = this.__charBounds) === null || _this$__charBounds === void 0 ? void 0 : _this$__charBounds.length, 'lines');
27401
- console.log('textbox width:', this.width);
27402
28657
  if (!this._textLines || !this.__charBounds) {
27403
- console.log('EARLY RETURN: _textLines or __charBounds missing');
27404
28658
  return;
27405
28659
  }
28660
+
28661
+ // Kashida ratios: proportion of extra space distributed via kashida vs space expansion
28662
+ const kashidaRatios = {
28663
+ none: 0,
28664
+ short: 0.25,
28665
+ medium: 0.5,
28666
+ long: 0.75,
28667
+ stylistic: 1.0
28668
+ };
28669
+ const kashidaRatio = kashidaRatios[this.kashida] || 0;
28670
+
28671
+ // Reset kashida info
28672
+ this.__kashidaInfo = [];
27406
28673
  const totalLines = this._textLines.length;
27407
28674
  this._textLines.forEach((line, lineIndex) => {
27408
- const lineText = line.join('');
27409
- const isLastLine = lineIndex === totalLines - 1;
27410
- console.log(`\n--- Line ${lineIndex}: "${lineText}" isLast: ${isLastLine} ---`);
28675
+ // Initialize kashida info for this line
28676
+ this.__kashidaInfo[lineIndex] = [];
27411
28677
  if (!this.__charBounds || !this.__charBounds[lineIndex]) {
27412
- console.log(' SKIP: No charBounds for this line');
27413
28678
  return;
27414
28679
  }
27415
28680
 
27416
28681
  // Don't justify the last line
28682
+ const isLastLine = lineIndex === totalLines - 1;
27417
28683
  if (isLastLine) {
27418
- console.log(' SKIP: Last line - no justify');
27419
28684
  return;
27420
28685
  }
27421
28686
  const lineBounds = this.__charBounds[lineIndex];
27422
28687
 
27423
28688
  // Calculate current line width from charBounds
27424
28689
  const currentLineWidth = lineBounds.reduce((sum, b) => sum + ((b === null || b === void 0 ? void 0 : b.kernedWidth) || 0), 0);
27425
- console.log(' Current line width from charBounds:', currentLineWidth);
28690
+ const totalExtraSpace = this.width - currentLineWidth;
28691
+ if (totalExtraSpace <= 0) {
28692
+ return;
28693
+ }
27426
28694
 
27427
28695
  // Count spaces and find space indices
27428
28696
  const spaceIndices = [];
@@ -27432,53 +28700,118 @@
27432
28700
  }
27433
28701
  }
27434
28702
  const spaceCount = spaceIndices.length;
27435
- console.log(' Space count:', spaceCount, 'at indices:', spaceIndices);
27436
- if (spaceCount === 0) {
27437
- console.log(' SKIP: No spaces to expand');
27438
- return;
28703
+
28704
+ // Find kashida points if enabled
28705
+ const kashidaPoints = kashidaRatio > 0 ? findKashidaPoints(line) : [];
28706
+ const hasKashidaPoints = kashidaPoints.length > 0;
28707
+
28708
+ // Calculate space distribution
28709
+ let kashidaSpace = 0;
28710
+ if (hasKashidaPoints && kashidaRatio > 0) {
28711
+ // Distribute between kashida and spaces
28712
+ kashidaSpace = totalExtraSpace * kashidaRatio;
27439
28713
  }
27440
28714
 
27441
- // Calculate how much extra space we need
27442
- const remainingSpace = this.width - currentLineWidth;
27443
- console.log(' Remaining space to fill:', remainingSpace);
27444
- if (remainingSpace <= 0) {
27445
- console.log(' SKIP: Line already fills or exceeds width');
27446
- return;
28715
+ // Calculate per-kashida and per-space widths
28716
+ const perKashidaWidth = hasKashidaPoints ? kashidaSpace / kashidaPoints.length : 0;
28717
+
28718
+ // If kashida is enabled, insert actual tatweel characters
28719
+ if (hasKashidaPoints && perKashidaWidth > 0) {
28720
+ // console.log(`=== Inserting kashida in _applyBrowserJustifySpaces line ${lineIndex} ===`);
28721
+
28722
+ // Sort by charIndex descending to insert from end
28723
+ const sortedPoints = [...kashidaPoints].sort((a, b) => b.charIndex - a.charIndex);
28724
+
28725
+ // Calculate tatweel width
28726
+ const canvas = document.createElement('canvas');
28727
+ const ctx = canvas.getContext('2d');
28728
+ if (ctx) {
28729
+ ctx.font = this._getFontDeclaration();
28730
+ const tatweelWidth = ctx.measureText(ARABIC_TATWEEL).width;
28731
+ // console.log(` tatweelWidth: ${tatweelWidth}`);
28732
+
28733
+ if (tatweelWidth > 0) {
28734
+ const newLine = [...line];
28735
+ for (const point of sortedPoints) {
28736
+ const tatweelCount = Math.max(1, Math.round(perKashidaWidth / tatweelWidth));
28737
+ // console.log(` Point ${point.charIndex}: inserting ${tatweelCount} tatweels`);
28738
+
28739
+ // Insert tatweels after the character
28740
+ for (let t = 0; t < tatweelCount; t++) {
28741
+ newLine.splice(point.charIndex + 1, 0, ARABIC_TATWEEL);
28742
+ }
28743
+
28744
+ // Store kashida info with tatweelCount for index conversion
28745
+ this.__kashidaInfo[lineIndex].push({
28746
+ charIndex: point.charIndex,
28747
+ width: perKashidaWidth,
28748
+ tatweelCount: tatweelCount
28749
+ });
28750
+ }
28751
+
28752
+ // console.log(` New line: ${newLine.join('')}`);
28753
+
28754
+ // Update _textLines with kashida
28755
+ this._textLines[lineIndex] = newLine;
28756
+
28757
+ // Update textLines string version
28758
+ if (this.textLines && this.textLines[lineIndex] !== undefined) {
28759
+ this.textLines[lineIndex] = newLine.join('');
28760
+ }
28761
+
28762
+ // Recalculate charBounds
28763
+ this.__charBounds[lineIndex] = [];
28764
+ this.__lineWidths[lineIndex] = undefined;
28765
+ this._measureLine(lineIndex);
28766
+ }
28767
+ }
28768
+ } else {
28769
+ // No kashida - just store info for reference (tatweelCount is 0 since no tatweels inserted)
28770
+ for (const point of kashidaPoints) {
28771
+ this.__kashidaInfo[lineIndex].push({
28772
+ charIndex: point.charIndex,
28773
+ width: perKashidaWidth,
28774
+ tatweelCount: 0
28775
+ });
28776
+ }
27447
28777
  }
27448
- const extraPerSpace = remainingSpace / spaceCount;
27449
- console.log(' Extra per space:', extraPerSpace);
27450
-
27451
- // Apply expansion
27452
- let accumulated = 0;
27453
- for (let charIndex = 0; charIndex < line.length; charIndex++) {
27454
- const bound = lineBounds[charIndex];
27455
- if (!bound) continue;
27456
-
27457
- // Shift this character by accumulated expansion
27458
- bound.left += accumulated;
27459
-
27460
- // If this is a space, expand it
27461
- if (spaceIndices.includes(charIndex)) {
27462
- const oldWidth = bound.width;
27463
- const newWidth = oldWidth + extraPerSpace;
27464
- bound.width = newWidth;
27465
- bound.kernedWidth = newWidth;
27466
- accumulated += extraPerSpace;
27467
- console.log(` Space at char ${charIndex}: ${oldWidth.toFixed(2)} -> ${newWidth.toFixed(2)} (accumulated: ${accumulated.toFixed(2)})`);
28778
+
28779
+ // Now apply space expansion to remaining extra space
28780
+ const newLineBounds = this.__charBounds[lineIndex];
28781
+ const newLineWidth = newLineBounds.reduce((sum, b) => sum + ((b === null || b === void 0 ? void 0 : b.kernedWidth) || 0), 0);
28782
+ const remainingSpace = this.width - newLineWidth;
28783
+ if (remainingSpace > 0 && spaceCount > 0) {
28784
+ const extraPerSpace = remainingSpace / spaceCount;
28785
+ let accumulated = 0;
28786
+ for (let charIndex = 0; charIndex < this._textLines[lineIndex].length; charIndex++) {
28787
+ const bound = newLineBounds[charIndex];
28788
+ if (!bound) continue;
28789
+ bound.left += accumulated;
28790
+
28791
+ // Check if this is a space (need to check against the updated line)
28792
+ if (/\s/.test(this._textLines[lineIndex][charIndex])) {
28793
+ bound.width += extraPerSpace;
28794
+ bound.kernedWidth += extraPerSpace;
28795
+ accumulated += extraPerSpace;
28796
+ }
27468
28797
  }
27469
28798
  }
27470
28799
 
27471
28800
  // Update cached line width
27472
- const finalLineWidth = lineBounds.reduce((max, b) => Math.max(max, b.left + b.width), 0);
28801
+ const finalLineBounds = this.__charBounds[lineIndex];
28802
+ const finalLineWidth = finalLineBounds.reduce((max, b) => Math.max(max, ((b === null || b === void 0 ? void 0 : b.left) || 0) + ((b === null || b === void 0 ? void 0 : b.width) || 0)), 0);
27473
28803
  this.__lineWidths[lineIndex] = finalLineWidth;
27474
- console.log(' Final line width:', finalLineWidth.toFixed(2), 'target:', this.width);
27475
28804
  });
27476
- console.log('=== _applyBrowserJustifySpaces END ===\n');
27477
28805
  this.dirty = true;
27478
28806
  // Mark that justify has been applied - for debugging to detect if measureLine overwrites it
27479
28807
  this._justifyApplied = true;
27480
- // Don't call requestRenderAll here - it will be called by the caller
27481
- // and calling it here might trigger another initDimensions that clears justify
28808
+
28809
+ // Debug log final kashida state
28810
+ // console.log('=== _applyBrowserJustifySpaces END ===');
28811
+ // console.log('Final __kashidaInfo:', JSON.stringify(this.__kashidaInfo.map((lineInfo, i) => ({
28812
+ // line: i,
28813
+ // entries: lineInfo.map(k => ({ charIndex: k.charIndex, tatweelCount: k.tatweelCount }))
28814
+ // }))));
27482
28815
  }
27483
28816
 
27484
28817
  /**