@@ -332,12 +332,18 @@ export class FormattedString
332
332
}
333
333
334
334
const sep = separator ?? "" ;
335
- return items . reduce < FormattedString > ( ( acc , item , index ) => {
335
+ const result = items . reduce < FormattedString > ( ( acc , item , index ) => {
336
336
if ( index === 0 ) {
337
337
return fmt `${ item } ` ;
338
338
}
339
339
return fmt `${ acc } ${ sep } ${ item } ` ;
340
340
} , new FormattedString ( "" ) ) ;
341
+
342
+ // Consolidate adjacent/overlapping entities of the same type
343
+ return new FormattedString (
344
+ result . rawText ,
345
+ result . consolidateEntities ( result . rawEntities ) ,
346
+ ) ;
341
347
}
342
348
343
349
// Instance formatting methods
@@ -561,6 +567,190 @@ export class FormattedString
561
567
562
568
return new FormattedString ( slicedText , slicedEntities ) ;
563
569
}
570
+
571
+ /**
572
+ * Finds the first occurrence of a FormattedString pattern within this FormattedString
573
+ * that matches both the raw text and raw entities exactly.
574
+ * @param pattern The FormattedString pattern to search for
575
+ * @returns The offset where the pattern is found, or -1 if not found
576
+ */
577
+ find ( pattern : FormattedString ) : number {
578
+ // Handle empty pattern - matches at the beginning
579
+ if ( pattern . rawText . length === 0 ) {
580
+ return 0 ;
581
+ }
582
+
583
+ // Pattern cannot be longer than source
584
+ if ( pattern . rawText . length > this . rawText . length ) {
585
+ return - 1 ;
586
+ }
587
+
588
+ // Use indexOf to find text matches efficiently
589
+ let searchStart = 0 ;
590
+ let textIndex = this . rawText . indexOf ( pattern . rawText , searchStart ) ;
591
+
592
+ while ( textIndex !== - 1 ) {
593
+ // Use slice to extract candidate and compare entities
594
+ const candidate = this . slice (
595
+ textIndex ,
596
+ textIndex + pattern . rawText . length ,
597
+ ) ;
598
+
599
+ // Compare entities for exact match
600
+ if ( this . entitiesEqual ( candidate . rawEntities , pattern . rawEntities ) ) {
601
+ return textIndex ;
602
+ }
603
+
604
+ // Continue searching from the next position
605
+ searchStart = textIndex + 1 ;
606
+ textIndex = this . rawText . indexOf ( pattern . rawText , searchStart ) ;
607
+ }
608
+
609
+ return - 1 ;
610
+ }
611
+
612
+ /**
613
+ * Consolidates overlapping or adjacent entities of the same type
614
+ * @param entities Array of entities to consolidate
615
+ * @returns New array with consolidated entities
616
+ */
617
+ protected consolidateEntities ( entities : MessageEntity [ ] ) : MessageEntity [ ] {
618
+ if ( entities . length <= 1 ) {
619
+ return [ ...entities ] ;
620
+ }
621
+
622
+ // Sort entities by offset to process them in order
623
+ const sortedEntities = [ ...entities ] . sort ( ( a , b ) => a . offset - b . offset ) ;
624
+ const consolidated : MessageEntity [ ] = [ ] ;
625
+
626
+ let current = { ...sortedEntities [ 0 ] } ;
627
+
628
+ for ( let i = 1 ; i < sortedEntities . length ; i ++ ) {
629
+ const next = sortedEntities [ i ] ;
630
+
631
+ // Check if entities can be consolidated
632
+ if ( this . canConsolidateEntities ( current , next ) ) {
633
+ // Merge the entities by extending the current entity
634
+ const currentEnd = current . offset + current . length ;
635
+ const nextEnd = next . offset + next . length ;
636
+ current . length = Math . max ( currentEnd , nextEnd ) - current . offset ;
637
+ } else {
638
+ // Cannot consolidate, add current to result and move to next
639
+ consolidated . push ( current ) ;
640
+ current = { ...next } ;
641
+ }
642
+ }
643
+
644
+ // Add the last entity
645
+ consolidated . push ( current ) ;
646
+
647
+ return consolidated ;
648
+ }
649
+
650
+ /**
651
+ * Helper method to check if two entities can be consolidated
652
+ * @param entity1 First entity
653
+ * @param entity2 Second entity (should have offset >= entity1.offset)
654
+ * @returns true if entities can be consolidated, false otherwise
655
+ */
656
+ private canConsolidateEntities (
657
+ entity1 : MessageEntity ,
658
+ entity2 : MessageEntity ,
659
+ ) : boolean {
660
+ // Must have the same type
661
+ if ( entity1 . type !== entity2 . type ) {
662
+ return false ;
663
+ }
664
+
665
+ // Check type-specific properties for compatibility
666
+ if ( entity1 . type === "text_link" && entity2 . type === "text_link" ) {
667
+ if ( entity1 . url !== entity2 . url ) {
668
+ return false ;
669
+ }
670
+ }
671
+
672
+ if ( entity1 . type === "pre" && entity2 . type === "pre" ) {
673
+ if ( entity1 . language !== entity2 . language ) {
674
+ return false ;
675
+ }
676
+ }
677
+
678
+ if ( entity1 . type === "custom_emoji" && entity2 . type === "custom_emoji" ) {
679
+ if ( entity1 . custom_emoji_id !== entity2 . custom_emoji_id ) {
680
+ return false ;
681
+ }
682
+ }
683
+
684
+ if ( entity1 . type === "text_mention" && entity2 . type === "text_mention" ) {
685
+ if ( entity1 . user !== entity2 . user ) {
686
+ return false ;
687
+ }
688
+ }
689
+
690
+ // Check if entities overlap or are adjacent
691
+ const entity1End = entity1 . offset + entity1 . length ;
692
+ const entity2Start = entity2 . offset ;
693
+
694
+ // Adjacent (touching) or overlapping entities can be consolidated
695
+ return entity2Start <= entity1End ;
696
+ }
697
+
698
+ /**
699
+ * Helper method to compare two arrays of message entities for exact equality
700
+ * @param entities1 First array of entities
701
+ * @param entities2 Second array of entities
702
+ * @returns true if the entities are exactly equal, false otherwise
703
+ */
704
+ private entitiesEqual (
705
+ entities1 : MessageEntity [ ] ,
706
+ entities2 : MessageEntity [ ] ,
707
+ ) : boolean {
708
+ if ( entities1 . length !== entities2 . length ) {
709
+ return false ;
710
+ }
711
+
712
+ for ( let i = 0 ; i < entities1 . length ; i ++ ) {
713
+ const entity1 = entities1 [ i ] ;
714
+ const entity2 = entities2 [ i ] ;
715
+
716
+ // Compare all properties of the entities
717
+ if (
718
+ entity1 . type !== entity2 . type ||
719
+ entity1 . offset !== entity2 . offset ||
720
+ entity1 . length !== entity2 . length
721
+ ) {
722
+ return false ;
723
+ }
724
+
725
+ // Compare type-specific properties based on entity type
726
+ if ( entity1 . type === "text_link" && entity2 . type === "text_link" ) {
727
+ if ( entity1 . url !== entity2 . url ) {
728
+ return false ;
729
+ }
730
+ }
731
+
732
+ if ( entity1 . type === "pre" && entity2 . type === "pre" ) {
733
+ if ( entity1 . language !== entity2 . language ) {
734
+ return false ;
735
+ }
736
+ }
737
+
738
+ if ( entity1 . type === "custom_emoji" && entity2 . type === "custom_emoji" ) {
739
+ if ( entity1 . custom_emoji_id !== entity2 . custom_emoji_id ) {
740
+ return false ;
741
+ }
742
+ }
743
+
744
+ if ( entity1 . type === "text_mention" && entity2 . type === "text_mention" ) {
745
+ // Compare user property (basic equality check)
746
+ if ( entity1 . user !== entity2 . user ) {
747
+ return false ;
748
+ }
749
+ }
750
+ }
751
+
752
+ return true ;
753
+ }
564
754
}
565
755
566
756
function buildFormatter < T extends Array < unknown > = never > (
0 commit comments