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