@@ -126,6 +126,12 @@ class PDFThumbnailViewer {
126126
127127 #isCut = false ;
128128
129+ #isOneColumnView = false ;
130+
131+ #scrollableContainerWidth = 0 ;
132+
133+ #scrollableContainerHeight = 0 ;
134+
129135 /**
130136 * @param {PDFThumbnailViewerOptions } options
131137 */
@@ -716,18 +722,36 @@ class PDFThumbnailViewer {
716722 }
717723
718724 #moveDraggedContainer( dx , dy ) {
719- this . #draggedImageOffsetX += dx ;
720- this . #draggedImageOffsetY += dy ;
725+ if ( this . #isOneColumnView) {
726+ dx = 0 ;
727+ }
728+ if (
729+ this . #draggedImageX + dx < 0 ||
730+ this . #draggedImageX + this . #draggedImageWidth + dx >
731+ this . #scrollableContainerWidth
732+ ) {
733+ dx = 0 ;
734+ }
735+ if (
736+ this . #draggedImageY + dy < 0 ||
737+ this . #draggedImageY + this . #draggedImageHeight + dy >
738+ this . #scrollableContainerHeight
739+ ) {
740+ dy = 0 ;
741+ }
742+
721743 this . #draggedImageX += dx ;
722744 this . #draggedImageY += dy ;
745+ this . #draggedImageOffsetX += dx ;
746+ this . #draggedImageOffsetY += dy ;
723747 this . #draggedContainer. style . translate = `${ this . #draggedImageOffsetX} px ${ this . #draggedImageOffsetY} px` ;
724748 if (
725749 this . #draggedImageY + this . #draggedImageHeight >
726750 this . #currentScrollBottom
727751 ) {
728752 this . scrollableContainer . scrollTop = Math . min (
729753 this . scrollableContainer . scrollTop + PIXELS_TO_SCROLL_WHEN_DRAGGING ,
730- this . scrollableContainer . scrollHeight
754+ this . #scrollableContainerHeight
731755 ) ;
732756 } else if ( this . #draggedImageY < this . #currentScrollTop) {
733757 this . scrollableContainer . scrollTop = Math . max (
@@ -839,6 +863,11 @@ class PDFThumbnailViewer {
839863 lastSpace : ( positionsLastX . at ( - 1 ) - lastRightX ) / 2 ,
840864 bbox,
841865 } ;
866+ this . #isOneColumnView = positionsX . length === 1 ;
867+ ( {
868+ clientWidth : this . #scrollableContainerWidth,
869+ scrollHeight : this . #scrollableContainerHeight,
870+ } = this . scrollableContainer ) ;
842871 }
843872
844873 #addEventListeners( ) {
@@ -950,6 +979,7 @@ class PDFThumbnailViewer {
950979 pointerId : dragPointerId ,
951980 } = e ;
952981 if (
982+ e . button !== 0 || // Skip right click.
953983 this . #pagesMapper. copiedPageNumbers ?. length > 0 ||
954984 ! isNaN ( this . #lastDraggedOverIndex) ||
955985 ! draggedImage . classList . contains ( "thumbnailImageContainer" )
@@ -969,11 +999,18 @@ class PDFThumbnailViewer {
969999 // same position on the thumbnail, we need to adjust the offset
9701000 // accordingly.
9711001 const scaleFactor = PDFThumbnailViewer . #getScaleFactor( draggedImage ) ;
972- this . #draggedImageOffsetX =
973- ( ( scaleFactor - 1 ) * e . layerX + draggedImage . offsetLeft ) / scaleFactor ;
9741002 this . #draggedImageOffsetY =
9751003 ( ( scaleFactor - 1 ) * e . layerY + draggedImage . offsetTop ) / scaleFactor ;
9761004
1005+ if ( this . #isOneColumnView) {
1006+ this . #draggedImageOffsetX =
1007+ draggedImage . offsetLeft +
1008+ ( ( scaleFactor - 1 ) * 0.5 * draggedImage . offsetWidth ) / scaleFactor ;
1009+ } else {
1010+ this . #draggedImageOffsetX =
1011+ ( ( scaleFactor - 1 ) * e . layerX + draggedImage . offsetLeft ) /
1012+ scaleFactor ;
1013+ }
9771014 this . #draggedImageX = thumbnail . offsetLeft + this . #draggedImageOffsetX;
9781015 this . #draggedImageY = thumbnail . offsetTop + this . #draggedImageOffsetY;
9791016 this . #draggedImageWidth = draggedImage . offsetWidth / scaleFactor ;
@@ -983,16 +1020,16 @@ class PDFThumbnailViewer {
9831020 "pointermove" ,
9841021 ev => {
9851022 const { clientX : x , clientY : y , pointerId } = ev ;
986- if (
987- pointerId !== dragPointerId ||
988- ( Math . abs ( x - clickX ) <= DRAG_THRESHOLD_IN_PIXELS &&
989- Math . abs ( y - clickY ) <= DRAG_THRESHOLD_IN_PIXELS )
990- ) {
991- // Not enough movement to be considered a drag.
992- return ;
993- }
994-
9951023 if ( isNaN ( this . #lastDraggedOverIndex) ) {
1024+ if (
1025+ pointerId !== dragPointerId ||
1026+ ( Math . abs ( x - clickX ) <= DRAG_THRESHOLD_IN_PIXELS &&
1027+ Math . abs ( y - clickY ) <= DRAG_THRESHOLD_IN_PIXELS )
1028+ ) {
1029+ // Not enough movement to be considered a drag.
1030+ return ;
1031+ }
1032+
9961033 // First movement while dragging.
9971034 this . #onStartDragging( thumbnail ) ;
9981035 const stopDragging = ( _e , isDropping = false ) => {
@@ -1043,6 +1080,18 @@ class PDFThumbnailViewer {
10431080 passive : false ,
10441081 signal,
10451082 } ) ;
1083+ window . addEventListener (
1084+ "keydown" ,
1085+ kEv => {
1086+ if (
1087+ kEv . key === "Escape" &&
1088+ ! isNaN ( this . #lastDraggedOverIndex)
1089+ ) {
1090+ stopDragging ( kEv ) ;
1091+ }
1092+ } ,
1093+ { signal }
1094+ ) ;
10461095 }
10471096
10481097 const dx = x - prevDragX ;
@@ -1151,6 +1200,16 @@ class PDFThumbnailViewer {
11511200 }
11521201 }
11531202
1203+ // Given the drag center (x, y), find the drop slot index: the drag marker
1204+ // will be placed after thumbnail[index], or before all thumbnails if index
1205+ // is -1. Returns null when the drop slot hasn't changed (no marker update
1206+ // needed), or [index, space] where space is the gap (in px) between
1207+ // thumbnails at that slot, used to position the marker.
1208+ //
1209+ // positionsX holds the x-center of each column, positionsY the y-center of
1210+ // each row. positionsLastX holds the x-centers for an incomplete last row
1211+ // (when the total number of thumbnails is not a multiple of the column
1212+ // count).
11541213 #findClosestThumbnail( x , y ) {
11551214 if ( ! this . #thumbnailsPositions) {
11561215 this . #computeThumbnailsPosition( ) ;
@@ -1163,6 +1222,9 @@ class PDFThumbnailViewer {
11631222 lastSpace : lastSpaceBetweenThumbnails ,
11641223 } = this . #thumbnailsPositions;
11651224 const lastDraggedOverIndex = this . #lastDraggedOverIndex;
1225+
1226+ // Fast-path: reconstruct the row/col of the previous drop slot and check
1227+ // whether (x, y) still falls inside the same cell's bounds.
11661228 let xPos = lastDraggedOverIndex % positionsX . length ;
11671229 let yPos = Math . floor ( lastDraggedOverIndex / positionsX . length ) ;
11681230 let xArray = yPos === positionsY . length - 1 ? positionsLastX : positionsX ;
@@ -1176,28 +1238,58 @@ class PDFThumbnailViewer {
11761238 return null ;
11771239 }
11781240
1179- yPos = binarySearchFirstItem ( positionsY , cy => y < cy ) - 1 ;
1180- xArray =
1181- yPos === positionsY . length - 1 && positionsLastX . length > 0
1182- ? positionsLastX
1183- : positionsX ;
1184- xPos = Math . max ( 0 , binarySearchFirstItem ( xArray , cx => x < cx ) - 1 ) ;
1185- if ( yPos < 0 ) {
1186- if ( xPos <= 0 ) {
1187- xPos = - 1 ;
1241+ let index ;
1242+ // binarySearchFirstItem returns the first row index whose center is below
1243+ // y, i.e. the first i such that positionsY[i] > y.
1244+ yPos = binarySearchFirstItem ( positionsY , cy => y < cy ) ;
1245+ if ( this . #isOneColumnView) {
1246+ // In a single column the drop slot is simply the row boundary: the marker
1247+ // goes after row (yPos - 1), meaning before row yPos. index = -1 when y
1248+ // is above the first thumbnail's center (drop before thumbnail 0).
1249+ index = yPos - 1 ;
1250+ } else {
1251+ // Grid layout: first pick the nearest row, then the nearest column.
1252+
1253+ if ( yPos === positionsY . length ) {
1254+ // y is below the last row's center — clamp to the last row.
1255+ yPos = positionsY . length - 1 ;
1256+ } else {
1257+ // Choose between the row just above (yPos - 1) and the row at yPos by
1258+ // comparing distances, so the marker snaps to whichever row center is
1259+ // closer to y.
1260+ const dist1 = Math . abs ( positionsY [ yPos - 1 ] - y ) ;
1261+ const dist2 = Math . abs ( positionsY [ yPos ] - y ) ;
1262+ yPos = dist1 < dist2 ? yPos - 1 : yPos ;
11881263 }
1189- yPos = 0 ;
1264+ // The last row may be incomplete, so use its own x-center array.
1265+ xArray =
1266+ yPos === positionsY . length - 1 && positionsLastX . length > 0
1267+ ? positionsLastX
1268+ : positionsX ;
1269+ // Find the column: the first column whose center is to the right of x,
1270+ // minus 1, gives the column the cursor is in (or -1 if before column 0).
1271+ xPos = binarySearchFirstItem ( xArray , cx => x < cx ) - 1 ;
1272+ if ( yPos < 0 ) {
1273+ // y is above the first row: force drop before the very first thumbnail.
1274+ if ( xPos <= 0 ) {
1275+ xPos = - 1 ;
1276+ }
1277+ yPos = 0 ;
1278+ }
1279+ // Convert (row, col) to a flat thumbnail index, clamped to
1280+ // [-1, length-1].
1281+ index = MathClamp (
1282+ yPos * positionsX . length + xPos ,
1283+ - 1 ,
1284+ this . _thumbnails . length - 1
1285+ ) ;
11901286 }
1191- const index = MathClamp (
1192- yPos * positionsX . length + xPos ,
1193- - 1 ,
1194- this . _thumbnails . length - 1
1195- ) ;
11961287 if ( index === lastDraggedOverIndex ) {
11971288 // No change.
11981289 return null ;
11991290 }
12001291 this . #lastDraggedOverIndex = index ;
1292+ // Use the last-row gap when the drop slot is in the incomplete last row.
12011293 const space =
12021294 yPos === positionsY . length - 1 && positionsLastX . length > 0 && xPos >= 0
12031295 ? lastSpaceBetweenThumbnails
0 commit comments