From 812dd43783118ba7983047c49d20f869759115fd Mon Sep 17 00:00:00 2001 From: Sumit Sahoo Date: Sat, 11 Apr 2026 13:33:42 +0530 Subject: [PATCH 1/4] feat: add TouchDragOverlay component for improved touch drag experience --- src/components/TouchDragOverlay.tsx | 36 +++++++++++++++++++++++++++++ src/hooks/useSortableDrag.ts | 9 +++++++- src/tools/AddBlankPage.tsx | 26 ++++++++++++++++++++- src/tools/DuplicatePage.tsx | 27 +++++++++++++++++++++- src/tools/ReorderPages.tsx | 16 ++++++++++++- 5 files changed, 110 insertions(+), 4 deletions(-) create mode 100644 src/components/TouchDragOverlay.tsx diff --git a/src/components/TouchDragOverlay.tsx b/src/components/TouchDragOverlay.tsx new file mode 100644 index 0000000..0e7afeb --- /dev/null +++ b/src/components/TouchDragOverlay.tsx @@ -0,0 +1,36 @@ +/** + * TouchDragOverlay — floating preview that follows the finger during touch drag. + * + * On desktop, the HTML5 Drag API provides a built-in ghost image. On mobile, + * touch events have no equivalent, so this component renders a small + * semi-transparent thumbnail at the current touch position via a portal. + */ + +import { createPortal } from "react-dom"; + +interface TouchDragOverlayProps { + /** Current touch coordinates (null when not dragging via touch). */ + touchPos: { x: number; y: number } | null; + children: React.ReactNode; +} + +export function TouchDragOverlay({ touchPos, children }: TouchDragOverlayProps) { + if (!touchPos) return null; + + return createPortal( +
+ {children} +
, + document.body, + ); +} diff --git a/src/hooks/useSortableDrag.ts b/src/hooks/useSortableDrag.ts index 208ab92..c432a82 100644 --- a/src/hooks/useSortableDrag.ts +++ b/src/hooks/useSortableDrag.ts @@ -21,6 +21,8 @@ import { useState, useEffect, useRef, useCallback } from "react"; export function useSortableDrag(onMove: (fromIndex: number, toSlot: number) => void) { const [dragIndex, setDragIndex] = useState(null); const [dragOverSlot, setDragOverSlot] = useState(null); + /** Current touch position — non-null only during an active touch drag. */ + const [touchPos, setTouchPos] = useState<{ x: number; y: number } | null>(null); // Refs so the document-level listeners don't need to be re-registered const touchStartSlot = useRef(null); @@ -65,6 +67,9 @@ export function useSortableDrag(onMove: (fromIndex: number, toSlot: number) => v // Prevent page scroll while reordering. e.preventDefault(); + // Track finger position for the drag overlay. + setTouchPos({ x: touch.clientX, y: touch.clientY }); + // Find which drop-zone slot is under the finger. const el = document.elementFromPoint(touch.clientX, touch.clientY); const zone = el?.closest("[data-drop-slot]") as HTMLElement | null; @@ -97,6 +102,7 @@ export function useSortableDrag(onMove: (fromIndex: number, toSlot: number) => v touchStartPos.current = null; setDragIndex(null); setDragOverSlot(null); + setTouchPos(null); }; const handleTouchCancel = () => { @@ -105,6 +111,7 @@ export function useSortableDrag(onMove: (fromIndex: number, toSlot: number) => v touchStartPos.current = null; setDragIndex(null); setDragOverSlot(null); + setTouchPos(null); }; // Non-passive so we can call preventDefault() during active drag. @@ -119,5 +126,5 @@ export function useSortableDrag(onMove: (fromIndex: number, toSlot: number) => v }; }, []); // all mutable state accessed via refs — no deps needed - return { dragIndex, dragOverSlot, setDragIndex, setDragOverSlot, getTouchHandlers }; + return { dragIndex, dragOverSlot, touchPos, setDragIndex, setDragOverSlot, getTouchHandlers }; } diff --git a/src/tools/AddBlankPage.tsx b/src/tools/AddBlankPage.tsx index a0ebf9f..56c742b 100644 --- a/src/tools/AddBlankPage.tsx +++ b/src/tools/AddBlankPage.tsx @@ -11,6 +11,7 @@ import { FileDropZone } from "../components/FileDropZone.tsx"; import { categoryAccent, categoryGlow } from "../config/theme.ts"; import { downloadPdf, formatFileSize } from "../utils/file-helpers.ts"; import { useSortableDrag } from "../hooks/useSortableDrag.ts"; +import { TouchDragOverlay } from "../components/TouchDragOverlay.tsx"; import { addBlankPages } from "../utils/pdf-operations.ts"; import { renderAllThumbnails } from "../utils/pdf-renderer.ts"; import { Undo2, Plus } from "lucide-react"; @@ -43,7 +44,7 @@ export default function AddBlankPage() { }, []); // Drag state (desktop + mobile touch) - const { dragIndex, dragOverSlot, setDragIndex, setDragOverSlot, getTouchHandlers } = + const { dragIndex, dragOverSlot, touchPos, setDragIndex, setDragOverSlot, getTouchHandlers } = useSortableDrag(handleMove); const handleFile = useCallback(async (files: File[]) => { @@ -330,6 +331,29 @@ export default function AddBlankPage() { {renderItems()} + {dragIndex !== null && ( + + {items[dragIndex]?.type === "blank" ? ( +
+ + +
+ ) : ( +
+ +
+ )} +
+ )} + {hasBlankPages && (

{items.filter((it) => it.type === "blank").length} blank page(s) added — click diff --git a/src/tools/DuplicatePage.tsx b/src/tools/DuplicatePage.tsx index 993f5ce..1a849ed 100644 --- a/src/tools/DuplicatePage.tsx +++ b/src/tools/DuplicatePage.tsx @@ -11,6 +11,7 @@ import { FileDropZone } from "../components/FileDropZone.tsx"; import { categoryAccent, categoryGlow } from "../config/theme.ts"; import { downloadPdf, formatFileSize } from "../utils/file-helpers.ts"; import { useSortableDrag } from "../hooks/useSortableDrag.ts"; +import { TouchDragOverlay } from "../components/TouchDragOverlay.tsx"; import { duplicatePages } from "../utils/pdf-operations.ts"; import { renderAllThumbnails } from "../utils/pdf-renderer.ts"; import { Undo2 } from "lucide-react"; @@ -43,7 +44,7 @@ export default function DuplicatePage() { }, []); // Drag state (desktop + mobile touch) - const { dragIndex, dragOverSlot, setDragIndex, setDragOverSlot, getTouchHandlers } = + const { dragIndex, dragOverSlot, touchPos, setDragIndex, setDragOverSlot, getTouchHandlers } = useSortableDrag(handleMove); const handleFile = useCallback(async (files: File[]) => { @@ -335,6 +336,30 @@ export default function DuplicatePage() { {renderItems()} + {dragIndex !== null && + (() => { + const item = items[dragIndex]; + const srcIdx = + item?.type === "copy" + ? item.sourceIndex + : item?.type === "original" + ? item.index + : null; + if (srcIdx === null) return null; + return ( + +

+ +
+ + ); + })()} + {hasCopies && (

{items.filter((it) => it.type === "copy").length} copy(ies) added — click below diff --git a/src/tools/ReorderPages.tsx b/src/tools/ReorderPages.tsx index 1fb0949..640216b 100644 --- a/src/tools/ReorderPages.tsx +++ b/src/tools/ReorderPages.tsx @@ -15,6 +15,7 @@ import { reorderPages } from "../utils/pdf-operations.ts"; import { renderAllThumbnails } from "../utils/pdf-renderer.ts"; import { downloadPdf } from "../utils/file-helpers.ts"; import { useSortableDrag } from "../hooks/useSortableDrag.ts"; +import { TouchDragOverlay } from "../components/TouchDragOverlay.tsx"; import { Undo2 } from "lucide-react"; export default function ReorderPages() { @@ -37,7 +38,7 @@ export default function ReorderPages() { }, []); // Drag state (desktop + mobile touch) - const { dragIndex, dragOverSlot, setDragIndex, setDragOverSlot, getTouchHandlers } = + const { dragIndex, dragOverSlot, touchPos, setDragIndex, setDragOverSlot, getTouchHandlers } = useSortableDrag(handleMove); const handleFile = useCallback(async (files: File[]) => { @@ -266,6 +267,19 @@ export default function ReorderPages() { {renderItems()} + {dragIndex !== null && ( + +

+ +
+ + )} + {isReordered && (

Order changed — click below to apply From 4c01a661217fcd32b608de214c3e6f939763484c Mon Sep 17 00:00:00 2001 From: Sumit Sahoo Date: Sat, 11 Apr 2026 13:46:06 +0530 Subject: [PATCH 2/4] feat: implement SortableGrid component for drag-and-drop functionality - Added SortableGrid component to facilitate reusable drag-and-drop grid for item reordering. - Updated TouchDragOverlay to remove rotation for better alignment. - Refactored useSortableDrag hook to provide getItemProps for handling both HTML5 and touch events. - Integrated SortableGrid into AddBlankPage, DuplicatePage, and ReorderPages components for consistent drag-and-drop behavior. - Removed redundant drag-and-drop logic from AddBlankPage and DuplicatePage, leveraging SortableGrid instead. --- src/components/SortableGrid.tsx | 122 +++++++++++ src/components/TouchDragOverlay.tsx | 2 +- src/hooks/useSortableDrag.ts | 39 +++- src/tools/AddBlankPage.tsx | 297 +++++++++++---------------- src/tools/DuplicatePage.tsx | 308 ++++++++++++---------------- src/tools/ReorderPages.tsx | 209 ++++++------------- 6 files changed, 467 insertions(+), 510 deletions(-) create mode 100644 src/components/SortableGrid.tsx diff --git a/src/components/SortableGrid.tsx b/src/components/SortableGrid.tsx new file mode 100644 index 0000000..bfb9c68 --- /dev/null +++ b/src/components/SortableGrid.tsx @@ -0,0 +1,122 @@ +/** + * SortableGrid — reusable drag-and-drop grid for page reordering. + * + * Renders an interleaved layout of drop-zones and items: + * [drop_0] [item_0] [drop_1] [item_1] ... [item_N-1] [drop_N] + * + * Drop-zones expand when hovering during a drag to show where the item will + * land. A floating TouchDragOverlay follows the finger on mobile. + * + * Usage: + * const drag = useSortableDrag(handleMove); + * + * } + * renderOverlay={(dragIndex) => } + * /> + */ + +import type { SortableDrag } from "../hooks/useSortableDrag.ts"; +import { TouchDragOverlay } from "./TouchDragOverlay.tsx"; + +interface SortableGridProps { + /** Total number of items in the list. */ + itemCount: number; + /** Drag state bag from useSortableDrag. */ + drag: SortableDrag; + /** Called when an item is dropped at a new slot (desktop HTML5 drag path). */ + onMove: (fromIndex: number, toSlot: number) => void; + /** + * Render a single item at the given slot. + * The item is responsible for spreading `drag.getItemProps(slot)` (or a + * subset of it) onto its root element to make it draggable. + */ + renderItem: (slot: number, isSource: boolean) => React.ReactNode; + /** Render the content shown inside the floating touch-drag overlay. */ + renderOverlay?: (dragIndex: number) => React.ReactNode; + /** Optional class override for the grid wrapper. */ + className?: string; +} + +export function SortableGrid({ + itemCount, + drag, + onMove, + renderItem, + renderOverlay, + className, +}: SortableGridProps) { + const { dragIndex, dragOverSlot, touchPos, setDragIndex, setDragOverSlot } = drag; + const isDragging = dragIndex !== null; + + const elements: React.ReactNode[] = []; + + for (let slot = 0; slot <= itemCount; slot++) { + const isAdjacentToDrag = dragIndex !== null && (slot === dragIndex || slot === dragIndex + 1); + + // ── Drop zone ── + elements.push( +

{ + if (isAdjacentToDrag) return; + e.preventDefault(); + setDragOverSlot(slot); + }} + onDragLeave={(e) => { + if (!e.currentTarget.contains(e.relatedTarget as Node)) { + if (dragOverSlot === slot) setDragOverSlot(null); + } + }} + onDrop={(e) => { + e.preventDefault(); + if (dragIndex === null || isAdjacentToDrag) return; + onMove(dragIndex, slot); + setDragIndex(null); + setDragOverSlot(null); + }} + className={`self-stretch flex items-center justify-center rounded-lg transition-all duration-200 ${ + isDragging && !isAdjacentToDrag + ? dragOverSlot === slot + ? "w-20 sm:w-24 bg-primary-50 dark:bg-primary-900/20" + : "w-3 sm:w-4" + : "w-0" + }`} + > + {isDragging && !isAdjacentToDrag && ( +
+ )} +
, + ); + + // ── Item ── + if (slot < itemCount) { + elements.push(renderItem(slot, dragIndex === slot)); + } + } + + return ( + <> +
+ {elements} +
+ + {dragIndex !== null && renderOverlay && ( + {renderOverlay(dragIndex)} + )} + + ); +} diff --git a/src/components/TouchDragOverlay.tsx b/src/components/TouchDragOverlay.tsx index 0e7afeb..ebe3b61 100644 --- a/src/components/TouchDragOverlay.tsx +++ b/src/components/TouchDragOverlay.tsx @@ -23,7 +23,7 @@ export function TouchDragOverlay({ touchPos, children }: TouchDragOverlayProps) position: "fixed", left: touchPos.x, top: touchPos.y, - transform: "translate(-50%, -60%) rotate(-3deg)", + transform: "translate(-50%, -60%)", pointerEvents: "none", zIndex: 9999, }} diff --git a/src/hooks/useSortableDrag.ts b/src/hooks/useSortableDrag.ts index c432a82..c672b36 100644 --- a/src/hooks/useSortableDrag.ts +++ b/src/hooks/useSortableDrag.ts @@ -4,9 +4,11 @@ * Supports both the HTML5 drag API (desktop) and touch events (mobile). * * Components must: - * 1. Spread `getTouchHandlers(slot)` onto every draggable item. + * 1. Spread `getItemProps(slot)` onto every draggable item — this wires up + * both the HTML5 drag API and touch handlers in one call. * 2. Add `data-drop-slot={slot}` to every drop-zone div so touch tracking * can identify the target slot via `document.elementFromPoint`. + * (If using `SortableGrid`, drop-zones are handled automatically.) * * Touch behaviour: * - Drag activates after an 8 px movement threshold so short taps still @@ -31,7 +33,7 @@ export function useSortableDrag(onMove: (fromIndex: number, toSlot: number) => v const onMoveRef = useRef(onMove); onMoveRef.current = onMove; - /** Call this from each draggable item's onTouchStart. */ + /** Touch handlers for a single draggable item (used internally by getItemProps). */ const getTouchHandlers = useCallback( (slot: number) => ({ onTouchStart(e: React.TouchEvent) { @@ -49,6 +51,26 @@ export function useSortableDrag(onMove: (fromIndex: number, toSlot: number) => v [], ); + /** + * All props needed to make an element draggable (HTML5 + touch). + * Spread the result onto the draggable element: `{...getItemProps(slot)}` + */ + const getItemProps = useCallback( + (slot: number) => ({ + draggable: true as const, + onDragStart(e: React.DragEvent) { + e.dataTransfer.effectAllowed = "move"; + setDragIndex(slot); + }, + onDragEnd() { + setDragIndex(null); + setDragOverSlot(null); + }, + ...getTouchHandlers(slot), + }), + [getTouchHandlers], + ); + useEffect(() => { const handleTouchMove = (e: TouchEvent) => { if (touchStartSlot.current === null || touchStartPos.current === null) return; @@ -126,5 +148,16 @@ export function useSortableDrag(onMove: (fromIndex: number, toSlot: number) => v }; }, []); // all mutable state accessed via refs — no deps needed - return { dragIndex, dragOverSlot, touchPos, setDragIndex, setDragOverSlot, getTouchHandlers }; + return { + dragIndex, + dragOverSlot, + touchPos, + setDragIndex, + setDragOverSlot, + getItemProps, + getTouchHandlers, + }; } + +/** Convenience type for passing the full drag bag to SortableGrid. */ +export type SortableDrag = ReturnType; diff --git a/src/tools/AddBlankPage.tsx b/src/tools/AddBlankPage.tsx index 56c742b..37a3e12 100644 --- a/src/tools/AddBlankPage.tsx +++ b/src/tools/AddBlankPage.tsx @@ -8,10 +8,10 @@ import { useState, useCallback } from "react"; import { FileDropZone } from "../components/FileDropZone.tsx"; +import { SortableGrid } from "../components/SortableGrid.tsx"; import { categoryAccent, categoryGlow } from "../config/theme.ts"; import { downloadPdf, formatFileSize } from "../utils/file-helpers.ts"; import { useSortableDrag } from "../hooks/useSortableDrag.ts"; -import { TouchDragOverlay } from "../components/TouchDragOverlay.tsx"; import { addBlankPages } from "../utils/pdf-operations.ts"; import { renderAllThumbnails } from "../utils/pdf-renderer.ts"; import { Undo2, Plus } from "lucide-react"; @@ -44,8 +44,7 @@ export default function AddBlankPage() { }, []); // Drag state (desktop + mobile touch) - const { dragIndex, dragOverSlot, touchPos, setDragIndex, setDragOverSlot, getTouchHandlers } = - useSortableDrag(handleMove); + const drag = useSortableDrag(handleMove); const handleFile = useCallback(async (files: File[]) => { const pdf = files[0]; @@ -72,7 +71,7 @@ export default function AddBlankPage() { }, []); const hasBlankPages = items.some((it) => it.type === "blank"); - const isDragging = dragIndex !== null; + const isDragging = drag.dragIndex !== null; const handleAddBlank = useCallback(() => { setItems((prev) => [{ type: "blank", id: nextBlankId() }, ...prev]); @@ -80,9 +79,9 @@ export default function AddBlankPage() { const handleReset = useCallback(() => { setItems((prev) => prev.filter((it) => it.type === "original")); - setDragIndex(null); - setDragOverSlot(null); - }, [setDragIndex, setDragOverSlot]); + drag.setDragIndex(null); + drag.setDragOverSlot(null); + }, [drag]); const handleApply = useCallback(async () => { if (!file || !hasBlankPages) return; @@ -110,158 +109,6 @@ export default function AddBlankPage() { } }, [file, hasBlankPages, items]); - const renderItems = () => { - const elements: React.ReactNode[] = []; - - for (let slot = 0; slot <= items.length; slot++) { - // ── Drop zone ── - const isAdjacentToDrag = dragIndex !== null && (slot === dragIndex || slot === dragIndex + 1); - - elements.push( -
{ - if (isAdjacentToDrag) return; - e.preventDefault(); - setDragOverSlot(slot); - }} - onDragLeave={(e) => { - if (!e.currentTarget.contains(e.relatedTarget as Node)) { - if (dragOverSlot === slot) setDragOverSlot(null); - } - }} - onDrop={(e) => { - e.preventDefault(); - if (dragIndex === null || isAdjacentToDrag) return; - setItems((prev) => { - const next = [...prev]; - const [moved] = next.splice(dragIndex, 1); - const adjustedSlot = dragIndex < slot ? slot - 1 : slot; - next.splice(adjustedSlot, 0, moved); - return next; - }); - setDragIndex(null); - setDragOverSlot(null); - }} - className={`self-stretch flex items-center justify-center rounded-lg transition-all duration-200 ${ - isDragging && !isAdjacentToDrag - ? dragOverSlot === slot - ? "w-20 sm:w-24 bg-primary-50 dark:bg-primary-900/20" - : "w-3 sm:w-4" - : "w-0" - }`} - > - {isDragging && !isAdjacentToDrag && ( -
- )} -
, - ); - - // ── Page card ── - if (slot < items.length) { - const item = items[slot]; - const isSource = dragIndex === slot; - - if (item.type === "blank") { - elements.push( -
{ - e.dataTransfer.effectAllowed = "move"; - setDragIndex(slot); - }} - onDragEnd={() => { - setDragIndex(null); - setDragOverSlot(null); - }} - {...getTouchHandlers(slot)} - className={`shrink-0 pt-2 pr-2 flex flex-col items-center gap-1.5 cursor-grab active:cursor-grabbing select-none transition-all duration-200 ${ - isSource ? "scale-95 opacity-30" : "scale-100 opacity-100" - }`} - > -
-
- + -
-
- New -
-
- Blank -
, - ); - } else { - elements.push( -
{ - e.dataTransfer.effectAllowed = "move"; - setDragIndex(slot); - }} - onDragEnd={() => { - setDragIndex(null); - setDragOverSlot(null); - }} - {...getTouchHandlers(slot)} - className={`shrink-0 pt-2 pr-2 flex flex-col items-center gap-1.5 cursor-grab active:cursor-grabbing select-none transition-all duration-200 ${ - isSource ? "scale-95 opacity-30" : "scale-100 opacity-100" - }`} - > -
-
- {`Page -
-
- {item.index + 1} -
-
- - Page {item.index + 1} - -
, - ); - } - } - } - - return elements; - }; - return (
{!file ? ( @@ -327,32 +174,118 @@ export default function AddBlankPage() {
-
- {renderItems()} -
+ { + const item = items[slot]; - {dragIndex !== null && ( - - {items[dragIndex]?.type === "blank" ? ( -
- + + if (item.type === "blank") { + return ( +
+
+
+ + +
+
+ New +
+
+ Blank +
+ ); + } + + return ( +
+
+
+ {`Page +
+
+ {item.index + 1} +
+
+ + Page {item.index + 1} +
- ) : ( -
- + ); + }} + renderOverlay={(idx) => { + const item = items[idx]; + if (item?.type === "blank") { + return ( +
+
+ + +
+
+ New +
+
+ ); + } + const pageIndex = (item as OriginalItem).index; + return ( +
+
+ +
+
+ {pageIndex + 1} +
- )} - - )} + ); + }} + /> {hasBlankPages && (

diff --git a/src/tools/DuplicatePage.tsx b/src/tools/DuplicatePage.tsx index 1a849ed..79066b7 100644 --- a/src/tools/DuplicatePage.tsx +++ b/src/tools/DuplicatePage.tsx @@ -8,10 +8,10 @@ import { useCallback, useState } from "react"; import { FileDropZone } from "../components/FileDropZone.tsx"; +import { SortableGrid } from "../components/SortableGrid.tsx"; import { categoryAccent, categoryGlow } from "../config/theme.ts"; import { downloadPdf, formatFileSize } from "../utils/file-helpers.ts"; import { useSortableDrag } from "../hooks/useSortableDrag.ts"; -import { TouchDragOverlay } from "../components/TouchDragOverlay.tsx"; import { duplicatePages } from "../utils/pdf-operations.ts"; import { renderAllThumbnails } from "../utils/pdf-renderer.ts"; import { Undo2 } from "lucide-react"; @@ -44,8 +44,7 @@ export default function DuplicatePage() { }, []); // Drag state (desktop + mobile touch) - const { dragIndex, dragOverSlot, touchPos, setDragIndex, setDragOverSlot, getTouchHandlers } = - useSortableDrag(handleMove); + const drag = useSortableDrag(handleMove); const handleFile = useCallback(async (files: File[]) => { const pdf = files[0]; @@ -70,7 +69,7 @@ export default function DuplicatePage() { }, []); const hasCopies = items.some((it) => it.type === "copy"); - const isDragging = dragIndex !== null; + const isDragging = drag.dragIndex !== null; const handleDuplicatePage = useCallback((pageIndex: number, afterSlot: number) => { setItems((prev) => { @@ -82,9 +81,9 @@ export default function DuplicatePage() { const handleReset = useCallback(() => { setItems((prev) => prev.filter((it) => it.type === "original")); - setDragIndex(null); - setDragOverSlot(null); - }, [setDragIndex, setDragOverSlot]); + drag.setDragIndex(null); + drag.setDragOverSlot(null); + }, [drag]); const handleApply = useCallback(async () => { if (!file || !hasCopies) return; @@ -111,170 +110,6 @@ export default function DuplicatePage() { } }, [file, hasCopies, items]); - const renderItems = () => { - const elements: React.ReactNode[] = []; - - for (let slot = 0; slot <= items.length; slot++) { - // ── Drop zone ── - const isAdjacentToDrag = dragIndex !== null && (slot === dragIndex || slot === dragIndex + 1); - - elements.push( -

{ - if (isAdjacentToDrag) return; - e.preventDefault(); - setDragOverSlot(slot); - }} - onDragLeave={(e) => { - if (!e.currentTarget.contains(e.relatedTarget as Node)) { - if (dragOverSlot === slot) setDragOverSlot(null); - } - }} - onDrop={(e) => { - e.preventDefault(); - if (dragIndex === null || isAdjacentToDrag) return; - setItems((prev) => { - const next = [...prev]; - const [moved] = next.splice(dragIndex, 1); - const adjustedSlot = dragIndex < slot ? slot - 1 : slot; - next.splice(adjustedSlot, 0, moved); - return next; - }); - setDragIndex(null); - setDragOverSlot(null); - }} - className={`self-stretch flex items-center justify-center rounded-lg transition-all duration-200 ${ - isDragging && !isAdjacentToDrag - ? dragOverSlot === slot - ? "w-20 sm:w-24 bg-primary-50 dark:bg-primary-900/20" - : "w-3 sm:w-4" - : "w-0" - }`} - > - {isDragging && !isAdjacentToDrag && ( -
- )} -
, - ); - - // ── Page card ── - if (slot < items.length) { - const item = items[slot]; - const isSource = dragIndex === slot; - - if (item.type === "copy") { - elements.push( -
{ - e.dataTransfer.effectAllowed = "move"; - setDragIndex(slot); - }} - onDragEnd={() => { - setDragIndex(null); - setDragOverSlot(null); - }} - {...getTouchHandlers(slot)} - className={`shrink-0 p-2 flex flex-col items-center gap-1.5 cursor-grab active:cursor-grabbing select-none transition-all duration-200 ${ - isSource ? "scale-95 opacity-30" : "scale-100 opacity-100" - }`} - > -
-
- {`Copy -
-
-
- Copy -
-
- - Copy of {item.sourceIndex + 1} - -
, - ); - } else { - elements.push( -
{ - if (!isDragging) handleDuplicatePage(item.index, slot); - }} - onDragStart={(e) => { - if (!hasCopies) return; - e.dataTransfer.effectAllowed = "move"; - setDragIndex(slot); - }} - onDragEnd={() => { - setDragIndex(null); - setDragOverSlot(null); - }} - {...(hasCopies ? getTouchHandlers(slot) : {})} - className={`shrink-0 p-2 flex flex-col items-center gap-1.5 select-none transition-all duration-200 cursor-pointer ${ - hasCopies ? "active:cursor-grabbing" : "hover:scale-105" - } ${isSource ? "scale-95 opacity-30" : "scale-100 opacity-100"}`} - > -
-
- {`Page -
-
- {item.index + 1} -
-
- - Page {item.index + 1} - -
, - ); - } - } - } - - return elements; - }; - return (
{!file ? ( @@ -332,13 +167,112 @@ export default function DuplicatePage() { )}
-
- {renderItems()} -
+ { + const item = items[slot]; + + if (item.type === "copy") { + return ( +
+
+
+ {`Copy +
+
+
+ Copy +
+
+ + Copy of {item.sourceIndex + 1} + +
+ ); + } - {dragIndex !== null && - (() => { - const item = items[dragIndex]; + // Original page card — conditionally draggable (only after copies exist). + // Uses getTouchHandlers directly instead of getItemProps for selective control. + return ( +
{ + if (!isDragging) handleDuplicatePage(item.index, slot); + }} + onDragStart={(e) => { + if (!hasCopies) return; + e.dataTransfer.effectAllowed = "move"; + drag.setDragIndex(slot); + }} + onDragEnd={() => { + drag.setDragIndex(null); + drag.setDragOverSlot(null); + }} + {...(hasCopies ? drag.getTouchHandlers(slot) : {})} + className={`shrink-0 p-2 flex flex-col items-center gap-1.5 select-none transition-all duration-200 cursor-pointer ${ + hasCopies ? "active:cursor-grabbing" : "hover:scale-105" + } ${isSource ? "scale-95 opacity-30" : "scale-100 opacity-100"}`} + > +
+
+ {`Page +
+
+ {item.index + 1} +
+
+ + Page {item.index + 1} + +
+ ); + }} + renderOverlay={(idx) => { + const item = items[idx]; const srcIdx = item?.type === "copy" ? item.sourceIndex @@ -346,19 +280,31 @@ export default function DuplicatePage() { ? item.index : null; if (srcIdx === null) return null; + const isCopy = item?.type === "copy"; return ( - -
+
+
+ {isCopy && ( +
+ )} +
+
+ {isCopy ? "Copy" : srcIdx + 1}
- +
); - })()} + }} + /> {hasCopies && (

diff --git a/src/tools/ReorderPages.tsx b/src/tools/ReorderPages.tsx index 640216b..eb40a62 100644 --- a/src/tools/ReorderPages.tsx +++ b/src/tools/ReorderPages.tsx @@ -10,12 +10,12 @@ import { useState, useCallback } from "react"; import { FileDropZone } from "../components/FileDropZone.tsx"; +import { SortableGrid } from "../components/SortableGrid.tsx"; import { categoryAccent, categoryGlow } from "../config/theme.ts"; import { reorderPages } from "../utils/pdf-operations.ts"; import { renderAllThumbnails } from "../utils/pdf-renderer.ts"; import { downloadPdf } from "../utils/file-helpers.ts"; import { useSortableDrag } from "../hooks/useSortableDrag.ts"; -import { TouchDragOverlay } from "../components/TouchDragOverlay.tsx"; import { Undo2 } from "lucide-react"; export default function ReorderPages() { @@ -38,8 +38,7 @@ export default function ReorderPages() { }, []); // Drag state (desktop + mobile touch) - const { dragIndex, dragOverSlot, touchPos, setDragIndex, setDragOverSlot, getTouchHandlers } = - useSortableDrag(handleMove); + const drag = useSortableDrag(handleMove); const handleFile = useCallback(async (files: File[]) => { const pdf = files[0]; @@ -80,133 +79,12 @@ export default function ReorderPages() { const handleReset = useCallback(() => { setOrder(thumbnails.map((_, i) => i)); - setDragIndex(null); - setDragOverSlot(null); - }, [thumbnails, setDragIndex, setDragOverSlot]); + drag.setDragIndex(null); + drag.setDragOverSlot(null); + }, [thumbnails, drag]); const isReordered = order.some((pageIdx, i) => pageIdx !== i); - const isDragging = dragIndex !== null; - - /** - * Build the interleaved layout: [drop_0] [page_0] [drop_1] [page_1] ... [page_N-1] [drop_N] - * - * When dragging, the drop-zone gaps expand and show a vertical bar indicator - * on hover—exactly like AddBlankPage. The source page card gets a highlighted - * ring to show it's selected. Dropping on a gap moves the page to that slot. - */ - const renderItems = () => { - const items: React.ReactNode[] = []; - - for (let slot = 0; slot <= order.length; slot++) { - // ── Drop zone ── - // Don't show drop zones immediately adjacent to the dragged card - // (dropping there would be a no-op). - const isAdjacentToDrag = dragIndex !== null && (slot === dragIndex || slot === dragIndex + 1); - - items.push( -

{ - if (isAdjacentToDrag) return; - e.preventDefault(); - setDragOverSlot(slot); - }} - onDragLeave={(e) => { - if (!e.currentTarget.contains(e.relatedTarget as Node)) { - if (dragOverSlot === slot) setDragOverSlot(null); - } - }} - onDrop={(e) => { - e.preventDefault(); - if (dragIndex === null || isAdjacentToDrag) return; - setOrder((prev) => { - const next = [...prev]; - const [moved] = next.splice(dragIndex, 1); - // Adjust target: if we removed from before the slot the index shifts - const adjustedSlot = dragIndex < slot ? slot - 1 : slot; - next.splice(adjustedSlot, 0, moved); - return next; - }); - setDragIndex(null); - setDragOverSlot(null); - }} - className={`self-stretch flex items-center justify-center rounded-lg transition-all duration-200 ${ - isDragging && !isAdjacentToDrag - ? dragOverSlot === slot - ? "w-20 sm:w-24 bg-primary-50 dark:bg-primary-900/20" - : "w-3 sm:w-4" - : "w-0" - }`} - > - {isDragging && !isAdjacentToDrag && ( -
- )} -
, - ); - - // ── Page card ── - if (slot < order.length) { - const originalIndex = order[slot]; - const isSource = dragIndex === slot; - - items.push( -
{ - e.dataTransfer.effectAllowed = "move"; - setDragIndex(slot); - }} - onDragEnd={() => { - setDragIndex(null); - setDragOverSlot(null); - }} - {...getTouchHandlers(slot)} - className={`shrink-0 pt-2 pr-2 flex flex-col items-center gap-1.5 cursor-grab active:cursor-grabbing select-none transition-all duration-200 ${ - isSource ? "scale-95 opacity-30" : "scale-100 opacity-100" - }`} - > -
-
- {`Page -
-
- {originalIndex + 1} -
-
- - Page {originalIndex + 1} - -
, - ); - } - } - - return items; - }; + const isDragging = drag.dragIndex !== null; return (
@@ -263,22 +141,67 @@ export default function ReorderPages() { )}
-
- {renderItems()} -
- - {dragIndex !== null && ( - -
- + { + const originalIndex = order[slot]; + return ( +
+
+
+ {`Page +
+
+ {originalIndex + 1} +
+
+ + Page {originalIndex + 1} + +
+ ); + }} + renderOverlay={(idx) => ( +
+
+ +
+
+ {order[idx] + 1} +
- - )} + )} + /> {isReordered && (

From 070b8a7112edfa6c61e65c803744009deb71aa94 Mon Sep 17 00:00:00 2001 From: Sumit Sahoo Date: Sat, 11 Apr 2026 13:55:04 +0530 Subject: [PATCH 3/4] fix: ensure TouchDragOverlay only renders when touch position is available --- src/components/SortableGrid.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/SortableGrid.tsx b/src/components/SortableGrid.tsx index bfb9c68..0448819 100644 --- a/src/components/SortableGrid.tsx +++ b/src/components/SortableGrid.tsx @@ -114,7 +114,7 @@ export function SortableGrid({ {elements}

- {dragIndex !== null && renderOverlay && ( + {dragIndex !== null && touchPos !== null && renderOverlay && ( {renderOverlay(dragIndex)} )} From 3c9a1535aaa06d8bca429c4bb449cacddf891f09 Mon Sep 17 00:00:00 2001 From: Sumit Sahoo Date: Sat, 11 Apr 2026 14:01:25 +0530 Subject: [PATCH 4/4] feat: add scroll-to-top functionality on view change and enhance logo filter transition --- src/App.tsx | 6 ++++++ src/components/Layout.tsx | 8 +++++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/App.tsx b/src/App.tsx index f40362d..779fd17 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -695,6 +695,12 @@ export function App() { setShowPrivacy(true); }, []); + /** Scroll to top whenever the view changes. */ + // eslint-disable-next-line react-hooks/exhaustive-deps -- activeTool and showPrivacy are intentional trigger deps + useEffect(() => { + window.scrollTo(0, 0); + }, [activeTool, showPrivacy]); + /** Metadata for the active tool (memoised to avoid redundant lookups). */ const activeMeta = useMemo( () => (activeTool ? (tools.find((t) => t.id === activeTool) ?? null) : null), diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index e43e5d6..1ec0756 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -102,7 +102,13 @@ export function Layout({ children, onHome, showBack, onPrivacy, badgeAccent }: L
{/* Brand + copyright */}
- + CloakPDF