@@ -26,11 +26,19 @@ const formElementNames = new Set(["BUTTON", "INPUT", "SELECT", "TEXTAREA"]);
2626export class SelectionView {
2727 private rectElement ?: HTMLDivElement ;
2828 private captureElement ?: HTMLElement ;
29+ private scrollHost ?: HTMLElement ;
2930
3031 private dragPending = false ;
3132 private isDragging = false ;
3233 private startX = 0 ;
3334 private startY = 0 ;
35+ private startScrollLeft = 0 ;
36+ private startScrollTop = 0 ;
37+ private lastPointerX = 0 ;
38+ private lastPointerY = 0 ;
39+ private autoScrollTimer ?: ReturnType < typeof setInterval > ;
40+ private autoScrollDX = 0 ;
41+ private autoScrollDY = 0 ;
3442
3543 /**
3644 * Ratio of viewport pixels to CSS pixels inside #trackViewerContainer.
@@ -54,6 +62,16 @@ export class SelectionView {
5462 requisitions . unregister ( "selectionChanged" , this . handleSelectionChanged ) ;
5563 }
5664
65+ /**
66+ * Sets the scroll host element whose scroll position is tracked during drag selection
67+ * so the selection rectangle stays anchored to the content. Also enables edge auto-scroll.
68+ *
69+ * @param host The scrollable container element (typically `#trackViewerHost`).
70+ */
71+ public setScrollHost ( host : HTMLElement ) : void {
72+ this . scrollHost = host ;
73+ }
74+
5775 private handlePointerDown = ( event : PointerEvent ) : void => {
5876 if ( event . defaultPrevented || this . isFormElement ( event . target ) ) {
5977 return ;
@@ -69,6 +87,15 @@ export class SelectionView {
6987 this . dragPending = true ;
7088 this . startX = event . clientX ;
7189 this . startY = event . clientY ;
90+ this . lastPointerX = event . clientX ;
91+ this . lastPointerY = event . clientY ;
92+
93+ if ( this . scrollHost ) {
94+ this . startScrollLeft = this . scrollHost . scrollLeft ;
95+ this . startScrollTop = this . scrollHost . scrollTop ;
96+ this . scrollHost . addEventListener ( "scroll" , this . handleScroll , { passive : true } ) ;
97+ }
98+
7299 this . createRectElement ( event . clientX , event . clientY ) ;
73100
74101 event . preventDefault ( ) ;
@@ -79,7 +106,11 @@ export class SelectionView {
79106 return ;
80107 }
81108
109+ this . lastPointerX = event . clientX ;
110+ this . lastPointerY = event . clientY ;
111+
82112 this . updateRectElement ( event . clientX , event . clientY ) ;
113+ this . updateAutoScroll ( event . clientX , event . clientY ) ;
83114
84115 const rect = this . rectElement . getBoundingClientRect ( ) ;
85116 if ( rect . width > 2 || rect . height > 2 ) {
@@ -116,6 +147,31 @@ export class SelectionView {
116147 this . cancelDrag ( ) ;
117148 } ;
118149
150+ /**
151+ * Adjusts the selection start point when the scroll host scrolls during a drag,
152+ * keeping the anchor pinned to the content rather than the viewport.
153+ */
154+ private handleScroll = ( ) : void => {
155+ if ( ! this . scrollHost || ! this . rectElement ) {
156+ return ;
157+ }
158+
159+ const deltaX = this . scrollHost . scrollLeft - this . startScrollLeft ;
160+ const deltaY = this . scrollHost . scrollTop - this . startScrollTop ;
161+
162+ this . startX -= deltaX ;
163+ this . startY -= deltaY ;
164+ this . startScrollLeft = this . scrollHost . scrollLeft ;
165+ this . startScrollTop = this . scrollHost . scrollTop ;
166+
167+ this . updateRectElement ( this . lastPointerX , this . lastPointerY ) ;
168+
169+ if ( this . isDragging ) {
170+ const rect = this . rectElement . getBoundingClientRect ( ) ;
171+ void requisitions . execute ( "selectionRectChanged" , { rect } ) ;
172+ }
173+ } ;
174+
119175 private selectionModeFromEvent ( event : KeyboardEvent | MouseEvent ) : SelectionMode {
120176 if ( event . shiftKey ) {
121177 return SelectionMode . Add ;
@@ -144,8 +200,15 @@ export class SelectionView {
144200 private cancelDrag ( ) : void {
145201 this . isDragging = false ;
146202 this . dragPending = false ;
203+ this . stopAutoScroll ( ) ;
147204 this . removeRectElement ( ) ;
148205
206+ if ( this . scrollHost ) {
207+ this . scrollHost . removeEventListener ( "scroll" , this . handleScroll ) ;
208+ this . startScrollLeft = 0 ;
209+ this . startScrollTop = 0 ;
210+ }
211+
149212 if ( this . captureElement ) {
150213 this . captureElement . removeEventListener ( "pointermove" , this . handlePointerMove ) ;
151214 this . captureElement . removeEventListener ( "pointerup" , this . handlePointerUp ) ;
@@ -154,6 +217,61 @@ export class SelectionView {
154217 }
155218 }
156219
220+ /**
221+ * Starts, updates or stops auto-scroll based on the pointer's distance from the scroll host edges.
222+ * The further the pointer is outside the host, the faster the scroll speed.
223+ *
224+ * @param clientX The current pointer X position in viewport coordinates.
225+ * @param clientY The current pointer Y position in viewport coordinates.
226+ */
227+ private updateAutoScroll ( clientX : number , clientY : number ) : void {
228+ if ( ! this . scrollHost ) {
229+ return ;
230+ }
231+
232+ const hostRect = this . scrollHost . getBoundingClientRect ( ) ;
233+ const edgeThreshold = 10 ;
234+ let scrollDX = 0 ;
235+ let scrollDY = 0 ;
236+
237+ const distLeft = clientX - hostRect . left ;
238+ const distRight = hostRect . right - clientX ;
239+ const distTop = clientY - hostRect . top ;
240+ const distBottom = hostRect . bottom - clientY ;
241+
242+ if ( distLeft < edgeThreshold ) {
243+ scrollDX = - Math . max ( 1 , Math . ceil ( ( edgeThreshold - distLeft ) / 2 ) ) ;
244+ } else if ( distRight < edgeThreshold ) {
245+ scrollDX = Math . max ( 1 , Math . ceil ( ( edgeThreshold - distRight ) / 2 ) ) ;
246+ }
247+
248+ if ( distTop < edgeThreshold ) {
249+ scrollDY = - Math . max ( 1 , Math . ceil ( ( edgeThreshold - distTop ) / 2 ) ) ;
250+ } else if ( distBottom < edgeThreshold ) {
251+ scrollDY = Math . max ( 1 , Math . ceil ( ( edgeThreshold - distBottom ) / 2 ) ) ;
252+ }
253+
254+ this . autoScrollDX = scrollDX ;
255+ this . autoScrollDY = scrollDY ;
256+
257+ if ( scrollDX !== 0 || scrollDY !== 0 ) {
258+ this . autoScrollTimer ??= setInterval ( ( ) => {
259+ this . scrollHost ! . scrollBy ( this . autoScrollDX , this . autoScrollDY ) ;
260+ } , 16 ) ;
261+ } else {
262+ this . stopAutoScroll ( ) ;
263+ }
264+ }
265+
266+ private stopAutoScroll ( ) : void {
267+ if ( this . autoScrollTimer ) {
268+ clearInterval ( this . autoScrollTimer ) ;
269+ this . autoScrollTimer = undefined ;
270+ this . autoScrollDX = 0 ;
271+ this . autoScrollDY = 0 ;
272+ }
273+ }
274+
157275 private createRectElement ( x : number , y : number ) : void {
158276 this . removeRectElement ( ) ;
159277
@@ -906,9 +1024,9 @@ export class SelectionView {
9061024 const overlay = document . createElement ( "div" ) ;
9071025 overlay . className = selectionOverlayClass ;
9081026 overlay . style . position = "absolute" ;
909- overlay . style . left = `${ rect . x } px` ;
1027+ overlay . style . left = `${ rect . x - 2 } px` ;
9101028 overlay . style . top = `${ rect . y } px` ;
911- overlay . style . width = `${ rect . width } px` ;
1029+ overlay . style . width = `${ rect . width + 4 } px` ;
9121030 overlay . style . height = `${ rect . height } px` ;
9131031 container . appendChild ( overlay ) ;
9141032 }
0 commit comments