diff --git a/web/pdf_thumbnail_viewer.js b/web/pdf_thumbnail_viewer.js index e823979cb..63ec83611 100644 --- a/web/pdf_thumbnail_viewer.js +++ b/web/pdf_thumbnail_viewer.js @@ -126,6 +126,12 @@ class PDFThumbnailViewer { #isCut = false; + #isOneColumnView = false; + + #scrollableContainerWidth = 0; + + #scrollableContainerHeight = 0; + /** * @param {PDFThumbnailViewerOptions} options */ @@ -716,10 +722,28 @@ class PDFThumbnailViewer { } #moveDraggedContainer(dx, dy) { - this.#draggedImageOffsetX += dx; - this.#draggedImageOffsetY += dy; + if (this.#isOneColumnView) { + dx = 0; + } + if ( + this.#draggedImageX + dx < 0 || + this.#draggedImageX + this.#draggedImageWidth + dx > + this.#scrollableContainerWidth + ) { + dx = 0; + } + if ( + this.#draggedImageY + dy < 0 || + this.#draggedImageY + this.#draggedImageHeight + dy > + this.#scrollableContainerHeight + ) { + dy = 0; + } + this.#draggedImageX += dx; this.#draggedImageY += dy; + this.#draggedImageOffsetX += dx; + this.#draggedImageOffsetY += dy; this.#draggedContainer.style.translate = `${this.#draggedImageOffsetX}px ${this.#draggedImageOffsetY}px`; if ( this.#draggedImageY + this.#draggedImageHeight > @@ -727,7 +751,7 @@ class PDFThumbnailViewer { ) { this.scrollableContainer.scrollTop = Math.min( this.scrollableContainer.scrollTop + PIXELS_TO_SCROLL_WHEN_DRAGGING, - this.scrollableContainer.scrollHeight + this.#scrollableContainerHeight ); } else if (this.#draggedImageY < this.#currentScrollTop) { this.scrollableContainer.scrollTop = Math.max( @@ -839,6 +863,11 @@ class PDFThumbnailViewer { lastSpace: (positionsLastX.at(-1) - lastRightX) / 2, bbox, }; + this.#isOneColumnView = positionsX.length === 1; + ({ + clientWidth: this.#scrollableContainerWidth, + scrollHeight: this.#scrollableContainerHeight, + } = this.scrollableContainer); } #addEventListeners() { @@ -950,6 +979,7 @@ class PDFThumbnailViewer { pointerId: dragPointerId, } = e; if ( + e.button !== 0 || // Skip right click. this.#pagesMapper.copiedPageNumbers?.length > 0 || !isNaN(this.#lastDraggedOverIndex) || !draggedImage.classList.contains("thumbnailImageContainer") @@ -969,11 +999,18 @@ class PDFThumbnailViewer { // same position on the thumbnail, we need to adjust the offset // accordingly. const scaleFactor = PDFThumbnailViewer.#getScaleFactor(draggedImage); - this.#draggedImageOffsetX = - ((scaleFactor - 1) * e.layerX + draggedImage.offsetLeft) / scaleFactor; this.#draggedImageOffsetY = ((scaleFactor - 1) * e.layerY + draggedImage.offsetTop) / scaleFactor; + if (this.#isOneColumnView) { + this.#draggedImageOffsetX = + draggedImage.offsetLeft + + ((scaleFactor - 1) * 0.5 * draggedImage.offsetWidth) / scaleFactor; + } else { + this.#draggedImageOffsetX = + ((scaleFactor - 1) * e.layerX + draggedImage.offsetLeft) / + scaleFactor; + } this.#draggedImageX = thumbnail.offsetLeft + this.#draggedImageOffsetX; this.#draggedImageY = thumbnail.offsetTop + this.#draggedImageOffsetY; this.#draggedImageWidth = draggedImage.offsetWidth / scaleFactor; @@ -983,16 +1020,16 @@ class PDFThumbnailViewer { "pointermove", ev => { const { clientX: x, clientY: y, pointerId } = ev; - if ( - pointerId !== dragPointerId || - (Math.abs(x - clickX) <= DRAG_THRESHOLD_IN_PIXELS && - Math.abs(y - clickY) <= DRAG_THRESHOLD_IN_PIXELS) - ) { - // Not enough movement to be considered a drag. - return; - } - if (isNaN(this.#lastDraggedOverIndex)) { + if ( + pointerId !== dragPointerId || + (Math.abs(x - clickX) <= DRAG_THRESHOLD_IN_PIXELS && + Math.abs(y - clickY) <= DRAG_THRESHOLD_IN_PIXELS) + ) { + // Not enough movement to be considered a drag. + return; + } + // First movement while dragging. this.#onStartDragging(thumbnail); const stopDragging = (_e, isDropping = false) => { @@ -1043,6 +1080,18 @@ class PDFThumbnailViewer { passive: false, signal, }); + window.addEventListener( + "keydown", + kEv => { + if ( + kEv.key === "Escape" && + !isNaN(this.#lastDraggedOverIndex) + ) { + stopDragging(kEv); + } + }, + { signal } + ); } const dx = x - prevDragX; @@ -1151,6 +1200,16 @@ class PDFThumbnailViewer { } } + // Given the drag center (x, y), find the drop slot index: the drag marker + // will be placed after thumbnail[index], or before all thumbnails if index + // is -1. Returns null when the drop slot hasn't changed (no marker update + // needed), or [index, space] where space is the gap (in px) between + // thumbnails at that slot, used to position the marker. + // + // positionsX holds the x-center of each column, positionsY the y-center of + // each row. positionsLastX holds the x-centers for an incomplete last row + // (when the total number of thumbnails is not a multiple of the column + // count). #findClosestThumbnail(x, y) { if (!this.#thumbnailsPositions) { this.#computeThumbnailsPosition(); @@ -1163,6 +1222,9 @@ class PDFThumbnailViewer { lastSpace: lastSpaceBetweenThumbnails, } = this.#thumbnailsPositions; const lastDraggedOverIndex = this.#lastDraggedOverIndex; + + // Fast-path: reconstruct the row/col of the previous drop slot and check + // whether (x, y) still falls inside the same cell's bounds. let xPos = lastDraggedOverIndex % positionsX.length; let yPos = Math.floor(lastDraggedOverIndex / positionsX.length); let xArray = yPos === positionsY.length - 1 ? positionsLastX : positionsX; @@ -1176,28 +1238,58 @@ class PDFThumbnailViewer { return null; } - yPos = binarySearchFirstItem(positionsY, cy => y < cy) - 1; - xArray = - yPos === positionsY.length - 1 && positionsLastX.length > 0 - ? positionsLastX - : positionsX; - xPos = Math.max(0, binarySearchFirstItem(xArray, cx => x < cx) - 1); - if (yPos < 0) { - if (xPos <= 0) { - xPos = -1; + let index; + // binarySearchFirstItem returns the first row index whose center is below + // y, i.e. the first i such that positionsY[i] > y. + yPos = binarySearchFirstItem(positionsY, cy => y < cy); + if (this.#isOneColumnView) { + // In a single column the drop slot is simply the row boundary: the marker + // goes after row (yPos - 1), meaning before row yPos. index = -1 when y + // is above the first thumbnail's center (drop before thumbnail 0). + index = yPos - 1; + } else { + // Grid layout: first pick the nearest row, then the nearest column. + + if (yPos === positionsY.length) { + // y is below the last row's center — clamp to the last row. + yPos = positionsY.length - 1; + } else { + // Choose between the row just above (yPos - 1) and the row at yPos by + // comparing distances, so the marker snaps to whichever row center is + // closer to y. + const dist1 = Math.abs(positionsY[yPos - 1] - y); + const dist2 = Math.abs(positionsY[yPos] - y); + yPos = dist1 < dist2 ? yPos - 1 : yPos; } - yPos = 0; + // The last row may be incomplete, so use its own x-center array. + xArray = + yPos === positionsY.length - 1 && positionsLastX.length > 0 + ? positionsLastX + : positionsX; + // Find the column: the first column whose center is to the right of x, + // minus 1, gives the column the cursor is in (or -1 if before column 0). + xPos = binarySearchFirstItem(xArray, cx => x < cx) - 1; + if (yPos < 0) { + // y is above the first row: force drop before the very first thumbnail. + if (xPos <= 0) { + xPos = -1; + } + yPos = 0; + } + // Convert (row, col) to a flat thumbnail index, clamped to + // [-1, length-1]. + index = MathClamp( + yPos * positionsX.length + xPos, + -1, + this._thumbnails.length - 1 + ); } - const index = MathClamp( - yPos * positionsX.length + xPos, - -1, - this._thumbnails.length - 1 - ); if (index === lastDraggedOverIndex) { // No change. return null; } this.#lastDraggedOverIndex = index; + // Use the last-row gap when the drop slot is in the incomplete last row. const space = yPos === positionsY.length - 1 && positionsLastX.length > 0 && xPos >= 0 ? lastSpaceBetweenThumbnails