Merge pull request #951 from AB527/feature/drag-and-drop-arrangement-for-multiple-file-conversions

(Feature) Add drag-and-drop arrangement for multi-file to PDF conversions
This commit is contained in:
Alam
2026-10-26 15:00:46 +05:30
committed by GitHub
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');
+77
View File
@@ -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<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);
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,
})
);
}
+100
View File
@@ -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);
});
});
+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"