Ignoring revisions in .git-blame-ignore-revs. Click here to bypass and see the normal blame view.

1207 lines
37 KiB
JavaScript
Raw Normal View History

Feature: Support manual redaction (#2433) # Description ## Manual Redaction: - ### Text Selection-based redaction: - ![image](https://github.com/user-attachments/assets/e59c5e6c-ef52-4f54-a98e-fc26e3226c8e) - Users can now redact currently selected text by selecting the text then clicking `ctrl + s` shortcut or by pressing on **apply/save/disk icon** in the toolbar. - Users can delete/cancel the redacted area by clicking on the box containing the text, then clicking on `delete/trash` icon or by using the shortcut `delete`. - Users can customize the color of the redacted area/text (after the redaction was applied) by simply clicking on the box containing the text/area then clicking on the `color palette` icon and choosing the color they want. - Users can choose to select the color of redaction before redacting text or applying changes (this only affects newly created redaction areas, to change the color of an existing one; check the previous bullet point). - ### Draw/Area-based redaction: - ![image](https://github.com/user-attachments/assets/e2968ae3-ebaf-497e-b3bd-0c8c8f4ee157) - Users can now redact an area in the page by selecting the then clicking `ctrl + s` shortcut or by pressing on **apply/save/disk icon** in the toolbar. - Users can delete/cancel the redacted area by clicking on the drawn box, then clicking on `delete/trash` icon or by using the shortcut `delete` (requires temporarily turning off drawing mode). - Users can customize the color of the redacted area (after the redaction was applied) by simply clicking on the box containing the area then clicking on the `color palette` icon and choosing the color they want. - Users can choose to select the color of redaction before drawing the box or applying changes (this only affects newly created redaction areas, to change the color of an existing one; check the previous bullet point). - ### Page-based redaction: - ![image](https://github.com/user-attachments/assets/aba59432-23e7-4fe6-aa28-872c23a91242) - Users can now redact **ENTIRE** pages by specifying the page number(s), range(s) or functions. - Users can customize the color of page-based redaction (doesn't affect text-based nor draw-based redactions). ### Redaction modes: There are three modes of redaction/operation currently supported - Text Selection-based redaction (TEXT) - Draw/Area-based redaction (DRAWING) - None - by simply not choosing any of the above modes (NONE). ## How to use: - **Text Selection-based redaction:** click on this icon in the toolbar ![image](https://github.com/user-attachments/assets/52cc31ef-6946-482c-84a2-1ddb79646dd8) to enable `text-selection redaction mode` then select the text you want to redact then press `ctrl + s` or click on the disk/save icon ![image](https://github.com/user-attachments/assets/f2bdf2f2-ee07-4682-bb9a-95e13a1004cf). - **Draw/Area-based redaction:** click on this icon in the toolbar ![image](https://github.com/user-attachments/assets/fe00dca9-761e-47a0-a748-2041830dc73e) to enable `draw/area-based redaction` then `left mouse click (LMB)` on the starting point of the rectangle, then once you are satisfied with the rectangle's placement/dimensions then `left mouse click (LMB)` again to apply the redaction. - **Example:** `Left mouse click (LMB)` then move mouse to the right then bottom then `Left mouse click (LMB)`. - Note: Red box/rectangle borders indicate that you have not yet saved (you need to left click on the page to save) ![image](https://github.com/user-attachments/assets/5ce5f789-9d6f-4984-8555-e8fef2a3e3cc) once saved the borders will become green ![image](https://github.com/user-attachments/assets/85cabb9f-e7ee-4268-90cd-80493b625466) (they also become clickable/hover-able when drawing mode is off). - **Page-based redactions:**: Insert the page number(s), range(s) and/or functions (separated by `,`) then select your preferred color and click on `Redact` to submit. ![image](https://github.com/user-attachments/assets/ed8a0a98-32b2-4ae1-a3c7-c54bfe0fea66) - **Color Customizations:** - You can change the redaction color for new redactions by clicking on this icon in the toolbar ![image](https://github.com/user-attachments/assets/bad573ee-0545-4329-b131-2022f970f134). - You can change the redaction color for existing redactions by hovering over the redaction box then clicking on it (`Left mouse click LMB`) then clicking on color palette (highlighted in red in the picture) ![image](https://github.com/user-attachments/assets/22281a81-2cd9-4771-9a93-a75b6dd93433) then select your preferred color. - **Deletions:** - You can delete a redacted area by hovering over the redaction box then clicking on it (`Left mouse click LMB`) then clicking on the trash icon (highlighted in red in the picture) ![image](https://github.com/user-attachments/assets/f0347279-8211-4b1c-a91d-c1fcb929cc5d). ## Card in the home page: ![image](https://github.com/user-attachments/assets/b3fb16eb-5ff0-4548-9f22-b1b8fe162c8b) Closes #465 ## Checklist - [x] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [x] I have performed a self-review of my own code - [x] I have attached images of the change if it is UI based - [ ] I have commented my code, particularly in hard-to-understand areas - [ ] If my code has heavily changed functionality I have updated relevant docs on [Stirling-PDFs doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) - [ ] My changes generate no new warnings - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) --------- Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
2025-01-06 13:38:44 +02:00
import { PDFViewerApplication } from "../pdfjs-legacy/js/viewer.mjs";
import UUID from "./uuid.js";
let zoomScaleValue = 1.0;
let activeOverlay;
let drawingLayer = null;
const doNothing = () => {};
function addRedactedPagePreview(pagesSelector) {
document.querySelectorAll(pagesSelector).forEach((page) => {
let textLayer = page.querySelector(".textLayer");
if (textLayer) textLayer.classList.add("redacted-page-preview");
});
}
function addRedactedThumbnailPreview(sidebarPagesSelector) {
document.querySelectorAll(sidebarPagesSelector).forEach((thumbnail) => {
thumbnail.classList.add("redacted-thumbnail-preview");
let thumbnailImage = thumbnail.querySelector(".thumbnailImage");
if (thumbnailImage)
thumbnailImage.classList.add("redacted-thumbnail-image-preview");
});
}
function removeRedactedPagePreview() {
document
.querySelectorAll(".textLayer")
.forEach((textLayer) =>
textLayer.classList.remove("redacted-page-preview")
);
document
.querySelectorAll("#thumbnailView > a > div.thumbnail")
.forEach((thumbnail) => {
thumbnail.classList.remove("redacted-thumbnail-preview");
let thumbnailImage = thumbnail.querySelector(".thumbnailImage");
if (thumbnailImage)
thumbnailImage.classList.remove("redacted-thumbnail-image-preview");
});
}
function extractPagesDetailed(pagesInput, totalPageCount) {
let parts = pagesInput.split(",").filter((s) => s);
let pagesDetailed = {
numbers: new Set(),
functions: new Set(),
ranges: new Set(),
all: false,
};
for (let part of parts) {
let trimmedPart = part.trim();
if ("all" == trimmedPart) {
pagesDetailed.all = true;
return pagesDetailed;
} else if (isValidFunction(trimmedPart)) {
pagesDetailed.functions.add(formatNFunction(trimmedPart));
} else if (trimmedPart.includes("-")) {
let range = trimmedPart
.replaceAll(" ", "")
.split("-")
.filter((s) => s);
if (
range &&
range.length == 2 &&
range[0].trim() > 0 &&
range[1].trim() > 0
)
pagesDetailed.ranges.add({
low: range[0].trim(),
high: range[1].trim(),
});
} else if (isPageNumber(trimmedPart)) {
pagesDetailed.numbers.add(
trimmedPart <= totalPageCount ? trimmedPart : totalPageCount
);
}
}
return pagesDetailed;
}
function formatNFunction(expression) {
let result = insertMultiplicationBeforeN(expression.replaceAll(" ", ""));
let multiplyByOpeningRoundBracketPattern = /([0-9n)])\(/g; // example: n(n-1), 9(n-1), (n-1)(n-2)
result = result.replaceAll(multiplyByOpeningRoundBracketPattern, "$1*(");
let multiplyByClosingRoundBracketPattern = /\)([0-9n)])/g; // example: (n-1)n, (n-1)9, (n-1)(n-2)
result = result.replaceAll(multiplyByClosingRoundBracketPattern, ")*$1");
return result;
}
function insertMultiplicationBeforeN(expression) {
let result = expression.replaceAll(/(\d)n/g, "$1*n");
while (result.match(/nn/)) {
result = result.replaceAll(/nn/g, "n*n"); // From nn -> n*n
}
return result;
}
function validatePages(pages) {
let parts = pages.split(",").filter((s) => s);
let errors = [];
for (let part of parts) {
let trimmedPart = part.trim();
if ("all" == trimmedPart) continue;
else if (trimmedPart.includes("n")) {
if (!isValidFunction(trimmedPart))
errors.push(
`${trimmedPart} is an invalid function, it should consist of digits 0-9, n, *, -, /, (, ), \\.`
);
} else if (trimmedPart.includes("-")) {
let range = trimmedPart.split("-").filter((s) => s);
if (!range || range.length != 2)
errors.push(
`${trimmedPart} is an invalid range, it should consist of from-to, example: 1-5`
);
else if (range[0].trim() <= 0 || range[1].trim() <= 0)
errors.push(
`${trimmedPart} has invalid range(s), page numbers should be positive.`
);
} else if (!isPageNumber(trimmedPart)) {
errors.push(
`${trimmedPart} is invalid, it should either be a function, page number or a range.`
);
}
}
return { errors };
}
function isPageNumber(page) {
return /^[0-9]*$/.test(page);
}
function isValidFunction(part) {
return part.includes("n") && /[0-9n+\-*/() ]+$/.test(part);
}
function hideContainer(container) {
container?.classList.add("d-none");
}
const RedactionModes = Object.freeze({
DRAWING: Symbol("drawing"),
TEXT: Symbol("text"),
NONE: Symbol("none"),
});
function removePDFJSButtons() {
document.getElementById("print")?.remove();
document.getElementById("download")?.remove();
document.getElementById("editorStamp")?.remove();
document.getElementById("editorFreeText")?.remove();
document.getElementById("editorInk")?.remove();
document.getElementById("secondaryToolbarToggle")?.remove();
document.getElementById("openFile")?.remove();
}
function hideInitialPage() {
document.body.style.overflowY = "hidden";
let redactionsFormContainer = document.getElementById(
"redactionFormContainer"
);
for (
let el = redactionsFormContainer.previousElementSibling;
el && el instanceof HTMLBRElement;
el = el.previousElementSibling
) {
el.classList.add("d-none");
}
redactionsFormContainer.classList.add("d-none");
document.getElementsByTagName("footer")[0].classList.add("d-none");
}
window.addEventListener("load", (e) => {
let isChromium =
!!window.chrome ||
(!!navigator.userAgentData &&
navigator.userAgentData.brands.some((data) => data.brand == "Chromium"));
let isSafari =
/constructor/i.test(window.HTMLElement) ||
(function (p) {
return p.toString() === "[object SafariRemoteNotification]";
})(
!window["safari"] ||
(typeof safari !== "undefined" && window["safari"].pushNotification)
);
let isWebkit = navigator.userAgent.search(/webkit/i) > 0;
let isGecko = navigator.userAgent.search(/gecko/i) > 0;
let isFirefox = typeof InstallTrigger !== "undefined";
let hiddenInput = document.getElementById("fileInput");
let outerContainer = document.getElementById("outerContainer");
let printContainer = document.getElementById("printContainer");
let toolbarViewerRight = document.getElementById("toolbarViewerRight");
let showMoreBtn = document.getElementById("showMoreBtn");
window.onresize = (e) => {
if (window.innerWidth > 1125 && showMoreBtn.classList.contains("toggled")) {
showMoreBtn.click();
} else if (
window.innerWidth > 1125 &&
toolbarViewerRight.hasAttribute("style")
) {
toolbarViewerRight.style.removeProperty("display");
}
};
showMoreBtn.onclick = (e) => {
if (showMoreBtn.classList.contains("toggled")) {
toolbarViewerRight.style.display = "none";
showMoreBtn.classList.remove("toggled");
} else {
toolbarViewerRight.style.display = "flex";
showMoreBtn.classList.add("toggled");
}
};
let viewer = document.getElementById("viewer");
hiddenInput.files = undefined;
let redactionMode = RedactionModes.NONE;
let redactions = [];
let redactionsInput = document.getElementById("redactions-input");
let redactionsPalette = document.getElementById("redactions-palette");
let redactionsPaletteInput = redactionsPalette.querySelector("input");
let redactionsPaletteContainer = document.getElementById(
"redactionsPaletteContainer"
);
let applyRedactionBtn = document.getElementById("apply-redaction");
let redactedPagesDetails = {
numbers: new Set(),
ranges: new Set(),
functions: new Set(),
all: false,
};
let pageBasedRedactionBtn = document.getElementById("pageBasedRedactionBtn");
let pageBasedRedactionOverlay = document.getElementById(
"pageBasedRedactionOverlay"
);
pageBasedRedactionBtn.onclick = (e) =>
pageBasedRedactionOverlay.classList.remove("d-none");
pageBasedRedactionOverlay.querySelector("input[type=text]").onchange = (
e
) => {
let input = e.target;
let parentElement = input.parentElement;
resetFieldFeedbackMessages(input, parentElement);
let value = input.value.trim();
let { errors } = validatePages(value);
if (errors && errors.length > 0) {
applyPageRedactionBtn.disabled = "true";
displayFieldErrorMessages(input, errors);
} else {
applyPageRedactionBtn.removeAttribute("disabled");
input.classList.add("is-valid");
}
};
let applyPageRedactionBtn = document.getElementById("applyPageRedactionBtn");
applyPageRedactionBtn.onclick = (e) => {
pageBasedRedactionOverlay.querySelectorAll("input").forEach((input) => {
const id = input.getAttribute("data-for");
if (id == "pageNumbers") {
let { errors } = validatePages(input.value);
resetFieldFeedbackMessages(input, input.parentElement);
if (errors?.length > 0) {
applyPageRedactionBtn.disabled = true;
displayFieldErrorMessages(input, errors);
} else {
pageBasedRedactionOverlay.classList.add("d-none");
applyRedactionBtn.removeAttribute("disabled");
input.classList.remove("is-valid");
let totalPagesCount = PDFViewerApplication.pdfViewer.pagesCount;
let pagesDetailed = extractPagesDetailed(
input.value,
totalPagesCount
);
redactedPagesDetails = pagesDetailed;
addPageRedactionPreviewToPages(pagesDetailed, totalPagesCount);
}
} else if (id == "pageRedactColor") setPageRedactionColor(input.value);
let formInput = document.getElementById(id);
if (formInput) formInput.value = input.value;
});
};
let closePageRedactionBtn = document.getElementById("closePageRedactionBtn");
closePageRedactionBtn.onclick = (e) => {
pageBasedRedactionOverlay.classList.add("d-none");
pageBasedRedactionOverlay.querySelectorAll("input").forEach((input) => {
const id = input.getAttribute("data-for");
if (id == "pageNumbers") {
resetFieldFeedbackMessages(input, input.parentElement);
}
let formInput = document.getElementById(id);
if (formInput) input.value = formInput.value;
});
};
let pdfToImageCheckbox = document.getElementById("convertPDFToImage");
let pdfToImageBtn = document.getElementById("pdfToImageBtn");
pdfToImageBtn.onclick = (e) => {
pdfToImageBtn.classList.toggle("btn-success");
pdfToImageBtn.classList.toggle("btn-danger");
pdfToImageCheckbox.checked = !pdfToImageCheckbox.checked;
};
let fileChooser = document.getElementsByClassName("custom-file-chooser")[0];
let fileChooserInput = fileChooser.querySelector(
`#${fileChooser.getAttribute("data-bs-element-id")}`
);
let uploadButton = document.getElementById("uploadBtn");
uploadButton.onclick = (e) => fileChooserInput.click();
document.addEventListener("file-input-change", (e) => {
redactions = [];
_setRedactionsInput(redactions);
});
let submitBtn = document.getElementById("submitBtn");
let downloadBtn = document.getElementById("downloadBtn");
let downloadBtnIcon = document.getElementById("downloadBtnIcon");
downloadBtn.onclick = (e) => {
submitBtn.click();
setTimeout(_showOrHideLoadingSpinner, 100); // wait 100 milliseconds so that submitBtn would be disabled
};
function _showOrHideLoadingSpinner() {
if (!submitBtn.disabled) {
downloadBtnIcon.innerHTML = "download";
downloadBtnIcon.classList.remove("spin-animation");
return;
}
downloadBtnIcon.innerHTML = "progress_activity";
downloadBtnIcon.classList.add("spin-animation");
setTimeout(_showOrHideLoadingSpinner, 500);
}
redactionsPaletteContainer.onclick = (e) => redactionsPalette.click();
viewer.onmouseup = (e) => {
if (redactionMode !== RedactionModes.TEXT) return;
const containsText =
window.getSelection() && window.getSelection().toString() != "";
applyRedactionBtn.disabled = !containsText;
};
applyRedactionBtn.onclick = (e) => {
if (redactionMode !== RedactionModes.TEXT) {
applyRedactionBtn.disabled = true;
return;
}
redactTextSelection();
applyRedactionBtn.disabled = true;
};
redactionsPaletteInput.onchange = (e) => {
let color = e.target.value;
redactionsPalette.style.setProperty("--palette-color", color);
};
document.addEventListener("file-input-change", (e) => {
let fileChooser = document.getElementsByClassName("custom-file-chooser")[0];
let fileChooserInput = fileChooser.querySelector(
`#${fileChooser.getAttribute("data-bs-element-id")}`
);
hiddenInput.files = fileChooserInput.files;
if (!hiddenInput.files || hiddenInput.files.length === 0) {
hideContainer(outerContainer);
hideContainer(printContainer);
} else {
outerContainer?.classList.remove("d-none");
printContainer?.classList.remove("d-none");
hideInitialPage();
}
hiddenInput.dispatchEvent(new Event("change", { bubbles: true }));
});
PDFViewerApplication.downloadOrSave = doNothing;
PDFViewerApplication.triggerPrinting = doNothing;
let redactionContainersDivs = {};
PDFViewerApplication.eventBus.on("pagerendered", (e) => {
removePDFJSButtons();
let textSelectionRedactionBtn = document.getElementById(
"man-text-select-redact"
);
let drawRedactionBtn = document.getElementById("man-shape-redact");
textSelectionRedactionBtn.onclick = _handleTextSelectionRedactionBtnClick;
drawRedactionBtn.onclick = _handleDrawRedactionBtnClick;
let layer = e.source.textLayer.div;
layer.setAttribute("data-page", e.pageNumber);
if (
redactedPagesDetails.all ||
redactedPagesDetails.numbers.has(e.pageNumber)
) {
layer.classList.add("redacted-page-preview");
} else {
layer.classList.remove("redacted-page-preview");
}
zoomScaleValue = e.source.scale ? e.source.scale : e.source.pageScale;
document.documentElement.style.setProperty("--zoom-scale", zoomScaleValue);
let redactionsContainer = document.getElementById(
`redactions-container-${e.pageNumber}`
);
if (!redactionsContainer && !redactionContainersDivs[`${e.pageNumber}`]) {
redactionsContainer = document.createElement("div");
redactionsContainer.style.position = "relative";
redactionsContainer.style.height = "100%";
redactionsContainer.style.width = "100%";
redactionsContainer.id = `redactions-container-${e.pageNumber}`;
redactionsContainer.style.setProperty("z-index", "unset");
layer.appendChild(redactionsContainer);
redactionContainersDivs[`${e.pageNumber}`] = redactionsContainer;
} else if (
!redactionsContainer &&
redactionContainersDivs[`${e.pageNumber}`]
) {
redactionsContainer = redactionContainersDivs[`${e.pageNumber}`];
layer.appendChild(redactionsContainer);
// Dispatch event to update text layer references for elements' events
redactionsContainer
.querySelectorAll(".selected-wrapper")
.forEach((area) =>
area.dispatchEvent(
new CustomEvent("textLayer-reference-changed", {
bubbles: true,
detail: { textLayer: layer },
})
)
);
}
document.onpointerup = (e) => {
if (drawingLayer && e.target != drawingLayer && e.button == 0)
drawingLayer.dispatchEvent(new Event("external-pointerup"));
};
initDraw(layer, redactionsContainer);
function _handleTextSelectionRedactionBtnClick(e) {
if (textSelectionRedactionBtn.classList.contains("toggled")) {
resetTextSelection();
} else {
resetDrawRedactions();
textSelectionRedactionBtn.classList.add("toggled");
redactionMode = RedactionModes.TEXT;
const containsText =
window.getSelection() && window.getSelection().toString() != "";
applyRedactionBtn.disabled = !containsText;
applyRedactionBtn.classList.remove("d-none");
}
}
function resetTextSelection() {
textSelectionRedactionBtn.classList.remove("toggled");
redactionMode = RedactionModes.NONE;
clearSelection();
applyRedactionBtn.disabled = true;
applyRedactionBtn.classList.add("d-none");
}
function clearSelection() {
if (window.getSelection) {
if (window.getSelection().empty) {
// Chrome
window.getSelection().empty();
} else if (window.getSelection().removeAllRanges) {
// Firefox
window.getSelection().removeAllRanges();
}
} else if (document.selection) {
// IE?
document.selection.empty();
}
}
function _handleDrawRedactionBtnClick(e) {
if (drawRedactionBtn.classList.contains("toggled")) {
resetDrawRedactions();
} else {
resetTextSelection();
drawRedactionBtn.classList.add("toggled");
document.documentElement.style.setProperty(
"--textLayer-pointer-events",
"none"
);
document.documentElement.style.setProperty(
"--textLayer-user-select",
"none"
);
redactionMode = RedactionModes.DRAWING;
}
}
function resetDrawRedactions() {
redactionMode = RedactionModes.NONE;
drawRedactionBtn.classList.remove("toggled");
document.documentElement.style.setProperty(
"--textLayer-pointer-events",
"auto"
);
document.documentElement.style.setProperty(
"--textLayer-user-select",
"auto"
);
window.dispatchEvent(new CustomEvent("reset-drawing", { bubbles: true }));
}
function initDraw(canvas, redactionsContainer) {
let mouse = {
x: 0,
y: 0,
startX: 0,
startY: 0,
};
let element = null;
let drawnRedaction = null;
window.addEventListener("reset-drawing", (e) => {
_clearDrawing();
canvas.style.cursor = "default";
document.documentElement.style.setProperty(
"--textLayer-pointer-events",
"auto"
);
document.documentElement.style.setProperty(
"--textLayer-user-select",
"auto"
);
});
window.addEventListener("drawing-entered", (e) => {
let target = e.detail?.target;
if (canvas === target) return;
_clearDrawing();
});
window.addEventListener("cancel-drawing", (e) => {
_clearDrawing();
canvas.style.cursor = "default";
});
function setMousePosition(e) {
if (isChromium || isSafari || isWebkit) {
mouse.x = e.offsetX;
mouse.y = e.offsetY;
} else if (isFirefox || isGecko) {
mouse.x = e.layerX;
mouse.y = e.layerY;
} else {
let rect = (e.target || e.srcElement).getBoundingClientRect();
mouse.x = e.clientX - rect.left;
mouse.y = e.clientY - rect.top;
}
}
window.onkeydown = (e) => {
if (e.key === "Escape" && redactionMode === RedactionModes.DRAWING) {
window.dispatchEvent(
new CustomEvent("cancel-drawing", { bubbles: true })
);
}
};
canvas.onpointerenter = (e) => {
window.dispatchEvent(
new CustomEvent("drawing-entered", {
bubbles: true,
detail: { target: canvas },
})
);
};
canvas.onpointerup = (e) => {
let isLeftClick = e.button == 0;
if (!isLeftClick) return;
if (element !== null) {
_saveAndResetDrawnRedaction();
console.log("finished.");
}
};
canvas.addEventListener("external-pointerup", (e) => {
if (element != null) {
_saveAndResetDrawnRedaction();
}
});
canvas.onpointerleave = (e) => {
let ev = copyEvent(e, "pointerleave");
let { left, top } = calculateMouseCoordinateToRotatedBox(canvas, e);
ev.layerX = left;
ev.offsetX = left;
ev.layerY = top;
ev.offsetY = top;
setMousePosition(ev);
if (element !== null) {
draw();
}
};
canvas.onpointerdown = (e) => {
let isLeftClick = e.button == 0;
if (!isLeftClick) return;
if (element == null) {
if (redactionMode !== RedactionModes.DRAWING) {
console.warn(
"Drawing attempt when redaction mode is",
redactionMode.description
);
return;
}
console.log("begun.");
_captureAndDrawStartingPointOfDrawnRedaction();
}
};
canvas.onpointermove = function (e) {
setMousePosition(e);
if (element !== null) {
draw();
}
};
function draw() {
let scaleFactor = _getScaleFactor();
let width = Math.abs(mouse.x - mouse.startX);
element.style.width = _toCalcZoomPx(_scaleToDisplay(width));
let height = Math.abs(mouse.y - mouse.startY);
element.style.height = _toCalcZoomPx(_scaleToDisplay(height));
let left = mouse.x - mouse.startX < 0 ? mouse.x : mouse.startX;
element.style.left = _toCalcZoomPx(_scaleToDisplay(left));
let top = mouse.y - mouse.startY < 0 ? mouse.y : mouse.startY;
element.style.top = _toCalcZoomPx(_scaleToDisplay(top));
if (drawnRedaction) {
drawnRedaction.left = _scaleToPDF(left, scaleFactor);
drawnRedaction.top = _scaleToPDF(top, scaleFactor);
drawnRedaction.width = _scaleToPDF(width, scaleFactor);
drawnRedaction.height = _scaleToPDF(height, scaleFactor);
}
}
function _clearDrawing() {
if (element) element.remove();
if (drawingLayer == canvas) drawingLayer = null;
element = null;
drawnRedaction = null;
}
function _saveAndResetDrawnRedaction() {
if (!element) return;
if (
!element.style.height ||
element.style.height.includes("(0px * var") ||
!element.style.width ||
element.style.width.includes("(0px * var")
) {
element.remove();
} else {
element.classList.add("selected-wrapper");
element.classList.remove("rectangle");
addRedactionOverlay(element, drawnRedaction, canvas);
redactions.push(drawnRedaction);
_setRedactionsInput(redactions);
}
drawingLayer = null;
element = null;
drawnRedaction = null;
canvas.style.cursor = "default";
}
function _captureAndDrawStartingPointOfDrawnRedaction() {
mouse.startX = mouse.x;
mouse.startY = mouse.y;
element = document.createElement("div");
element.className = "rectangle";
drawingLayer = canvas;
let left = mouse.x;
let top = mouse.y;
element.style.left = _toCalcZoomPx(_scaleToDisplay(left));
element.style.top = _toCalcZoomPx(_scaleToDisplay(top));
let scaleFactor = _getScaleFactor();
let color = redactionsPalette.style.getPropertyValue("--palette-color");
element.style.setProperty("--palette-color", color);
drawnRedaction = {
left: _scaleToPDF(left, scaleFactor),
top: _scaleToPDF(top, scaleFactor),
width: 0.0,
height: 0.0,
color: color,
pageNumber: parseInt(canvas.getAttribute("data-page")),
element: element,
id: UUID.uuidv4(),
};
redactionsContainer.appendChild(element);
canvas.style.cursor = "crosshair";
}
}
});
PDFViewerApplication.eventBus.on("rotationchanging", (e) => {
if (!activeOverlay) return;
hideOverlay();
});
function _getScaleFactor() {
return parseFloat(viewer.style.getPropertyValue("--scale-factor"));
}
function getTextLayer(element) {
let current = element;
while (current) {
if (
current instanceof HTMLDivElement &&
current.classList.contains("textLayer")
)
return current;
current = current.parentElement;
}
return current;
}
document.onclick = (e) => {
if (
(e.target &&
e.target.classList.contains("selected-wrapper") &&
e.target.firstChild == activeOverlay) ||
e.target == activeOverlay
)
return;
if (activeOverlay) hideOverlay();
};
document.addEventListener("keydown", (e) => {
if (e.key === "Delete" && activeOverlay) {
activeOverlay
.querySelector(".delete-icon")
?.dispatchEvent(new Event("click", { bubbles: true }));
return;
}
const isRedactionShortcut =
e.ctrlKey && (e.key == "s" || e.key == "S" || e.code == "KeyS");
if (!isRedactionShortcut || redactionMode !== RedactionModes.TEXT) return;
redactTextSelection();
});
function rotateTextBox(rect, textLayerRect, angle) {
let left, top, width, height;
if (!angle || angle == 0) {
left = rect.left - textLayerRect.left;
top = rect.top - textLayerRect.top;
width = rect.width;
height = rect.height;
} else if (angle == 90) {
left = rect.top - textLayerRect.top;
top = textLayerRect.right - rect.right;
width = rect.height;
height = rect.width;
} else if (angle == 180) {
left = textLayerRect.right - rect.right;
top = textLayerRect.bottom - rect.bottom;
width = rect.width;
height = rect.height;
} else if (angle == 270) {
left = textLayerRect.bottom - rect.bottom;
top = rect.left - textLayerRect.left;
width = rect.height;
height = rect.width;
}
return { left, top, width, height };
}
function redactTextSelection() {
let selection = window.getSelection();
if (!selection || selection.rangeCount <= 0) return;
let range = selection.getRangeAt(0);
let textLayer = getTextLayer(range.startContainer);
if (!textLayer) return;
const pageNumber = textLayer.getAttribute("data-page");
let redactionsArea = textLayer.querySelector(
`#redactions-container-${pageNumber}`
);
let textLayerRect = textLayer.getBoundingClientRect();
let rects = range.getClientRects();
let scaleFactor = _getScaleFactor();
let color = redactionsPalette.style.getPropertyValue("--palette-color");
let angle = textLayer.getAttribute("data-main-rotation");
for (const rect of rects) {
if (!rect || !rect.width || !rect.height) continue;
let redactionElement = document.createElement("div");
redactionElement.classList.add("selected-wrapper");
let { left, top, width, height } = rotateTextBox(
rect,
textLayerRect,
angle
);
let leftDisplayScaled = _scaleToDisplay(left);
let topDisplayScaled = _scaleToDisplay(top);
let widthDisplayScaled = _scaleToDisplay(width);
let heightDisplayScaled = _scaleToDisplay(height);
let redactionInfo = {
left: _scaleToPDF(left, scaleFactor),
top: _scaleToPDF(top, scaleFactor),
width: _scaleToPDF(width, scaleFactor),
height: _scaleToPDF(height, scaleFactor),
pageNumber: parseInt(pageNumber),
color: color,
element: redactionElement,
id: UUID.uuidv4(),
};
redactions.push(redactionInfo);
redactionElement.style.left = _toCalcZoomPx(leftDisplayScaled);
redactionElement.style.top = _toCalcZoomPx(topDisplayScaled);
redactionElement.style.width = _toCalcZoomPx(widthDisplayScaled);
redactionElement.style.height = _toCalcZoomPx(heightDisplayScaled);
redactionElement.style.setProperty("--palette-color", color);
redactionsArea.appendChild(redactionElement);
addRedactionOverlay(redactionElement, redactionInfo, textLayer);
}
_setRedactionsInput(redactions);
applyRedactionBtn.disabled = true;
}
function _scaleToDisplay(value) {
return value / zoomScaleValue;
}
function _scaleToPDF(value, scaleFactor) {
if (!scaleFactor)
scaleFactor = document.documentElement.getPropertyValue("--scale-factor");
return value / scaleFactor;
}
function _toCalcZoomPx(val) {
return `calc(${val}px * var(--zoom-scale))`;
}
function _setRedactionsInput(redactions) {
let stringifiedRedactions = JSON.stringify(
redactions.filter(_nonEmptyRedaction).map((red) => ({
x: red.left,
y: red.top,
width: red.width,
height: red.height,
color: red.color,
page: red.pageNumber,
}))
);
redactionsInput.value = stringifiedRedactions;
}
function addRedactionOverlay(redactionElement, redactionInfo, textLayer) {
let redactionOverlay = document.createElement("div");
let deleteBtn = $(
`<svg class="delete-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960" fill="#e8eaed"><path d="M312-144q-29.7 0-50.85-21.15Q240-186.3 240-216v-480h-48v-72h192v-48h192v48h192v72h-48v479.57Q720-186 698.85-165T648-144H312Zm336-552H312v480h336v-480ZM384-288h72v-336h-72v336Zm120 0h72v-336h-72v336ZM312-696v480-480Z"/></svg>`
)[0];
deleteBtn.onclick = (e) => {
redactions = redactions.filter((red) => redactionInfo.id != red.id);
redactionElement.remove();
_setRedactionsInput(redactions);
activeOverlay = null;
};
let colorPaletteLabel = $(
`<label class="material-symbols-rounded palette-color position-relative">
palette
</label>`
)[0];
let colorPaletteInput = $(`
<input type="color" name="color-picker" class="overlay-colorpicker-window">
`)[0];
colorPaletteLabel.appendChild(colorPaletteInput);
colorPaletteLabel.onclick = (e) => {
if (colorPaletteLabel === e.target) {
e.stopPropagation();
}
};
colorPaletteInput.onchange = (e) => {
let color = e.target.value;
redactionElement.style.setProperty("--palette-color", color);
let redactionIdx = redactions.findIndex(
(red) => redactionInfo.id === red.id
);
if (redactionIdx < 0) return;
redactions[redactionIdx].color = color;
_setRedactionsInput(redactions);
};
redactionOverlay.appendChild(deleteBtn);
redactionOverlay.appendChild(colorPaletteLabel);
redactionOverlay.classList.add("redaction-overlay");
redactionOverlay.style.display = "none";
redactionElement.addEventListener("textLayer-reference-changed", (e) => {
textLayer = e.detail.textLayer;
});
redactionElement.onclick = (e) => {
if (e.target != redactionElement) return;
if (activeOverlay) hideOverlay();
redactionElement.classList.add("active-redaction");
activeOverlay = redactionOverlay;
_adjustActiveOverlayCoordinates();
};
redactionElement.appendChild(redactionOverlay);
// Adjust active overlay coordinates to avoid placing the overlay out of page bounds
function _adjustActiveOverlayCoordinates() {
activeOverlay.style.visibility = "hidden";
activeOverlay.style.display = "flex";
textLayer = textLayer || getTextLayer(redactionElement);
let angle = parseInt(textLayer.getAttribute("data-main-rotation"));
if (textLayer)
redactionOverlay.style.transform = `rotate(${angle * -1}deg)`;
activeOverlay.style.removeProperty("left");
activeOverlay.style.removeProperty("top");
let textRect = textLayer.getBoundingClientRect();
let overlayRect = redactionOverlay.getBoundingClientRect();
let leftOffset = 0,
topOffset = 0;
if (overlayRect.right > textRect.right) {
leftOffset = textRect.right - overlayRect.right;
} else if (overlayRect.left < textRect.left) {
leftOffset = textRect.left - overlayRect.left;
}
if (overlayRect.top < textRect.top) {
topOffset = textRect.top - overlayRect.top;
} else if (overlayRect.bottom > textRect.bottom) {
topOffset = textRect.bottom - overlayRect.bottom;
}
switch (angle) {
case 90:
[leftOffset, topOffset] = [topOffset, -leftOffset];
break;
case 180:
[leftOffset, topOffset] = [-leftOffset, -topOffset];
break;
case 270:
[leftOffset, topOffset] = [-topOffset, leftOffset];
break;
}
if (leftOffset != 0)
activeOverlay.style.left = `calc(50% + ${leftOffset}px`;
if (topOffset != 0)
activeOverlay.style.top = `calc(100% + ${topOffset}px`;
activeOverlay.style.visibility = "unset";
}
}
});
function calculateMouseCoordinateToRotatedBox(canvas, e) {
let textRect = canvas.getBoundingClientRect();
let left,
top = 0;
let angle = parseInt(canvas.getAttribute("data-main-rotation"));
switch (angle) {
case 0:
left = clamp(e.pageX - textRect.left, 0, textRect.width);
top = clamp(e.pageY - textRect.top, 0, textRect.height);
break;
case 90:
left = clamp(e.pageY - textRect.top, 0, textRect.height);
top = clamp(textRect.right - e.pageX, 0, textRect.width);
break;
case 180:
left = clamp(textRect.right - e.pageX, 0, textRect.width);
top = clamp(textRect.bottom - e.pageY, 0, textRect.width);
break;
case 270:
left = clamp(textRect.bottom - e.pageY, 0, textRect.height);
top = clamp(e.pageX - textRect.left, 0, textRect.width);
break;
}
return { left, top };
}
function clamp(value, min, max) {
return Math.max(min, Math.min(value, max));
}
function addPageRedactionPreviewToPages(pagesDetailed, totalPagesCount) {
if (pagesDetailed.all) {
addRedactedPagePreview("#viewer > .page");
addRedactedThumbnailPreview("#thumbnailView > a > div.thumbnail");
} else {
removeRedactedPagePreview();
setPageNumbersFromRange(pagesDetailed, totalPagesCount);
setPageNumbersFromNFunctions(pagesDetailed, totalPagesCount);
let pageNumbers = Array.from(pagesDetailed.numbers);
if (pageNumbers?.length > 0) {
let pagesSelector = pageNumbers
.map((number) => `#viewer > .page[data-page-number="${number}"]`)
.join(",");
addRedactedPagePreview(pagesSelector);
let thumbnailSelector = pageNumbers
.map(
(number) =>
`#thumbnailView > a > div.thumbnail[data-page-number="${number}"]`
)
.join(",");
addRedactedThumbnailPreview(thumbnailSelector);
}
}
}
function resetFieldFeedbackMessages(input, parentElement) {
if (parentElement)
parentElement
.querySelectorAll(".invalid-feedback")
.forEach((feedback) => feedback.remove());
if (input) {
input.classList.remove("is-invalid");
input.classList.remove("is-valid");
}
}
function displayFieldErrorMessages(input, errors) {
input.classList.add("is-invalid");
errors.forEach((error) => {
let element = document.createElement("div");
element.classList.add("invalid-feedback");
element.classList.add("list-styling");
element.textContent = error;
input.parentElement.appendChild(element);
});
}
function setPageRedactionColor(color) {
document.documentElement.style.setProperty("--page-redaction-color", color);
}
function setPageNumbersFromNFunctions(pagesDetailed, totalPagesCount) {
pagesDetailed.functions.forEach((fun) => {
if (!isValidFunction(fun)) return;
for (let n = 1; n <= totalPagesCount; n++) {
let pageNumber = eval(fun);
if (!pageNumber || pageNumber <= 0 || pageNumber > totalPagesCount)
continue;
pagesDetailed.numbers.add(pageNumber);
}
});
}
function setPageNumbersFromRange(pagesDetailed, totalPagesCount) {
pagesDetailed.ranges.forEach((range) => {
for (let i = range.low; i <= range.high && i <= totalPagesCount; i++) {
pagesDetailed.numbers.add(i);
}
});
}
function hideOverlay() {
activeOverlay.style.display = "none";
activeOverlay.parentElement.classList.remove("active-redaction");
activeOverlay = null;
}
function _isEmptyRedaction(redaction) {
return (
redaction.left == null ||
redaction.top == null ||
redaction.width == null ||
redaction.height == null ||
redaction.pageNumber == null
);
}
function _nonEmptyRedaction(redaction) {
return !_isEmptyRedaction(redaction);
}
function copyEvent(e, type) {
if (type == "pointerleave")
return {
layerX: e.layerX,
layerY: e.layerY,
pageX: e.pageX,
pageY: e.pageY,
clientX: e.clientX,
clientY: e.clientY,
button: e.button,
height: e.height,
width: e.width,
offsetX: e.offsetX,
offsetY: e.offsetY,
pointerId: e.pointerId,
pointerType: e.pointerType,
type: e.type,
screenX: e.screenX,
screenY: e.screenY,
tiltX: e.tiltX,
tiltY: e.tiltY,
x: e.x,
y: e.y,
altKey: e.altKey,
ctrlKey: e.ctrlKey,
isPrimary: e.isPrimary,
isTrusted: e.isTrusted,
metaKey: e.metaKey,
pressure: e.pressure,
returnValue: e.returnValue,
shiftKey: e.shiftKey,
timeStamp: e.timeStamp,
which: e.which,
twist: e.twist,
tangentialPressure: e.tangentialPressure,
target: e.target,
srcElement: e.srcElement,
relatedTarget: e.relatedTarget,
rangeOffset: e.rangeOffset,
rangeParent: e.rangeParent,
explicitOriginalTarget: e.explicitOriginalTarget,
eventPhase: e.eventPhase,
detail: e.detail,
defaultPrevented: e.defaultPrevented,
currentTarget: e.currentTarget,
buttons: e.buttons,
azimuthAngle: e.azimuthAngle,
altitudeAngle: e.altitudeAngle,
};
return {};
}