diff --git a/client/src/components/document-editor/document-editor-local.component.jsx b/client/src/components/document-editor/document-editor-local.component.jsx index 18bf0ead8..724caea34 100644 --- a/client/src/components/document-editor/document-editor-local.component.jsx +++ b/client/src/components/document-editor/document-editor-local.component.jsx @@ -1,5 +1,5 @@ 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"; @@ -9,6 +9,12 @@ import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selecto import { handleUpload } from "../documents-local-upload/documents-local-upload.utility"; 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, @@ -24,7 +30,9 @@ export function DocumentEditorLocalComponent({ imageUrl, filename, jobid }) { 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 [uploading, setUploading] = useState(false); @@ -32,6 +40,7 @@ export function DocumentEditorLocalComponent({ imageUrl, filename, jobid }) { async (dataUrl) => { if (uploading) return; setUploading(true); + setLoading(true); const blob = await b64toBlob(dataUrl); const nameWithoutExt = filename.split(".").slice(0, -1).join(".").trim(); const parts = nameWithoutExt.split("-"); @@ -70,6 +79,23 @@ export function DocumentEditorLocalComponent({ imageUrl, filename, jobid }) { [filename, jobid, notification, uploading] ); + 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 @@ -93,8 +119,10 @@ export function DocumentEditorLocalComponent({ imageUrl, filename, jobid }) { 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 (!imageUrl) return; @@ -106,6 +134,7 @@ export function DocumentEditorLocalComponent({ imageUrl, filename, jobid }) { try { const response = await axios.get(imageUrl, { responseType: "blob", signal: controller.signal }); const blobUrl = URL.createObjectURL(response.data); + imageHistory.current = []; setLoadedImageUrl((prevUrl) => { if (prevUrl) URL.revokeObjectURL(prevUrl); return blobUrl; @@ -142,7 +171,7 @@ export function DocumentEditorLocalComponent({ imageUrl, filename, jobid }) { } return ( -
+
{!loading && !uploaded && loadedImageUrl && ( )} - {uploaded && } + {uploaded && ( + {t("documents.successes.edituploaded")}} + /> + )}
); } 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": "",