1+ import { EditorSelection } from "@codemirror/state" ;
12import selectionMenu from "lib/selectionMenu" ;
23
34const TAP_MAX_DELAY = 500 ;
@@ -126,9 +127,12 @@ class TouchSelectionMenuController {
126127 #view;
127128 #container;
128129 #getActiveFile;
130+ #isShiftSelectionActive;
129131 #stateSyncRaf = 0 ;
130132 #isScrolling = false ;
131133 #isPointerInteracting = false ;
134+ #shiftSelectionSession = null ;
135+ #pendingShiftSelectionClick = null ;
132136 #menuActive = false ;
133137 #menuRequested = false ;
134138 #enabled = true ;
@@ -140,6 +144,8 @@ class TouchSelectionMenuController {
140144 this . #container =
141145 options . container || view . dom . closest ( ".editor-container" ) || view . dom ;
142146 this . #getActiveFile = options . getActiveFile || ( ( ) => null ) ;
147+ this . #isShiftSelectionActive =
148+ options . isShiftSelectionActive || ( ( ) => false ) ;
143149 this . $menu = document . createElement ( "menu" ) ;
144150 this . $menu . className = "cursor-menu" ;
145151 this . #bindEvents( ) ;
@@ -170,12 +176,16 @@ class TouchSelectionMenuController {
170176 this . #clearMenuShowTimer( ) ;
171177 cancelAnimationFrame ( this . #stateSyncRaf) ;
172178 this . #stateSyncRaf = 0 ;
179+ this . #shiftSelectionSession = null ;
180+ this . #pendingShiftSelectionClick = null ;
173181 this . #hideMenu( true ) ;
174182 }
175183
176184 setEnabled ( enabled ) {
177185 this . #enabled = ! ! enabled ;
178186 if ( this . #enabled) return ;
187+ this . #shiftSelectionSession = null ;
188+ this . #pendingShiftSelectionClick = null ;
179189 this . #menuRequested = false ;
180190 this . #isPointerInteracting = false ;
181191 this . #isScrolling = false ;
@@ -246,6 +256,8 @@ class TouchSelectionMenuController {
246256
247257 onSessionChanged ( ) {
248258 if ( ! this . #enabled) return ;
259+ this . #shiftSelectionSession = null ;
260+ this . #pendingShiftSelectionClick = null ;
249261 this . #menuRequested = false ;
250262 this . #isPointerInteracting = false ;
251263 this . #isScrolling = false ;
@@ -265,19 +277,29 @@ class TouchSelectionMenuController {
265277 #onGlobalPointerDown = ( event ) => {
266278 const target = event . target ;
267279 if ( this . $menu . contains ( target ) ) return ;
268- if ( this . #isIgnoredPointerTarget( target ) ) return ;
280+ if ( this . #isIgnoredPointerTarget( target ) ) {
281+ this . #shiftSelectionSession = null ;
282+ return ;
283+ }
269284 if ( target instanceof Node && this . #view. dom . contains ( target ) ) {
285+ this . #captureShiftSelection( event ) ;
270286 this . #isPointerInteracting = true ;
271287 this . #clearMenuShowTimer( ) ;
272288 this . #hideMenu( ) ;
273289 return ;
274290 }
291+ this . #shiftSelectionSession = null ;
275292 this . #isPointerInteracting = false ;
276293 this . #menuRequested = false ;
277294 this . #hideMenu( ) ;
278295 } ;
279296
280- #onGlobalPointerUp = ( ) => {
297+ #onGlobalPointerUp = ( event ) => {
298+ if ( event . type === "pointerup" ) {
299+ this . #commitShiftSelection( event ) ;
300+ } else {
301+ this . #shiftSelectionSession = null ;
302+ }
281303 if ( ! this . #isPointerInteracting) return ;
282304 this . #isPointerInteracting = false ;
283305 if ( ! this . #enabled) return ;
@@ -291,6 +313,79 @@ class TouchSelectionMenuController {
291313 this . #hideMenu( ) ;
292314 } ;
293315
316+ #captureShiftSelection( event ) {
317+ if ( ! this . #canExtendSelection( event ) ) {
318+ this . #shiftSelectionSession = null ;
319+ return ;
320+ }
321+
322+ this . #shiftSelectionSession = {
323+ pointerId : event . pointerId ,
324+ anchor : this . #view. state . selection . main . anchor ,
325+ x : event . clientX ,
326+ y : event . clientY ,
327+ } ;
328+ }
329+
330+ #commitShiftSelection( event ) {
331+ const session = this . #shiftSelectionSession;
332+ this . #shiftSelectionSession = null ;
333+ if ( ! session ) return ;
334+ if ( ! this . #canExtendSelection( event ) ) return ;
335+ if ( event . pointerId !== session . pointerId ) return ;
336+ if (
337+ Math . hypot ( event . clientX - session . x , event . clientY - session . y ) >
338+ TAP_MAX_DISTANCE
339+ ) {
340+ return ;
341+ }
342+ const target = event . target ;
343+ if ( ! ( target instanceof Node ) || ! this . #view. dom . contains ( target ) ) return ;
344+ if ( this . #isIgnoredPointerTarget( target ) ) return ;
345+
346+ // Rely on pointer coordinates instead of click events so touch selection
347+ // keeps working when the browser/native path owns the actual tap.
348+ const head = this . #view. posAtCoords (
349+ { x : event . clientX , y : event . clientY } ,
350+ false ,
351+ ) ;
352+ this . #view. dispatch ( {
353+ selection : EditorSelection . range ( session . anchor , head ) ,
354+ userEvent : "select.extend" ,
355+ } ) ;
356+ this . #pendingShiftSelectionClick = {
357+ x : event . clientX ,
358+ y : event . clientY ,
359+ timeStamp : event . timeStamp ,
360+ } ;
361+ event . preventDefault ( ) ;
362+ }
363+
364+ #canExtendSelection( event ) {
365+ if ( ! this . #enabled) return false ;
366+ if ( ! ( event . isTrusted && event . isPrimary ) ) return false ;
367+ if ( typeof event . button === "number" && event . button !== 0 ) return false ;
368+ return ! ! this . #isShiftSelectionActive( event ) ;
369+ }
370+
371+ consumePendingShiftSelectionClick ( event ) {
372+ const pending = this . #pendingShiftSelectionClick;
373+ this . #pendingShiftSelectionClick = null ;
374+ if ( ! pending || ! this . #enabled) return false ;
375+ if ( event . timeStamp - pending . timeStamp > TAP_MAX_DELAY ) return false ;
376+ if (
377+ Math . hypot ( event . clientX - pending . x , event . clientY - pending . y ) >
378+ TAP_MAX_DISTANCE
379+ ) {
380+ return false ;
381+ }
382+ const target = event . target ;
383+ if ( ! ( target instanceof Node ) || ! this . #view. dom . contains ( target ) )
384+ return false ;
385+ if ( this . #isIgnoredPointerTarget( target ) ) return false ;
386+ return true ;
387+ }
388+
294389 #shouldShowMenu( ) {
295390 if ( this . #isScrolling || this . #isPointerInteracting || ! this . #view. hasFocus )
296391 return false ;
0 commit comments