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..2fcbbbd5
--- /dev/null
+++ b/src/js/utils/sortable-file-list.ts
@@ -0,0 +1,77 @@
+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);
+ const icon = document.createElement('i');
+ icon.className = 'ph ph-dots-six-vertical text-base';
+ handle.appendChild(icon);
+ 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..6de68aff
--- /dev/null
+++ b/src/tests/sortable-file-list.test.ts
@@ -0,0 +1,100 @@
+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('i.ph.ph-dots-six-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"