);
}
diff --git a/client/src/components/document-editor/document-editor.component.jsx b/client/src/components/document-editor/document-editor.component.jsx
index 1bdf0f9e7..d9b7f8758 100644
--- a/client/src/components/document-editor/document-editor.component.jsx
+++ b/client/src/components/document-editor/document-editor.component.jsx
@@ -1,15 +1,21 @@
//import "tui-image-editor/dist/tui-image-editor.css";
import axios from "axios";
-import { Result } from "antd";
+import { Result, theme } from "antd";
import * as markerjs2 from "markerjs2";
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
+import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors";
import { handleUpload } from "../documents-upload-imgproxy/documents-upload-imgproxy.utility.js";
import LoadingSpinner from "../loading-spinner/loading-spinner.component";
-import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
+import {
+ addGreyscaleButtonToMarkerArea,
+ addImageHistoryUndoToMarkerArea,
+ applyGreyscaleToMarkerAreaImage,
+ setMarkerAreaImageSource
+} from "./document-editor.utility";
const mapStateToProps = createStructuredSelector({
currentUser: selectCurrentUser,
@@ -27,7 +33,9 @@ export function DocumentEditorComponent({ currentUser, bodyshop, document }) {
const [imageLoaded, setImageLoaded] = useState(false);
const [imageLoading, setImageLoading] = useState(true);
const markerArea = useRef(null);
+ const imageHistory = useRef([]);
const { t } = useTranslation();
+ const { token } = theme.useToken();
const notification = useNotification();
const triggerUpload = useCallback(
@@ -57,6 +65,23 @@ export function DocumentEditorComponent({ currentUser, bodyshop, document }) {
[bodyshop, currentUser, document, notification]
);
+ const handleGreyscale = useCallback(() => {
+ if (!imgRef.current || loading || uploaded || imageLoading || !imageLoaded) return;
+
+ imageHistory.current.push(imgRef.current.src);
+ applyGreyscaleToMarkerAreaImage(markerArea.current, imgRef.current);
+ }, [imageLoaded, imageLoading, loading, uploaded]);
+
+ const undoImageEdit = useCallback(() => {
+ if (!imgRef.current) return;
+
+ const previousSrc = imageHistory.current.pop();
+
+ if (previousSrc) {
+ setMarkerAreaImageSource(markerArea.current, imgRef.current, previousSrc);
+ }
+ }, []);
+
useEffect(() => {
if (imgRef.current !== null && imageLoaded && !markerArea.current) {
// create a marker.js MarkerArea
@@ -80,8 +105,10 @@ export function DocumentEditorComponent({ currentUser, bodyshop, document }) {
markerArea.current.renderImageQuality = 1;
//markerArea.current.settings.displayMode = "inline";
markerArea.current.show();
+ addGreyscaleButtonToMarkerArea(markerArea.current, handleGreyscale, t("documents.labels.greyscale"));
+ addImageHistoryUndoToMarkerArea(markerArea.current, () => imageHistory.current.length > 0, undoImageEdit);
}
- }, [triggerUpload, imageLoaded]);
+ }, [handleGreyscale, imageLoaded, t, triggerUpload, undoImageEdit]);
useEffect(() => {
if (!document?.id) return;
@@ -100,6 +127,7 @@ export function DocumentEditorComponent({ currentUser, bodyshop, document }) {
}
);
const blobUrl = URL.createObjectURL(response.data);
+ imageHistory.current = [];
setImageUrl((prevUrl) => {
if (prevUrl) URL.revokeObjectURL(prevUrl);
return blobUrl;
@@ -134,7 +162,7 @@ export function DocumentEditorComponent({ currentUser, bodyshop, document }) {
}
return (
-
+
{!loading && !uploaded && imageUrl && (
![]()
)}
- {uploaded &&
}
+ {uploaded && (
+
{t("documents.successes.edituploaded")}}
+ />
+ )}
);
}
diff --git a/client/src/components/document-editor/document-editor.utility.js b/client/src/components/document-editor/document-editor.utility.js
new file mode 100644
index 000000000..9efceec62
--- /dev/null
+++ b/client/src/components/document-editor/document-editor.utility.js
@@ -0,0 +1,123 @@
+/**
+ * Converts an image element to a greyscale data URL.
+ * @param imageElement
+ * @returns {string}
+ */
+export function convertImageElementToGreyscaleDataUrl(imageElement) {
+ if (!imageElement?.naturalWidth || !imageElement?.naturalHeight) {
+ throw new Error("Image must be loaded before it can be converted to greyscale.");
+ }
+
+ const canvas = document.createElement("canvas");
+ canvas.width = imageElement.naturalWidth;
+ canvas.height = imageElement.naturalHeight;
+
+ const context = canvas.getContext("2d");
+ context.drawImage(imageElement, 0, 0);
+
+ const imageData = context.getImageData(0, 0, canvas.width, canvas.height);
+ const pixels = imageData.data;
+
+ for (let i = 0; i < pixels.length; i += 4) {
+ const luminance = Math.round(pixels[i] * 0.299 + pixels[i + 1] * 0.587 + pixels[i + 2] * 0.114);
+ pixels[i] = luminance;
+ pixels[i + 1] = luminance;
+ pixels[i + 2] = luminance;
+ }
+
+ context.putImageData(imageData, 0, 0);
+
+ return canvas.toDataURL("image/jpeg", 1);
+}
+
+/**
+ * Adds a greyscale button to the marker area controls if it doesn't already exist.
+ * @param markerArea
+ * @param onGreyscale
+ * @param title
+ */
+export function addGreyscaleButtonToMarkerArea(markerArea, onGreyscale, title) {
+ requestAnimationFrame(() => {
+ const renderButton = markerArea?.coverDiv?.querySelector?.('[data-action="render"]');
+
+ if (!renderButton || markerArea.coverDiv.querySelector('[data-action="greyscale"]')) return;
+
+ const greyscaleButton = document.createElement("div");
+ greyscaleButton.className = renderButton.className;
+ greyscaleButton.innerHTML =
+ '
';
+ greyscaleButton.setAttribute("role", "button");
+ greyscaleButton.setAttribute("data-action", "greyscale");
+ greyscaleButton.setAttribute("aria-label", title);
+ greyscaleButton.title = title;
+ greyscaleButton.addEventListener("click", (event) => {
+ event.preventDefault();
+ event.stopPropagation();
+ onGreyscale();
+ });
+
+ renderButton.parentElement.insertBefore(greyscaleButton, renderButton);
+ });
+}
+
+/**
+ * Applies a greyscale filter to the image in the marker area and updates the image source.
+ * @param markerArea
+ * @param imageElement
+ * @returns {string}
+ */
+export function applyGreyscaleToMarkerAreaImage(markerArea, imageElement) {
+ const dataUrl = convertImageElementToGreyscaleDataUrl(imageElement);
+
+ setMarkerAreaImageSource(markerArea, imageElement, dataUrl);
+
+ return dataUrl;
+}
+
+/**
+ * Sets the image source for the marker area and updates the editing target if it's an image element.
+ * @param markerArea
+ * @param imageElement
+ * @param src
+ */
+export function setMarkerAreaImageSource(markerArea, imageElement, src) {
+ imageElement.src = src;
+
+ if (markerArea?.editingTarget instanceof HTMLImageElement) {
+ markerArea.editingTarget.src = src;
+ }
+}
+
+/**
+ * Adds undo functionality for image edits to the marker area by tracking the state before and after undo actions.
+ * @param markerArea
+ * @param canUndoImage
+ * @param undoImage
+ */
+export function addImageHistoryUndoToMarkerArea(markerArea, canUndoImage, undoImage) {
+ requestAnimationFrame(() => {
+ const undoButton = markerArea?.coverDiv?.querySelector?.('[data-action="undo"]');
+
+ if (!undoButton || undoButton.dataset.imageHistoryUndo === "true") return;
+
+ let markerStateBeforeUndo = null;
+
+ undoButton.dataset.imageHistoryUndo = "true";
+ undoButton.addEventListener(
+ "click",
+ () => {
+ markerStateBeforeUndo = JSON.stringify(markerArea.getState(true));
+ },
+ true
+ );
+ undoButton.addEventListener("click", () => {
+ const markerStateAfterUndo = JSON.stringify(markerArea.getState(true));
+
+ if (markerStateBeforeUndo === markerStateAfterUndo && canUndoImage()) {
+ undoImage();
+ }
+
+ markerStateBeforeUndo = null;
+ });
+ });
+}
diff --git a/client/src/components/jobs-documents-imgproxy-gallery/jobs-documents-imgproxy-gallery.component.jsx b/client/src/components/jobs-documents-imgproxy-gallery/jobs-documents-imgproxy-gallery.component.jsx
index 8f78cf675..d34e0645e 100644
--- a/client/src/components/jobs-documents-imgproxy-gallery/jobs-documents-imgproxy-gallery.component.jsx
+++ b/client/src/components/jobs-documents-imgproxy-gallery/jobs-documents-imgproxy-gallery.component.jsx
@@ -3,7 +3,7 @@ import { Button, Card, Col, Row, Space } from "antd";
import axios from "axios";
import i18n from "i18next";
import { isFunction } from "lodash";
-import { useCallback, useEffect, useState } from "react";
+import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import Lightbox from "react-image-lightbox";
import "react-image-lightbox/style.css";
@@ -12,12 +12,12 @@ import { createStructuredSelector } from "reselect";
import { selectBodyshop } from "../../redux/user/user.selectors";
import DocumentsUploadImgproxyComponent from "../documents-upload-imgproxy/documents-upload-imgproxy.component";
import { HasFeatureAccess } from "../feature-wrapper/feature-wrapper.component";
+import LocalMediaGrid from "../jobs-documents-local-gallery/local-media-grid.component";
import UpsellComponent, { upsellEnum } from "../upsell/upsell.component";
import JobsDocumentsDownloadButton from "./jobs-document-imgproxy-gallery.download.component";
import JobsDocumentsGalleryReassign from "./jobs-document-imgproxy-gallery.reassign.component";
import JobsDocumentsDeleteButton from "./jobs-documents-imgproxy-gallery.delete.component";
import JobsDocumentsGallerySelectAllComponent from "./jobs-documents-imgproxy-gallery.selectall.component";
-import LocalMediaGrid from "../jobs-documents-local-gallery/local-media-grid.component";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop
@@ -38,6 +38,9 @@ function JobsDocumentsImgproxyComponent({
const [galleryImages, setGalleryImages] = useState({ images: [], other: [] });
const { t } = useTranslation();
const [modalState, setModalState] = useState({ open: false, index: 0 });
+ const [previewUrls, setPreviewUrls] = useState({});
+ const [previewError, setPreviewError] = useState(null);
+ const previewUrlsRef = useRef({});
const fetchThumbnails = useCallback(() => {
fetchImgproxyThumbnails({ setStateCallback: setGalleryImages, jobId, billId });
@@ -49,8 +52,86 @@ function JobsDocumentsImgproxyComponent({
}
}, [data, fetchThumbnails]);
+ useEffect(() => {
+ return () => {
+ Object.values(previewUrlsRef.current).forEach(URL.revokeObjectURL);
+ };
+ }, []);
+
+ const selectedImage = modalState.open ? galleryImages.images[modalState.index] : null;
+
+ useEffect(() => {
+ if (!modalState.open || !selectedImage?.id) return;
+
+ if (previewUrlsRef.current[selectedImage.id]) {
+ setPreviewError(null);
+ return;
+ }
+
+ const controller = new AbortController();
+
+ async function loadPreviewImage() {
+ setPreviewError(null);
+
+ try {
+ const response = await axios.post(
+ "/media/imgproxy/original",
+ { documentId: selectedImage.id },
+ {
+ responseType: "blob",
+ signal: controller.signal
+ }
+ );
+ const blobUrl = URL.createObjectURL(response.data);
+
+ previewUrlsRef.current = {
+ ...previewUrlsRef.current,
+ [selectedImage.id]: blobUrl
+ };
+ setPreviewUrls(previewUrlsRef.current);
+ } catch (error) {
+ if (axios.isCancel?.(error) || error.name === "CanceledError") return;
+
+ console.error("Failed to fetch original image blob", error);
+ setPreviewError(error);
+ }
+ }
+
+ loadPreviewImage();
+
+ return () => {
+ controller.abort();
+ };
+ }, [modalState.open, selectedImage?.id]);
+
+ useEffect(() => {
+ if (modalState.open && !selectedImage) {
+ setModalState({ open: false, index: 0 });
+ }
+ }, [modalState.open, selectedImage]);
+
+ const openEditorForImage = useCallback((image) => {
+ if (!image?.id) return;
+
+ const newWindow = window.open(
+ `${window.location.protocol}//${window.location.host}/edit?documentId=${image.id}`,
+ "_blank",
+ "noopener,noreferrer"
+ );
+ if (newWindow) newWindow.opener = null;
+ }, []);
+
const hasMediaAccess = HasFeatureAccess({ bodyshop, featureName: "media" });
const hasMobileAccess = HasFeatureAccess({ bodyshop, featureName: "mobile" });
+ const previewSrc = selectedImage ? previewUrls[selectedImage.id] : null;
+ const getLightboxImageSrc = useCallback(
+ (index) => {
+ const image = galleryImages.images[index];
+ return image ? previewUrls[image.id] || image.src : undefined;
+ },
+ [galleryImages.images, previewUrls]
+ );
+
return (
@@ -147,30 +228,33 @@ function JobsDocumentsImgproxyComponent({
/>
- {modalState.open && (
+ {modalState.open && selectedImage && (
{
- const newWindow = window.open(
- `${window.location.protocol}//${window.location.host}/edit?documentId=${
- galleryImages.images[modalState.index].id
- }`,
- "_blank",
- "noopener,noreferrer"
- );
- if (newWindow) newWindow.opener = null;
+ openEditorForImage(selectedImage);
}}
/>
]}
- mainSrc={galleryImages.images[modalState.index].fullsize}
- nextSrc={galleryImages.images[(modalState.index + 1) % galleryImages.images.length].fullsize}
- prevSrc={
+ imageLoadErrorMessage={previewError ? t("general.errors.notfound") : undefined}
+ mainSrc={previewSrc || selectedImage.src}
+ mainSrcThumbnail={selectedImage.src}
+ nextSrc={getLightboxImageSrc((modalState.index + 1) % galleryImages.images.length)}
+ nextSrcThumbnail={galleryImages.images[(modalState.index + 1) % galleryImages.images.length]?.src}
+ prevSrc={getLightboxImageSrc(
+ (modalState.index + galleryImages.images.length - 1) % galleryImages.images.length
+ )}
+ prevSrcThumbnail={
galleryImages.images[(modalState.index + galleryImages.images.length - 1) % galleryImages.images.length]
- .fullsize
+ ?.src
}
- onCloseRequest={() => setModalState({ open: false, index: 0 })}
+ reactModalProps={{ ariaHideApp: false }}
+ onCloseRequest={() => {
+ setModalState({ open: false, index: 0 });
+ setPreviewError(null);
+ }}
onMovePrevRequest={() =>
setModalState({
...modalState,
diff --git a/client/src/translations/en_us/common.json b/client/src/translations/en_us/common.json
index 7252d162e..09a365c0c 100644
--- a/client/src/translations/en_us/common.json
+++ b/client/src/translations/en_us/common.json
@@ -1222,6 +1222,7 @@
"confirmdelete": "Are you sure you want to delete these documents. This CANNOT be undone.",
"doctype": "Document Type",
"dragtoupload": "Click or drag files to this area to upload",
+ "greyscale": "Greyscale",
"newjobid": "Assign to Job",
"openinexplorer": "Open in Explorer",
"optimizedimage": "The below image is optimized. Click on the picture below to open in a new window and view it full size, or open it in explorer.",
diff --git a/client/src/translations/es/common.json b/client/src/translations/es/common.json
index 1ade10f9f..99bc1a064 100644
--- a/client/src/translations/es/common.json
+++ b/client/src/translations/es/common.json
@@ -1216,6 +1216,7 @@
"confirmdelete": "",
"doctype": "",
"dragtoupload": "",
+ "greyscale": "Escala de grises",
"newjobid": "",
"openinexplorer": "",
"optimizedimage": "",
diff --git a/client/src/translations/fr/common.json b/client/src/translations/fr/common.json
index f1681e201..042a3c5a5 100644
--- a/client/src/translations/fr/common.json
+++ b/client/src/translations/fr/common.json
@@ -1216,6 +1216,7 @@
"confirmdelete": "",
"doctype": "",
"dragtoupload": "",
+ "greyscale": "Niveaux de gris",
"newjobid": "",
"openinexplorer": "",
"optimizedimage": "",