mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2025-11-16 01:21:16 +01:00
# 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:
parent
6b6699ed70
commit
e4cfb8befe
@ -1402,6 +1402,7 @@ multiTool.delete=Delete
|
|||||||
multiTool.dragDropMessage=Page(s) Selected
|
multiTool.dragDropMessage=Page(s) Selected
|
||||||
multiTool.undo=Undo (CTRL + Z)
|
multiTool.undo=Undo (CTRL + Z)
|
||||||
multiTool.redo=Redo (CTRL + Y)
|
multiTool.redo=Redo (CTRL + Y)
|
||||||
|
multiTool.duplicate=Duplicate
|
||||||
|
|
||||||
multiTool.svgNotSupported=SVG files are not supported in Multi Tool and were ignored.
|
multiTool.svgNotSupported=SVG files are not supported in Multi Tool and were ignored.
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { DeletePageCommand } from "./commands/delete-page.js";
|
import { DeletePageCommand } from "./commands/delete-page.js";
|
||||||
|
import { DuplicatePageCommand } from "./commands/duplicate-page.js";
|
||||||
import { SelectPageCommand } from "./commands/select.js";
|
import { SelectPageCommand } from "./commands/select.js";
|
||||||
import { SplitFileCommand } from "./commands/split.js";
|
import { SplitFileCommand } from "./commands/split.js";
|
||||||
import { UndoManager } from "./UndoManager.js";
|
import { UndoManager } from "./UndoManager.js";
|
||||||
@ -78,6 +79,18 @@ class PdfActionsManager {
|
|||||||
this._pushUndoClearRedo(deletePageCommand);
|
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) {
|
insertFileButtonCallback(e) {
|
||||||
var imgContainer = this.getPageContainer(e.target);
|
var imgContainer = this.getPageContainer(e.target);
|
||||||
this.addFiles(imgContainer);
|
this.addFiles(imgContainer);
|
||||||
@ -101,10 +114,11 @@ class PdfActionsManager {
|
|||||||
this.undoManager.pushUndoClearRedo(command);
|
this.undoManager.pushUndoClearRedo(command);
|
||||||
}
|
}
|
||||||
|
|
||||||
setActions({ movePageTo, addFiles, rotateElement }) {
|
setActions({ movePageTo, addFiles, rotateElement, duplicatePage }) {
|
||||||
this.movePageTo = movePageTo;
|
this.movePageTo = movePageTo;
|
||||||
this.addFiles = addFiles;
|
this.addFiles = addFiles;
|
||||||
this.rotateElement = rotateElement;
|
this.rotateElement = rotateElement;
|
||||||
|
this.duplicatePage = duplicatePage;
|
||||||
|
|
||||||
this.moveUpButtonCallback = this.moveUpButtonCallback.bind(this);
|
this.moveUpButtonCallback = this.moveUpButtonCallback.bind(this);
|
||||||
this.moveDownButtonCallback = this.moveDownButtonCallback.bind(this);
|
this.moveDownButtonCallback = this.moveDownButtonCallback.bind(this);
|
||||||
@ -114,6 +128,7 @@ class PdfActionsManager {
|
|||||||
this.insertFileButtonCallback = this.insertFileButtonCallback.bind(this);
|
this.insertFileButtonCallback = this.insertFileButtonCallback.bind(this);
|
||||||
this.insertFileBlankButtonCallback = this.insertFileBlankButtonCallback.bind(this);
|
this.insertFileBlankButtonCallback = this.insertFileBlankButtonCallback.bind(this);
|
||||||
this.splitFileButtonCallback = this.splitFileButtonCallback.bind(this);
|
this.splitFileButtonCallback = this.splitFileButtonCallback.bind(this);
|
||||||
|
this.duplicatePageButtonCallback = this.duplicatePageButtonCallback.bind(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -154,6 +169,13 @@ class PdfActionsManager {
|
|||||||
rotateCW.onclick = this.rotateCWButtonCallback;
|
rotateCW.onclick = this.rotateCWButtonCallback;
|
||||||
buttonContainer.appendChild(rotateCW);
|
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");
|
const deletePage = document.createElement("button");
|
||||||
deletePage.classList.add("btn", "btn-danger");
|
deletePage.classList.add("btn", "btn-danger");
|
||||||
deletePage.setAttribute('title', window.translations.delete);
|
deletePage.setAttribute('title', window.translations.delete);
|
||||||
@ -195,7 +217,7 @@ class PdfActionsManager {
|
|||||||
|
|
||||||
const insertFileButton = document.createElement("button");
|
const insertFileButton = document.createElement("button");
|
||||||
insertFileButton.classList.add("btn", "btn-primary");
|
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.innerHTML = `<span class="material-symbols-rounded">add</span>`;
|
||||||
insertFileButton.onclick = this.insertFileButtonCallback;
|
insertFileButton.onclick = this.insertFileButtonCallback;
|
||||||
insertFileButtonContainer.appendChild(insertFileButton);
|
insertFileButtonContainer.appendChild(insertFileButton);
|
||||||
|
|||||||
@ -73,6 +73,7 @@ class PdfContainer {
|
|||||||
this.setDownloadAttribute = this.setDownloadAttribute.bind(this);
|
this.setDownloadAttribute = this.setDownloadAttribute.bind(this);
|
||||||
this.preventIllegalChars = this.preventIllegalChars.bind(this);
|
this.preventIllegalChars = this.preventIllegalChars.bind(this);
|
||||||
this.addImageFile = this.addImageFile.bind(this);
|
this.addImageFile = this.addImageFile.bind(this);
|
||||||
|
this.duplicatePage = this.duplicatePage.bind(this);
|
||||||
this.nameAndArchiveFiles = this.nameAndArchiveFiles.bind(this);
|
this.nameAndArchiveFiles = this.nameAndArchiveFiles.bind(this);
|
||||||
this.splitPDF = this.splitPDF.bind(this);
|
this.splitPDF = this.splitPDF.bind(this);
|
||||||
this.splitAll = this.splitAll.bind(this);
|
this.splitAll = this.splitAll.bind(this);
|
||||||
@ -99,6 +100,7 @@ class PdfContainer {
|
|||||||
rotateElement: this.rotateElement,
|
rotateElement: this.rotateElement,
|
||||||
updateFilename: this.updateFilename,
|
updateFilename: this.updateFilename,
|
||||||
deleteSelected: this.deleteSelected,
|
deleteSelected: this.deleteSelected,
|
||||||
|
duplicatePage: this.duplicatePage,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -197,19 +199,31 @@ class PdfContainer {
|
|||||||
return movePageCommand;
|
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,
|
element,
|
||||||
window.selectedPages,
|
window.selectedPages,
|
||||||
this.addFilesAction.bind(this),
|
action,
|
||||||
this.pagesContainer
|
this.pagesContainer
|
||||||
);
|
);
|
||||||
|
|
||||||
await addFilesCommand.execute();
|
await addFilesCommand.execute();
|
||||||
|
|
||||||
this.undoManager.pushUndoClearRedo(addFilesCommand);
|
this.undoManager.pushUndoClearRedo(addFilesCommand);
|
||||||
window.tooltipSetup();
|
window.tooltipSetup();
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async addFilesAction(nextSiblingElement) {
|
async addFilesAction(nextSiblingElement) {
|
||||||
@ -433,6 +447,30 @@ class PdfContainer {
|
|||||||
return pages;
|
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) {
|
async loadFile(file) {
|
||||||
var objectUrl = URL.createObjectURL(file);
|
var objectUrl = URL.createObjectURL(file);
|
||||||
var pdfDocument = await this.toPdfLib(objectUrl);
|
var pdfDocument = await this.toPdfLib(objectUrl);
|
||||||
|
|||||||
@ -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) {
|
constructor(element, selectedPages, addFilesAction, pagesContainer) {
|
||||||
super();
|
super();
|
||||||
this.element = element;
|
this.element = element;
|
||||||
this.selectedPages = selectedPages;
|
this.selectedPages = selectedPages;
|
||||||
this.addFilesAction = addFilesAction;
|
this.addFilesAction = addFilesAction;
|
||||||
this.pagesContainer = pagesContainer;
|
this.pagesContainer = pagesContainer;
|
||||||
|
|
||||||
|
/** @type {HTMLElement[]} */
|
||||||
this.addedElements = [];
|
this.addedElements = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Anchors captured on undo to support redo reinsertion.
|
||||||
|
* @type {{ el: HTMLElement, nextSibling: ChildNode|null, index: number }[]}
|
||||||
|
*/
|
||||||
|
this._anchors = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
async execute() {
|
async execute() {
|
||||||
const undoBtn = document.getElementById('undo-btn');
|
const undoBtn = document.getElementById('undo-btn');
|
||||||
undoBtn.disabled = true;
|
if (undoBtn) undoBtn.disabled = true;
|
||||||
if (this.element) {
|
|
||||||
const newElement = await this.addFilesAction(this.element);
|
const result = await this.addFilesAction(this.element || false);
|
||||||
if (newElement) {
|
if (Array.isArray(result)) {
|
||||||
this.addedElements = newElement;
|
this.addedElements = result;
|
||||||
}
|
} else if (result) {
|
||||||
|
this.addedElements = [result];
|
||||||
} else {
|
} else {
|
||||||
const newElement = await this.addFilesAction(false);
|
this.addedElements = [];
|
||||||
if (newElement) {
|
|
||||||
this.addedElements = newElement;
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
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() {
|
undo() {
|
||||||
this.addedElements.forEach((element) => {
|
this._anchors = [];
|
||||||
const nextSibling = element.nextSibling;
|
|
||||||
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) {
|
if (this.pagesContainer.childElementCount === 0) {
|
||||||
const filenameInput = document.getElementById('filename-input');
|
const filenameInput = document.getElementById('filename-input');
|
||||||
const downloadBtn = document.getElementById('export-button');
|
const downloadBtn = document.getElementById('export-button');
|
||||||
|
if (filenameInput) {
|
||||||
filenameInput.disabled = true;
|
filenameInput.disabled = true;
|
||||||
filenameInput.value = '';
|
filenameInput.value = '';
|
||||||
|
}
|
||||||
|
if (downloadBtn) {
|
||||||
downloadBtn.disabled = true;
|
downloadBtn.disabled = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
element._nextSibling = nextSibling;
|
|
||||||
});
|
|
||||||
this.addedElements = [];
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
redo() {
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,3 +3,63 @@ export class Command {
|
|||||||
undo() {}
|
undo() {}
|
||||||
redo() {}
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -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 {
|
export class CommandSequence extends Command {
|
||||||
|
/** @param {Command[]} commands - Commands to be executed/undone/redone as a group. */
|
||||||
constructor(commands) {
|
constructor(commands) {
|
||||||
super();
|
super();
|
||||||
this.commands = commands;
|
this.commands = commands;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Execute: run each command in order. */
|
||||||
execute() {
|
execute() {
|
||||||
this.commands.forEach((command) => command.execute())
|
this.commands.forEach((command) => command.execute());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Undo: undo in reverse order. */
|
||||||
undo() {
|
undo() {
|
||||||
this.commands.slice().reverse().forEach((command) => command.undo())
|
this.commands.slice().reverse().forEach((command) => command.undo());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Redo: simply execute again. */
|
||||||
redo() {
|
redo() {
|
||||||
this.execute();
|
this.execute();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,20 +1,32 @@
|
|||||||
import { Command } from "./command.js";
|
import { Command } from "./command.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes a page from the container and restores it on undo.
|
||||||
|
*/
|
||||||
export class DeletePageCommand extends Command {
|
export class DeletePageCommand extends Command {
|
||||||
|
/**
|
||||||
|
* @param {HTMLElement} element - Page container to delete.
|
||||||
|
* @param {HTMLElement} pagesContainer - Parent container holding all pages.
|
||||||
|
*/
|
||||||
constructor(element, pagesContainer) {
|
constructor(element, pagesContainer) {
|
||||||
super();
|
super();
|
||||||
|
|
||||||
this.element = element;
|
this.element = element;
|
||||||
this.pagesContainer = pagesContainer;
|
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");
|
const filenameParagraph = document.getElementById("filename");
|
||||||
this.filenameParagraphText = filenameParagraph
|
/** @type {string} */
|
||||||
? filenameParagraph.innerText
|
this.filenameParagraphText = filenameParagraph ? filenameParagraph.innerText : "";
|
||||||
: "";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Execute: remove the page and update empty-state UI if needed. */
|
||||||
execute() {
|
execute() {
|
||||||
this.nextSibling = this.element.nextSibling;
|
this.nextSibling = this.element.nextSibling;
|
||||||
|
|
||||||
@ -23,15 +35,19 @@ export class DeletePageCommand extends Command {
|
|||||||
const filenameInput = document.getElementById("filename-input");
|
const filenameInput = document.getElementById("filename-input");
|
||||||
const downloadBtn = document.getElementById("export-button");
|
const downloadBtn = document.getElementById("export-button");
|
||||||
|
|
||||||
|
if (filenameInput) {
|
||||||
filenameInput.disabled = true;
|
filenameInput.disabled = true;
|
||||||
filenameInput.value = "";
|
filenameInput.value = "";
|
||||||
|
}
|
||||||
|
if (downloadBtn) {
|
||||||
downloadBtn.disabled = true;
|
downloadBtn.disabled = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Undo: reinsert the page at its original position. */
|
||||||
undo() {
|
undo() {
|
||||||
let node = this.nextSibling;
|
const node = /** @type {ChildNode|null} */ (this.nextSibling);
|
||||||
if (node) this.pagesContainer.insertBefore(this.element, node);
|
if (node) this.pagesContainer.insertBefore(this.element, node);
|
||||||
else this.pagesContainer.appendChild(this.element);
|
else this.pagesContainer.appendChild(this.element);
|
||||||
|
|
||||||
@ -43,12 +59,16 @@ export class DeletePageCommand extends Command {
|
|||||||
const filenameInput = document.getElementById("filename-input");
|
const filenameInput = document.getElementById("filename-input");
|
||||||
const downloadBtn = document.getElementById("export-button");
|
const downloadBtn = document.getElementById("export-button");
|
||||||
|
|
||||||
|
if (filenameInput) {
|
||||||
filenameInput.disabled = false;
|
filenameInput.disabled = false;
|
||||||
filenameInput.value = this.filenameInputValue;
|
filenameInput.value = this.filenameInputValue;
|
||||||
|
}
|
||||||
|
if (downloadBtn) {
|
||||||
downloadBtn.disabled = false;
|
downloadBtn.disabled = false;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Redo: remove again and maintain empty-state UI. */
|
||||||
redo() {
|
redo() {
|
||||||
const pageNumberElement = this.element.querySelector(".page-number");
|
const pageNumberElement = this.element.querySelector(".page-number");
|
||||||
if (pageNumberElement) {
|
if (pageNumberElement) {
|
||||||
@ -60,10 +80,13 @@ export class DeletePageCommand extends Command {
|
|||||||
const filenameInput = document.getElementById("filename-input");
|
const filenameInput = document.getElementById("filename-input");
|
||||||
const downloadBtn = document.getElementById("export-button");
|
const downloadBtn = document.getElementById("export-button");
|
||||||
|
|
||||||
|
if (filenameInput) {
|
||||||
filenameInput.disabled = true;
|
filenameInput.disabled = true;
|
||||||
filenameInput.value = "";
|
filenameInput.value = "";
|
||||||
|
}
|
||||||
|
if (downloadBtn) {
|
||||||
downloadBtn.disabled = true;
|
downloadBtn.disabled = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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?.();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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 {
|
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) {
|
constructor(startElement, endElement, pagesContainer, pagesContainerWrapper, scrollTo = false) {
|
||||||
super();
|
super();
|
||||||
|
|
||||||
this.pagesContainer = pagesContainer;
|
this.pagesContainer = pagesContainer;
|
||||||
const childArray = Array.from(this.pagesContainer.childNodes);
|
const childArray = Array.from(this.pagesContainer.childNodes);
|
||||||
|
|
||||||
|
/** @type {number} */
|
||||||
this.startIndex = childArray.indexOf(startElement);
|
this.startIndex = childArray.indexOf(startElement);
|
||||||
|
/** @type {number} */
|
||||||
this.endIndex = childArray.indexOf(endElement);
|
this.endIndex = childArray.indexOf(endElement);
|
||||||
|
|
||||||
this.startElement = startElement;
|
this.startElement = startElement;
|
||||||
@ -16,8 +28,10 @@ export class MovePageCommand extends Command {
|
|||||||
this.scrollTo = scrollTo;
|
this.scrollTo = scrollTo;
|
||||||
this.pagesContainerWrapper = pagesContainerWrapper;
|
this.pagesContainerWrapper = pagesContainerWrapper;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Execute: perform DOM move and optional scroll. */
|
||||||
execute() {
|
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');
|
const pageNumberElement = this.startElement.querySelector('.page-number');
|
||||||
if (pageNumberElement) {
|
if (pageNumberElement) {
|
||||||
this.startElement.removeChild(pageNumberElement);
|
this.startElement.removeChild(pageNumberElement);
|
||||||
@ -31,7 +45,7 @@ export class MovePageCommand extends Command {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (this.scrollTo) {
|
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;
|
const vector = this.endIndex !== -1 && this.startIndex > this.endIndex ? 0 - width : width;
|
||||||
|
|
||||||
this.pagesContainerWrapper.scroll({
|
this.pagesContainerWrapper.scroll({
|
||||||
@ -40,16 +54,17 @@ export class MovePageCommand extends Command {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Undo: restore original order and optional scroll back. */
|
||||||
undo() {
|
undo() {
|
||||||
if (this.startElement) {
|
if (this.startElement) {
|
||||||
this.pagesContainer.removeChild(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)
|
previousNeighbour?.insertAdjacentElement('beforebegin', this.startElement)
|
||||||
?? this.pagesContainer.append(this.startElement);
|
?? this.pagesContainer.append(this.startElement);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.scrollTo) {
|
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;
|
const vector = this.endIndex === -1 || this.startIndex <= this.endIndex ? 0 - width : width;
|
||||||
|
|
||||||
this.pagesContainerWrapper.scroll({
|
this.pagesContainerWrapper.scroll({
|
||||||
@ -58,6 +73,7 @@ export class MovePageCommand extends Command {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Redo: same as execute. */
|
||||||
redo() {
|
redo() {
|
||||||
this.execute();
|
this.execute();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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) {
|
constructor(elements, isSelectedInWindow, selectedPages, pageBreakCallback, pagesContainer) {
|
||||||
super();
|
super();
|
||||||
this.elements = elements;
|
this.elements = elements;
|
||||||
@ -8,7 +15,17 @@ export class PageBreakCommand extends Command {
|
|||||||
this.selectedPages = selectedPages;
|
this.selectedPages = selectedPages;
|
||||||
this.pageBreakCallback = pageBreakCallback;
|
this.pageBreakCallback = pageBreakCallback;
|
||||||
this.pagesContainer = pagesContainer;
|
this.pagesContainer = pagesContainer;
|
||||||
|
|
||||||
|
/** @type {HTMLElement[]} */
|
||||||
this.addedElements = [];
|
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) => ({
|
this.originalStates = Array.from(elements, (element) => ({
|
||||||
element,
|
element,
|
||||||
hasContent: element.innerHTML.trim() !== '',
|
hasContent: element.innerHTML.trim() !== '',
|
||||||
@ -17,43 +34,72 @@ export class PageBreakCommand extends Command {
|
|||||||
|
|
||||||
async execute() {
|
async execute() {
|
||||||
const undoBtn = document.getElementById('undo-btn');
|
const undoBtn = document.getElementById('undo-btn');
|
||||||
undoBtn.disabled = true;
|
if (undoBtn) 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 (newElement) {
|
for (const [index, element] of this.elements.entries()) {
|
||||||
this.addedElements = newElement;
|
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() {
|
undo() {
|
||||||
this.addedElements.forEach((element) => {
|
this._anchors = [];
|
||||||
const nextSibling = element.nextSibling;
|
|
||||||
|
|
||||||
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) {
|
if (this.pagesContainer.childElementCount === 0) {
|
||||||
const filenameInput = document.getElementById('filename-input');
|
const filenameInput = document.getElementById('filename-input');
|
||||||
const filenameParagraph = document.getElementById('filename');
|
const filenameParagraph = document.getElementById('filename');
|
||||||
const downloadBtn = document.getElementById('export-button');
|
const downloadBtn = document.getElementById('export-button');
|
||||||
|
|
||||||
|
if (filenameInput) {
|
||||||
filenameInput.disabled = true;
|
filenameInput.disabled = true;
|
||||||
filenameInput.value = '';
|
filenameInput.value = '';
|
||||||
|
}
|
||||||
|
if (filenameParagraph) {
|
||||||
filenameParagraph.innerText = '';
|
filenameParagraph.innerText = '';
|
||||||
|
}
|
||||||
|
if (downloadBtn) {
|
||||||
downloadBtn.disabled = true;
|
downloadBtn.disabled = true;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
element._nextSibling = nextSibling;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
redo() {
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,35 +1,45 @@
|
|||||||
import { Command } from "./command.js";
|
import { Command } from "./command.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes a set of selected pages and restores them on undo.
|
||||||
|
*/
|
||||||
export class RemoveSelectedCommand extends Command {
|
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) {
|
constructor(pagesContainer, selectedPages, updatePageNumbersAndCheckboxes) {
|
||||||
super();
|
super();
|
||||||
this.pagesContainer = pagesContainer;
|
this.pagesContainer = pagesContainer;
|
||||||
this.selectedPages = selectedPages;
|
this.selectedPages = selectedPages;
|
||||||
|
|
||||||
|
/** @type {{idx:number, childNode:HTMLElement}[]} */
|
||||||
this.deletedChildren = [];
|
this.deletedChildren = [];
|
||||||
|
|
||||||
if (updatePageNumbersAndCheckboxes) {
|
this.updatePageNumbersAndCheckboxes = updatePageNumbersAndCheckboxes || (() => {
|
||||||
this.updatePageNumbersAndCheckboxes = updatePageNumbersAndCheckboxes;
|
|
||||||
} else {
|
|
||||||
const pageDivs = document.querySelectorAll(".pdf-actions_container");
|
const pageDivs = document.querySelectorAll(".pdf-actions_container");
|
||||||
|
|
||||||
pageDivs.forEach((div, index) => {
|
pageDivs.forEach((div, index) => {
|
||||||
const pageNumber = index + 1;
|
const pageNumber = index + 1;
|
||||||
const checkbox = div.querySelector(".pdf-actions_checkbox");
|
const checkbox = div.querySelector(".pdf-actions_checkbox");
|
||||||
|
if (checkbox) {
|
||||||
checkbox.id = `selectPageCheckbox-${pageNumber}`;
|
checkbox.id = `selectPageCheckbox-${pageNumber}`;
|
||||||
checkbox.setAttribute("data-page-number", pageNumber);
|
checkbox.setAttribute("data-page-number", pageNumber);
|
||||||
checkbox.checked = window.selectedPages.includes(pageNumber);
|
checkbox.checked = window.selectedPages.includes(pageNumber);
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
const filenameInput = document.getElementById("filename-input");
|
const filenameInput = document.getElementById("filename-input");
|
||||||
const filenameParagraph = document.getElementById("filename");
|
const filenameParagraph = document.getElementById("filename");
|
||||||
|
|
||||||
|
/** @type {string} */
|
||||||
this.originalFilenameInputValue = filenameInput ? filenameInput.value : "";
|
this.originalFilenameInputValue = filenameInput ? filenameInput.value : "";
|
||||||
if (filenameParagraph)
|
/** @type {string|undefined} */
|
||||||
this.originalFilenameParagraphText = filenameParagraph.innerText;
|
this.originalFilenameParagraphText = filenameParagraph?.innerText;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Execute: remove selected pages and update empty state. */
|
||||||
execute() {
|
execute() {
|
||||||
let deletions = 0;
|
let deletions = 0;
|
||||||
|
|
||||||
@ -53,10 +63,9 @@ export class RemoveSelectedCommand extends Command {
|
|||||||
const downloadBtn = document.getElementById("export-button");
|
const downloadBtn = document.getElementById("export-button");
|
||||||
|
|
||||||
if (filenameInput) filenameInput.disabled = true;
|
if (filenameInput) filenameInput.disabled = true;
|
||||||
filenameInput.value = "";
|
if (filenameInput) filenameInput.value = "";
|
||||||
if (filenameParagraph) filenameParagraph.innerText = "";
|
if (filenameParagraph) filenameParagraph.innerText = "";
|
||||||
|
if (downloadBtn) downloadBtn.disabled = true;
|
||||||
downloadBtn.disabled = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
window.selectedPages = [];
|
window.selectedPages = [];
|
||||||
@ -64,12 +73,13 @@ export class RemoveSelectedCommand extends Command {
|
|||||||
document.dispatchEvent(new Event("selectedPagesUpdated"));
|
document.dispatchEvent(new Event("selectedPagesUpdated"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Undo: restore all removed nodes at their original indices. */
|
||||||
undo() {
|
undo() {
|
||||||
while (this.deletedChildren.length > 0) {
|
while (this.deletedChildren.length > 0) {
|
||||||
let deletedChild = this.deletedChildren.pop();
|
const deletedChild = this.deletedChildren.pop();
|
||||||
if (this.pagesContainer.children.length <= deletedChild.idx)
|
if (this.pagesContainer.children.length <= deletedChild.idx) {
|
||||||
this.pagesContainer.appendChild(deletedChild.childNode);
|
this.pagesContainer.appendChild(deletedChild.childNode);
|
||||||
else {
|
} else {
|
||||||
this.pagesContainer.insertBefore(
|
this.pagesContainer.insertBefore(
|
||||||
deletedChild.childNode,
|
deletedChild.childNode,
|
||||||
this.pagesContainer.children[deletedChild.idx]
|
this.pagesContainer.children[deletedChild.idx]
|
||||||
@ -83,11 +93,11 @@ export class RemoveSelectedCommand extends Command {
|
|||||||
const downloadBtn = document.getElementById("export-button");
|
const downloadBtn = document.getElementById("export-button");
|
||||||
|
|
||||||
if (filenameInput) filenameInput.disabled = false;
|
if (filenameInput) filenameInput.disabled = false;
|
||||||
filenameInput.value = this.originalFilenameInputValue;
|
if (filenameInput) filenameInput.value = this.originalFilenameInputValue;
|
||||||
if (filenameParagraph)
|
if (filenameParagraph && this.originalFilenameParagraphText !== undefined) {
|
||||||
filenameParagraph.innerText = this.originalFilenameParagraphText;
|
filenameParagraph.innerText = this.originalFilenameParagraphText;
|
||||||
|
}
|
||||||
downloadBtn.disabled = false;
|
if (downloadBtn) downloadBtn.disabled = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
window.selectedPages = this.selectedPages;
|
window.selectedPages = this.selectedPages;
|
||||||
@ -95,6 +105,7 @@ export class RemoveSelectedCommand extends Command {
|
|||||||
document.dispatchEvent(new Event("selectedPagesUpdated"));
|
document.dispatchEvent(new Event("selectedPagesUpdated"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Redo mirrors execute. */
|
||||||
redo() {
|
redo() {
|
||||||
this.execute();
|
this.execute();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,53 +1,61 @@
|
|||||||
import { Command } from "./command.js";
|
import { Command } from "./command.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rotates a single image element by a relative degree.
|
||||||
|
*/
|
||||||
export class RotateElementCommand extends Command {
|
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) {
|
constructor(element, degree) {
|
||||||
super();
|
super();
|
||||||
this.element = element;
|
this.element = element;
|
||||||
this.degree = degree;
|
this.degree = degree;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Execute: apply rotation. */
|
||||||
execute() {
|
execute() {
|
||||||
let lastTransform = this.element.style.rotate;
|
let lastTransform = this.element.style.rotate || "0";
|
||||||
if (!lastTransform) {
|
|
||||||
lastTransform = "0";
|
|
||||||
}
|
|
||||||
const lastAngle = parseInt(lastTransform.replace(/[^\d-]/g, ""));
|
const lastAngle = parseInt(lastTransform.replace(/[^\d-]/g, ""));
|
||||||
const newAngle = lastAngle + parseInt(this.degree);
|
const newAngle = lastAngle + parseInt(this.degree);
|
||||||
|
|
||||||
this.element.style.rotate = newAngle + "deg";
|
this.element.style.rotate = newAngle + "deg";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Undo: revert by subtracting the same delta. */
|
||||||
undo() {
|
undo() {
|
||||||
let lastTransform = this.element.style.rotate;
|
let lastTransform = this.element.style.rotate || "0";
|
||||||
if (!lastTransform) {
|
|
||||||
lastTransform = "0";
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentAngle = parseInt(lastTransform.replace(/[^\d-]/g, ""));
|
const currentAngle = parseInt(lastTransform.replace(/[^\d-]/g, ""));
|
||||||
const undoAngle = currentAngle + -parseInt(this.degree);
|
const undoAngle = currentAngle + -parseInt(this.degree);
|
||||||
|
|
||||||
this.element.style.rotate = undoAngle + "deg";
|
this.element.style.rotate = undoAngle + "deg";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Redo mirrors execute. */
|
||||||
redo() {
|
redo() {
|
||||||
this.execute();
|
this.execute();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rotates a set of image elements by a relative degree.
|
||||||
|
*/
|
||||||
export class RotateAllCommand extends Command {
|
export class RotateAllCommand extends Command {
|
||||||
|
/**
|
||||||
|
* @param {HTMLElement[]} elements - Image elements to rotate.
|
||||||
|
* @param {number} degree - Relative degrees to add for all.
|
||||||
|
*/
|
||||||
constructor(elements, degree) {
|
constructor(elements, degree) {
|
||||||
super();
|
super();
|
||||||
this.elements = elements;
|
this.elements = elements;
|
||||||
this.degree = degree;
|
this.degree = degree;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Execute: apply rotation to all. */
|
||||||
execute() {
|
execute() {
|
||||||
for (let element of this.elements) {
|
for (const element of this.elements) {
|
||||||
let lastTransform = element.style.rotate;
|
let lastTransform = element.style.rotate || "0";
|
||||||
if (!lastTransform) {
|
|
||||||
lastTransform = "0";
|
|
||||||
}
|
|
||||||
const lastAngle = parseInt(lastTransform.replace(/[^\d-]/g, ""));
|
const lastAngle = parseInt(lastTransform.replace(/[^\d-]/g, ""));
|
||||||
const newAngle = lastAngle + this.degree;
|
const newAngle = lastAngle + this.degree;
|
||||||
|
|
||||||
@ -55,12 +63,10 @@ export class RotateAllCommand extends Command {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Undo: revert rotation for all. */
|
||||||
undo() {
|
undo() {
|
||||||
for (let element of this.elements) {
|
for (const element of this.elements) {
|
||||||
let lastTransform = element.style.rotate;
|
let lastTransform = element.style.rotate || "0";
|
||||||
if (!lastTransform) {
|
|
||||||
lastTransform = "0";
|
|
||||||
}
|
|
||||||
const currentAngle = parseInt(lastTransform.replace(/[^\d-]/g, ""));
|
const currentAngle = parseInt(lastTransform.replace(/[^\d-]/g, ""));
|
||||||
const undoAngle = currentAngle + -this.degree;
|
const undoAngle = currentAngle + -this.degree;
|
||||||
|
|
||||||
@ -68,6 +74,7 @@ export class RotateAllCommand extends Command {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Redo mirrors execute. */
|
||||||
redo() {
|
redo() {
|
||||||
this.execute();
|
this.execute();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,57 +1,45 @@
|
|||||||
import { Command } from "./command.js";
|
import { Command } from "./command.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggles selection state of a single page via its checkbox.
|
||||||
|
*/
|
||||||
export class SelectPageCommand extends Command {
|
export class SelectPageCommand extends Command {
|
||||||
|
/**
|
||||||
|
* @param {number} pageNumber - 1-based page number.
|
||||||
|
* @param {HTMLInputElement} checkbox - Checkbox linked to the page.
|
||||||
|
*/
|
||||||
constructor(pageNumber, checkbox) {
|
constructor(pageNumber, checkbox) {
|
||||||
super();
|
super();
|
||||||
this.pageNumber = pageNumber;
|
this.pageNumber = pageNumber;
|
||||||
this.selectCheckbox = checkbox;
|
this.selectCheckbox = checkbox;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Execute: apply current checkbox state to global selection. */
|
||||||
execute() {
|
execute() {
|
||||||
if (this.selectCheckbox.checked) {
|
if (this.selectCheckbox.checked) {
|
||||||
//adds to array of selected pages
|
|
||||||
window.selectedPages.push(this.pageNumber);
|
window.selectedPages.push(this.pageNumber);
|
||||||
} else {
|
} else {
|
||||||
//remove page from selected pages array
|
|
||||||
const index = window.selectedPages.indexOf(this.pageNumber);
|
const index = window.selectedPages.indexOf(this.pageNumber);
|
||||||
if (index !== -1) {
|
if (index !== -1) window.selectedPages.splice(index, 1);
|
||||||
window.selectedPages.splice(index, 1);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (window.selectedPages.length > 0 && !window.selectPage) {
|
if (window.selectedPages.length > 0 && !window.selectPage) {
|
||||||
window.toggleSelectPageVisibility();
|
window.toggleSelectPageVisibility();
|
||||||
}
|
}
|
||||||
if (window.selectedPages.length == 0 && window.selectPage) {
|
if (window.selectedPages.length === 0 && window.selectPage) {
|
||||||
window.toggleSelectPageVisibility();
|
window.toggleSelectPageVisibility();
|
||||||
}
|
}
|
||||||
|
|
||||||
window.updateSelectedPagesDisplay();
|
window.updateSelectedPagesDisplay();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Undo: invert checkbox and apply same logic as execute. */
|
||||||
undo() {
|
undo() {
|
||||||
this.selectCheckbox.checked = !this.selectCheckbox.checked;
|
this.selectCheckbox.checked = !this.selectCheckbox.checked;
|
||||||
if (this.selectCheckbox.checked) {
|
this.execute();
|
||||||
//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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Redo: invert again then execute. */
|
||||||
redo() {
|
redo() {
|
||||||
this.selectCheckbox.checked = !this.selectCheckbox.checked;
|
this.selectCheckbox.checked = !this.selectCheckbox.checked;
|
||||||
this.execute();
|
this.execute();
|
||||||
|
|||||||
@ -1,26 +1,45 @@
|
|||||||
import { Command } from "./command.js";
|
import { Command } from "./command.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggles a split class on a single page element.
|
||||||
|
*/
|
||||||
export class SplitFileCommand extends Command {
|
export class SplitFileCommand extends Command {
|
||||||
|
/**
|
||||||
|
* @param {HTMLElement} element - Target page container.
|
||||||
|
* @param {string} splitClass - CSS class to toggle for split markers.
|
||||||
|
*/
|
||||||
constructor(element, splitClass) {
|
constructor(element, splitClass) {
|
||||||
super();
|
super();
|
||||||
this.element = element;
|
this.element = element;
|
||||||
this.splitClass = splitClass;
|
this.splitClass = splitClass;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Execute: toggle split class. */
|
||||||
execute() {
|
execute() {
|
||||||
this.element.classList.toggle(this.splitClass);
|
this.element.classList.toggle(this.splitClass);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Undo: toggle split class back. */
|
||||||
undo() {
|
undo() {
|
||||||
this.element.classList.toggle(this.splitClass);
|
this.element.classList.toggle(this.splitClass);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Redo: same as execute. */
|
||||||
redo() {
|
redo() {
|
||||||
this.execute();
|
this.execute();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggles split class across a set of elements, optionally limited by selection.
|
||||||
|
*/
|
||||||
export class SplitAllCommand extends Command {
|
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) {
|
constructor(elements, isSelectedInWindow, selectedPages, splitClass) {
|
||||||
super();
|
super();
|
||||||
this.elements = elements;
|
this.elements = elements;
|
||||||
@ -29,72 +48,41 @@ export class SplitAllCommand extends Command {
|
|||||||
this.splitClass = splitClass;
|
this.splitClass = splitClass;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Execute: toggle split for all or selected pages. */
|
||||||
execute() {
|
execute() {
|
||||||
if (!this.isSelectedInWindow) {
|
if (!this.isSelectedInWindow) {
|
||||||
const hasSplit = this._hasSplit(this.elements, this.splitClass);
|
const hasSplit = this._hasSplit();
|
||||||
|
(this.elements || []).forEach((page) => {
|
||||||
if (hasSplit) {
|
if (hasSplit) {
|
||||||
this.elements.forEach((page) => {
|
|
||||||
page.classList.remove(this.splitClass);
|
page.classList.remove(this.splitClass);
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
this.elements.forEach((page) => {
|
|
||||||
page.classList.add(this.splitClass);
|
page.classList.add(this.splitClass);
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.elements.forEach((page, index) => {
|
this.elements.forEach((page, index) => {
|
||||||
const pageIndex = index;
|
if (!this.selectedPages.includes(index)) return;
|
||||||
if (this.isSelectedInWindow && !this.selectedPages.includes(pageIndex))
|
page.classList.toggle(this.splitClass);
|
||||||
return;
|
|
||||||
|
|
||||||
if (page.classList.contains(this.splitClass)) {
|
|
||||||
page.classList.remove(this.splitClass);
|
|
||||||
} else {
|
|
||||||
page.classList.add(this.splitClass);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @returns {boolean} true if any element currently has the split class. */
|
||||||
_hasSplit() {
|
_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) {
|
for (const node of this.elements) {
|
||||||
if (node.classList.contains(this.splitClass)) return true;
|
if (node.classList.contains(this.splitClass)) return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Undo mirrors execute logic. */
|
||||||
undo() {
|
undo() {
|
||||||
if (!this.isSelectedInWindow) {
|
this.execute();
|
||||||
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);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Redo mirrors execute logic. */
|
||||||
redo() {
|
redo() {
|
||||||
this.execute();
|
this.execute();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -142,6 +142,7 @@
|
|||||||
split: '[[#{multiTool.split}]]',
|
split: '[[#{multiTool.split}]]',
|
||||||
addFile: '[[#{multiTool.addFile}]]',
|
addFile: '[[#{multiTool.addFile}]]',
|
||||||
insertPageBreak: '[[#{multiTool.insertPageBreak}]]',
|
insertPageBreak: '[[#{multiTool.insertPageBreak}]]',
|
||||||
|
duplicate: '[[#{multiTool.duplicate}]]',
|
||||||
dragDropMessage: '[[#{multiTool.dragDropMessage}]]',
|
dragDropMessage: '[[#{multiTool.dragDropMessage}]]',
|
||||||
undo: '[[#{multiTool.undo}]]',
|
undo: '[[#{multiTool.undo}]]',
|
||||||
redo: '[[#{multiTool.redo}]]',
|
redo: '[[#{multiTool.redo}]]',
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user