From 49ab2ef17f50be4410a96ab8f0a5bb71d202c650 Mon Sep 17 00:00:00 2001 From: Atharva Bedekar Date: Sat, 24 Oct 2026 16:38:27 +0530 Subject: [PATCH] feat: drag-and-drop reordering for multi-file to PDF conversions Tools that combine several files into a single PDF used the upload order as the page order, with no way to change it short of clearing and re-adding files. Each file row now has a drag handle, and the new order is used when the PDF is built. Applies to the JPG, PNG, WebP, BMP, HEIC, SVG, TIFF, Image and TXT to PDF tools. The shared logic lives in utils/sortable-file-list.ts (built on the existing sortablejs dependency) instead of being duplicated per page. --- src/js/logic/bmp-to-pdf-page.ts | 11 ++- src/js/logic/heic-to-pdf-page.ts | 11 ++- src/js/logic/image-to-pdf-page.ts | 11 ++- src/js/logic/jpg-to-pdf-page.ts | 11 ++- src/js/logic/png-to-pdf-page.ts | 11 ++- src/js/logic/svg-to-pdf-page.ts | 11 ++- src/js/logic/tiff-to-pdf-page.ts | 11 ++- src/js/logic/txt-to-pdf-page.ts | 21 +++++- src/js/logic/webp-to-pdf-page.ts | 11 ++- src/js/utils/sortable-file-list.ts | 75 ++++++++++++++++++++ src/tests/sortable-file-list.test.ts | 102 +++++++++++++++++++++++++++ translations/ui-source.json | 6 ++ 12 files changed, 281 insertions(+), 11 deletions(-) create mode 100644 src/js/utils/sortable-file-list.ts create mode 100644 src/tests/sortable-file-list.test.ts diff --git a/src/js/logic/bmp-to-pdf-page.ts b/src/js/logic/bmp-to-pdf-page.ts index 7909c9d9..d106b10a 100644 --- a/src/js/logic/bmp-to-pdf-page.ts +++ b/src/js/logic/bmp-to-pdf-page.ts @@ -1,5 +1,10 @@ import { showLoader, hideLoader, showAlert } from '../ui.js'; import { downloadFile, formatBytes } from '../utils/helpers.js'; +import { + createDragHandle, + enableFileListReorder, + moveItem, +} from '../utils/sortable-file-list.js'; import { createIcons, icons } from 'lucide'; import { PDFDocument as PDFLibDocument } from 'pdf-lib'; @@ -72,7 +77,7 @@ const updateUI = () => { size: formatBytes(file.size), }); - infoContainer.append(nameSpan, sizeSpan); + infoContainer.append(createDragHandle(), nameSpan, sizeSpan); const removeBtn = document.createElement('button'); removeBtn.className = @@ -86,6 +91,10 @@ const updateUI = () => { fileDiv.append(infoContainer, removeBtn); fileDisplayArea.appendChild(fileDiv); }); + enableFileListReorder(fileDisplayArea, (oldIndex, newIndex) => { + files = moveItem(files, oldIndex, newIndex); + updateUI(); + }); createIcons({ icons }); } else { fileControls.classList.add('hidden'); diff --git a/src/js/logic/heic-to-pdf-page.ts b/src/js/logic/heic-to-pdf-page.ts index b8ce788a..5aecc9be 100644 --- a/src/js/logic/heic-to-pdf-page.ts +++ b/src/js/logic/heic-to-pdf-page.ts @@ -1,5 +1,10 @@ import { showLoader, hideLoader, showAlert } from '../ui.js'; import { downloadFile, formatBytes } from '../utils/helpers.js'; +import { + createDragHandle, + enableFileListReorder, + moveItem, +} from '../utils/sortable-file-list.js'; import { createIcons, icons } from 'lucide'; import heic2any from 'heic2any'; import { PDFDocument as PDFLibDocument } from 'pdf-lib'; @@ -39,7 +44,7 @@ const updateUI = () => { size: formatBytes(file.size), }); - infoContainer.append(nameSpan, sizeSpan); + infoContainer.append(createDragHandle(), nameSpan, sizeSpan); const removeBtn = document.createElement('button'); removeBtn.className = @@ -53,6 +58,10 @@ const updateUI = () => { fileDiv.append(infoContainer, removeBtn); fileDisplayArea.appendChild(fileDiv); }); + enableFileListReorder(fileDisplayArea, (oldIndex, newIndex) => { + files = moveItem(files, oldIndex, newIndex); + updateUI(); + }); createIcons({ icons }); } else { fileControls.classList.add('hidden'); diff --git a/src/js/logic/image-to-pdf-page.ts b/src/js/logic/image-to-pdf-page.ts index 3de5a004..82e6dbe1 100644 --- a/src/js/logic/image-to-pdf-page.ts +++ b/src/js/logic/image-to-pdf-page.ts @@ -1,6 +1,11 @@ import { createIcons, icons } from 'lucide'; import { showAlert, showLoader, hideLoader } from '@/js/ui.js'; import { downloadFile, formatBytes } from '@/js/utils/helpers.js'; +import { + createDragHandle, + enableFileListReorder, + moveItem, +} from '@/js/utils/sortable-file-list.js'; import { loadPyMuPDF } from '@/js/utils/pymupdf-loader.js'; import type { PyMuPDFInstance } from '@/types'; import { @@ -145,7 +150,7 @@ function updateUI() { size: formatBytes(file.size), }); - infoContainer.append(nameSpan, sizeSpan); + infoContainer.append(createDragHandle(), nameSpan, sizeSpan); const removeBtn = document.createElement('button'); removeBtn.className = @@ -159,6 +164,10 @@ function updateUI() { fileDiv.append(infoContainer, removeBtn); fileDisplayArea.appendChild(fileDiv); }); + enableFileListReorder(fileDisplayArea, (oldIndex, newIndex) => { + files = moveItem(files, oldIndex, newIndex); + updateUI(); + }); createIcons({ icons }); } else { fileControls.classList.add('hidden'); diff --git a/src/js/logic/jpg-to-pdf-page.ts b/src/js/logic/jpg-to-pdf-page.ts index 61e7add7..544029ce 100644 --- a/src/js/logic/jpg-to-pdf-page.ts +++ b/src/js/logic/jpg-to-pdf-page.ts @@ -1,6 +1,11 @@ import { createIcons, icons } from 'lucide'; import { showAlert, showLoader, hideLoader } from '../ui.js'; import { downloadFile, formatBytes } from '../utils/helpers.js'; +import { + createDragHandle, + enableFileListReorder, + moveItem, +} from '../utils/sortable-file-list.js'; import { loadPyMuPDF } from '../utils/pymupdf-loader.js'; import type { PyMuPDFInstance } from '@/types'; import { @@ -148,7 +153,7 @@ function updateUI() { size: formatBytes(file.size), }); - infoContainer.append(nameSpan, sizeSpan); + infoContainer.append(createDragHandle(), nameSpan, sizeSpan); const removeBtn = document.createElement('button'); removeBtn.className = @@ -162,6 +167,10 @@ function updateUI() { fileDiv.append(infoContainer, removeBtn); fileDisplayArea.appendChild(fileDiv); }); + enableFileListReorder(fileDisplayArea, (oldIndex, newIndex) => { + files = moveItem(files, oldIndex, newIndex); + updateUI(); + }); createIcons({ icons }); } else { fileControls.classList.add('hidden'); diff --git a/src/js/logic/png-to-pdf-page.ts b/src/js/logic/png-to-pdf-page.ts index 21998ce1..9903a7cf 100644 --- a/src/js/logic/png-to-pdf-page.ts +++ b/src/js/logic/png-to-pdf-page.ts @@ -5,6 +5,11 @@ import { readFileAsArrayBuffer, formatBytes, } from '../utils/helpers.js'; +import { + createDragHandle, + enableFileListReorder, + moveItem, +} from '../utils/sortable-file-list.js'; import { PDFDocument as PDFLibDocument } from 'pdf-lib'; import { getSelectedQuality, @@ -139,7 +144,7 @@ function updateUI() { size: formatBytes(file.size), }); - infoContainer.append(nameSpan, sizeSpan); + infoContainer.append(createDragHandle(), nameSpan, sizeSpan); const removeBtn = document.createElement('button'); removeBtn.className = @@ -153,6 +158,10 @@ function updateUI() { fileDiv.append(infoContainer, removeBtn); fileDisplayArea.appendChild(fileDiv); }); + enableFileListReorder(fileDisplayArea, (oldIndex, newIndex) => { + files = moveItem(files, oldIndex, newIndex); + updateUI(); + }); createIcons({ icons }); } else { fileControls.classList.add('hidden'); diff --git a/src/js/logic/svg-to-pdf-page.ts b/src/js/logic/svg-to-pdf-page.ts index 2c8daa89..306e8269 100644 --- a/src/js/logic/svg-to-pdf-page.ts +++ b/src/js/logic/svg-to-pdf-page.ts @@ -1,6 +1,11 @@ import { createIcons, icons } from 'lucide'; import { showAlert, showLoader, hideLoader } from '../ui.js'; import { downloadFile, formatBytes } from '../utils/helpers.js'; +import { + createDragHandle, + enableFileListReorder, + moveItem, +} from '../utils/sortable-file-list.js'; import { PDFDocument as PDFLibDocument } from 'pdf-lib'; import { getSelectedQuality, @@ -135,7 +140,7 @@ function updateUI() { size: formatBytes(file.size), }); - infoContainer.append(nameSpan, sizeSpan); + infoContainer.append(createDragHandle(), nameSpan, sizeSpan); const removeBtn = document.createElement('button'); removeBtn.className = @@ -149,6 +154,10 @@ function updateUI() { fileDiv.append(infoContainer, removeBtn); fileDisplayArea.appendChild(fileDiv); }); + enableFileListReorder(fileDisplayArea, (oldIndex, newIndex) => { + files = moveItem(files, oldIndex, newIndex); + updateUI(); + }); createIcons({ icons }); } else { fileControls.classList.add('hidden'); diff --git a/src/js/logic/tiff-to-pdf-page.ts b/src/js/logic/tiff-to-pdf-page.ts index 03137c3a..d5ea8382 100644 --- a/src/js/logic/tiff-to-pdf-page.ts +++ b/src/js/logic/tiff-to-pdf-page.ts @@ -4,6 +4,11 @@ import { formatBytes, readFileAsArrayBuffer, } from '../utils/helpers.js'; +import { + createDragHandle, + enableFileListReorder, + moveItem, +} from '../utils/sortable-file-list.js'; import { createIcons, icons } from 'lucide'; import { PDFDocument as PDFLibDocument } from 'pdf-lib'; import { decode } from 'tiff'; @@ -47,7 +52,7 @@ const updateUI = () => { size: formatBytes(file.size), }); - infoContainer.append(nameSpan, sizeSpan); + infoContainer.append(createDragHandle(), nameSpan, sizeSpan); const removeBtn = document.createElement('button'); removeBtn.className = @@ -61,6 +66,10 @@ const updateUI = () => { fileDiv.append(infoContainer, removeBtn); fileDisplayArea.appendChild(fileDiv); }); + enableFileListReorder(fileDisplayArea, (oldIndex, newIndex) => { + files = moveItem(files, oldIndex, newIndex); + updateUI(); + }); createIcons({ icons }); } else { fileControls.classList.add('hidden'); diff --git a/src/js/logic/txt-to-pdf-page.ts b/src/js/logic/txt-to-pdf-page.ts index 09128fad..d6838bbe 100644 --- a/src/js/logic/txt-to-pdf-page.ts +++ b/src/js/logic/txt-to-pdf-page.ts @@ -1,5 +1,10 @@ import { showLoader, hideLoader, showAlert } from '../ui.js'; import { downloadFile, formatBytes } from '../utils/helpers.js'; +import { + createDragHandle, + enableFileListReorder, + moveItem, +} from '../utils/sortable-file-list.js'; import { createIcons, icons } from 'lucide'; import { loadPyMuPDF } from '../utils/pymupdf-loader.js'; @@ -34,27 +39,37 @@ const updateUI = () => { fileDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm'; + const infoContainer = document.createElement('div'); + infoContainer.className = 'flex items-center gap-2 overflow-hidden'; + const infoSpan = document.createElement('span'); infoSpan.className = 'truncate font-medium text-gray-200'; infoSpan.textContent = file.name; const sizeSpan = document.createElement('span'); - sizeSpan.className = 'text-gray-400 text-xs ml-2'; + sizeSpan.className = 'flex-shrink-0 text-gray-400 text-xs'; sizeSpan.textContent = trText('({{size}})', { size: formatBytes(file.size), }); + infoContainer.append(createDragHandle(), infoSpan, sizeSpan); + const removeBtn = document.createElement('button'); - removeBtn.className = 'ml-4 text-red-400 hover:text-red-300'; + removeBtn.className = + 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0'; removeBtn.innerHTML = ''; removeBtn.onclick = () => { files = files.filter((_, i) => i !== index); updateUI(); }; - fileDiv.append(infoSpan, sizeSpan, removeBtn); + fileDiv.append(infoContainer, removeBtn); fileDisplayArea.appendChild(fileDiv); }); + enableFileListReorder(fileDisplayArea, (oldIndex, newIndex) => { + files = moveItem(files, oldIndex, newIndex); + updateUI(); + }); createIcons({ icons }); } else { dropZone.classList.remove('hidden'); diff --git a/src/js/logic/webp-to-pdf-page.ts b/src/js/logic/webp-to-pdf-page.ts index 89bf4ece..596020e9 100644 --- a/src/js/logic/webp-to-pdf-page.ts +++ b/src/js/logic/webp-to-pdf-page.ts @@ -5,6 +5,11 @@ import { readFileAsArrayBuffer, formatBytes, } from '../utils/helpers.js'; +import { + createDragHandle, + enableFileListReorder, + moveItem, +} from '../utils/sortable-file-list.js'; import { PDFDocument as PDFLibDocument } from 'pdf-lib'; import { getSelectedQuality, @@ -139,7 +144,7 @@ function updateUI() { size: formatBytes(file.size), }); - infoContainer.append(nameSpan, sizeSpan); + infoContainer.append(createDragHandle(), nameSpan, sizeSpan); const removeBtn = document.createElement('button'); removeBtn.className = @@ -153,6 +158,10 @@ function updateUI() { fileDiv.append(infoContainer, removeBtn); fileDisplayArea.appendChild(fileDiv); }); + enableFileListReorder(fileDisplayArea, (oldIndex, newIndex) => { + files = moveItem(files, oldIndex, newIndex); + updateUI(); + }); createIcons({ icons }); } else { fileControls.classList.add('hidden'); diff --git a/src/js/utils/sortable-file-list.ts b/src/js/utils/sortable-file-list.ts new file mode 100644 index 00000000..b8cd54dc --- /dev/null +++ b/src/js/utils/sortable-file-list.ts @@ -0,0 +1,75 @@ +import Sortable from 'sortablejs'; +import { trText } from '../i18n/i18n.js'; + +type ReorderHandler = (oldIndex: number, newIndex: number) => void; + +const sortables = new WeakMap(); + +export function moveItem( + items: readonly T[], + oldIndex: number, + newIndex: number +): T[] { + const result = [...items]; + if ( + oldIndex === newIndex || + oldIndex < 0 || + oldIndex >= result.length || + newIndex < 0 || + newIndex >= result.length + ) { + return result; + } + const [moved] = result.splice(oldIndex, 1); + result.splice(newIndex, 0, moved); + return result; +} + +export function createDragHandle(): HTMLElement { + const handle = document.createElement('div'); + handle.className = + 'drag-handle cursor-move text-gray-400 hover:text-white p-1 rounded transition-colors flex-shrink-0'; + const label = trText('Drag to reorder'); + handle.title = label; + handle.setAttribute('aria-label', label); + handle.innerHTML = ''; + return handle; +} + +// Rows must contain an element created by createDragHandle(). The list is +// re-rendered by the caller, so one Sortable per container is kept and only +// its reorder callback is swapped on subsequent calls. +export function enableFileListReorder( + container: HTMLElement, + onReorder: ReorderHandler +): void { + const onEnd = (evt: Sortable.SortableEvent) => { + const { oldIndex, newIndex } = evt; + if ( + oldIndex === undefined || + newIndex === undefined || + oldIndex === newIndex + ) { + return; + } + onReorder(oldIndex, newIndex); + }; + + const existing = sortables.get(container); + if (existing) { + existing.option('onEnd', onEnd); + return; + } + + sortables.set( + container, + Sortable.create(container, { + handle: '.drag-handle', + animation: 150, + ghostClass: 'sortable-ghost', + chosenClass: 'sortable-chosen', + dragClass: 'sortable-drag', + onEnd, + }) + ); +} diff --git a/src/tests/sortable-file-list.test.ts b/src/tests/sortable-file-list.test.ts new file mode 100644 index 00000000..e70a6efb --- /dev/null +++ b/src/tests/sortable-file-list.test.ts @@ -0,0 +1,102 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const { createMock, optionMock } = vi.hoisted(() => ({ + createMock: vi.fn(), + optionMock: vi.fn(), +})); + +vi.mock('sortablejs', () => ({ + default: { create: createMock }, +})); + +import { + createDragHandle, + enableFileListReorder, + moveItem, +} from '@/js/utils/sortable-file-list'; + +type OnEnd = (evt: { oldIndex?: number; newIndex?: number }) => void; + +function lastOnEnd(): OnEnd { + return createMock.mock.calls[createMock.mock.calls.length - 1][1].onEnd; +} + +describe('moveItem', () => { + it('should move an item forward', () => { + expect(moveItem(['a', 'b', 'c', 'd'], 0, 2)).toEqual(['b', 'c', 'a', 'd']); + }); + + it('should move an item backward', () => { + expect(moveItem(['a', 'b', 'c', 'd'], 3, 1)).toEqual(['a', 'd', 'b', 'c']); + }); + + it('should not mutate the input array', () => { + const items = ['a', 'b', 'c']; + moveItem(items, 0, 2); + expect(items).toEqual(['a', 'b', 'c']); + }); + + it('should return an unchanged copy for out-of-range indices', () => { + expect(moveItem(['a', 'b'], -1, 1)).toEqual(['a', 'b']); + expect(moveItem(['a', 'b'], 0, 5)).toEqual(['a', 'b']); + }); +}); + +describe('createDragHandle', () => { + it('should create an element Sortable can use as a handle', () => { + const handle = createDragHandle(); + expect(handle.classList.contains('drag-handle')).toBe(true); + expect(handle.getAttribute('aria-label')).toBe('Drag to reorder'); + expect( + handle.querySelector('[data-lucide="grip-vertical"]') + ).not.toBeNull(); + }); +}); + +describe('enableFileListReorder', () => { + beforeEach(() => { + createMock.mockReset(); + optionMock.mockReset(); + createMock.mockImplementation(() => ({ option: optionMock })); + }); + + it('should create Sortable restricted to the drag handle', () => { + const container = document.createElement('div'); + enableFileListReorder(container, vi.fn()); + + expect(createMock).toHaveBeenCalledTimes(1); + expect(createMock.mock.calls[0][0]).toBe(container); + expect(createMock.mock.calls[0][1].handle).toBe('.drag-handle'); + }); + + it('should report moves to the callback', () => { + const onReorder = vi.fn(); + enableFileListReorder(document.createElement('div'), onReorder); + + lastOnEnd()({ oldIndex: 2, newIndex: 0 }); + expect(onReorder).toHaveBeenCalledWith(2, 0); + }); + + it('should ignore drops that do not change position', () => { + const onReorder = vi.fn(); + enableFileListReorder(document.createElement('div'), onReorder); + + lastOnEnd()({ oldIndex: 1, newIndex: 1 }); + lastOnEnd()({ oldIndex: undefined, newIndex: 1 }); + expect(onReorder).not.toHaveBeenCalled(); + }); + + it('should reuse the Sortable instance and swap the callback on re-render', () => { + const container = document.createElement('div'); + enableFileListReorder(container, vi.fn()); + const second = vi.fn(); + enableFileListReorder(container, second); + + expect(createMock).toHaveBeenCalledTimes(1); + expect(optionMock).toHaveBeenCalledWith('onEnd', expect.any(Function)); + + const swapped: OnEnd = optionMock.mock.calls[0][1]; + swapped({ oldIndex: 0, newIndex: 1 }); + expect(second).toHaveBeenCalledWith(0, 1); + }); +}); diff --git a/translations/ui-source.json b/translations/ui-source.json index ad6389f7..bd5453ba 100644 --- a/translations/ui-source.json +++ b/translations/ui-source.json @@ -2647,6 +2647,12 @@ ], "fileCount": 1 }, + "Drag to reorder": { + "files": [ + "utils/sortable-file-list.ts" + ], + "fileCount": 1 + }, "Drops tagging, which screen readers rely on.": { "files": [ "logic/compress-pdf-page.ts"