1+ // Licensed to the .NET Foundation under one or more agreements.
2+ // The .NET Foundation licenses this file to you under the MIT license.
3+ // See the LICENSE file in the project root for more information.
4+
5+ using System ;
6+ using System . Linq ;
7+ using Windows . UI . Input ;
8+ using Windows . UI . Text ;
9+ using Windows . UI . Xaml . Controls ;
10+
11+ namespace Microsoft . Toolkit . Uwp . UI . Controls
12+ {
13+ /// <summary>
14+ /// The RichSuggestBox control extends <see cref="RichEditBox"/> control that suggests and embeds custom data in a rich document.
15+ /// </summary>
16+ public partial class RichSuggestBox
17+ {
18+ private void ExpandSelectionOnPartialTokenSelect ( ITextSelection selection , ITextRange tokenRange )
19+ {
20+ switch ( selection . Type )
21+ {
22+ case SelectionType . InsertionPoint :
23+ // Snap selection to token on click
24+ if ( tokenRange . StartPosition < selection . StartPosition && selection . EndPosition < tokenRange . EndPosition )
25+ {
26+ selection . Expand ( TextRangeUnit . Link ) ;
27+ InvokeTokenSelected ( selection ) ;
28+ }
29+
30+ break ;
31+
32+ case SelectionType . Normal :
33+ // We do not want user to partially select a token since pasting to a partial token can break
34+ // the token tracking system, which can result in unwanted character formatting issues.
35+ if ( ( tokenRange . StartPosition <= selection . StartPosition && selection . EndPosition < tokenRange . EndPosition ) ||
36+ ( tokenRange . StartPosition < selection . StartPosition && selection . EndPosition <= tokenRange . EndPosition ) )
37+ {
38+ // TODO: Figure out how to expand selection without breaking selection flow (with Shift select or pointer sweep select)
39+ selection . Expand ( TextRangeUnit . Link ) ;
40+ InvokeTokenSelected ( selection ) ;
41+ }
42+
43+ break ;
44+ }
45+ }
46+
47+ private void InvokeTokenSelected ( ITextSelection selection )
48+ {
49+ if ( TokenSelected == null || ! TryGetTokenFromRange ( selection , out var token ) || token . RangeEnd != selection . EndPosition )
50+ {
51+ return ;
52+ }
53+
54+ TokenSelected . Invoke ( this , new RichSuggestTokenSelectedEventArgs
55+ {
56+ Token = token ,
57+ Range = selection . GetClone ( )
58+ } ) ;
59+ }
60+
61+ private void InvokeTokenPointerOver ( PointerPoint pointer )
62+ {
63+ var pointerPosition = TransformToVisual ( _richEditBox ) . TransformPoint ( pointer . Position ) ;
64+ var padding = _richEditBox . Padding ;
65+ pointerPosition . X += HorizontalOffset - padding . Left ;
66+ pointerPosition . Y += VerticalOffset - padding . Top ;
67+ var range = TextDocument . GetRangeFromPoint ( pointerPosition , PointOptions . ClientCoordinates ) ;
68+ var linkRange = range . GetClone ( ) ;
69+ range . Expand ( TextRangeUnit . Character ) ;
70+ range . GetRect ( PointOptions . None , out var hitTestRect , out _ ) ;
71+ hitTestRect . X -= hitTestRect . Width ;
72+ hitTestRect . Width *= 2 ;
73+ if ( hitTestRect . Contains ( pointerPosition ) && linkRange . Expand ( TextRangeUnit . Link ) > 0 &&
74+ TryGetTokenFromRange ( linkRange , out var token ) )
75+ {
76+ this . TokenPointerOver . Invoke ( this , new RichSuggestTokenPointerOverEventArgs
77+ {
78+ Token = token ,
79+ Range = linkRange ,
80+ CurrentPoint = pointer
81+ } ) ;
82+ }
83+ }
84+
85+ private bool TryCommitSuggestionIntoDocument ( ITextRange range , string displayText , Guid id , ITextCharacterFormat format , bool addTrailingSpace = true )
86+ {
87+ // We don't want to set text when the display text doesn't change since it may lead to unexpected caret move.
88+ range . GetText ( TextGetOptions . NoHidden , out var existingText ) ;
89+ if ( existingText != displayText )
90+ {
91+ range . SetText ( TextSetOptions . Unhide , displayText ) ;
92+ }
93+
94+ var formatBefore = range . CharacterFormat . GetClone ( ) ;
95+ range . CharacterFormat . SetClone ( format ) ;
96+ PadRange ( range , formatBefore ) ;
97+ range . Link = $ "\" { id } \" ";
98+
99+ // In some rare case, setting Link can fail. Only observed when interacting with Undo/Redo feature.
100+ if ( range . Link != $ "\" { id } \" ")
101+ {
102+ range . Delete ( TextRangeUnit . Story , - 1 ) ;
103+ return false ;
104+ }
105+
106+ if ( addTrailingSpace )
107+ {
108+ var clone = range . GetClone ( ) ;
109+ clone . Collapse ( false ) ;
110+ clone . SetText ( TextSetOptions . Unhide , " " ) ;
111+ clone . Collapse ( false ) ;
112+ TextDocument . Selection . SetRange ( clone . EndPosition , clone . EndPosition ) ;
113+ }
114+
115+ return true ;
116+ }
117+
118+ private void ValidateTokensInDocument ( )
119+ {
120+ lock ( _tokensLock )
121+ {
122+ foreach ( var ( _, token ) in _tokens )
123+ {
124+ token . Active = false ;
125+ }
126+ }
127+
128+ ForEachLinkInDocument ( TextDocument , ValidateTokenFromRange ) ;
129+ }
130+
131+ private void ValidateTokenFromRange ( ITextRange range )
132+ {
133+ if ( range . Length == 0 || ! TryGetTokenFromRange ( range , out var token ) )
134+ {
135+ return ;
136+ }
137+
138+ // Check for duplicate tokens. This can happen if the user copies and pastes the token multiple times.
139+ if ( token . Active && token . RangeStart != range . StartPosition && token . RangeEnd != range . EndPosition )
140+ {
141+ lock ( _tokensLock )
142+ {
143+ var guid = Guid . NewGuid ( ) ;
144+ if ( TryCommitSuggestionIntoDocument ( range , token . DisplayText , guid , CreateTokenFormat ( range ) , false ) )
145+ {
146+ token = new RichSuggestToken ( guid , token . DisplayText ) { Active = true , Item = token . Item } ;
147+ token . UpdateTextRange ( range ) ;
148+ _tokens . Add ( range . Link , token ) ;
149+ }
150+
151+ return ;
152+ }
153+ }
154+
155+ if ( token . ToString ( ) != range . Text )
156+ {
157+ range . Delete ( TextRangeUnit . Story , 0 ) ;
158+ token . Active = false ;
159+ return ;
160+ }
161+
162+ token . UpdateTextRange ( range ) ;
163+ token . Active = true ;
164+ }
165+
166+ private bool TryExtractQueryFromSelection ( out string prefix , out string query , out ITextRange range )
167+ {
168+ prefix = string . Empty ;
169+ query = string . Empty ;
170+ range = null ;
171+ if ( TextDocument . Selection . Type != SelectionType . InsertionPoint )
172+ {
173+ return false ;
174+ }
175+
176+ // Check if selection is on existing link (suggestion)
177+ var expandCount = TextDocument . Selection . GetClone ( ) . Expand ( TextRangeUnit . Link ) ;
178+ if ( expandCount != 0 )
179+ {
180+ return false ;
181+ }
182+
183+ var selection = TextDocument . Selection . GetClone ( ) ;
184+ selection . MoveStart ( TextRangeUnit . Word , - 1 ) ;
185+ if ( selection . Length == 0 )
186+ {
187+ return false ;
188+ }
189+
190+ range = selection ;
191+ if ( TryExtractQueryFromRange ( selection , out prefix , out query ) )
192+ {
193+ return true ;
194+ }
195+
196+ selection . MoveStart ( TextRangeUnit . Word , - 1 ) ;
197+ if ( TryExtractQueryFromRange ( selection , out prefix , out query ) )
198+ {
199+ return true ;
200+ }
201+
202+ range = null ;
203+ return false ;
204+ }
205+
206+ private bool TryExtractQueryFromRange ( ITextRange range , out string prefix , out string query )
207+ {
208+ prefix = string . Empty ;
209+ query = string . Empty ;
210+ range . GetText ( TextGetOptions . NoHidden , out var possibleQuery ) ;
211+ if ( possibleQuery . Length > 0 && Prefixes . Contains ( possibleQuery [ 0 ] ) &&
212+ ! possibleQuery . Any ( char . IsWhiteSpace ) && string . IsNullOrEmpty ( range . Link ) )
213+ {
214+ if ( possibleQuery . Length == 1 )
215+ {
216+ prefix = possibleQuery ;
217+ return true ;
218+ }
219+
220+ prefix = possibleQuery [ 0 ] . ToString ( ) ;
221+ query = possibleQuery . Substring ( 1 ) ;
222+ return true ;
223+ }
224+
225+ return false ;
226+ }
227+
228+ private ITextCharacterFormat CreateTokenFormat ( ITextRange range )
229+ {
230+ var format = range . CharacterFormat . GetClone ( ) ;
231+ if ( this . TokenBackground != null )
232+ {
233+ format . BackgroundColor = this . TokenBackground . Color ;
234+ }
235+
236+ if ( this . TokenForeground != null )
237+ {
238+ format . ForegroundColor = this . TokenForeground . Color ;
239+ }
240+
241+ return format ;
242+ }
243+ }
244+ }
0 commit comments