feat(multitool): Add duplicate page action with undo/redo and harden command stack #4322 (#4333)

# Description of Changes

- Introduced **Duplicate Page** action in the MultiTool UI:
  - New `DuplicatePageCommand` with full undo/redo support
- Button added next to Delete, with tooltip text wired via i18n
(`multiTool.duplicate`)
- `PdfContainer.duplicatePage()` clones page image, rotation, and
adapter state; inserts after source; refreshes numbering
- Hooked duplication into the actions pipeline:
- `PdfActionsManager` now receives `duplicatePage` via `setActions(...)`
  - Added `duplicatePageButtonCallback` and control markup creation
- Refactored command architecture for robust undo/redo:
- Added `CommandWithAnchors` for capturing positions and deterministic
reinsertion (used by add/duplicate/page-break)
- Modernized `AddFilesCommand`, `PageBreakCommand` to use anchors, avoid
DOM state on nodes, and handle arrays consistently
- Improved `RemoveSelectedCommand`, `DeletePageCommand`,
`MovePageCommand`, `Rotate*` and `Split*` commands:
    - Safer null checks for filename/export controls
    - Clearer semantics and documentation
    - Consistent redo mirroring execute
- Minor UI/UX fix:
- Corrected tooltip wiring for the **Add File** button
(`insertFileButton.setAttribute('title', ...)`)
- Internationalization:
  - Added `multiTool.duplicate=Duplicate` to `messages_en_GB.properties`
  - Exposed `translations.duplicate` in `multi-tool.html`

**Why:** Adds a commonly requested workflow improvement (duplicate a
single page quickly) and makes the command stack more resilient
(especially redo after complex DOM mutations), improving reliability and
maintainability.

before:
<img width="186" height="34" alt="image"
src="https://github.com/user-attachments/assets/7edc8e9e-fc3d-411e-aaa1-4da7099c3173"
/>

after:
<img width="225" height="34" alt="image"
src="https://github.com/user-attachments/assets/bdf228c9-b3db-4690-bfb9-f198225459f4"
/>

Closes #4322

---

## Checklist

### General

- [x] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [x] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [x] I have performed a self-review of my own code
- [x] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### UI Changes (if applicable)

- [x] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
This commit is contained in:
Ludy 2025-10-30 00:20:28 +01:00 committed by GitHub
parent 6b6699ed70
commit e4cfb8befe
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 493 additions and 188 deletions

View File

@ -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.

View File

@ -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 = `<span class="material-symbols-rounded">control_point_duplicate</span>`;
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 = `<span class="material-symbols-rounded">add</span>`;
insertFileButton.onclick = this.insertFileButtonCallback;
insertFileButtonContainer.appendChild(insertFileButton);

View File

@ -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);

View File

@ -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);
}
}
}

View File

@ -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);
}
}

View File

@ -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();
}

View File

@ -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;
}
}
}
}

View File

@ -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?.();
}
}

View File

@ -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();
}

View File

@ -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);
}
}
}

View File

@ -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();
}

View File

@ -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 <img> 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();
}

View File

@ -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();

View File

@ -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>|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();
}

View File

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