mirror of
https://github.com/alam00000/bentopdf.git
synced 2026-11-05 03:50:29 +01:00
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:
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user