@@ -130,3 +130,250 @@ describe("URL parameter handling", () => {
130130 expect ( noOption ) . toHaveStyle ( { fontWeight : "bold" } ) ;
131131 } ) ;
132132} ) ;
133+
134+ describe ( "Smart cursor positioning" , ( ) => {
135+ test ( "selects placeholder in single-letter braces like {x}" , async ( ) => {
136+ render ( < App /> ) ;
137+ const textarea = screen . getByRole ( "textbox" ) as HTMLTextAreaElement ;
138+
139+ // Clear the textarea first
140+ fireEvent . change ( textarea , { target : { value : "" } } ) ;
141+
142+ // Click the \hat{x} button
143+ const hatButton = screen . getByTitle ( "\\hat{x}" ) ;
144+ fireEvent . click ( hatButton ) ;
145+
146+ await waitFor ( ( ) => {
147+ expect ( textarea . value ) . toBe ( "\\hat{x}" ) ;
148+ // Should select the 'x' (position 5, length 1)
149+ expect ( textarea . selectionStart ) . toBe ( 5 ) ;
150+ expect ( textarea . selectionEnd ) . toBe ( 6 ) ;
151+ } ) ;
152+ } ) ;
153+
154+ test ( "selects placeholder in {a} from \\frac{a}{b}" , async ( ) => {
155+ render ( < App /> ) ;
156+ const textarea = screen . getByRole ( "textbox" ) as HTMLTextAreaElement ;
157+
158+ fireEvent . change ( textarea , { target : { value : "" } } ) ;
159+
160+ // Click the fraction button
161+ const fracButton = screen . getByTitle ( "\\frac{a}{b}" ) ;
162+ fireEvent . click ( fracButton ) ;
163+
164+ await waitFor ( ( ) => {
165+ expect ( textarea . value ) . toBe ( "\\frac{a}{b}" ) ;
166+ // Should select the 'a' (first placeholder)
167+ expect ( textarea . selectionStart ) . toBe ( 6 ) ;
168+ expect ( textarea . selectionEnd ) . toBe ( 7 ) ;
169+ } ) ;
170+ } ) ;
171+
172+ test ( "positions cursor inside empty braces" , async ( ) => {
173+ render ( < App /> ) ;
174+ const textarea = screen . getByRole ( "textbox" ) as HTMLTextAreaElement ;
175+
176+ fireEvent . change ( textarea , { target : { value : "" } } ) ;
177+
178+ // Manually trigger insertSource with text containing empty braces
179+ // We can simulate this by finding a component that would insert such text
180+ // For this test, let's directly test by typing and then clicking a symbol
181+
182+ // First, let's clear and type something with empty braces pattern
183+ fireEvent . change ( textarea , { target : { value : "test" } } ) ;
184+
185+ // Set cursor position
186+ textarea . setSelectionRange ( 4 , 4 ) ;
187+
188+ // Now we need to test the insertion. Let's click the sqrt button which has {x}
189+ const sqrtButton = screen . getByTitle ( "\\sqrt{x}" ) ;
190+ fireEvent . click ( sqrtButton ) ;
191+
192+ await waitFor ( ( ) => {
193+ expect ( textarea . value ) . toBe ( "test\\sqrt{x}" ) ;
194+ // Should select the 'x' placeholder
195+ expect ( textarea . selectionStart ) . toBe ( 10 ) ;
196+ expect ( textarea . selectionEnd ) . toBe ( 11 ) ;
197+ } ) ;
198+ } ) ;
199+
200+ test ( "positions cursor at ampersand in matrix templates" , async ( ) => {
201+ render ( < App /> ) ;
202+ const textarea = screen . getByRole ( "textbox" ) as HTMLTextAreaElement ;
203+
204+ fireEvent . change ( textarea , { target : { value : "" } } ) ;
205+
206+ // Click the matrix button which contains &
207+ const matrixButton = screen . getByTitle ( / \\ b e g i n \{ m a t r i x \} / ) ;
208+ fireEvent . click ( matrixButton ) ;
209+
210+ await waitFor ( ( ) => {
211+ const value = textarea . value ;
212+ expect ( value ) . toContain ( "\\begin{matrix}" ) ;
213+ expect ( value ) . toContain ( "&" ) ;
214+
215+ // Should position cursor at first &
216+ const ampersandIndex = value . indexOf ( "&" ) ;
217+ expect ( textarea . selectionStart ) . toBe ( ampersandIndex ) ;
218+ expect ( textarea . selectionEnd ) . toBe ( ampersandIndex ) ;
219+ } ) ;
220+ } ) ;
221+
222+ test ( "inserts at cursor position, not at end" , async ( ) => {
223+ render ( < App /> ) ;
224+ const textarea = screen . getByRole ( "textbox" ) as HTMLTextAreaElement ;
225+
226+ // Set initial value with clear delimiter
227+ fireEvent . change ( textarea , { target : { value : "START END" } } ) ;
228+
229+ // Wait for state to settle
230+ await waitFor ( ( ) => {
231+ expect ( textarea . value ) . toBe ( "START END" ) ;
232+ } ) ;
233+
234+ // Position cursor between START and END (at position 6, after "START ")
235+ textarea . focus ( ) ;
236+ textarea . setSelectionRange ( 6 , 6 ) ;
237+
238+ // Click alpha button
239+ const alphaButton = screen . getByTitle ( "\\alpha" ) ;
240+ fireEvent . click ( alphaButton ) ;
241+
242+ await waitFor ( ( ) => {
243+ expect ( textarea . value ) . toBe ( "START \\alphaEND" ) ;
244+ // Cursor should be at end of inserted text (6 + 6 = 12)
245+ expect ( textarea . selectionStart ) . toBe ( 12 ) ;
246+ } ) ;
247+ } ) ;
248+
249+ test ( "handles insertion when text is selected" , async ( ) => {
250+ render ( < App /> ) ;
251+ const textarea = screen . getByRole ( "textbox" ) as HTMLTextAreaElement ;
252+
253+ // Set initial value
254+ fireEvent . change ( textarea , { target : { value : "abc xyz" } } ) ;
255+
256+ // Select "xyz"
257+ textarea . focus ( ) ;
258+ textarea . setSelectionRange ( 4 , 7 ) ;
259+
260+ // Click beta button to replace selection
261+ const betaButton = screen . getByTitle ( "\\beta" ) ;
262+ fireEvent . click ( betaButton ) ;
263+
264+ await waitFor ( ( ) => {
265+ expect ( textarea . value ) . toBe ( "abc \\beta" ) ;
266+ // Cursor should be at end of inserted text
267+ expect ( textarea . selectionStart ) . toBe ( 9 ) ;
268+ } ) ;
269+ } ) ;
270+
271+ test ( "focuses textarea after insertion" , async ( ) => {
272+ render ( < App /> ) ;
273+ const textarea = screen . getByRole ( "textbox" ) as HTMLTextAreaElement ;
274+
275+ fireEvent . change ( textarea , { target : { value : "" } } ) ;
276+
277+ // Click gamma button
278+ const gammaButton = screen . getByTitle ( "\\gamma" ) ;
279+
280+ // Blur the textarea first
281+ textarea . blur ( ) ;
282+ expect ( document . activeElement ) . not . toBe ( textarea ) ;
283+
284+ fireEvent . click ( gammaButton ) ;
285+
286+ await waitFor ( ( ) => {
287+ expect ( textarea . value ) . toBe ( "\\gamma" ) ;
288+ // Textarea should be focused after insertion
289+ expect ( document . activeElement ) . toBe ( textarea ) ;
290+ } ) ;
291+ } ) ;
292+
293+ test ( "handles multiple consecutive insertions" , async ( ) => {
294+ render ( < App /> ) ;
295+ const textarea = screen . getByRole ( "textbox" ) as HTMLTextAreaElement ;
296+
297+ fireEvent . change ( textarea , { target : { value : "" } } ) ;
298+
299+ await waitFor ( ( ) => {
300+ expect ( textarea . value ) . toBe ( "" ) ;
301+ } ) ;
302+
303+ // Insert alpha
304+ const alphaButton = screen . getByTitle ( "\\alpha" ) ;
305+ fireEvent . click ( alphaButton ) ;
306+
307+ await waitFor ( ( ) => {
308+ expect ( textarea . value ) . toBe ( "\\alpha" ) ;
309+ } ) ;
310+
311+ // Insert beta
312+ const betaButton = screen . getByTitle ( "\\beta" ) ;
313+ fireEvent . click ( betaButton ) ;
314+
315+ await waitFor ( ( ) => {
316+ expect ( textarea . value ) . toBe ( "\\alpha\\beta" ) ;
317+ } ) ;
318+
319+ // Insert gamma
320+ const gammaButton = screen . getByTitle ( "\\gamma" ) ;
321+ fireEvent . click ( gammaButton ) ;
322+
323+ await waitFor ( ( ) => {
324+ const expectedValue = "\\alpha\\beta\\gamma" ;
325+ expect ( textarea . value ) . toBe ( expectedValue ) ;
326+ // Cursor should be at the end (length = 17)
327+ expect ( textarea . selectionStart ) . toBe ( expectedValue . length ) ;
328+ } ) ;
329+ } ) ;
330+
331+ test ( "placeholder selection allows immediate typing to replace" , async ( ) => {
332+ render ( < App /> ) ;
333+ const textarea = screen . getByRole ( "textbox" ) as HTMLTextAreaElement ;
334+
335+ fireEvent . change ( textarea , { target : { value : "" } } ) ;
336+
337+ // Click \vec{x} which should select 'x'
338+ const vecButton = screen . getByTitle ( "\\vec{x}" ) ;
339+ fireEvent . click ( vecButton ) ;
340+
341+ await waitFor ( ( ) => {
342+ expect ( textarea . value ) . toBe ( "\\vec{x}" ) ;
343+ expect ( textarea . selectionStart ) . toBe ( 5 ) ;
344+ expect ( textarea . selectionEnd ) . toBe ( 6 ) ;
345+ } ) ;
346+
347+ // Simulate typing to replace the selected 'x' with 'y'
348+ // When a user types with text selected, it replaces the selection
349+ const currentValue = textarea . value ;
350+ const newValue =
351+ currentValue . substring ( 0 , textarea . selectionStart ) +
352+ "y" +
353+ currentValue . substring ( textarea . selectionEnd ) ;
354+
355+ fireEvent . change ( textarea , { target : { value : newValue } } ) ;
356+
357+ await waitFor ( ( ) => {
358+ expect ( textarea . value ) . toBe ( "\\vec{y}" ) ;
359+ } ) ;
360+ } ) ;
361+
362+ test ( "handles LaTeX with no special cursor positioning" , async ( ) => {
363+ render ( < App /> ) ;
364+ const textarea = screen . getByRole ( "textbox" ) as HTMLTextAreaElement ;
365+
366+ fireEvent . change ( textarea , { target : { value : "" } } ) ;
367+
368+ // Click infty which has no placeholders or special positioning
369+ const inftyButton = screen . getByTitle ( "\\infty" ) ;
370+ fireEvent . click ( inftyButton ) ;
371+
372+ await waitFor ( ( ) => {
373+ expect ( textarea . value ) . toBe ( "\\infty" ) ;
374+ // Cursor should be at the end
375+ expect ( textarea . selectionStart ) . toBe ( 6 ) ;
376+ expect ( textarea . selectionEnd ) . toBe ( 6 ) ;
377+ } ) ;
378+ } ) ;
379+ } ) ;
0 commit comments