IO-3464 Local Media Edit Image

Signed-off-by: Allan Carr <allan@imexsystems.ca>
This commit is contained in:
Allan Carr
2025-12-12 20:56:39 -08:00
parent 56d50b855b
commit 6eb432b5b7
4 changed files with 216 additions and 1 deletions

View File

@@ -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({
<Route path="/edit/*" element={<PrivateRoute isAuthorized={currentUser.authorized} />}>
<Route path="*" element={<DocumentEditorContainer />} />
</Route>
<Route path="/edit-local/*" element={<PrivateRoute isAuthorized={currentUser.authorized} />}>
<Route path="*" element={<DocumentEditorLocalContainer />} />
</Route>
</Routes>
</SoundWrapper>
</NotificationProvider>

View File

@@ -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 (
<div>
{!loading && !uploaded && loadedImageUrl && (
<img
ref={imgRef}
src={loadedImageUrl}
alt="sample"
onLoad={() => setImageLoaded(true)}
onError={(error) => {
console.error("Failed to load image", error);
}}
style={{ maxWidth: "90vw", maxHeight: "90vh" }}
/>
)}
{(loading || imageLoading || !imageLoaded) && !uploaded && (
<LoadingSpinner message={t("documents.labels.uploading")} />
)}
{uploaded && <Result status="success" title={t("documents.successes.edituploaded")} />}
</div>
);
}
export default connect(mapStateToProps, mapDispatchToProps)(DocumentEditorLocalComponent);

View File

@@ -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 (
<div>
<DocumentEditorLocalComponent imageUrl={imageUrl} filename={filename} jobid={jobid} />
</div>
);
}

View File

@@ -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({
</Col>
{modalState.open && (
<Lightbox
toolbarButtons={[
<EditFilled
key="edit"
onClick={() => {
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}