Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link

Copilot AI Apr 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The react-hooks/exhaustive-deps suppression looks unnecessary here since the effect already lists the intended dependencies (activeTool, showPrivacy). Consider removing the eslint-disable comment to avoid masking real dependency issues in future edits.

Suggested change
// eslint-disable-next-line react-hooks/exhaustive-deps -- activeTool and showPrivacy are intentional trigger deps

Copilot uses AI. Check for mistakes.
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),
Expand Down
8 changes: 7 additions & 1 deletion src/components/Layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,13 @@ export function Layout({ children, onHome, showBack, onPrivacy, badgeAccent }: L
<div className="max-w-6xl mx-auto px-4 sm:px-6 py-4 flex flex-col sm:flex-row items-center justify-between gap-3">
{/* Brand + copyright */}
<div className="flex items-center gap-2">
<img src="/icons/logo.svg" alt="" aria-hidden="true" className="w-5 h-5 opacity-60" />
<img
src="/icons/logo.svg"
alt=""
aria-hidden="true"
className="w-5 h-5 opacity-60 transition-[filter] duration-300"
style={badgeAccent?.logoFilter ? { filter: badgeAccent.logoFilter } : undefined}
/>
<span className="text-xs font-medium text-slate-500 dark:text-dark-text-muted">
CloakPDF
</span>
Expand Down
122 changes: 122 additions & 0 deletions src/components/SortableGrid.tsx
Original file line number Diff line number Diff line change
@@ -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);
*
* <SortableGrid
* itemCount={items.length}
* drag={drag}
* onMove={handleMove}
* renderItem={(slot, isSource) => <PageCard ... />}
* renderOverlay={(dragIndex) => <OverlayContent ... />}
* />
*/

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(
<div
key={`drop-${slot}`}
data-drop-slot={slot}
onDragOver={(e) => {
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 && (
<div
className={`rounded-full transition-all duration-200 ${
dragOverSlot === slot
? "w-1 bg-primary-500"
: "w-0.5 bg-primary-200 dark:bg-primary-800"
}`}
style={{ height: dragOverSlot === slot ? "80%" : "60%" }}
/>
)}
</div>,
);

// ── Item ──
if (slot < itemCount) {
elements.push(renderItem(slot, dragIndex === slot));
}
}

return (
<>
<div
className={className ?? "flex flex-wrap items-end gap-y-6 overflow-x-auto pb-2 min-h-28"}
>
{elements}
</div>

{dragIndex !== null && touchPos !== null && renderOverlay && (
<TouchDragOverlay touchPos={touchPos}>{renderOverlay(dragIndex)}</TouchDragOverlay>
)}
</>
);
}
36 changes: 36 additions & 0 deletions src/components/TouchDragOverlay.tsx
Original file line number Diff line number Diff line change
@@ -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(
<div
style={{
position: "fixed",
left: touchPos.x,
top: touchPos.y,
transform: "translate(-50%, -60%)",
pointerEvents: "none",
zIndex: 9999,
}}
className="opacity-80 scale-90 shadow-xl rounded-lg"
>
{children}
</div>,
document.body,
);
}
46 changes: 43 additions & 3 deletions src/hooks/useSortableDrag.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -21,6 +23,8 @@ import { useState, useEffect, useRef, useCallback } from "react";
export function useSortableDrag(onMove: (fromIndex: number, toSlot: number) => void) {
const [dragIndex, setDragIndex] = useState<number | null>(null);
const [dragOverSlot, setDragOverSlot] = useState<number | null>(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<number | null>(null);
Expand All @@ -29,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) {
Expand All @@ -47,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;
Expand All @@ -65,6 +89,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;
Expand Down Expand Up @@ -97,6 +124,7 @@ export function useSortableDrag(onMove: (fromIndex: number, toSlot: number) => v
touchStartPos.current = null;
setDragIndex(null);
setDragOverSlot(null);
setTouchPos(null);
};

const handleTouchCancel = () => {
Expand All @@ -105,6 +133,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.
Expand All @@ -119,5 +148,16 @@ 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,
getItemProps,
getTouchHandlers,
};
}

/** Convenience type for passing the full drag bag to SortableGrid. */
export type SortableDrag = ReturnType<typeof useSortableDrag>;
Loading
Loading