Merged in release/2026-05-08 (pull request #3226)
feature/IO-3687-Grey-Scale-Invisible-text - implement
This commit is contained in:
@@ -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 (
|
||||
<div>
|
||||
<div style={{ background: token.colorBgBase, color: token.colorText, minHeight: "100vh" }}>
|
||||
{!loading && !uploaded && loadedImageUrl && (
|
||||
<img
|
||||
ref={imgRef}
|
||||
@@ -158,7 +187,12 @@ export function DocumentEditorLocalComponent({ imageUrl, filename, jobid }) {
|
||||
{(loading || imageLoading || !imageLoaded) && !uploaded && (
|
||||
<LoadingSpinner message={t("documents.labels.uploading")} />
|
||||
)}
|
||||
{uploaded && <Result status="success" title={t("documents.successes.edituploaded")} />}
|
||||
{uploaded && (
|
||||
<Result
|
||||
status="success"
|
||||
title={<span style={{ color: token.colorText }}>{t("documents.successes.edituploaded")}</span>}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div>
|
||||
<div style={{ background: token.colorBgBase, color: token.colorText, minHeight: "100vh" }}>
|
||||
{!loading && !uploaded && imageUrl && (
|
||||
<img
|
||||
ref={imgRef}
|
||||
@@ -150,7 +178,12 @@ export function DocumentEditorComponent({ currentUser, bodyshop, document }) {
|
||||
{(loading || imageLoading || !imageLoaded) && !uploaded && (
|
||||
<LoadingSpinner message={t("documents.labels.uploading")} />
|
||||
)}
|
||||
{uploaded && <Result status="success" title={t("documents.successes.edituploaded")} />}
|
||||
{uploaded && (
|
||||
<Result
|
||||
status="success"
|
||||
title={<span style={{ color: token.colorText }}>{t("documents.successes.edituploaded")}</span>}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
123
client/src/components/document-editor/document-editor.utility.js
Normal file
123
client/src/components/document-editor/document-editor.utility.js
Normal file
@@ -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 =
|
||||
'<svg viewBox="0 0 24 24"><path d="M12 2a10 10 0 1 0 0 20V2zm0 2.25v15.5a7.75 7.75 0 0 1 0-15.5z"/></svg>';
|
||||
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;
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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 (
|
||||
<div>
|
||||
<Row gutter={[16, 16]}>
|
||||
@@ -147,30 +228,33 @@ function JobsDocumentsImgproxyComponent({
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
{modalState.open && (
|
||||
{modalState.open && selectedImage && (
|
||||
<Lightbox
|
||||
toolbarButtons={[
|
||||
<EditFilled
|
||||
key="edit"
|
||||
onClick={() => {
|
||||
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,
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -1216,6 +1216,7 @@
|
||||
"confirmdelete": "",
|
||||
"doctype": "",
|
||||
"dragtoupload": "",
|
||||
"greyscale": "Escala de grises",
|
||||
"newjobid": "",
|
||||
"openinexplorer": "",
|
||||
"optimizedimage": "",
|
||||
|
||||
@@ -1216,6 +1216,7 @@
|
||||
"confirmdelete": "",
|
||||
"doctype": "",
|
||||
"dragtoupload": "",
|
||||
"greyscale": "Niveaux de gris",
|
||||
"newjobid": "",
|
||||
"openinexplorer": "",
|
||||
"optimizedimage": "",
|
||||
|
||||
Reference in New Issue
Block a user