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"