diff --git a/app/core/src/main/resources/messages_en_GB.properties b/app/core/src/main/resources/messages_en_GB.properties index 69f06bff5..0e003ffa8 100644 --- a/app/core/src/main/resources/messages_en_GB.properties +++ b/app/core/src/main/resources/messages_en_GB.properties @@ -1402,6 +1402,7 @@ multiTool.delete=Delete multiTool.dragDropMessage=Page(s) Selected multiTool.undo=Undo (CTRL + Z) multiTool.redo=Redo (CTRL + Y) +multiTool.duplicate=Duplicate multiTool.svgNotSupported=SVG files are not supported in Multi Tool and were ignored. diff --git a/app/core/src/main/resources/static/js/multitool/PdfActionsManager.js b/app/core/src/main/resources/static/js/multitool/PdfActionsManager.js index 1ef2978e9..14cb31006 100644 --- a/app/core/src/main/resources/static/js/multitool/PdfActionsManager.js +++ b/app/core/src/main/resources/static/js/multitool/PdfActionsManager.js @@ -1,4 +1,5 @@ import { DeletePageCommand } from "./commands/delete-page.js"; +import { DuplicatePageCommand } from "./commands/duplicate-page.js"; import { SelectPageCommand } from "./commands/select.js"; import { SplitFileCommand } from "./commands/split.js"; import { UndoManager } from "./UndoManager.js"; @@ -78,6 +79,18 @@ class PdfActionsManager { this._pushUndoClearRedo(deletePageCommand); } + duplicatePageButtonCallback(e) { + let imgContainer = this.getPageContainer(e.target); + let duplicatePageCommand = new DuplicatePageCommand( + imgContainer, + this.duplicatePage, + this.pagesContainer + ); + duplicatePageCommand.execute(); + + this._pushUndoClearRedo(duplicatePageCommand); + } + insertFileButtonCallback(e) { var imgContainer = this.getPageContainer(e.target); this.addFiles(imgContainer); @@ -101,10 +114,11 @@ class PdfActionsManager { this.undoManager.pushUndoClearRedo(command); } - setActions({ movePageTo, addFiles, rotateElement }) { + setActions({ movePageTo, addFiles, rotateElement, duplicatePage }) { this.movePageTo = movePageTo; this.addFiles = addFiles; this.rotateElement = rotateElement; + this.duplicatePage = duplicatePage; this.moveUpButtonCallback = this.moveUpButtonCallback.bind(this); this.moveDownButtonCallback = this.moveDownButtonCallback.bind(this); @@ -114,6 +128,7 @@ class PdfActionsManager { this.insertFileButtonCallback = this.insertFileButtonCallback.bind(this); this.insertFileBlankButtonCallback = this.insertFileBlankButtonCallback.bind(this); this.splitFileButtonCallback = this.splitFileButtonCallback.bind(this); + this.duplicatePageButtonCallback = this.duplicatePageButtonCallback.bind(this); } @@ -154,6 +169,13 @@ class PdfActionsManager { rotateCW.onclick = this.rotateCWButtonCallback; buttonContainer.appendChild(rotateCW); + const duplicatePage = document.createElement("button"); + duplicatePage.classList.add("btn", "btn-secondary"); + duplicatePage.setAttribute('title', window.translations.duplicate); + duplicatePage.innerHTML = `control_point_duplicate`; + duplicatePage.onclick = this.duplicatePageButtonCallback; + buttonContainer.appendChild(duplicatePage); + const deletePage = document.createElement("button"); deletePage.classList.add("btn", "btn-danger"); deletePage.setAttribute('title', window.translations.delete); @@ -195,7 +217,7 @@ class PdfActionsManager { const insertFileButton = document.createElement("button"); insertFileButton.classList.add("btn", "btn-primary"); - moveUp.setAttribute('title', window.translations.addFile); + insertFileButton.setAttribute('title', window.translations.addFile); insertFileButton.innerHTML = `add`; insertFileButton.onclick = this.insertFileButtonCallback; insertFileButtonContainer.appendChild(insertFileButton); diff --git a/app/core/src/main/resources/static/js/multitool/PdfContainer.js b/app/core/src/main/resources/static/js/multitool/PdfContainer.js index f71a5da70..0b4494c08 100644 --- a/app/core/src/main/resources/static/js/multitool/PdfContainer.js +++ b/app/core/src/main/resources/static/js/multitool/PdfContainer.js @@ -73,6 +73,7 @@ class PdfContainer { this.setDownloadAttribute = this.setDownloadAttribute.bind(this); this.preventIllegalChars = this.preventIllegalChars.bind(this); this.addImageFile = this.addImageFile.bind(this); + this.duplicatePage = this.duplicatePage.bind(this); this.nameAndArchiveFiles = this.nameAndArchiveFiles.bind(this); this.splitPDF = this.splitPDF.bind(this); this.splitAll = this.splitAll.bind(this); @@ -99,6 +100,7 @@ class PdfContainer { rotateElement: this.rotateElement, updateFilename: this.updateFilename, deleteSelected: this.deleteSelected, + duplicatePage: this.duplicatePage, }); }); @@ -197,19 +199,31 @@ class PdfContainer { return movePageCommand; } - async addFiles(element) { - let addFilesCommand = new AddFilesCommand( + /** + * Adds files or a single blank page (when blank=true) near an anchor element. + * @param {HTMLElement|null} element - Anchor element (insert before its nextSibling). + * @param {boolean} [blank=false] - When true, insert a single blank page. + */ + async addFiles(element, blank = false) { + // Choose the action: real file picker or blank page generator. + const action = blank + ? async (nextSiblingElement) => { + // Create exactly one blank page and return the created elements array. + const pages = await this.addFilesBlank(nextSiblingElement, []); + return pages; // array of inserted elements + } + : this.addFilesAction.bind(this); + + const addFilesCommand = new AddFilesCommand( element, window.selectedPages, - this.addFilesAction.bind(this), + action, this.pagesContainer ); await addFilesCommand.execute(); - this.undoManager.pushUndoClearRedo(addFilesCommand); window.tooltipSetup(); - } async addFilesAction(nextSiblingElement) { @@ -433,6 +447,30 @@ class PdfContainer { return pages; } + duplicatePage(element) { + const clone = document.createElement('div'); + clone.classList.add('page-container'); + + const originalImg = element.querySelector('img'); + const img = document.createElement('img'); + img.classList.add('page-image'); + img.src = originalImg.src; + img.pageIdx = originalImg.pageIdx; + img.rend = originalImg.rend; + img.doc = originalImg.doc; + img.style.rotate = originalImg.style.rotate; + clone.appendChild(img); + + this.pdfAdapters.forEach((adapter) => { + adapter?.adapt?.(clone); + }); + + const nextSibling = element.nextSibling; + this.pagesContainer.insertBefore(clone, nextSibling); + this.updatePageNumbersAndCheckboxes(); + return clone; + } + async loadFile(file) { var objectUrl = URL.createObjectURL(file); var pdfDocument = await this.toPdfLib(objectUrl); diff --git a/app/core/src/main/resources/static/js/multitool/commands/add-page.js b/app/core/src/main/resources/static/js/multitool/commands/add-page.js index b910320c9..09965042d 100644 --- a/app/core/src/main/resources/static/js/multitool/commands/add-page.js +++ b/app/core/src/main/resources/static/js/multitool/commands/add-page.js @@ -1,51 +1,84 @@ -import {Command} from './command.js'; +import { CommandWithAnchors } from './command.js'; -export class AddFilesCommand extends Command { +export class AddFilesCommand extends CommandWithAnchors { + /** + * @param {HTMLElement|null} element - Anchor element (optional, forwarded to addFilesAction) + * @param {number[]} selectedPages + * @param {Function} addFilesAction - async (nextSiblingElement|false) => HTMLElement[]|HTMLElement|null + * @param {HTMLElement} pagesContainer + */ constructor(element, selectedPages, addFilesAction, pagesContainer) { super(); this.element = element; this.selectedPages = selectedPages; this.addFilesAction = addFilesAction; this.pagesContainer = pagesContainer; + + /** @type {HTMLElement[]} */ this.addedElements = []; + + /** + * Anchors captured on undo to support redo reinsertion. + * @type {{ el: HTMLElement, nextSibling: ChildNode|null, index: number }[]} + */ + this._anchors = []; } async execute() { const undoBtn = document.getElementById('undo-btn'); - undoBtn.disabled = true; - if (this.element) { - const newElement = await this.addFilesAction(this.element); - if (newElement) { - this.addedElements = newElement; - } + if (undoBtn) undoBtn.disabled = true; + + const result = await this.addFilesAction(this.element || false); + if (Array.isArray(result)) { + this.addedElements = result; + } else if (result) { + this.addedElements = [result]; } else { - const newElement = await this.addFilesAction(false); - if (newElement) { - this.addedElements = newElement; - } + this.addedElements = []; } - undoBtn.disabled = false; + + // Capture anchors right after insertion so redo does not depend on undo. + this._anchors = this.addedElements.map((el) => this.captureAnchor(el, this.pagesContainer)); + + if (undoBtn) undoBtn.disabled = false; } undo() { - this.addedElements.forEach((element) => { - const nextSibling = element.nextSibling; - this.pagesContainer.removeChild(element); + this._anchors = []; - if (this.pagesContainer.childElementCount === 0) { - const filenameInput = document.getElementById('filename-input'); - const downloadBtn = document.getElementById('export-button'); + for (const el of this.addedElements) { + this._anchors.push(this.captureAnchor(el, this.pagesContainer)); + this.pagesContainer.removeChild(el); + } + if (this.pagesContainer.childElementCount === 0) { + const filenameInput = document.getElementById('filename-input'); + const downloadBtn = document.getElementById('export-button'); + if (filenameInput) { filenameInput.disabled = true; filenameInput.value = ''; + } + if (downloadBtn) { downloadBtn.disabled = true; } - - element._nextSibling = nextSibling; - }); - this.addedElements = []; + } } + redo() { - this.execute(); + if (!this.addedElements.length) return; + // If the elements are already in the DOM (no prior undo), do nothing. + const alreadyInDom = + this.addedElements[0].parentNode === this.pagesContainer; + if (alreadyInDom) return; + + // Use pre-captured anchors (from execute) or fall back to capturing now. + const anchors = (this._anchors && this._anchors.length) + ? this._anchors + : this.addedElements.map((el) => + this.captureAnchor(el, this.pagesContainer)); + + for (const anchor of anchors) { + this.insertWithAnchor(this.pagesContainer, anchor); + } } } diff --git a/app/core/src/main/resources/static/js/multitool/commands/command.js b/app/core/src/main/resources/static/js/multitool/commands/command.js index a4ae5fdf0..e6bc9b11c 100644 --- a/app/core/src/main/resources/static/js/multitool/commands/command.js +++ b/app/core/src/main/resources/static/js/multitool/commands/command.js @@ -3,3 +3,63 @@ export class Command { undo() {} redo() {} } + +/** + * Base class that provides anchor capture and reinsertion helpers + * to avoid storing custom state on DOM nodes. + */ +export class CommandWithAnchors extends Command { + constructor() { + super(); + /** @type {{ el: HTMLElement, nextSibling: ChildNode|null, index: number }[]} */ + this._anchors = []; + } + + /** + * Returns the child index of an element in a container. + * @param {HTMLElement} container + * @param {HTMLElement} el + * @returns {number} + */ + _indexOf(container, el) { + return Array.prototype.indexOf.call(container.children, el); + } + + /** + * Captures an anchor for later reinsertion. + * @param {HTMLElement} el + * @param {HTMLElement} container + * @returns {{ el: HTMLElement, nextSibling: ChildNode|null, index: number }} + */ + captureAnchor(el, container) { + return { + el, + nextSibling: el.nextSibling, + index: this._indexOf(container, el), + }; + } + + /** + * Reinserts an element using a previously captured anchor. + * Prefers stored nextSibling when still valid; otherwise falls back to index. + * @param {HTMLElement} container + * @param {{ el: HTMLElement, nextSibling: ChildNode|null, index: number }} anchor + */ + insertWithAnchor(container, anchor) { + const { el, nextSibling, index } = anchor; + const nextValid = nextSibling && nextSibling.parentNode === container; + + let ref = null; + if (nextValid) { + ref = nextSibling; + } else if ( + Number.isInteger(index) && + index >= 0 && + index < container.children.length + ) { + ref = container.children[index] || null; + } + + container.insertBefore(el, ref || null); + } +} diff --git a/app/core/src/main/resources/static/js/multitool/commands/commands-sequence.js b/app/core/src/main/resources/static/js/multitool/commands/commands-sequence.js index 61d2ba469..ef2f93c21 100644 --- a/app/core/src/main/resources/static/js/multitool/commands/commands-sequence.js +++ b/app/core/src/main/resources/static/js/multitool/commands/commands-sequence.js @@ -1,19 +1,27 @@ -import {Command} from './command.js'; +import { Command } from './command.js'; +/** + * Composes multiple commands into a single atomic operation. + * Executes in order; undo in reverse order. + */ export class CommandSequence extends Command { + /** @param {Command[]} commands - Commands to be executed/undone/redone as a group. */ constructor(commands) { super(); this.commands = commands; - } + + /** Execute: run each command in order. */ execute() { - this.commands.forEach((command) => command.execute()) + this.commands.forEach((command) => command.execute()); } + /** Undo: undo in reverse order. */ undo() { - this.commands.slice().reverse().forEach((command) => command.undo()) + this.commands.slice().reverse().forEach((command) => command.undo()); } + /** Redo: simply execute again. */ redo() { this.execute(); } diff --git a/app/core/src/main/resources/static/js/multitool/commands/delete-page.js b/app/core/src/main/resources/static/js/multitool/commands/delete-page.js index 751b115e2..a52b4e962 100644 --- a/app/core/src/main/resources/static/js/multitool/commands/delete-page.js +++ b/app/core/src/main/resources/static/js/multitool/commands/delete-page.js @@ -1,20 +1,32 @@ import { Command } from "./command.js"; +/** + * Removes a page from the container and restores it on undo. + */ export class DeletePageCommand extends Command { + /** + * @param {HTMLElement} element - Page container to delete. + * @param {HTMLElement} pagesContainer - Parent container holding all pages. + */ constructor(element, pagesContainer) { super(); this.element = element; this.pagesContainer = pagesContainer; - this.filenameInputValue = document.getElementById("filename-input").value; + /** @type {ChildNode|null} */ + this.nextSibling = null; + + const filenameInput = document.getElementById("filename-input"); + /** @type {string} */ + this.filenameInputValue = filenameInput ? filenameInput.value : ""; const filenameParagraph = document.getElementById("filename"); - this.filenameParagraphText = filenameParagraph - ? filenameParagraph.innerText - : ""; + /** @type {string} */ + this.filenameParagraphText = filenameParagraph ? filenameParagraph.innerText : ""; } + /** Execute: remove the page and update empty-state UI if needed. */ execute() { this.nextSibling = this.element.nextSibling; @@ -23,15 +35,19 @@ export class DeletePageCommand extends Command { const filenameInput = document.getElementById("filename-input"); const downloadBtn = document.getElementById("export-button"); - filenameInput.disabled = true; - filenameInput.value = ""; - - downloadBtn.disabled = true; + if (filenameInput) { + filenameInput.disabled = true; + filenameInput.value = ""; + } + if (downloadBtn) { + downloadBtn.disabled = true; + } } } + /** Undo: reinsert the page at its original position. */ undo() { - let node = this.nextSibling; + const node = /** @type {ChildNode|null} */ (this.nextSibling); if (node) this.pagesContainer.insertBefore(this.element, node); else this.pagesContainer.appendChild(this.element); @@ -43,12 +59,16 @@ export class DeletePageCommand extends Command { const filenameInput = document.getElementById("filename-input"); const downloadBtn = document.getElementById("export-button"); - filenameInput.disabled = false; - filenameInput.value = this.filenameInputValue; - - downloadBtn.disabled = false; + if (filenameInput) { + filenameInput.disabled = false; + filenameInput.value = this.filenameInputValue; + } + if (downloadBtn) { + downloadBtn.disabled = false; + } } + /** Redo: remove again and maintain empty-state UI. */ redo() { const pageNumberElement = this.element.querySelector(".page-number"); if (pageNumberElement) { @@ -60,10 +80,13 @@ export class DeletePageCommand extends Command { const filenameInput = document.getElementById("filename-input"); const downloadBtn = document.getElementById("export-button"); - filenameInput.disabled = true; - filenameInput.value = ""; - - downloadBtn.disabled = true; + if (filenameInput) { + filenameInput.disabled = true; + filenameInput.value = ""; + } + if (downloadBtn) { + downloadBtn.disabled = true; + } } } } diff --git a/app/core/src/main/resources/static/js/multitool/commands/duplicate-page.js b/app/core/src/main/resources/static/js/multitool/commands/duplicate-page.js new file mode 100644 index 000000000..8071bc9b2 --- /dev/null +++ b/app/core/src/main/resources/static/js/multitool/commands/duplicate-page.js @@ -0,0 +1,63 @@ +import { CommandWithAnchors } from './command.js'; + +export class DuplicatePageCommand extends CommandWithAnchors { + /** + * @param {HTMLElement} element - The page element to duplicate. + * @param {Function} duplicatePageAction - (element) => HTMLElement (new clone already inserted) + * @param {HTMLElement} pagesContainer + */ + constructor(element, duplicatePageAction, pagesContainer) { + super(); + this.element = element; + this.duplicatePageAction = duplicatePageAction; + this.pagesContainer = pagesContainer; + + /** @type {HTMLElement|null} */ + this.newElement = null; + + /** @type {{ el: HTMLElement, nextSibling: ChildNode|null, index: number }|null} */ + this._anchor = null; + } + + execute() { + // Create and insert a duplicate next to the original + this.newElement = this.duplicatePageAction(this.element); + } + + undo() { + if (!this.newElement) return; + + // Capture anchor before removal so redo can reinsert at the same position + this._anchor = this.captureAnchor(this.newElement, this.pagesContainer); + + this.pagesContainer.removeChild(this.newElement); + + if (this.pagesContainer.childElementCount === 0) { + const filenameInput = document.getElementById('filename-input'); + const downloadBtn = document.getElementById('export-button'); + if (filenameInput) { + filenameInput.disabled = true; + filenameInput.value = ''; + } + if (downloadBtn) { + downloadBtn.disabled = true; + } + } + + window.updatePageNumbersAndCheckboxes?.(); + } + + redo() { + if (!this.newElement) { + this.execute(); + return; + } + if (this._anchor) { + this.insertWithAnchor(this.pagesContainer, this._anchor); + } else { + // Fallback: insert relative to the original element + this.pagesContainer.insertBefore(this.newElement, this.element.nextSibling || null); + } + window.updatePageNumbersAndCheckboxes?.(); + } +} diff --git a/app/core/src/main/resources/static/js/multitool/commands/move-page.js b/app/core/src/main/resources/static/js/multitool/commands/move-page.js index b209e6a7d..d4436050f 100644 --- a/app/core/src/main/resources/static/js/multitool/commands/move-page.js +++ b/app/core/src/main/resources/static/js/multitool/commands/move-page.js @@ -1,13 +1,25 @@ -import {Command} from './command.js'; +import { Command } from './command.js'; +/** + * Moves a page (or multiple pages, via PdfContainer wrapper) inside the container. + */ export class MovePageCommand extends Command { + /** + * @param {HTMLElement} startElement - Dragged page container. + * @param {HTMLElement|null} endElement - Destination reference; insert before this node. Null = append. + * @param {HTMLElement} pagesContainer - Parent container with all pages. + * @param {HTMLElement} pagesContainerWrapper - Scrollable wrapper element. + * @param {boolean} [scrollTo=false] - Whether to apply a subtle scroll after move. + */ constructor(startElement, endElement, pagesContainer, pagesContainerWrapper, scrollTo = false) { super(); this.pagesContainer = pagesContainer; const childArray = Array.from(this.pagesContainer.childNodes); + /** @type {number} */ this.startIndex = childArray.indexOf(startElement); + /** @type {number} */ this.endIndex = childArray.indexOf(endElement); this.startElement = startElement; @@ -16,8 +28,10 @@ export class MovePageCommand extends Command { this.scrollTo = scrollTo; this.pagesContainerWrapper = pagesContainerWrapper; } + + /** Execute: perform DOM move and optional scroll. */ execute() { - // Check & remove page number elements here too if they exist because Firefox doesn't fire the relevant event on page move. + // Remove stale page number badge if present (Firefox sometimes misses the event) const pageNumberElement = this.startElement.querySelector('.page-number'); if (pageNumberElement) { this.startElement.removeChild(pageNumberElement); @@ -31,7 +45,7 @@ export class MovePageCommand extends Command { } if (this.scrollTo) { - const {width} = this.startElement.getBoundingClientRect(); + const { width } = this.startElement.getBoundingClientRect(); const vector = this.endIndex !== -1 && this.startIndex > this.endIndex ? 0 - width : width; this.pagesContainerWrapper.scroll({ @@ -40,16 +54,17 @@ export class MovePageCommand extends Command { } } + /** Undo: restore original order and optional scroll back. */ undo() { if (this.startElement) { this.pagesContainer.removeChild(this.startElement); - let previousNeighbour = Array.from(this.pagesContainer.childNodes)[this.startIndex]; + const previousNeighbour = Array.from(this.pagesContainer.childNodes)[this.startIndex]; previousNeighbour?.insertAdjacentElement('beforebegin', this.startElement) ?? this.pagesContainer.append(this.startElement); } if (this.scrollTo) { - const {width} = this.startElement.getBoundingClientRect(); + const { width } = this.startElement.getBoundingClientRect(); const vector = this.endIndex === -1 || this.startIndex <= this.endIndex ? 0 - width : width; this.pagesContainerWrapper.scroll({ @@ -58,6 +73,7 @@ export class MovePageCommand extends Command { } } + /** Redo: same as execute. */ redo() { this.execute(); } diff --git a/app/core/src/main/resources/static/js/multitool/commands/page-break.js b/app/core/src/main/resources/static/js/multitool/commands/page-break.js index 2321a5e0b..0f910ec49 100644 --- a/app/core/src/main/resources/static/js/multitool/commands/page-break.js +++ b/app/core/src/main/resources/static/js/multitool/commands/page-break.js @@ -1,6 +1,13 @@ -import {Command} from './command.js'; +import { CommandWithAnchors } from './command.js'; -export class PageBreakCommand extends Command { +export class PageBreakCommand extends CommandWithAnchors { + /** + * @param {HTMLElement[]} elements + * @param {boolean} isSelectedInWindow + * @param {number[]} selectedPages - 0-based indices of selected pages + * @param {Function} pageBreakCallback - async (element, addedSoFar) => HTMLElement[]|HTMLElement|null + * @param {HTMLElement} pagesContainer + */ constructor(elements, isSelectedInWindow, selectedPages, pageBreakCallback, pagesContainer) { super(); this.elements = elements; @@ -8,7 +15,17 @@ export class PageBreakCommand extends Command { this.selectedPages = selectedPages; this.pageBreakCallback = pageBreakCallback; this.pagesContainer = pagesContainer; + + /** @type {HTMLElement[]} */ this.addedElements = []; + + /** + * Anchors captured on undo to support redo reinsertion. + * @type {{ el: HTMLElement, nextSibling: ChildNode|null, index: number }[]} + */ + this._anchors = []; + + // Keep content snapshot if needed for future enhancements this.originalStates = Array.from(elements, (element) => ({ element, hasContent: element.innerHTML.trim() !== '', @@ -17,43 +34,72 @@ export class PageBreakCommand extends Command { async execute() { const undoBtn = document.getElementById('undo-btn'); - undoBtn.disabled = true; - for (const [index, element] of this.elements.entries()) { - if (!this.isSelectedInWindow || this.selectedPages.includes(index)) { - if (index !== 0) { - const newElement = await this.pageBreakCallback(element, this.addedElements); + if (undoBtn) undoBtn.disabled = true; - if (newElement) { - this.addedElements = newElement; - } + for (const [index, element] of this.elements.entries()) { + const withinSelection = !this.isSelectedInWindow || this.selectedPages.includes(index); + if (!withinSelection) continue; + + if (index !== 0) { + const result = await this.pageBreakCallback(element, this.addedElements); + if (!Array.isArray(this.addedElements)) { + this.addedElements = []; + } + + if (Array.isArray(result)) { + this.addedElements.push(...result); + } else if (result) { + this.addedElements.push(result); } } } - undoBtn.disabled = false; + + // Capture anchors right after insertion so redo does not depend on undo. + this._anchors = this.addedElements.map((el) => this.captureAnchor(el, this.pagesContainer)); + + if (undoBtn) undoBtn.disabled = false; } undo() { - this.addedElements.forEach((element) => { - const nextSibling = element.nextSibling; + this._anchors = []; - this.pagesContainer.removeChild(element); + for (const el of this.addedElements) { + this._anchors.push(this.captureAnchor(el, this.pagesContainer)); + this.pagesContainer.removeChild(el); + } - if (this.pagesContainer.childElementCount === 0) { - const filenameInput = document.getElementById('filename-input'); - const filenameParagraph = document.getElementById('filename'); - const downloadBtn = document.getElementById('export-button'); + if (this.pagesContainer.childElementCount === 0) { + const filenameInput = document.getElementById('filename-input'); + const filenameParagraph = document.getElementById('filename'); + const downloadBtn = document.getElementById('export-button'); + if (filenameInput) { filenameInput.disabled = true; filenameInput.value = ''; + } + if (filenameParagraph) { filenameParagraph.innerText = ''; + } + if (downloadBtn) { downloadBtn.disabled = true; } - - element._nextSibling = nextSibling; - }); + } } redo() { - this.execute(); + // If elements are already present (no prior undo), do nothing. + if (!this.addedElements.length) return; + const alreadyInDom = + this.addedElements[0].parentNode === this.pagesContainer; + if (alreadyInDom) return; + + // Use pre-captured anchors (from execute) or fall back to current ones. + const anchors = (this._anchors && this._anchors.length) + ? this._anchors + : this.addedElements.map((el) => this.captureAnchor(el, this.pagesContainer)); + + for (const anchor of anchors) { + this.insertWithAnchor(this.pagesContainer, anchor); + } } } diff --git a/app/core/src/main/resources/static/js/multitool/commands/remove.js b/app/core/src/main/resources/static/js/multitool/commands/remove.js index 0c2dc14e1..e7c6bb7ca 100644 --- a/app/core/src/main/resources/static/js/multitool/commands/remove.js +++ b/app/core/src/main/resources/static/js/multitool/commands/remove.js @@ -1,35 +1,45 @@ import { Command } from "./command.js"; +/** + * Deletes a set of selected pages and restores them on undo. + */ export class RemoveSelectedCommand extends Command { + /** + * @param {HTMLElement} pagesContainer - Parent container. + * @param {number[]} selectedPages - 1-based page numbers to remove. + * @param {Function} updatePageNumbersAndCheckboxes - Callback to refresh UI state. + */ constructor(pagesContainer, selectedPages, updatePageNumbersAndCheckboxes) { super(); this.pagesContainer = pagesContainer; this.selectedPages = selectedPages; + /** @type {{idx:number, childNode:HTMLElement}[]} */ this.deletedChildren = []; - if (updatePageNumbersAndCheckboxes) { - this.updatePageNumbersAndCheckboxes = updatePageNumbersAndCheckboxes; - } else { + this.updatePageNumbersAndCheckboxes = updatePageNumbersAndCheckboxes || (() => { const pageDivs = document.querySelectorAll(".pdf-actions_container"); - pageDivs.forEach((div, index) => { const pageNumber = index + 1; const checkbox = div.querySelector(".pdf-actions_checkbox"); - checkbox.id = `selectPageCheckbox-${pageNumber}`; - checkbox.setAttribute("data-page-number", pageNumber); - checkbox.checked = window.selectedPages.includes(pageNumber); + if (checkbox) { + checkbox.id = `selectPageCheckbox-${pageNumber}`; + checkbox.setAttribute("data-page-number", pageNumber); + checkbox.checked = window.selectedPages.includes(pageNumber); + } }); - } + }); const filenameInput = document.getElementById("filename-input"); const filenameParagraph = document.getElementById("filename"); + /** @type {string} */ this.originalFilenameInputValue = filenameInput ? filenameInput.value : ""; - if (filenameParagraph) - this.originalFilenameParagraphText = filenameParagraph.innerText; + /** @type {string|undefined} */ + this.originalFilenameParagraphText = filenameParagraph?.innerText; } + /** Execute: remove selected pages and update empty state. */ execute() { let deletions = 0; @@ -53,10 +63,9 @@ export class RemoveSelectedCommand extends Command { const downloadBtn = document.getElementById("export-button"); if (filenameInput) filenameInput.disabled = true; - filenameInput.value = ""; + if (filenameInput) filenameInput.value = ""; if (filenameParagraph) filenameParagraph.innerText = ""; - - downloadBtn.disabled = true; + if (downloadBtn) downloadBtn.disabled = true; } window.selectedPages = []; @@ -64,12 +73,13 @@ export class RemoveSelectedCommand extends Command { document.dispatchEvent(new Event("selectedPagesUpdated")); } + /** Undo: restore all removed nodes at their original indices. */ undo() { while (this.deletedChildren.length > 0) { - let deletedChild = this.deletedChildren.pop(); - if (this.pagesContainer.children.length <= deletedChild.idx) + const deletedChild = this.deletedChildren.pop(); + if (this.pagesContainer.children.length <= deletedChild.idx) { this.pagesContainer.appendChild(deletedChild.childNode); - else { + } else { this.pagesContainer.insertBefore( deletedChild.childNode, this.pagesContainer.children[deletedChild.idx] @@ -83,11 +93,11 @@ export class RemoveSelectedCommand extends Command { const downloadBtn = document.getElementById("export-button"); if (filenameInput) filenameInput.disabled = false; - filenameInput.value = this.originalFilenameInputValue; - if (filenameParagraph) + if (filenameInput) filenameInput.value = this.originalFilenameInputValue; + if (filenameParagraph && this.originalFilenameParagraphText !== undefined) { filenameParagraph.innerText = this.originalFilenameParagraphText; - - downloadBtn.disabled = false; + } + if (downloadBtn) downloadBtn.disabled = false; } window.selectedPages = this.selectedPages; @@ -95,6 +105,7 @@ export class RemoveSelectedCommand extends Command { document.dispatchEvent(new Event("selectedPagesUpdated")); } + /** Redo mirrors execute. */ redo() { this.execute(); } diff --git a/app/core/src/main/resources/static/js/multitool/commands/rotate.js b/app/core/src/main/resources/static/js/multitool/commands/rotate.js index 6fb08cb94..e3236fe81 100644 --- a/app/core/src/main/resources/static/js/multitool/commands/rotate.js +++ b/app/core/src/main/resources/static/js/multitool/commands/rotate.js @@ -1,53 +1,61 @@ import { Command } from "./command.js"; +/** + * Rotates a single image element by a relative degree. + */ export class RotateElementCommand extends Command { + /** + * @param {HTMLElement} element - The element to rotate. + * @param {number|string} degree - Relative degrees to add (e.g., 90 or "-90"). + */ constructor(element, degree) { super(); this.element = element; this.degree = degree; } + /** Execute: apply rotation. */ execute() { - let lastTransform = this.element.style.rotate; - if (!lastTransform) { - lastTransform = "0"; - } + let lastTransform = this.element.style.rotate || "0"; const lastAngle = parseInt(lastTransform.replace(/[^\d-]/g, "")); const newAngle = lastAngle + parseInt(this.degree); this.element.style.rotate = newAngle + "deg"; } + /** Undo: revert by subtracting the same delta. */ undo() { - let lastTransform = this.element.style.rotate; - if (!lastTransform) { - lastTransform = "0"; - } - + let lastTransform = this.element.style.rotate || "0"; const currentAngle = parseInt(lastTransform.replace(/[^\d-]/g, "")); const undoAngle = currentAngle + -parseInt(this.degree); this.element.style.rotate = undoAngle + "deg"; } + /** Redo mirrors execute. */ redo() { this.execute(); } } +/** + * Rotates a set of image elements by a relative degree. + */ export class RotateAllCommand extends Command { + /** + * @param {HTMLElement[]} elements - Image elements to rotate. + * @param {number} degree - Relative degrees to add for all. + */ constructor(elements, degree) { super(); this.elements = elements; this.degree = degree; } + /** Execute: apply rotation to all. */ execute() { - for (let element of this.elements) { - let lastTransform = element.style.rotate; - if (!lastTransform) { - lastTransform = "0"; - } + for (const element of this.elements) { + let lastTransform = element.style.rotate || "0"; const lastAngle = parseInt(lastTransform.replace(/[^\d-]/g, "")); const newAngle = lastAngle + this.degree; @@ -55,12 +63,10 @@ export class RotateAllCommand extends Command { } } + /** Undo: revert rotation for all. */ undo() { - for (let element of this.elements) { - let lastTransform = element.style.rotate; - if (!lastTransform) { - lastTransform = "0"; - } + for (const element of this.elements) { + let lastTransform = element.style.rotate || "0"; const currentAngle = parseInt(lastTransform.replace(/[^\d-]/g, "")); const undoAngle = currentAngle + -this.degree; @@ -68,6 +74,7 @@ export class RotateAllCommand extends Command { } } + /** Redo mirrors execute. */ redo() { this.execute(); } diff --git a/app/core/src/main/resources/static/js/multitool/commands/select.js b/app/core/src/main/resources/static/js/multitool/commands/select.js index b76a25ca1..61199cba1 100644 --- a/app/core/src/main/resources/static/js/multitool/commands/select.js +++ b/app/core/src/main/resources/static/js/multitool/commands/select.js @@ -1,57 +1,45 @@ import { Command } from "./command.js"; +/** + * Toggles selection state of a single page via its checkbox. + */ export class SelectPageCommand extends Command { + /** + * @param {number} pageNumber - 1-based page number. + * @param {HTMLInputElement} checkbox - Checkbox linked to the page. + */ constructor(pageNumber, checkbox) { super(); this.pageNumber = pageNumber; this.selectCheckbox = checkbox; } + /** Execute: apply current checkbox state to global selection. */ execute() { if (this.selectCheckbox.checked) { - //adds to array of selected pages window.selectedPages.push(this.pageNumber); } else { - //remove page from selected pages array const index = window.selectedPages.indexOf(this.pageNumber); - if (index !== -1) { - window.selectedPages.splice(index, 1); - } + if (index !== -1) window.selectedPages.splice(index, 1); } if (window.selectedPages.length > 0 && !window.selectPage) { window.toggleSelectPageVisibility(); } - if (window.selectedPages.length == 0 && window.selectPage) { + if (window.selectedPages.length === 0 && window.selectPage) { window.toggleSelectPageVisibility(); } window.updateSelectedPagesDisplay(); } + /** Undo: invert checkbox and apply same logic as execute. */ undo() { this.selectCheckbox.checked = !this.selectCheckbox.checked; - if (this.selectCheckbox.checked) { - //adds to array of selected pages - window.selectedPages.push(this.pageNumber); - } else { - //remove page from selected pages array - const index = window.selectedPages.indexOf(this.pageNumber); - if (index !== -1) { - window.selectedPages.splice(index, 1); - } - } - - if (window.selectedPages.length > 0 && !window.selectPage) { - window.toggleSelectPageVisibility(); - } - if (window.selectedPages.length == 0 && window.selectPage) { - window.toggleSelectPageVisibility(); - } - - window.updateSelectedPagesDisplay(); + this.execute(); } + /** Redo: invert again then execute. */ redo() { this.selectCheckbox.checked = !this.selectCheckbox.checked; this.execute(); diff --git a/app/core/src/main/resources/static/js/multitool/commands/split.js b/app/core/src/main/resources/static/js/multitool/commands/split.js index 3cbc32cde..a4fad6ffb 100644 --- a/app/core/src/main/resources/static/js/multitool/commands/split.js +++ b/app/core/src/main/resources/static/js/multitool/commands/split.js @@ -1,26 +1,45 @@ import { Command } from "./command.js"; +/** + * Toggles a split class on a single page element. + */ export class SplitFileCommand extends Command { + /** + * @param {HTMLElement} element - Target page container. + * @param {string} splitClass - CSS class to toggle for split markers. + */ constructor(element, splitClass) { super(); this.element = element; this.splitClass = splitClass; } + /** Execute: toggle split class. */ execute() { this.element.classList.toggle(this.splitClass); } + /** Undo: toggle split class back. */ undo() { this.element.classList.toggle(this.splitClass); } + /** Redo: same as execute. */ redo() { this.execute(); } } +/** + * Toggles split class across a set of elements, optionally limited by selection. + */ export class SplitAllCommand extends Command { + /** + * @param {NodeListOf|HTMLElement[]} elements - All page containers. + * @param {boolean} isSelectedInWindow - Whether multi-select mode is active. + * @param {number[]} selectedPages - 0-based indices of selected pages (when active). + * @param {string} splitClass - CSS class used as split marker. + */ constructor(elements, isSelectedInWindow, selectedPages, splitClass) { super(); this.elements = elements; @@ -29,72 +48,41 @@ export class SplitAllCommand extends Command { this.splitClass = splitClass; } + /** Execute: toggle split for all or selected pages. */ execute() { if (!this.isSelectedInWindow) { - const hasSplit = this._hasSplit(this.elements, this.splitClass); - if (hasSplit) { - this.elements.forEach((page) => { + const hasSplit = this._hasSplit(); + (this.elements || []).forEach((page) => { + if (hasSplit) { page.classList.remove(this.splitClass); - }); - } else { - this.elements.forEach((page) => { + } else { page.classList.add(this.splitClass); - }); - } + } + }); return; } this.elements.forEach((page, index) => { - const pageIndex = index; - if (this.isSelectedInWindow && !this.selectedPages.includes(pageIndex)) - return; - - if (page.classList.contains(this.splitClass)) { - page.classList.remove(this.splitClass); - } else { - page.classList.add(this.splitClass); - } + if (!this.selectedPages.includes(index)) return; + page.classList.toggle(this.splitClass); }); } + /** @returns {boolean} true if any element currently has the split class. */ _hasSplit() { - if (!this.elements || this.elements.length == 0) return false; - + if (!this.elements || this.elements.length === 0) return false; for (const node of this.elements) { if (node.classList.contains(this.splitClass)) return true; } - return false; } + /** Undo mirrors execute logic. */ undo() { - if (!this.isSelectedInWindow) { - const hasSplit = this._hasSplit(this.elements, this.splitClass); - if (hasSplit) { - this.elements.forEach((page) => { - page.classList.remove(this.splitClass); - }); - } else { - this.elements.forEach((page) => { - page.classList.add(this.splitClass); - }); - } - return; - } - - this.elements.forEach((page, index) => { - const pageIndex = index; - if (this.isSelectedInWindow && !this.selectedPages.includes(pageIndex)) - return; - - if (page.classList.contains(this.splitClass)) { - page.classList.remove(this.splitClass); - } else { - page.classList.add(this.splitClass); - } - }); + this.execute(); } + /** Redo mirrors execute logic. */ redo() { this.execute(); } diff --git a/app/core/src/main/resources/templates/multi-tool.html b/app/core/src/main/resources/templates/multi-tool.html index d3dd3c37c..a1160d43f 100644 --- a/app/core/src/main/resources/templates/multi-tool.html +++ b/app/core/src/main/resources/templates/multi-tool.html @@ -142,6 +142,7 @@ split: '[[#{multiTool.split}]]', addFile: '[[#{multiTool.addFile}]]', insertPageBreak: '[[#{multiTool.insertPageBreak}]]', + duplicate: '[[#{multiTool.duplicate}]]', dragDropMessage: '[[#{multiTool.dragDropMessage}]]', undo: '[[#{multiTool.undo}]]', redo: '[[#{multiTool.redo}]]',