From 6eb432b5b714786bd3c2f8663b168417d36e922d Mon Sep 17 00:00:00 2001 From: Allan Carr Date: Fri, 12 Dec 2025 20:56:39 -0800 Subject: [PATCH] IO-3464 Local Media Edit Image Signed-off-by: Allan Carr --- client/src/App/App.jsx | 4 + .../document-editor-local.component.jsx | 162 ++++++++++++++++++ .../document-editor-local.container.jsx | 34 ++++ ...jobs-documents-local-gallery.container.jsx | 17 +- 4 files changed, 216 insertions(+), 1 deletion(-) create mode 100644 client/src/components/document-editor/document-editor-local.component.jsx create mode 100644 client/src/components/document-editor/document-editor-local.container.jsx diff --git a/client/src/App/App.jsx b/client/src/App/App.jsx index e14162274..69da6f077 100644 --- a/client/src/App/App.jsx +++ b/client/src/App/App.jsx @@ -7,6 +7,7 @@ import { connect } from "react-redux"; import { Route, Routes, useNavigate } from "react-router-dom"; import { createStructuredSelector } from "reselect"; import DocumentEditorContainer from "../components/document-editor/document-editor.container"; +import DocumentEditorLocalContainer from "../components/document-editor/document-editor-local.container"; import ErrorBoundary from "../components/error-boundary/error-boundary.component"; import LoadingSpinner from "../components/loading-spinner/loading-spinner.component"; import DisclaimerPage from "../pages/disclaimer/disclaimer.page"; @@ -241,6 +242,9 @@ export function App({ }> } /> + }> + } /> + diff --git a/client/src/components/document-editor/document-editor-local.component.jsx b/client/src/components/document-editor/document-editor-local.component.jsx new file mode 100644 index 000000000..b1511352a --- /dev/null +++ b/client/src/components/document-editor/document-editor-local.component.jsx @@ -0,0 +1,162 @@ +import axios from "axios"; +import { Result } 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 { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors"; +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"; + +const mapStateToProps = createStructuredSelector({ + currentUser: selectCurrentUser, + bodyshop: selectBodyshop +}); +const mapDispatchToProps = () => ({}); + +export function DocumentEditorLocalComponent({ imageUrl, filename, jobid }) { + const imgRef = useRef(null); + const [loading, setLoading] = useState(false); + const [uploaded, setuploaded] = useState(false); + const [loadedImageUrl, setLoadedImageUrl] = useState(null); + const [imageLoaded, setImageLoaded] = useState(false); + const [imageLoading, setImageLoading] = useState(true); + const markerArea = useRef(null); + const { t } = useTranslation(); + const notification = useNotification(); + const [uploading, setUploading] = useState(false); + + const triggerUpload = useCallback( + async (dataUrl) => { + if (uploading) return; + setUploading(true); + const blob = await b64toBlob(dataUrl); + const nameWithoutExt = filename.split(".").slice(0, -1).join(".").trim(); + const parts = nameWithoutExt.split("-"); + const baseParts = []; + for (let i = 0; i < parts.length; i++) { + if (/^\d+$/.test(parts[i])) { + break; + } + baseParts.push(parts[i]); + } + const adjustedBase = baseParts.length > 0 ? baseParts.join("-") : "edited"; + const adjustedFilename = `${adjustedBase}.jpg`; + const file = new File([blob], adjustedFilename, { type: "image/jpeg" }); + + handleUpload({ + ev: { + file: file, + filename: adjustedFilename, + onSuccess: () => { + setUploading(false); + setLoading(false); + setuploaded(true); + }, + onError: () => { + setUploading(false); + setLoading(false); + } + }, + context: { + jobid: jobid, + callback: () => {} // Optional callback + }, + notification + }); + }, + [filename, jobid, notification, uploading] + ); + + useEffect(() => { + if (imgRef.current !== null && imageLoaded && !markerArea.current) { + // create a marker.js MarkerArea + markerArea.current = new markerjs2.MarkerArea(imgRef.current); + + // attach an event handler to assign annotated image back to our image element + markerArea.current.addEventListener("close", () => { + // NO OP + }); + + markerArea.current.addEventListener("render", (event) => { + const dataUrl = event.dataUrl; + imgRef.current.src = dataUrl; + markerArea.current.close(); + triggerUpload(dataUrl); + }); + // launch marker.js + + markerArea.current.renderAtNaturalSize = true; + markerArea.current.renderImageType = "image/jpeg"; + markerArea.current.renderImageQuality = 1; + //markerArea.current.settings.displayMode = "inline"; + markerArea.current.show(); + } + }, [triggerUpload, imageLoaded]); + + useEffect(() => { + // Load the image from imageUrl + let isCancelled = false; + + const loadImage = async () => { + if (!imageUrl) return; + + setImageLoaded(false); + setImageLoading(true); + + try { + const response = await axios.get(imageUrl, { responseType: "blob" }); + + if (isCancelled) return; + + const blobUrl = URL.createObjectURL(response.data); + setLoadedImageUrl((prevUrl) => { + if (prevUrl) URL.revokeObjectURL(prevUrl); + return blobUrl; + }); + } catch (error) { + console.error("Failed to fetch image blob", error); + } finally { + if (!isCancelled) { + setImageLoading(false); + } + } + }; + + loadImage(); + + return () => { + isCancelled = true; + }; + }, [imageUrl]); + + async function b64toBlob(url) { + const res = await fetch(url); + return await res.blob(); + } + + return ( +
+ {!loading && !uploaded && loadedImageUrl && ( + sample setImageLoaded(true)} + onError={(error) => { + console.error("Failed to load image", error); + }} + style={{ maxWidth: "90vw", maxHeight: "90vh" }} + /> + )} + {(loading || imageLoading || !imageLoaded) && !uploaded && ( + + )} + {uploaded && } +
+ ); +} + +export default connect(mapStateToProps, mapDispatchToProps)(DocumentEditorLocalComponent); diff --git a/client/src/components/document-editor/document-editor-local.container.jsx b/client/src/components/document-editor/document-editor-local.container.jsx new file mode 100644 index 000000000..ce8aab23d --- /dev/null +++ b/client/src/components/document-editor/document-editor-local.container.jsx @@ -0,0 +1,34 @@ +import { useQuery } from "@apollo/client"; +import queryString from "query-string"; +import { useEffect } from "react"; +import { connect } from "react-redux"; +import { useLocation } from "react-router-dom"; +import { QUERY_BODYSHOP } from "../../graphql/bodyshop.queries"; +import { setBodyshop } from "../../redux/user/user.actions"; +import DocumentEditorLocalComponent from "./document-editor-local.component"; + +const mapDispatchToProps = (dispatch) => ({ + setBodyshop: (bs) => dispatch(setBodyshop(bs)) +}); + +export default connect(null, mapDispatchToProps)(DocumentEditorLocalContainer); + +export function DocumentEditorLocalContainer({ setBodyshop }) { + // Get the imageUrl, filename, jobid from the search string. + const { imageUrl, filename, jobid } = queryString.parse(useLocation().search); + + const { data: dataShop } = useQuery(QUERY_BODYSHOP, { + fetchPolicy: "network-only", + nextFetchPolicy: "network-only" + }); + + useEffect(() => { + if (dataShop) setBodyshop(dataShop.bodyshops[0]); + }, [dataShop, setBodyshop]); + + return ( +
+ +
+ ); +} diff --git a/client/src/components/jobs-documents-local-gallery/jobs-documents-local-gallery.container.jsx b/client/src/components/jobs-documents-local-gallery/jobs-documents-local-gallery.container.jsx index f5570257f..da9274eef 100644 --- a/client/src/components/jobs-documents-local-gallery/jobs-documents-local-gallery.container.jsx +++ b/client/src/components/jobs-documents-local-gallery/jobs-documents-local-gallery.container.jsx @@ -1,4 +1,4 @@ -import { FileExcelFilled, SyncOutlined } from "@ant-design/icons"; +import { EditFilled, FileExcelFilled, SyncOutlined } from "@ant-design/icons"; import { Alert, Button, Card, Col, Row, Space } from "antd"; import { useEffect, useState } from "react"; import { Gallery } from "react-grid-gallery"; @@ -185,6 +185,21 @@ export function JobsDocumentsLocalGallery({ {modalState.open && ( { + const newWindow = window.open( + `${window.location.protocol}//${window.location.host}/edit-local?imageUrl=${ + jobMedia.images[modalState.index].fullsize + }&filename=${jobMedia.images[modalState.index].filename}&jobid=${job.id}`, + "_blank", + "noopener,noreferrer" + ); + if (newWindow) newWindow.opener = null; + }} + /> + ]} mainSrc={jobMedia.images[modalState.index].fullsize} nextSrc={jobMedia.images[(modalState.index + 1) % jobMedia.images.length].fullsize} prevSrc={jobMedia.images[(modalState.index + jobMedia.images.length - 1) % jobMedia.images.length].fullsize}