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.
This commit is contained in:
Atharva Bedekar
2026-10-25 15:34:16 +05:30
parent 4edb1718cc
commit 49ab2ef17f
12 changed files with 281 additions and 11 deletions
+10 -1
View File
@@ -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');
+10 -1
View File
@@ -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');
+10 -1
View File
@@ -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');
+10 -1
View File
@@ -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');
+10 -1
View File
@@ -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');
+10 -1
View File
@@ -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');
+10 -1
View File
@@ -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');
+18 -3
View File
@@ -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 = '<i data-lucide="trash-2" class="w-4 h-4"></i>';
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');
+10 -1
View File
@@ -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');
+75
View File
@@ -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<HTMLElement, Sortable>();
export function moveItem<T>(
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 = '<i data-lucide="grip-vertical" class="w-4 h-4"></i>';
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,
})
);
}
+102
View File
@@ -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);
});
});
+6
View File
@@ -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"