@@ -389,7 +389,8 @@ export class Text extends Shape<TextConfig> {
389389 this . _partialTextX = lineTranslateX ;
390390 this . _partialTextY = translateY + lineTranslateY ;
391391 this . _partialText = letter ;
392- const letterWidth = this . measureSize ( letter ) . width ;
392+ // the font of the shape is already set on the context
393+ const letterWidth = context . measureText ( letter ) . width ;
393394
394395 if ( charRenderFunc ) {
395396 context . save ( ) ;
@@ -619,108 +620,95 @@ export class Text extends Shape<TextConfig> {
619620 * if width is fixed and line does not fit entirely
620621 * break the line into multiple fitting lines
621622 */
622- while ( line . length > 0 ) {
623- // Compute the grapheme array once per iteration. `line` is constant
624- // within this block (only reassigned at the bottom), so calling
625- // `stringToArray(line)` inside the binary search and again afterwards
626- // is redundant and makes resize O(N·logN) on long text.
627- const lineArray = stringToArray ( line ) ;
623+ const graphemes = stringToArray ( line ) ;
624+ const length = graphemes . length ;
625+ let start = 0 ;
626+ const text = ( end : number ) => graphemes . slice ( start , end ) . join ( '' ) ;
627+ const isBreak = ( char : string ) => char === SPACE || char === DASH ;
628+ // graphemes per line, estimated from the average grapheme width
629+ const perLine = Math . max ( 1 , Math . ceil ( ( length * maxWidth ) / lineWidth ) ) ;
630+ while ( start < length ) {
631+ // only reserve the ellipsis width on a line that may be the last
632+ // visible one
633+ const extraWidth =
634+ shouldAddEllipsis &&
635+ fixedHeight &&
636+ currentHeightPx + lineHeightPx > maxHeightPx
637+ ? additionalWidth
638+ : 0 ;
639+ // width of the longest fitting prefix found so far
640+ let matchWidth = 0 ;
641+ const fits = ( end : number ) => {
642+ const width = this . _getTextWidth ( text ( end ) , end - start ) ;
643+ if ( width + extraWidth > maxWidth ) {
644+ return false ;
645+ }
646+ matchWidth = width ;
647+ return true ;
648+ } ;
628649 /*
629- * use binary search to find the longest substring that
630- * that would fit in the specified width
650+ * find the longest prefix that fits in the specified width:
651+ * grow a window from the estimate until it stops fitting, then
652+ * binary search inside it. Every probe measures about one line,
653+ * not the whole remaining text, so wrapping stays linear
631654 */
632- let low = 0 ,
633- high = lineArray . length , // Convert to array for proper emoji handling
634- match = '' ,
635- matchWidth = 0 ;
636- while ( low < high ) {
637- const mid = ( low + high ) >>> 1 ,
638- // Convert array indices to string
639- substr = lineArray . slice ( 0 , mid + 1 ) . join ( '' ) ,
640- substrWidth = this . _getTextWidth ( substr ) ;
641-
642- // Only add ellipsis width when we need to consider truncation
643- // for the current line (when it might be the last visible line)
644- const shouldConsiderEllipsis =
645- shouldAddEllipsis &&
646- fixedHeight &&
647- currentHeightPx + lineHeightPx > maxHeightPx ;
648-
649- const effectiveWidth = shouldConsiderEllipsis
650- ? substrWidth + additionalWidth
651- : substrWidth ;
652-
653- if ( effectiveWidth <= maxWidth ) {
654- low = mid + 1 ;
655- match = substr ;
656- matchWidth = substrWidth ; // Store actual text width without ellipsis
655+ let low = start ,
656+ high = Math . min ( length , start + perLine ) ;
657+ while ( fits ( high ) ) {
658+ low = high ;
659+ if ( high === length ) {
660+ break ;
661+ }
662+ high = Math . min ( length , high + ( high - start ) ) ;
663+ }
664+ while ( high - low > 1 ) {
665+ const mid = ( low + high ) >>> 1 ;
666+ if ( fits ( mid ) ) {
667+ low = mid ;
657668 } else {
658669 high = mid ;
659670 }
660671 }
661- /*
662- * 'low' is now the index of the substring end
663- * 'match' is the substring
664- * 'matchWidth' is the substring width in px
665- */
666- if ( match ) {
667- // a fitting substring was found
668- if ( wrapAtWord ) {
669- // try to find a space or dash where wrapping could be done
670- const matchArray = stringToArray ( match ) ;
671- const nextChar = lineArray [ matchArray . length ] ;
672- const nextIsSpaceOrDash = nextChar === SPACE || nextChar === DASH ;
673-
674- let wrapIndex ;
675- if ( nextIsSpaceOrDash && matchWidth <= maxWidth ) {
676- wrapIndex = matchArray . length ;
677- } else {
678- // Find last space or dash in the array
679- const lastSpaceIndex = matchArray . lastIndexOf ( SPACE ) ;
680- const lastDashIndex = matchArray . lastIndexOf ( DASH ) ;
681- wrapIndex = Math . max ( lastSpaceIndex , lastDashIndex ) + 1 ;
682- }
683-
684- if ( wrapIndex > 0 ) {
685- low = wrapIndex ;
686- match = lineArray . slice ( 0 , low ) . join ( '' ) ;
687- matchWidth = this . _getTextWidth ( match ) ;
688- }
689- }
690- // if (align === 'right') {
691- match = match . trimRight ( ) ;
692- // }
693- this . _addTextLine ( match ) ;
694- textWidth = Math . max ( textWidth , matchWidth ) ;
672+ if ( low === start ) {
673+ // not even one character could fit in the element, abort
674+ break ;
675+ }
676+ if ( low === length ) {
677+ // the rest of the paragraph fits on one line: kept untrimmed and
678+ // without the ellipsis check, like a paragraph that never wrapped
679+ this . _addTextLine ( text ( length ) ) ;
695680 currentHeightPx += lineHeightPx ;
696-
697- const shouldHandleEllipsis =
698- this . _shouldHandleEllipsis ( currentHeightPx ) ;
699- if ( shouldHandleEllipsis ) {
700- this . _tryToAddEllipsisToLastLine ( ) ;
701- /*
702- * stop wrapping if wrapping is disabled or if adding
703- * one more line would overflow the fixed height
704- */
705- break ;
681+ textWidth = Math . max ( textWidth , matchWidth ) ;
682+ break ;
683+ }
684+ if ( wrapAtWord && ! isBreak ( graphemes [ low ] ) ) {
685+ // wrap at the last space or dash of the line instead
686+ let wrapIndex = low - 1 ;
687+ while ( wrapIndex >= start && ! isBreak ( graphemes [ wrapIndex ] ) ) {
688+ wrapIndex -- ;
706689 }
707-
708- // Reuse the cached `lineArray` to compute the remaining text.
709- line = lineArray . slice ( low ) . join ( '' ) . trimLeft ( ) ;
710-
711- if ( line . length > 0 ) {
712- lineWidth = this . _getTextWidth ( line ) ;
713- if ( lineWidth <= maxWidth ) {
714- this . _addTextLine ( line ) ;
715- currentHeightPx += lineHeightPx ;
716- textWidth = Math . max ( textWidth , lineWidth ) ;
717- break ;
718- }
690+ if ( wrapIndex >= start ) {
691+ low = wrapIndex + 1 ;
692+ matchWidth = this . _getTextWidth ( text ( low ) , low - start ) ;
719693 }
720- } else {
721- // not even one character could fit in the element, abort
694+ }
695+ this . _addTextLine ( text ( low ) . trimRight ( ) ) ;
696+ textWidth = Math . max ( textWidth , matchWidth ) ;
697+ currentHeightPx += lineHeightPx ;
698+
699+ if ( this . _shouldHandleEllipsis ( currentHeightPx ) ) {
700+ this . _tryToAddEllipsisToLastLine ( ) ;
701+ /*
702+ * stop wrapping if wrapping is disabled or if adding
703+ * one more line would overflow the fixed height
704+ */
722705 break ;
723706 }
707+ // the next line starts after the whitespace of the break
708+ start = low ;
709+ while ( start < length && ! graphemes [ start ] . trim ( ) ) {
710+ start ++ ;
711+ }
724712 }
725713 } else {
726714 // element width is automatically adjusted to max line width
0 commit comments