mirror of
https://github.com/alam00000/bentopdf.git
synced 2026-11-05 03:50:29 +01:00
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:
@@ -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,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,
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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