diff --git a/src/main/resources/messages_ar_AR.properties b/src/main/resources/messages_ar_AR.properties index 1202616bf..ff0b274c7 100644 --- a/src/main/resources/messages_ar_AR.properties +++ b/src/main/resources/messages_ar_AR.properties @@ -1263,6 +1263,11 @@ splitByChapters.desc.3=تمثيل البيانات الأصلية: إذا تم splitByChapters.desc.4=سماح بالتكرار: إذا تم اختياره، يسمح بوجود معاينات متعددة في الصفحة نفسها لخلق ملفات PDF منفصلة. splitByChapters.submit=تقطيع ملف PDF +#File Chooser +fileChooser.click=انقر هنا +fileChooser.or=أو +fileChooser.dragAndDrop=قم بسحب الملفات وإفلاتها +fileChooser.hoveredDragAndDrop=قم بسحب المفات وإفلاتها هنا #release notes releases.footer=Releases diff --git a/src/main/resources/messages_en_GB.properties b/src/main/resources/messages_en_GB.properties index 3596175a7..210498ea3 100644 --- a/src/main/resources/messages_en_GB.properties +++ b/src/main/resources/messages_en_GB.properties @@ -1263,6 +1263,11 @@ splitByChapters.desc.3=Include Metadata: If checked, the original PDF's metadata splitByChapters.desc.4=Allow Duplicates: If checked, allows multiple bookmarks on the same page to create separate PDFs. splitByChapters.submit=Split PDF +#File Chooser +fileChooser.click=Click +fileChooser.or=or +fileChooser.dragAndDrop=Drag & Drop +fileChooser.hoveredDragAndDrop=Drag & Drop file(s) here #release notes releases.footer=Releases diff --git a/src/main/resources/static/css/fileSelect.css b/src/main/resources/static/css/fileSelect.css index e8f129795..4e93840ff 100644 --- a/src/main/resources/static/css/fileSelect.css +++ b/src/main/resources/static/css/fileSelect.css @@ -1,10 +1,221 @@ +.custom-file-chooser { + display: flex; + flex-direction: column; + position: relative; + + min-height: 55px; + border-radius: 1rem; + --selected-files-display: none; +} + +.input-container { + position: relative; + border-radius: 1rem; + border: 1px dashed rgb(105, 116, 134); + + column-gap: 7px; + row-gap: 7px; + height: 150px; + width: 100%; + + --overlay-display: none; + transition: background-color 0.5s linear; +} + +.input-container:hover { + outline: none; + border: none; + background-color: var(--md-sys-color-surface-container-low); + + -webkit-transition: box-shadow 1s ease, background-color 2s linear; + -moz-transition: box-shadow 1s ease, background-color 2s linear; + -o-transition: box-shadow 1s ease, background-color 2s linear; + -ms-transition: box-shadow 1s ease, background-color 2s linear; + transition: box-shadow 1s ease, background-color 2s linear; + + box-shadow: 0 0 10px rgb(105, 116, 134); + cursor: pointer; +} + +.input-container * { + user-select: none; + pointer-events: none; +} + + +.input-container::before { + display: var(--overlay-display); + position: absolute; + + content: ''; + + top: 0; + left: 0; + + height: 100%; + width: 100%; + + background-color: var(--md-sys-color-surface); + z-index: 1; + + white-space: pre; + border-radius: 1rem; +} + +.input-container::after { + display: var(--overlay-display); + position: absolute; + + content: attr(data-text); + font-size: 0.9rem; + font-weight: 550; + color: var(--md-sys-color-on-surface); + + background-color: transparent; + + min-width: 150px; + + top: 50%; + left: 50%; + + transform: translateX(-50%) translateY(-50%); + text-align: center; + + z-index: 2; +} + +input[type="file"] { + display: none; +} + +.input-container div:nth-of-type(2) { + color: var(--md-sys-color-on-surface); +} + +.input-container div:nth-of-type(1), .input-container div:nth-of-type(3) { + color: var(--md-sys-color-on-surface); + font-size: 16px; + font-weight: bold; +} + +.file-input-btn { + display: inline-block; + + border: 1px solid #ccc; + padding: 6px 12px; + cursor: pointer; + + color: #212529; + font-size: 1rem; + border-radius: 3rem; + + background-color: #DDE0E3; +} + +.small-file-container { + padding-top: 1px; + position: relative; + row-gap: 1px; + height: 60px; + width: 60px; +} + +.file-icon { + display: flex; + align-items: center; + justify-content: center; + height: 30px; + width: 30px; +} + +.file-icon * { + height: inherit; + width: inherit; +} + +.file-info { + min-width: 0; +} + +.file-info > div:nth-child(1) { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--md-sys-color-on-surface); + + max-width: 60px; + font-size: 0.75rem; +} + +.file-info > div:nth-child(2) { + overflow: hidden; + text-overflow: ellipsis; + color: grey; + + max-width: 60px; + font-size: 10px; +} + +.remove-selected-file { + display: flex; + justify-content: center; + align-items: center; + + position: absolute; + height: 15px; + width: 15px; + + right: 10px; + top: -5px; +} + +.remove-selected-file * { + overflow: hidden; + height: inherit; + width: inherit; + z-index: 3; + pointer-events: none; + user-select: none; +} + +.remove-selected-file:after { + content: ''; + position: absolute; + + left: 1; + + width: 10px; + height: 10px; + border-radius: 50%; + + background-color: white; + z-index: 2; + + user-select: none; + pointer-events: none; +} + +.remove-selected-file:hover { + cursor: pointer; +} + .custom-file-label { padding-right: 90px; } .selected-files { - margin-top: 10px; - max-height: 150px; - overflow-y: auto; + display: var(--selected-files-display); + padding-left: 5px; + padding-right: 3px; + padding-top: 15px; + padding-bottom: 15px; + + flex: 1; white-space: pre-wrap; + + row-gap: 12px; + column-gap: 5px; + + border-radius: 1rem; + border: 1px solid rgb(105, 116, 134, 0.5); } diff --git a/src/main/resources/static/js/file-icon-factory.js b/src/main/resources/static/js/file-icon-factory.js new file mode 100644 index 000000000..81328f423 --- /dev/null +++ b/src/main/resources/static/js/file-icon-factory.js @@ -0,0 +1,52 @@ +class FileIconFactory { + static createFileIcon(fileExtension) { + let ext = fileExtension.toLowerCase(); + switch (ext) { + case "pdf": + return this.createPDFIcon(); + case "csv": + return this.createCSVIcon(); + case "jpe": + case "jpg": + case "jpeg": + case "gif": + case "png": + case "bmp": + case "ico": + case "svg": + case "svgz": + case "tif": + case "tiff": + case "ai": + case "drw": + case "pct": + case "psp": + case "xcf": + case "psd": + case "raw": + case "webp": + case "heic": + return this.createImageIcon(); + default: + return this.createUnknownFileIcon(); + } + } + + static createPDFIcon() { + return ` + + + + `; + } + + static createImageIcon() { + return ``; + } + + static createUnknownFileIcon() { + return ``; + } +} + +export default FileIconFactory; diff --git a/src/main/resources/static/js/file-utils.js b/src/main/resources/static/js/file-utils.js new file mode 100644 index 000000000..98772cb2f --- /dev/null +++ b/src/main/resources/static/js/file-utils.js @@ -0,0 +1,31 @@ +class FileUtils { + static extractFileExtension(filename) { + if (!filename || filename.trim().length <= 0) return ""; + let trimmedName = filename.trim(); + return trimmedName.substring(trimmedName.lastIndexOf(".") + 1); + } + + static transformFileSize(size) { + if (!size) return `0Bs`; + let oneKB = 1024; + let oneMB = oneKB * 1024; + let oneGB = oneMB * 1024; + let oneTB = oneGB * 1024; + + if (size < oneKB) return `${this._toFixed(size)}Bs`; + else if (oneKB <= size && size < oneMB) return `${this._toFixed(size / oneKB)}KBs`; + else if (oneMB <= size && size < oneGB) return `${this._toFixed(size / oneMB)}MBs`; + else if (oneGB <= size && size < oneTB) return `${this._toFixed(size / oneGB)}GBs`; + else return `${this._toFixed(size / oneTB)}TBs`; + } + + static _toFixed(val, digits = 1) { + // Return value without ending 0s after decimal point + // Example: if res == 145.0 then return 145, else if 145.x (where x != 0) return 145.x + let res = val.toFixed(digits); + let resRounded = (res|0); + return res == resRounded ? resRounded : res; + } +} + +export default FileUtils; diff --git a/src/main/resources/static/js/fileInput.js b/src/main/resources/static/js/fileInput.js index 4b0cdd33d..e288f5b84 100644 --- a/src/main/resources/static/js/fileInput.js +++ b/src/main/resources/static/js/fileInput.js @@ -1,3 +1,7 @@ +import FileIconFactory from "./file-icon-factory.js"; +import FileUtils from "./file-utils.js"; +import UUID from './uuid.js'; + let isScriptExecuted = false; if (!isScriptExecuted) { isScriptExecuted = true; @@ -6,54 +10,61 @@ if (!isScriptExecuted) { }); } + function setupFileInput(chooser) { const elementId = chooser.getAttribute("data-bs-element-id"); const filesSelected = chooser.getAttribute("data-bs-files-selected"); const pdfPrompt = chooser.getAttribute("data-bs-pdf-prompt"); + const inputContainerId = chooser.getAttribute('data-bs-element-container-id'); + + let inputContainer = document.getElementById(inputContainerId); let allFiles = []; let overlay; let dragCounter = 0; + inputContainer.addEventListener('click', (e) => { + let inputBtn = document.getElementById(elementId); + inputBtn.click(); + }) + const dragenterListener = function () { dragCounter++; if (!overlay) { - overlay = document.createElement("div"); - overlay.style.position = "fixed"; - overlay.style.top = 0; - overlay.style.left = 0; - overlay.style.width = "100%"; - overlay.style.height = "100%"; - overlay.style.background = "rgba(0, 0, 0, 0.5)"; - overlay.style.color = "#fff"; - overlay.style.zIndex = "1000"; - overlay.style.display = "flex"; - overlay.style.alignItems = "center"; - overlay.style.justifyContent = "center"; - overlay.style.pointerEvents = "none"; - overlay.innerHTML = "

Drop files anywhere to upload

"; - document.getElementById("content-wrap").appendChild(overlay); + // Show overlay by removing display: none from pseudo elements (::before and ::after) + inputContainer.style.setProperty('--overlay-display', "''"); + overlay = true; } }; const dragleaveListener = function () { dragCounter--; if (dragCounter === 0) { - if (overlay) { - overlay.remove(); - overlay = null; - } + hideOverlay(); } }; + function hideOverlay() { + if (!overlay) return; + inputContainer.style.setProperty('--overlay-display', 'none'); + overlay = false; + } + const dropListener = function (e) { e.preventDefault(); + // Drag and Drop shall only affect the target file chooser + if (e.target !== inputContainer) { + hideOverlay(); + dragCounter = 0; + return; + } + const dt = e.dataTransfer; const files = dt.files; const fileInput = document.getElementById(elementId); if (fileInput?.hasAttribute("multiple")) { - files.forEach(file => allFiles.push(file)); + pushFileListTo(files, allFiles); } else if (fileInput) { allFiles = [files[0]]; } @@ -63,16 +74,19 @@ function setupFileInput(chooser) { fileInput.files = dataTransfer.files; - if (overlay) { - overlay.remove(); - overlay = null; - } + hideOverlay(); dragCounter = 0; fileInput.dispatchEvent(new CustomEvent("change", { bubbles: true, detail: {source: 'drag-drop'} })); }; + function pushFileListTo(fileList, container) { + for (let file of fileList) { + container.push(file); + } + } + ["dragenter", "dragover", "dragleave", "drop"].forEach((eventName) => { document.body.addEventListener(eventName, preventDefaults, false); }); @@ -96,9 +110,13 @@ function setupFileInput(chooser) { allFiles = Array.from(isDragAndDrop ? allFiles : [element.files[0]]); } + allFiles = allFiles.map(file => { + if (!file.uniqueId) file.uniqueId = UUID.uuidv4(); + return file; + }); + if (!isDragAndDrop) { - let dataTransfer = new DataTransfer(); - allFiles.forEach(file => dataTransfer.items.add(file)); + let dataTransfer = toDataTransfer(allFiles); element.files = dataTransfer.files; } @@ -106,28 +124,109 @@ function setupFileInput(chooser) { this.dispatchEvent(new CustomEvent("file-input-change", { bubbles: true })); }); + function toDataTransfer(files) { + let dataTransfer = new DataTransfer(); + files.forEach(file => dataTransfer.items.add(file)); + return dataTransfer; + } + function handleFileInputChange(inputElement) { const files = allFiles; - const fileNames = files.map((f) => f.name); - const selectedFilesContainer = $(inputElement).siblings(".selected-files"); + showOrHideSelectedFilesContainer(files); + + const filesInfo = files.map((f) => ({name: f.name, size: f.size, uniqueId: f.uniqueId})); + + const selectedFilesContainer = $(inputContainer).siblings(".selected-files"); selectedFilesContainer.empty(); - fileNames.forEach((fileName) => { - selectedFilesContainer.append("
" + fileName + "
"); + filesInfo.forEach((info) => { + let fileContainerClasses = 'small-file-container d-flex flex-column justify-content-center align-items-center'; + + let fileContainer = document.createElement('div'); + $(fileContainer).addClass(fileContainerClasses); + $(fileContainer).attr('id', info.uniqueId); + + let fileIconContainer = createFileIconContainer(info); + + let fileInfoContainer = createFileInfoContainer(info); + + let removeBtn = document.createElement('div'); + removeBtn.classList.add('remove-selected-file'); + + let removeBtnIconHTML = ``; + $(removeBtn).append(removeBtnIconHTML); + $(removeBtn).attr('data-file-id', info.uniqueId).click(removeFileListener); + + $(fileContainer).append(fileIconContainer); + $(fileContainer).append(fileInfoContainer); + $(fileContainer).append(removeBtn); + + selectedFilesContainer.append(fileContainer); }); - if (fileNames.length === 1) { - $(inputElement).siblings(".custom-file-label").addClass("selected").html(fileNames[0]); - } else if (fileNames.length > 1) { - $(inputElement) - .siblings(".custom-file-label") - .addClass("selected") - .html(fileNames.length + " " + filesSelected); - } else { - $(inputElement).siblings(".custom-file-label").addClass("selected").html(pdfPrompt); - } + + showOrHideSelectedFilesContainer(filesInfo); } + + function showOrHideSelectedFilesContainer(files) { + if (files && files.length > 0) + chooser.style.setProperty('--selected-files-display', 'flex'); + else + chooser.style.setProperty('--selected-files-display', 'none'); + } + + function removeFileListener(e) { + const fileId = (e.target).getAttribute('data-file-id'); + + let inputElement = document.getElementById(elementId); + removeFileById(fileId, inputElement); + + showOrHideSelectedFilesContainer(allFiles); + + inputElement.dispatchEvent(new CustomEvent("file-input-change", { bubbles: true })); + } + + function removeFileById(fileId, inputElement) { + let fileContainer = document.getElementById(fileId); + fileContainer.remove(); + + allFiles = allFiles.filter(v => v.uniqueId != fileId); + let dataTransfer = toDataTransfer(allFiles); + + if (inputElement) inputElement.files = dataTransfer.files; + } + + function createFileIconContainer(info) { + let fileIconContainer = document.createElement('div'); + fileIconContainer.classList.add('file-icon'); + + // Add icon based on the extension + let fileExtension = FileUtils.extractFileExtension(info.name); + let fileIcon = FileIconFactory.createFileIcon(fileExtension); + + $(fileIconContainer).append(fileIcon); + return fileIconContainer; + } + + function createFileInfoContainer(info) { + let fileInfoContainer = document.createElement("div"); + let fileInfoContainerClasses = 'file-info d-flex flex-column align-items-center justify-content-center'; + + $(fileInfoContainer).addClass(fileInfoContainerClasses); + + $(fileInfoContainer).append( + `
${info.name}
` + ); + let fileSizeWithUnits = FileUtils.transformFileSize(info.size); + $(fileInfoContainer).append( + `
${fileSizeWithUnits}
` + ); + return fileInfoContainer; + } + //Listen for event of file being removed and the filter it out of the allFiles array document.addEventListener("fileRemoved", function (e) { - const fileName = e.detail; - allFiles = allFiles.filter(file => file.name !== fileName); + const fileId = e.detail; + let inputElement = document.getElementById(elementId); + removeFileById(fileId, inputElement); + showOrHideSelectedFilesContainer(allFiles); }); } diff --git a/src/main/resources/static/js/merge.js b/src/main/resources/static/js/merge.js index d1c5fabf0..9aa7c0113 100644 --- a/src/main/resources/static/js/merge.js +++ b/src/main/resources/static/js/merge.js @@ -29,6 +29,7 @@ async function displayFiles(files) { // Create filename div and set textContent to sanitize const fileNameDiv = document.createElement("div"); fileNameDiv.className = "filename"; + fileNameDiv.setAttribute("data-file-id", files[i].uniqueId); fileNameDiv.textContent = files[i].name; // Create page info div and set textContent to sanitize @@ -110,11 +111,13 @@ function attachMoveButtons() { event.preventDefault(); var parent = this.closest(".list-group-item"); //Get name of removed file - var fileName = parent.querySelector(".filename").innerText; + let filenameNode = parent.querySelector(".filename"); + var fileName = filenameNode.innerText; + const fileId = filenameNode.getAttribute("data-file-id"); parent.remove(); updateFiles(); //Dispatch a custom event with the name of the removed file - var event = new CustomEvent("fileRemoved", { detail: fileName }); + var event = new CustomEvent("fileRemoved", { detail: fileId }); document.dispatchEvent(event); }); } diff --git a/src/main/resources/static/js/uuid.js b/src/main/resources/static/js/uuid.js new file mode 100644 index 000000000..a68c1922c --- /dev/null +++ b/src/main/resources/static/js/uuid.js @@ -0,0 +1,9 @@ +class UUID { + static uuidv4() { + return "10000000-1000-4000-8000-100000000000".replace(/[018]/g, c => + (+c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> +c / 4).toString(16) + ); + } +} + +export default UUID; diff --git a/src/main/resources/templates/fragments/common.html b/src/main/resources/templates/fragments/common.html index 14e060e4c..f372d1dd5 100644 --- a/src/main/resources/templates/fragments/common.html +++ b/src/main/resources/templates/fragments/common.html @@ -204,11 +204,17 @@ -
-
- +
+
+ +
+
+
-
+
- +