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