diff --git a/src/main/resources/messages_en_GB.properties b/src/main/resources/messages_en_GB.properties index 8caba5a1..42069459 100644 --- a/src/main/resources/messages_en_GB.properties +++ b/src/main/resources/messages_en_GB.properties @@ -831,7 +831,7 @@ sign.first=First page sign.last=Last page sign.next=Next page sign.previous=Previous page - +sign.maintainRatio=Toggle maintain aspect ratio #repair repair.title=Repair repair.header=Repair PDFs @@ -1285,6 +1285,8 @@ splitByChapters.submit=Split PDF fileChooser.click=Click fileChooser.or=or fileChooser.dragAndDrop=Drag & Drop +fileChooser.dragAndDropPDF=Drag & Drop PDF file +fileChooser.dragAndDropImage=Drag & Drop Image file fileChooser.hoveredDragAndDrop=Drag & Drop file(s) here #release notes diff --git a/src/main/resources/static/css/add-image.css b/src/main/resources/static/css/add-image.css index 5a735b42..f51da024 100644 --- a/src/main/resources/static/css/add-image.css +++ b/src/main/resources/static/css/add-image.css @@ -2,22 +2,32 @@ position: relative; margin: 20px 0; } + #pdf-canvas { box-shadow: 0px 0px 8px rgba(0, 0, 0, 0.384); width: 100%; } + .draggable-buttons-box { - position: absolute; + position: relative; top: 0; padding: 10px; - width: 100%; + width: calc(100% + 4.4rem); display: flex; gap: 5px; + z-index: 5; + margin-left: -2.2rem; } -.draggable-buttons-box > button { - z-index: 10; + +.draggable-buttons-box>button { + z-index: 4; background-color: rgba(13, 110, 253, 0.1); + flex: 1 1 auto; + min-width: 2.5rem; + max-width: 4rem; } + + .draggable-canvas { border: 1px solid red; position: absolute; @@ -26,3 +36,20 @@ top: 0px; left: 0; } + +.input-with-icon { + position: relative; + display: inline-flex; + align-items: center; +} + +.input-with-icon .icon { + position: absolute; + left: 0.5rem; + pointer-events: none; + color: #aaa; +} + +.input-with-icon input { + padding-left: 2.2rem; +} diff --git a/src/main/resources/static/css/fileSelect.css b/src/main/resources/static/css/fileSelect.css index 3f96814f..afb0b075 100644 --- a/src/main/resources/static/css/fileSelect.css +++ b/src/main/resources/static/css/fileSelect.css @@ -118,6 +118,7 @@ row-gap: 1px; height: 60px; width: 60px; + top:4px; } .file-icon { @@ -165,8 +166,8 @@ height: 15px; width: 15px; - right: 10px; - top: -5px; + right: 0px; + top: -17px; } .remove-selected-file * { @@ -219,3 +220,54 @@ border-radius: 1rem; border: 1px solid rgb(105, 116, 134, 0.5); } + +.draggable-image-overlay{ + position: absolute; + background: rgba(0, 0, 0, 0.7); + display: none; + z-index: 10; + align-items: center; + justify-content: center; + color: white; + font-size: 16px; + font-weight: bold; + pointer-events: none; + left:0; + top:0; + height:100%; + width:100%; + border-radius: 1rem; +} + +.small-file-container:hover .drag-icon { + display: flex; +} + +.drag-icon { + display: none; + position: absolute; + top: 5px; + width: 20px; + height: 20px; + background: rgba(0, 0, 0, 0.5); + color: white; + align-items: center; + justify-content: center; + border-radius: 50%; + font-size: 14px; + pointer-events: none; + z-index: 1; +} + +#imagePreviewModal { + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + background-color: rgba(0, 0, 0, 0.5); + display: none; + justify-content: center; + align-items: center; + z-index: 9999; +} diff --git a/src/main/resources/static/css/sign.css b/src/main/resources/static/css/sign.css index 772fee6d..6de4efcd 100644 --- a/src/main/resources/static/css/sign.css +++ b/src/main/resources/static/css/sign.css @@ -22,18 +22,38 @@ select#font-select option { } .draggable-buttons-box { - position: absolute; + position: relative; top: 0; padding: 10px; - width: 100%; + width: calc(100% + 4.4rem); display: flex; gap: 5px; z-index: 5; + margin-left: -2.2rem; } .draggable-buttons-box>button { z-index: 4; background-color: rgba(13, 110, 253, 0.1); + flex: 1 1 auto; + min-width: 2.5rem; + max-width: 4rem; +} + + +.rotation-handle { + width: 20px; + height: 20px; + border: 2px solid #3498db; + background-color: rgba(52, 152, 219, 0.1); + color: white; + border-radius: 50%; + text-align: center; + line-height: 20px; + position: absolute; + cursor: grab; + top: -30px; + left: calc(50% - 10px); } .draggable-canvas { @@ -113,3 +133,33 @@ select#font-select option { text-align: right; padding: 0.5rem 1rem; } + +.input-with-icon { + position: relative; + display: inline-flex; + align-items: center; +} + +.input-with-icon .icon { + position: absolute; + left: 0.5rem; + pointer-events: none; + color: #aaa; +} + +.input-with-icon input { + padding-left: 2.2rem; +} + +.small-file-container-saved { + padding-top: 1px; + position: relative; + row-gap: 1px; + height: 60px; + width: 60px; + top: 4px; +} + +.small-file-container-saved:hover .drag-icon { + display: flex; +} diff --git a/src/main/resources/static/js/draggable-utils.js b/src/main/resources/static/js/draggable-utils.js index aa0c15a0..b6fa73b5 100644 --- a/src/main/resources/static/js/draggable-utils.js +++ b/src/main/resources/static/js/draggable-utils.js @@ -7,75 +7,137 @@ const DraggableUtils = { elementAllPages: [], documentsMap: new Map(), lastInteracted: null, - + padding: 15, + maintainRatioEnabled: true, init() { interact('.draggable-canvas') .draggable({ listeners: { + start(event) { + const target = event.target; + x = parseFloat(target.getAttribute('data-bs-x')); + y = parseFloat(target.getAttribute('data-bs-y')); + }, move: (event) => { const target = event.target; - const x = (parseFloat(target.getAttribute('data-bs-x')) || 0) + event.dx; - const y = (parseFloat(target.getAttribute('data-bs-y')) || 0) + event.dy; + // Retrieve position attributes + let x = parseFloat(target.getAttribute('data-bs-x')) || 0; + let y = parseFloat(target.getAttribute('data-bs-y')) || 0; + const angle = parseFloat(target.getAttribute('data-angle')) || 0; + + // Update position based on drag movement + x += event.dx; + y += event.dy; + + // Apply translation to the parent container (bounding box) target.style.transform = `translate(${x}px, ${y}px)`; + + // Preserve rotation on the inner canvas + const canvas = target.querySelector('.display-canvas'); + + const canvasWidth = parseFloat(canvas.style.width); + const canvasHeight = parseFloat(canvas.style.height); + + const cosAngle = Math.abs(Math.cos(angle)); + const sinAngle = Math.abs(Math.sin(angle)); + + const rotatedWidth = canvasWidth * cosAngle + canvasHeight * sinAngle; + const rotatedHeight = canvasWidth * sinAngle + canvasHeight * cosAngle; + + const offsetX = (rotatedWidth - canvasWidth) / 2; + const offsetY = (rotatedHeight - canvasHeight) / 2; + + canvas.style.transform = `translate(${offsetX}px, ${offsetY}px) rotate(${angle}rad)`; + + // Update attributes for persistence target.setAttribute('data-bs-x', x); target.setAttribute('data-bs-y', y); - this.onInteraction(target); - //update the last interacted element - this.lastInteracted = event.target; + // Set the last interacted element + this.lastInteracted = target; }, }, }) .resizable({ - edges: {left: true, right: true, bottom: true, top: true}, + edges: { left: true, right: true, bottom: true, top: true }, listeners: { + start: (event) => { + const target = event.target; + x = parseFloat(target.getAttribute('data-bs-x')) || 0; + y = parseFloat(target.getAttribute('data-bs-y')) || 0; + }, move: (event) => { - var target = event.target; - var x = parseFloat(target.getAttribute('data-bs-x')) || 0; - var y = parseFloat(target.getAttribute('data-bs-y')) || 0; + const target = event.target; - // check if control key is pressed - if (event.ctrlKey) { - const aspectRatio = target.offsetWidth / target.offsetHeight; - // preserve aspect ratio - let width = event.rect.width; - let height = event.rect.height; + const MAX_CHANGE = 60; - if (Math.abs(event.deltaRect.width) >= Math.abs(event.deltaRect.height)) { - height = width / aspectRatio; - } else { - width = height * aspectRatio; + let width = event.rect.width - 2 * this.padding; + let height = event.rect.height - 2 * this.padding; + + const canvas = target.querySelector('.display-canvas'); + if (canvas) { + const originalWidth = parseFloat(canvas.style.width) || canvas.width; + const originalHeight = parseFloat(canvas.style.height) || canvas.height; + const angle = parseFloat(target.getAttribute('data-angle')) || 0; + + const aspectRatio = originalWidth / originalHeight; + + if (!event.ctrlKey && this.maintainRatioEnabled) { + if (Math.abs(event.deltaRect.width) >= Math.abs(event.deltaRect.height)) { + height = width / aspectRatio; + } else { + width = height * aspectRatio; + } } - event.rect.width = width; - event.rect.height = height; + const widthChange = width - originalWidth; + const heightChange = height - originalHeight; + + if (Math.abs(widthChange) > MAX_CHANGE || Math.abs(heightChange) > MAX_CHANGE) { + const scale = MAX_CHANGE / Math.max(Math.abs(widthChange), Math.abs(heightChange)); + width = originalWidth + widthChange * scale; + height = originalHeight + heightChange * scale; + } + + const cosAngle = Math.abs(Math.cos(angle)); + const sinAngle = Math.abs(Math.sin(angle)); + const boundingWidth = width * cosAngle + height * sinAngle; + const boundingHeight = width * sinAngle + height * cosAngle; + + if (event.edges.left) { + const dx = event.deltaRect.left; + x += dx; + } + if (event.edges.top) { + const dy = event.deltaRect.top; + y += dy; + } + + target.style.transform = `translate(${x}px, ${y}px)`; + target.style.width = `${boundingWidth + 2 * this.padding}px`; + target.style.height = `${boundingHeight + 2 * this.padding}px`; + + canvas.style.width = `${width}px`; + canvas.style.height = `${height}px`; + canvas.style.transform = `translate(${(boundingWidth - width) / 2}px, ${(boundingHeight - height) / 2 + }px) rotate(${angle}rad)`; + + target.setAttribute('data-bs-x', x); + target.setAttribute('data-bs-y', y); + + this.lastInteracted = target; } - - target.style.width = event.rect.width + 'px'; - target.style.height = event.rect.height + 'px'; - - // translate when resizing from top or left edges - x += event.deltaRect.left; - y += event.deltaRect.top; - - target.style.transform = 'translate(' + x + 'px,' + y + 'px)'; - - target.setAttribute('data-bs-x', x); - target.setAttribute('data-bs-y', y); - target.textContent = Math.round(event.rect.width) + '\u00D7' + Math.round(event.rect.height); - - this.onInteraction(target); }, }, - modifiers: [ interact.modifiers.restrictSize({ - min: {width: 5, height: 5}, + min: { width: 50, height: 50 }, }), ], inertia: true, }); + //Arrow key Support for Add-Image and Sign pages if (window.location.pathname.endsWith('sign') || window.location.pathname.endsWith('add-image')) { window.addEventListener('keydown', (event) => { @@ -117,7 +179,8 @@ const DraggableUtils = { } // Update position - target.style.transform = `translate(${x}px, ${y}px)`; + const angle = parseFloat(target.getAttribute('data-angle')) || 0; + target.style.transform = `translate(${x}px, ${y}px) rotate(${angle}rad)`; target.setAttribute('data-bs-x', x); target.setAttribute('data-bs-y', y); @@ -125,72 +188,97 @@ const DraggableUtils = { }); } }, - onInteraction(target) { - this.boxDragContainer.appendChild(target); - }, - - createDraggableCanvas() { - const createdCanvas = document.createElement('canvas'); - createdCanvas.id = `draggable-canvas-${this.nextId++}`; - createdCanvas.classList.add('draggable-canvas'); - - const x = 0; - const y = 20; - createdCanvas.style.transform = `translate(${x}px, ${y}px)`; - createdCanvas.setAttribute('data-bs-x', x); - createdCanvas.setAttribute('data-bs-y', y); - - //Click element in order to enable arrow keys - createdCanvas.addEventListener('click', () => { - this.lastInteracted = createdCanvas; - }); - - createdCanvas.onclick = (e) => this.onInteraction(e.target); - - this.boxDragContainer.appendChild(createdCanvas); - - //Enable Arrow keys directly after the element is created - this.lastInteracted = createdCanvas; - - return createdCanvas; + this.lastInteracted = target; + // this.boxDragContainer.appendChild(target); + // target.appendChild(target.querySelector(".display-canvas")); }, createDraggableCanvasFromUrl(dataUrl) { return new Promise((resolve) => { - var myImage = new Image(); + const canvasContainer = document.createElement('div'); + const createdCanvas = document.createElement('canvas'); // Inner canvas + const padding = this.padding; + + canvasContainer.id = `draggable-canvas-${this.nextId++}`; + canvasContainer.classList.add('draggable-canvas'); + createdCanvas.classList.add('display-canvas'); + + canvasContainer.style.position = 'absolute'; + canvasContainer.style.padding = `${padding}px`; + canvasContainer.style.overflow = 'hidden'; + + let x = 0, + y = 30, + angle = 0; + canvasContainer.style.transform = `translate(${x}px, ${y}px)`; + canvasContainer.setAttribute('data-bs-x', x); + canvasContainer.setAttribute('data-bs-y', y); + canvasContainer.setAttribute('data-angle', angle); + + canvasContainer.addEventListener('click', () => { + this.lastInteracted = canvasContainer; + this.showRotationControls(canvasContainer); + }); + canvasContainer.appendChild(createdCanvas); + this.boxDragContainer.appendChild(canvasContainer); + + const myImage = new Image(); myImage.src = dataUrl; myImage.onload = () => { - var createdCanvas = this.createDraggableCanvas(); + const context = createdCanvas.getContext('2d'); createdCanvas.width = myImage.width; createdCanvas.height = myImage.height; const imgAspect = myImage.width / myImage.height; - const pdfAspect = this.boxDragContainer.offsetWidth / this.boxDragContainer.offsetHeight; + const containerWidth = this.boxDragContainer.offsetWidth; + const containerHeight = this.boxDragContainer.offsetHeight; - var scaleMultiplier; - if (imgAspect > pdfAspect) { - scaleMultiplier = this.boxDragContainer.offsetWidth / myImage.width; - } else { - scaleMultiplier = this.boxDragContainer.offsetHeight / myImage.height; - } + let scaleMultiplier = Math.min(containerWidth / myImage.width, containerHeight / myImage.height); + const scaleFactor = 0.5; - var newWidth = createdCanvas.width; - var newHeight = createdCanvas.height; - if (scaleMultiplier < 1) { - newWidth = newWidth * scaleMultiplier; - newHeight = newHeight * scaleMultiplier; - } + const newWidth = myImage.width * scaleMultiplier * scaleFactor; + const newHeight = myImage.height * scaleMultiplier * scaleFactor; - createdCanvas.style.width = newWidth + 'px'; - createdCanvas.style.height = newHeight + 'px'; + // Calculate initial bounding box size + const cosAngle = Math.abs(Math.cos(angle)); + const sinAngle = Math.abs(Math.sin(angle)); + const boundingWidth = newWidth * cosAngle + newHeight * sinAngle; + const boundingHeight = newWidth * sinAngle + newHeight * cosAngle; - var myContext = createdCanvas.getContext('2d'); - myContext.drawImage(myImage, 0, 0); - resolve(createdCanvas); + createdCanvas.style.width = `${newWidth}px`; + createdCanvas.style.height = `${newHeight}px`; + + canvasContainer.style.width = `${boundingWidth + 2 * padding}px`; + canvasContainer.style.height = `${boundingHeight + 2 * padding}px`; + + context.imageSmoothingEnabled = true; + context.imageSmoothingQuality = 'high'; + context.drawImage(myImage, 0, 0, myImage.width, myImage.height); + this.showRotationControls(canvasContainer); + this.lastInteracted = canvasContainer; + + resolve(canvasContainer); + }; + + myImage.onerror = () => { + console.error('Failed to load the image.'); + resolve(null); }; }); }, + toggleMaintainRatio() { + this.maintainRatioEnabled = !this.maintainRatioEnabled; + const button = document.getElementById('ratioToggleBtn'); + if (this.maintainRatioEnabled) { + button.classList.remove('btn-danger'); + button.classList.add('btn-outline-secondary'); + } else { + button.classList.remove('btn-outline-secondary'); + button.classList.add('btn-danger'); + } + }, + deleteAllDraggableCanvases() { this.boxDragContainer.querySelectorAll('.draggable-canvas').forEach((el) => el.remove()); }, @@ -266,9 +354,61 @@ const DraggableUtils = { } }, getLastInteracted() { - return this.boxDragContainer.querySelector('.draggable-canvas:last-of-type'); + return this.lastInteracted; }, + showRotationControls(element) { + const rotationControls = document.getElementById('rotation-controls'); + const rotationInput = document.getElementById('rotation-input'); + rotationControls.style.display = 'flex'; + rotationInput.value = Math.round((parseFloat(element.getAttribute('data-angle')) * 180) / Math.PI); + rotationInput.addEventListener('input', this.handleRotationInputChange); + }, + hideRotationControls() { + const rotationControls = document.getElementById('rotation-controls'); + const rotationInput = document.getElementById('rotation-input'); + rotationControls.style.display = 'none'; + rotationInput.addEventListener('input', this.handleRotationInputChange); + }, + applyRotationToElement(element, degrees) { + const radians = degrees * (Math.PI / 180); // Convert degrees to radians + // Get current position + const x = parseFloat(element.getAttribute('data-bs-x')) || 0; + const y = parseFloat(element.getAttribute('data-bs-y')) || 0; + + // Get the inner canvas (image) + const canvas = element.querySelector('.display-canvas'); + if (canvas) { + const originalWidth = parseFloat(canvas.style.width); + const originalHeight = parseFloat(canvas.style.height); + const padding = this.padding; // Access the padding value + + // Calculate rotated bounding box dimensions + const cosAngle = Math.abs(Math.cos(radians)); + const sinAngle = Math.abs(Math.sin(radians)); + const boundingWidth = originalWidth * cosAngle + originalHeight * sinAngle + 2 * padding; + const boundingHeight = originalWidth * sinAngle + originalHeight * cosAngle + 2 * padding; + + // Update parent container to fit the rotated bounding box + element.style.width = `${boundingWidth}px`; + element.style.height = `${boundingHeight}px`; + + // Center the canvas within the bounding box, accounting for padding + const offsetX = (boundingWidth - originalWidth) / 2 - padding; + const offsetY = (boundingHeight - originalHeight) / 2 - padding; + + canvas.style.transform = `translate(${offsetX}px, ${offsetY}px) rotate(${radians}rad)`; + } + + // Keep the bounding box positioned properly + element.style.transform = `translate(${x}px, ${y}px)`; + element.setAttribute('data-angle', radians); + }, + handleRotationInputChange() { + const rotationInput = document.getElementById('rotation-input'); + const degrees = parseFloat(rotationInput.value) || 0; + DraggableUtils.applyRotationToElement(DraggableUtils.lastInteracted, degrees); + }, storePageContents() { var pagesMap = this.documentsMap.get(this.pdfDoc); if (!pagesMap) { @@ -325,7 +465,7 @@ const DraggableUtils = { // render the page onto the canvas var renderContext = { canvasContext: this.pdfCanvas.getContext('2d'), - viewport: page.getViewport({scale: 1}), + viewport: page.getViewport({ scale: 1 }), }; await page.render(renderContext).promise; @@ -352,8 +492,6 @@ const DraggableUtils = { this.loadPageContents(); } }, - - parseTransform(element) {}, async getOverlayedPdfDocument() { const pdfBytes = await this.pdfDoc.getData(); const pdfDocModified = await PDFLib.PDFDocument.load(pdfBytes, { @@ -367,7 +505,6 @@ const DraggableUtils = { if (pageIdx.includes('offset')) { continue; } - console.log(typeof pageIdx); const page = pdfDocModified.getPage(parseInt(pageIdx)); let draggablesData = pagesMap[pageIdx]; @@ -376,45 +513,61 @@ const DraggableUtils = { const offsetHeight = pagesMap[pageIdx + '-offsetHeight']; for (const draggableData of draggablesData) { - // embed the draggable canvas - const draggableElement = draggableData.element; + // Embed the draggable canvas + const draggableElement = draggableData.element.querySelector('.display-canvas'); const response = await fetch(draggableElement.toDataURL()); const draggableImgBytes = await response.arrayBuffer(); const pdfImageObject = await pdfDocModified.embedPng(draggableImgBytes); - // calculate the position in the pdf document - const tansform = draggableElement.style.transform.replace(/[^.,-\d]/g, ''); - const transformComponents = tansform.split(','); + // Extract transformation data + const transform = draggableData.element.style.transform || ''; + const translateRegex = /translate\((-?\d+(?:\.\d+)?)px,\s*(-?\d+(?:\.\d+)?)px\)/; + + const translateMatch = transform.match(translateRegex); + + const translateX = translateMatch ? parseFloat(translateMatch[1]) : 0; + const translateY = translateMatch ? parseFloat(translateMatch[2]) : 0; + + const childTransform = draggableElement.style.transform || ''; + const childTranslateMatch = childTransform.match(translateRegex); + + const childOffsetX = childTranslateMatch ? parseFloat(childTranslateMatch[1]) : 0; + const childOffsetY = childTranslateMatch ? parseFloat(childTranslateMatch[2]) : 0; + + const rotateAngle = parseFloat(draggableData.element.getAttribute('data-angle')) || 0; + const draggablePositionPixels = { - x: parseFloat(transformComponents[0]), - y: parseFloat(transformComponents[1]), - width: draggableData.offsetWidth, - height: draggableData.offsetHeight, + x: translateX + childOffsetX + this.padding + 2, + y: translateY + childOffsetY + this.padding + 2, + width: parseFloat(draggableElement.style.width), + height: parseFloat(draggableElement.style.height), + angle: rotateAngle, // Store rotation }; - //Auxiliary variables + // Auxiliary variables let widthAdjusted = page.getWidth(); let heightAdjusted = page.getHeight(); const rotation = page.getRotation(); - //Normalizing angle + // Normalize page rotation angle let normalizedAngle = rotation.angle % 360; if (normalizedAngle < 0) { normalizedAngle += 360; } - //Changing the page dimension if the angle is 90 or 270 + // Adjust page dimensions for rotated pages if (normalizedAngle === 90 || normalizedAngle === 270) { - let widthTemp = widthAdjusted; - widthAdjusted = heightAdjusted; - heightAdjusted = widthTemp; + [widthAdjusted, heightAdjusted] = [heightAdjusted, widthAdjusted]; } + const draggablePositionRelative = { x: draggablePositionPixels.x / offsetWidth, y: draggablePositionPixels.y / offsetHeight, width: draggablePositionPixels.width / offsetWidth, height: draggablePositionPixels.height / offsetHeight, + angle: draggablePositionPixels.angle, }; + const draggablePositionPdf = { x: draggablePositionRelative.x * widthAdjusted, y: draggablePositionRelative.y * heightAdjusted, @@ -422,11 +575,13 @@ const DraggableUtils = { height: draggablePositionRelative.height * heightAdjusted, }; - //Defining the image if the page has a 0-degree angle + // Calculate position based on normalized page rotation let x = draggablePositionPdf.x; let y = heightAdjusted - draggablePositionPdf.y - draggablePositionPdf.height; - //Defining the image position if it is at other angles + let originx = x + draggablePositionPdf.width / 2; + let originy = heightAdjusted - draggablePositionPdf.y - draggablePositionPdf.height / 2; + if (normalizedAngle === 90) { x = draggablePositionPdf.y + draggablePositionPdf.height; y = draggablePositionPdf.x; @@ -437,17 +592,32 @@ const DraggableUtils = { x = heightAdjusted - draggablePositionPdf.y - draggablePositionPdf.height; y = widthAdjusted - draggablePositionPdf.x; } - - // draw the image + // let angle = draggablePositionPixels.angle % 360; + // if (angle < 0) angle += 360; // Normalize to positive angle + const radians = -draggablePositionPixels.angle; // Convert angle to radians + page.pushOperators( + PDFLib.pushGraphicsState(), + PDFLib.concatTransformationMatrix(1, 0, 0, 1, originx, originy), + PDFLib.concatTransformationMatrix( + Math.cos(radians), + Math.sin(radians), + -Math.sin(radians), + Math.cos(radians), + 0, + 0 + ), + PDFLib.concatTransformationMatrix(1, 0, 0, 1, -1 * originx, -1 * originy) + ); page.drawImage(pdfImageObject, { x: x, y: y, width: draggablePositionPdf.width, height: draggablePositionPdf.height, - rotate: rotation, }); + page.pushOperators(PDFLib.popGraphicsState()); } } + this.loadPageContents(); return pdfDocModified; }, diff --git a/src/main/resources/static/js/fileInput.js b/src/main/resources/static/js/fileInput.js index 63485c60..b2316f4f 100644 --- a/src/main/resources/static/js/fileInput.js +++ b/src/main/resources/static/js/fileInput.js @@ -9,6 +9,7 @@ if (!isScriptExecuted) { document.querySelectorAll('.custom-file-chooser').forEach(setupFileInput); }); } +let hasDroppedImage = false; function setupFileInput(chooser) { const elementId = chooser.getAttribute('data-bs-element-id'); @@ -18,6 +19,11 @@ function setupFileInput(chooser) { let inputContainer = document.getElementById(inputContainerId); + if (inputContainer.id === 'pdf-upload-input-container') { + inputContainer.querySelector('#dragAndDrop').innerHTML = window.fileInput.dragAndDropPDF; + } else if (inputContainer.id === 'image-upload-input-container') { + inputContainer.querySelector('#dragAndDrop').innerHTML = window.fileInput.dragAndDropImage; + } let allFiles = []; let overlay; let dragCounter = 0; @@ -141,12 +147,17 @@ function setupFileInput(chooser) { files.forEach((file) => dataTransfer.items.add(file)); return dataTransfer; } - function handleFileInputChange(inputElement) { const files = allFiles; showOrHideSelectedFilesContainer(files); - const filesInfo = files.map((f) => ({name: f.name, size: f.size, uniqueId: f.uniqueId})); + const filesInfo = files.map((f) => ({ + name: f.name, + size: f.size, + uniqueId: f.uniqueId, + type: f.type, + url: URL.createObjectURL(f), + })); const selectedFilesContainer = $(inputContainer).siblings('.selected-files'); selectedFilesContainer.empty(); @@ -157,30 +168,111 @@ function setupFileInput(chooser) { $(fileContainer).addClass(fileContainerClasses); $(fileContainer).attr('id', info.uniqueId); - let fileIconContainer = createFileIconContainer(info); + let fileIconContainer = document.createElement('div'); + const isDragAndDropEnabled = + window.location.pathname.includes('add-image') || window.location.pathname.includes('sign'); + if (info.type.startsWith('image/')) { + let imgPreview = document.createElement('img'); + imgPreview.src = info.url; + imgPreview.alt = 'Preview'; + imgPreview.style.width = '50px'; + imgPreview.style.height = '50px'; + imgPreview.style.objectFit = 'cover'; + $(fileIconContainer).append(imgPreview); + + if (isDragAndDropEnabled) { + let dragIcon = document.createElement('div'); + dragIcon.classList.add('drag-icon'); + dragIcon.innerHTML = + ''; + fileContainer.appendChild(dragIcon); + + $(fileContainer).attr('draggable', 'true'); + $(fileContainer).on('dragstart', (e) => { + e.originalEvent.dataTransfer.setData('fileUrl', info.url); + e.originalEvent.dataTransfer.setData('uniqueId', info.uniqueId); + e.originalEvent.dataTransfer.setDragImage(imgPreview, imgPreview.width / 2, imgPreview.height / 2); + }); + enableImagePreviewOnClick(fileIconContainer); + } else { + $(fileContainer).removeAttr('draggable'); + } + } else { + fileIconContainer = createFileIconContainer(info); + } let fileInfoContainer = createFileInfoContainer(info); - let removeBtn = document.createElement('div'); - removeBtn.classList.add('remove-selected-file'); + if (!isDragAndDropEnabled) { + 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); + let removeBtnIconHTML = ``; + $(removeBtn).append(removeBtnIconHTML); + $(removeBtn).attr('data-file-id', info.uniqueId).click(removeFileListener); + $(fileContainer).append(removeBtn); + } + $(fileContainer).append(fileIconContainer, fileInfoContainer); selectedFilesContainer.append(fileContainer); }); + const pageContainers = $('#box-drag-container'); + pageContainers.off('dragover').on('dragover', (e) => { + e.preventDefault(); + }); - showOrHideSelectedFilesContainer(filesInfo); + pageContainers.off('drop').on('drop', (e) => { + e.preventDefault(); + const fileUrl = e.originalEvent.dataTransfer.getData('fileUrl'); + + if (fileUrl) { + const existingImages = $(e.target).find(`img[src="${fileUrl}"]`); + if (existingImages.length === 0) { + DraggableUtils.createDraggableCanvasFromUrl(fileUrl); + } + } + const overlayElement = chooser.querySelector('.drag-drop-overlay'); + if (overlayElement) { + overlayElement.style.display = 'none'; + } + hasDroppedImage = true; + }); + + showOrHideSelectedFilesContainer(files); } function showOrHideSelectedFilesContainer(files) { - if (files && files.length > 0) chooser.style.setProperty('--selected-files-display', 'flex'); - else chooser.style.setProperty('--selected-files-display', 'none'); + if (files && files.length > 0) { + chooser.style.setProperty('--selected-files-display', 'flex'); + } else { + chooser.style.setProperty('--selected-files-display', 'none'); + } + const isDragAndDropEnabled = + (window.location.pathname.includes('add-image') || window.location.pathname.includes('sign')) && + files.some((file) => file.type.startsWith('image/')); + + if (!isDragAndDropEnabled) return; + + const selectedFilesContainer = chooser.querySelector('.selected-files'); + + let overlayElement = chooser.querySelector('.drag-drop-overlay'); + if (!overlayElement) { + selectedFilesContainer.style.position = 'relative'; + overlayElement = document.createElement('div'); + overlayElement.classList.add('draggable-image-overlay'); + + overlayElement.innerHTML = 'Drag images to add them to the page'; + selectedFilesContainer.appendChild(overlayElement); + } + if (hasDroppedImage) overlayElement.style.display = files && files.length > 0 ? 'flex' : 'none'; + + selectedFilesContainer.addEventListener('mouseenter', () => { + overlayElement.style.display = 'none'; + }); + + selectedFilesContainer.addEventListener('mouseleave', () => { + if (!hasDroppedImage) overlayElement.style.display = files && files.length > 0 ? 'flex' : 'none'; + }); } function removeFileListener(e) { @@ -235,4 +327,52 @@ function setupFileInput(chooser) { removeFileById(fileId, inputElement); showOrHideSelectedFilesContainer(allFiles); }); + function enableImagePreviewOnClick(container) { + const imagePreviewModal = document.getElementById('imagePreviewModal') || createImagePreviewModal(); + + container.querySelectorAll('img').forEach((img) => { + if (!img.hasPreviewListener) { + img.addEventListener('mouseup', function () { + const imgElement = imagePreviewModal.querySelector('img'); + imgElement.src = this.src; + imagePreviewModal.style.display = 'flex'; + }); + img.hasPreviewListener = true; + } + }); + + function createImagePreviewModal() { + const modal = document.createElement('div'); + modal.id = 'imagePreviewModal'; + modal.style.position = 'fixed'; + modal.style.top = '0'; + modal.style.left = '0'; + modal.style.width = '100vw'; + modal.style.height = '100vh'; + modal.style.backgroundColor = 'rgba(0, 0, 0, 0.7)'; + modal.style.display = 'none'; + modal.style.justifyContent = 'center'; + modal.style.alignItems = 'center'; + modal.style.zIndex = '2000'; + + const imgElement = document.createElement('img'); + imgElement.style.maxWidth = '90%'; + imgElement.style.maxHeight = '90%'; + + modal.appendChild(imgElement); + document.body.appendChild(modal); + + modal.addEventListener('click', () => { + modal.style.display = 'none'; + }); + + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape' && modal.style.display === 'flex') { + modal.style.display = 'none'; + } + }); + + return modal; + } + } } diff --git a/src/main/resources/static/js/pages/add-image.js b/src/main/resources/static/js/pages/add-image.js index 5899b53f..2bafd86e 100644 --- a/src/main/resources/static/js/pages/add-image.js +++ b/src/main/resources/static/js/pages/add-image.js @@ -1,23 +1,38 @@ +window.goToFirstOrLastPage = goToFirstOrLastPage; + document.getElementById('download-pdf').addEventListener('click', async () => { - const modifiedPdf = await DraggableUtils.getOverlayedPdfDocument(); - const modifiedPdfBytes = await modifiedPdf.save(); - const blob = new Blob([modifiedPdfBytes], {type: 'application/pdf'}); - const link = document.createElement('a'); - link.href = URL.createObjectURL(blob); - link.download = originalFileName + '_addedImage.pdf'; - link.click(); + const downloadButton = document.getElementById('download-pdf'); + const originalContent = downloadButton.innerHTML; + + downloadButton.disabled = true; + downloadButton.innerHTML = ` + + `; + + try { + const modifiedPdf = await DraggableUtils.getOverlayedPdfDocument(); + const modifiedPdfBytes = await modifiedPdf.save(); + const blob = new Blob([modifiedPdfBytes], { type: 'application/pdf' }); + const link = document.createElement('a'); + link.href = URL.createObjectURL(blob); + link.download = originalFileName + '_addedImage.pdf'; + link.click(); + } finally { + downloadButton.disabled = false; + downloadButton.innerHTML = originalContent; + } }); let originalFileName = ''; document.querySelector('input[name=pdf-upload]').addEventListener('change', async (event) => { const fileInput = event.target; fileInput.addEventListener('file-input-change', async (e) => { - const {allFiles} = e.detail; + const { allFiles } = e.detail; if (allFiles && allFiles.length > 0) { const file = allFiles[0]; originalFileName = file.name.replace(/\.[^/.]+$/, ''); const pdfData = await file.arrayBuffer(); pdfjsLib.GlobalWorkerOptions.workerSrc = './pdfjs-legacy/pdf.worker.mjs'; - const pdfDoc = await pdfjsLib.getDocument({data: pdfData}).promise; + const pdfDoc = await pdfjsLib.getDocument({ data: pdfData }).promise; await DraggableUtils.renderPage(pdfDoc, 0); document.querySelectorAll('.show-on-file-selected').forEach((el) => { @@ -30,6 +45,11 @@ document.addEventListener('DOMContentLoaded', () => { document.querySelectorAll('.show-on-file-selected').forEach((el) => { el.style.cssText = 'display:none !important'; }); + document.addEventListener('keydown', (e) => { + if (e.key === 'Delete') { + DraggableUtils.deleteDraggableCanvas(DraggableUtils.getLastInteracted()); + } + }); }); const imageUpload = document.querySelector('input[name=image-upload]'); @@ -45,3 +65,12 @@ imageUpload.addEventListener('change', (e) => { }; } }); + +async function goToFirstOrLastPage(page) { + if (page) { + const lastPage = DraggableUtils.pdfDoc.numPages; + await DraggableUtils.goToPage(lastPage - 1); + } else { + await DraggableUtils.goToPage(0); + } +} diff --git a/src/main/resources/static/js/pages/sign.js b/src/main/resources/static/js/pages/sign.js index 8d45c969..736ca1cd 100644 --- a/src/main/resources/static/js/pages/sign.js +++ b/src/main/resources/static/js/pages/sign.js @@ -73,6 +73,16 @@ document.addEventListener('DOMContentLoaded', () => { document.querySelectorAll('.show-on-file-selected').forEach((el) => { el.style.cssText = 'display:none !important'; }); + document.querySelectorAll('.small-file-container-saved img ').forEach((img) => { + img.addEventListener('dragstart', (e) => { + e.dataTransfer.setData('fileUrl', img.src); + }); + }); + document.addEventListener('keydown', (e) => { + if (e.key === 'Delete') { + DraggableUtils.deleteDraggableCanvas(DraggableUtils.getLastInteracted()); + } + }); }); const imageUpload = document.querySelector('input[name=image-upload]'); @@ -203,11 +213,26 @@ async function goToFirstOrLastPage(page) { } document.getElementById('download-pdf').addEventListener('click', async () => { - const modifiedPdf = await DraggableUtils.getOverlayedPdfDocument(); - const modifiedPdfBytes = await modifiedPdf.save(); - const blob = new Blob([modifiedPdfBytes], {type: 'application/pdf'}); - const link = document.createElement('a'); - link.href = URL.createObjectURL(blob); - link.download = originalFileName + '_signed.pdf'; - link.click(); + const downloadButton = document.getElementById('download-pdf'); + const originalContent = downloadButton.innerHTML; + + downloadButton.disabled = true; + downloadButton.innerHTML = ` + + `; + + try { + const modifiedPdf = await DraggableUtils.getOverlayedPdfDocument(); + const modifiedPdfBytes = await modifiedPdf.save(); + const blob = new Blob([modifiedPdfBytes], {type: 'application/pdf'}); + const link = document.createElement('a'); + link.href = URL.createObjectURL(blob); + link.download = originalFileName + '_signed.pdf'; + link.click(); + } catch (error) { + console.error('Error downloading PDF:', error); + } finally { + downloadButton.disabled = false; + downloadButton.innerHTML = originalContent; + } }); diff --git a/src/main/resources/static/js/sign/signature-canvas.js b/src/main/resources/static/js/sign/signature-canvas.js new file mode 100644 index 00000000..03052d9c --- /dev/null +++ b/src/main/resources/static/js/sign/signature-canvas.js @@ -0,0 +1,85 @@ +const signaturePadCanvas = document.getElementById('drawing-pad-canvas'); +const signaturePad = new SignaturePad(signaturePadCanvas, { + minWidth: 1, + maxWidth: 2, + penColor: 'black', +}); + +function addDraggableFromPad() { + if (signaturePad.isEmpty()) return; + const startTime = Date.now(); + const croppedDataUrl = getCroppedCanvasDataUrl(signaturePadCanvas); + console.log(Date.now() - startTime); + DraggableUtils.createDraggableCanvasFromUrl(croppedDataUrl); +} + +function getCroppedCanvasDataUrl(canvas) { + let originalCtx = canvas.getContext('2d'); + let originalWidth = canvas.width; + let originalHeight = canvas.height; + let imageData = originalCtx.getImageData(0, 0, originalWidth, originalHeight); + + let minX = originalWidth + 1, + maxX = -1, + minY = originalHeight + 1, + maxY = -1, + x = 0, + y = 0, + currentPixelColorValueIndex; + + for (y = 0; y < originalHeight; y++) { + for (x = 0; x < originalWidth; x++) { + currentPixelColorValueIndex = (y * originalWidth + x) * 4; + let currentPixelAlphaValue = imageData.data[currentPixelColorValueIndex + 3]; + if (currentPixelAlphaValue > 0) { + if (minX > x) minX = x; + if (maxX < x) maxX = x; + if (minY > y) minY = y; + if (maxY < y) maxY = y; + } + } + } + + let croppedWidth = maxX - minX; + let croppedHeight = maxY - minY; + if (croppedWidth < 0 || croppedHeight < 0) return null; + let cuttedImageData = originalCtx.getImageData(minX, minY, croppedWidth, croppedHeight); + + let croppedCanvas = document.createElement('canvas'), + croppedCtx = croppedCanvas.getContext('2d'); + + croppedCanvas.width = croppedWidth; + croppedCanvas.height = croppedHeight; + croppedCtx.putImageData(cuttedImageData, 0, 0); + + return croppedCanvas.toDataURL(); +} + +function isMobile() { + const userAgentCheck = /Mobi|Android|iPhone|iPad|iPod|Windows Phone|Opera Mini/i.test(navigator.userAgent); + const viewportCheck = window.matchMedia('(max-width: 768px)').matches; + return userAgentCheck || viewportCheck; +} + +function getDeviceScalingFactor() { + return isMobile() ? 3 : 10; +} + +function resizeCanvas() { + const ratio = Math.max(window.devicePixelRatio || 1, 1); + const additionalFactor = getDeviceScalingFactor(); + + signaturePadCanvas.width = signaturePadCanvas.offsetWidth * ratio * additionalFactor; + signaturePadCanvas.height = signaturePadCanvas.offsetHeight * ratio * additionalFactor; + signaturePadCanvas.getContext('2d').scale(ratio * additionalFactor, ratio * additionalFactor); + + signaturePad.clear(); +} + +new IntersectionObserver((entries, observer) => { + if (entries.some((entry) => entry.intersectionRatio > 0)) { + resizeCanvas(); + } +}).observe(signaturePadCanvas); + +new ResizeObserver(resizeCanvas).observe(signaturePadCanvas); diff --git a/src/main/resources/templates/fragments/common.html b/src/main/resources/templates/fragments/common.html index f03e83c5..1cd908a6 100644 --- a/src/main/resources/templates/fragments/common.html +++ b/src/main/resources/templates/fragments/common.html @@ -213,7 +213,10 @@ unexpectedError: '[[#{decrypt.unexpectedError}]]', serverError: '[[#{decrypt.serverError}]]', success: '[[#{decrypt.success}]]', - }; + }; + window.fileInput = { + dragAndDropPDF : '[[#{fileChooser.dragAndDropPDF}]]', + dragAndDropImage : '[[#{fileChooser.dragAndDropImage}]]'};
-
+
diff --git a/src/main/resources/templates/misc/add-image.html b/src/main/resources/templates/misc/add-image.html index 5d644dd3..9addaebd 100644 --- a/src/main/resources/templates/misc/add-image.html +++ b/src/main/resources/templates/misc/add-image.html @@ -1,65 +1,123 @@ - - + + + - - - + + + - -
-
- -

-
-
-
-
- add_photo_alternate - + +
+
+ +

+
+
+
+
+ add_photo_alternate + +
+ + +
+
+ + +
+
+
- -
- - -
-
-
- - -
- - -
- - - +
+ + + - - -
- -
+ + + + + + +
+ +
+ +
-
- + +
+ + \ No newline at end of file diff --git a/src/main/resources/templates/sign.html b/src/main/resources/templates/sign.html index 546cbe98..85bce6c2 100644 --- a/src/main/resources/templates/sign.html +++ b/src/main/resources/templates/sign.html @@ -21,8 +21,6 @@ - - @@ -32,7 +30,7 @@

-
+
signature @@ -46,7 +44,7 @@
+ th:replace="~{fragments/common :: fileSelector(name='image-upload', disableMultipleFiles=false, multipleInputsForSingleRequest=true, accept='image/*', inputText=#{imgPrompt})}">
@@ -61,13 +59,6 @@
- -
- -
- - -
- -
-
-
-
- + +
+
+
+
+
+ + +
+ +
+
+
- -
-
-
-
- + +
+
+
+
+
+ + +
+ +
- -

No saved signatures found

@@ -169,72 +143,99 @@
+ -
- - -
- - + + + + + + + +
- -
- +
+ + + + + +
+
+ + \ No newline at end of file