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}]]',