From de4637e8d487b9e6d5a84f186af4c95a3a32fffc Mon Sep 17 00:00:00 2001 From: Omar Ahmed Hassan <98468609+omar-ahmed42@users.noreply.github.com> Date: Mon, 2 Dec 2024 20:10:12 +0200 Subject: [PATCH] Fix drag and drop area for file choosers by adding separate ones (#2368) * Add separate drag and drop area for file choosers - Add separate drag and drop area for file choosers ### Why? Previously, when there were multiple file choosers in the same page, if you attempted to drag and drop any files, they would be added to both file choosers as it was designed at first to handle 1 file chooser present, now that we have multiple ones, it is necessary to adapt our design to match the changing functionality. ### Can you not preserve the old overlay when there's only one file chooser present? Yes, we can, but imagine as a user, you try to drag and drop a file in one page and the fields turn into drag and drop areas then you go to another page and try to drag and drop again but you encounter the old overlay instead, as a user you might get confused and ask yourself "What changed?" or if a user is telling another user the steps to drag and drop files and he didn't know about this case, then it would still be confusing, thus consistency is preferred in this case. * Update file chooser UI * Add support for listing and removing selected files and their file icons - Selected files are listed below the file chooser in a selected files container. - Users can now remove uploaded/selected files. - Hide selected files container/box unless there are files selected/uploaded. - Add separate overlay for each drag & drop area. ## FAQ: - Why did you assign a unique id to each file? isn't the filename enough? = Because a user might upload multiple files with the same name, if the user wanted to remove one of them, how would we differentiate between them? we won't be able to unless we assign an identifier, you might argue "we remove based on the filename and size", then what if the user uploaded the same file more than once (intentionally), then we would accidentally remove all the files even though that is not what the user wanted, so going with unique ID approach would prevent this issue/problem from occurring in the first place. * Rename remove-file css class to remove-selected-file - Rename remove-file css class to remove-selected-file to avoid css conflict with remove-file in merge.css * Use input element to dispatch event on file removal Use the correct element to dispatch "file-input-change" (input element is the correct one). * Adapt file chooser UI to themes - Adapt file chooser UI to themes by adjusting their font colors and background colors. - Make text more visible in overlay by increasing the font size by 0.1rem and setting font weight to 550. * Remove extra overlay border - Removing overlay's border as it is unnecessary and only causing a double border issue on the file input container. * Remove Browse button, highlight file chooser and make it clickable - Remove browse button. - Make the entire file chooser container clickable. - Add glowing effect on hover for file chooser. - Change color of file chooser on hover. * Replace crypto.randomUUID() with UUID.uuidv4() - Replace crypto.randomUUID() with UUID.uuidv4() as crypto.randomUUID() is only supported in secured contexts such as localhost 127.0.0.1 and over HTTPS * Fix merge file removal not being reflected in file chooser - Files removed from the list in merge page would now be reflected in the file chooser's container. * Make inputElement optional in removeFileById - Make inputElement optional in removeFileById, this way we could control changing inputElements files. * Add translation support to file chooser --------- Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> --- src/main/resources/messages_ar_AR.properties | 5 + src/main/resources/messages_en_GB.properties | 5 + src/main/resources/static/css/fileSelect.css | 217 +++++++++++++++++- .../resources/static/js/file-icon-factory.js | 52 +++++ src/main/resources/static/js/file-utils.js | 31 +++ src/main/resources/static/js/fileInput.js | 183 +++++++++++---- src/main/resources/static/js/merge.js | 7 +- src/main/resources/static/js/uuid.js | 9 + .../resources/templates/fragments/common.html | 16 +- 9 files changed, 473 insertions(+), 52 deletions(-) create mode 100644 src/main/resources/static/js/file-icon-factory.js create mode 100644 src/main/resources/static/js/file-utils.js create mode 100644 src/main/resources/static/js/uuid.js diff --git a/src/main/resources/messages_ar_AR.properties b/src/main/resources/messages_ar_AR.properties index 1202616b..ff0b274c 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 3596175a..210498ea 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 e8f12979..4e93840f 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 00000000..81328f42 --- /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 00000000..98772cb2 --- /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 4b0cdd33..e288f5b8 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("