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