romaine
Version:
React OpenCV Manipulation and Image Narration & Editing
1,178 lines (1,147 loc) • 63 kB
JSX
import React, { createContext, useState, useCallback, useMemo, useEffect, useReducer, useContext, useRef, forwardRef, useImperativeHandle } from 'react';
import T from 'prop-types';
import Draggable from 'react-draggable';
const calcDims = (width, height, externalMaxWidth, externalMaxHeight) => {
const ratio = width / height;
const maxWidth = externalMaxWidth || window.innerWidth;
const maxHeight = externalMaxHeight || window.innerHeight;
const calculated = {
width: maxWidth,
height: Math.round(maxWidth / ratio),
ratio: ratio,
};
if (calculated.height > maxHeight) {
calculated.height = maxHeight;
calculated.width = Math.round(maxHeight * ratio);
}
return calculated;
};
function isCrossOriginURL(url) {
const { location } = window;
const parts = url.match(/^(\w+:)\/\/([^:/?#]*):?(\d*)/i);
return (parts !== null &&
(parts[1] !== location.protocol ||
parts[2] !== location.hostname ||
parts[3] !== location.port));
}
/**
* Takes a file or a string, loads it and turns it into a DataURL
* @param file as File | string
* @returns DataURL (as string)
*/
const readFile = (file) => new Promise((resolve, reject) => {
if (file instanceof File) {
const reader = new FileReader();
reader.onload = () => {
resolve(reader.result);
};
reader.onerror = (err) => {
reject(err);
};
reader.readAsDataURL(file);
}
else if (typeof file === "string")
return resolve(file);
else
reject();
});
/*! *****************************************************************************
Copyright (c) Microsoft Corporation.
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
PERFORMANCE OF THIS SOFTWARE.
***************************************************************************** */
function __rest(s, e) {
var t = {};
for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p) && e.indexOf(p) < 0)
t[p] = s[p];
if (s != null && typeof Object.getOwnPropertySymbols === "function")
for (var i = 0, p = Object.getOwnPropertySymbols(s); i < p.length; i++) {
if (e.indexOf(p[i]) < 0 && Object.prototype.propertyIsEnumerable.call(s, p[i]))
t[p[i]] = s[p[i]];
}
return t;
}
function __awaiter(thisArg, _arguments, P, generator) {
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
}
// https://amin-ahmadi.com/2016/03/24/sepia-filter-opencv/
const applyFilter = (cv, docCanvas, filterCvParams = {}) => __awaiter(void 0, void 0, void 0, function* () {
// default options
const options = Object.assign({ blur: false, th: true, thMode: cv.ADAPTIVE_THRESH_MEAN_C, thMeanCorrection: 15, thBlockSize: 25, thMax: 255, grayScale: true }, filterCvParams);
const dst = cv.imread(docCanvas);
if (options.grayScale) {
cv.cvtColor(dst, dst, cv.COLOR_RGBA2GRAY, 0);
}
if (options.blur) {
const ksize = new cv.Size(5, 5);
cv.GaussianBlur(dst, dst, ksize, 0, 0, cv.BORDER_DEFAULT);
}
if (options.th) {
if (options.grayScale) {
cv.adaptiveThreshold(dst, dst, options.thMax, options.thMode, cv.THRESH_BINARY, options.thBlockSize, options.thMeanCorrection);
}
else {
//@ts-ignore need to fix this type error (add to OpenCV type)
dst.convertTo(dst, -1, 1, 60);
cv.threshold(dst, dst, 170, 255, cv.THRESH_BINARY);
}
}
cv.imshow(docCanvas, dst);
});
/**
* perspective cropping utility (AKA keystone correction)
* @param cv openCv
* @param src The source image pointer
* @param cropPoints
* @param imageResizeRatio
*/
const warpPerspective = (cv, src, cropPoints, imageResizeRatio) => {
// const src = cv.imread(docCanvas);
const bR = cropPoints["right-bottom"];
const bL = cropPoints["left-bottom"];
const tR = cropPoints["right-top"];
const tL = cropPoints["left-top"];
// create source coordinates matrix
const sourceCoordinates = [tL, tR, bR, bL].map((point) => [
point.x / imageResizeRatio,
point.y / imageResizeRatio,
]);
// get max width
const maxWidth = Math.max(bR.x - bL.x, tR.x - tL.x) / imageResizeRatio;
// get max height
const maxHeight = Math.max(bL.y - tL.y, bR.y - tR.y) / imageResizeRatio;
// create dest coordinates matrix
const destCoordinates = [
[0, 0],
[maxWidth - 1, 0],
[maxWidth - 1, maxHeight - 1],
[0, maxHeight - 1],
];
// convert to open cv matrix objects
const Ms = cv.matFromArray(4, 1, cv.CV_32FC2, [].concat(...sourceCoordinates));
const Md = cv.matFromArray(4, 1, cv.CV_32FC2, [].concat(...destCoordinates));
const transformMatrix = cv.getPerspectiveTransform(Ms, Md);
// set new image size
const dsize = new cv.Size(maxWidth, maxHeight);
// perform warp
cv.warpPerspective(src, src, transformMatrix, dsize, cv.INTER_LINEAR, cv.BORDER_CONSTANT, new cv.Scalar());
// cv.imshow(docCanvas, src);
// src.delete();
Ms.delete();
Md.delete();
transformMatrix.delete();
};
function multiplyMatrices(m1, m2) {
const result = [];
for (let i = 0; i < m1.length; i++) {
result[i] = [];
for (let j = 0; j < m2[0].length; j++) {
let sum = 0;
for (let k = 0; k < m1[0].length; k++) {
sum += m1[i][k] * m2[k][j];
}
result[i][j] = sum;
}
}
return result;
}
/**
* Function that adds transparent (or whatever the fill color is) to the image canvas
* @see https://stackoverflow.com/questions/43391205/add-padding-to-images-to-get-them-into-the-same-shape#43391469
* @example
* addPadding(cv, canvasRef.current, { left: 500 }, { setPreviewPaneDimensions, showPreview });
*/
const addPadding = (cv, src, { top = 0, bottom = 0, left = 0, right = 0 }, { showPreview, setPreviewPaneDimensions }) => {
const dst = new cv.Mat();
// @ts-ignore
cv.copyMakeBorder(cv.imread(src), dst, top, bottom, left, right, cv.BORDER_CONSTANT);
setPreviewPaneDimensions({
width: src.width + left + right,
height: src.height + top + bottom,
});
cv.imshow(src, dst);
showPreview();
dst.delete();
};
/**
* perspective cropping utility (AKA keystone correction)
* @param cv openCv
* @param docCanvas
* @param cropPoints
* @param imageResizeRatio
* @param setPreviewPaneDimensions
*/
const cropOpenCV = (cv, dst, cropPoints, imageResizeRatio
// _canvasRef: HTMLCanvasElement,
) => {
// const dst = cv.imread(docCanvas);
// const bR = cropPoints["right-bottom"];
const bL = cropPoints["left-bottom"];
const tR = cropPoints["right-top"];
const tL = cropPoints["left-top"];
const l = tL.x / imageResizeRatio;
const t = tL.y / imageResizeRatio;
const r = tR.x / imageResizeRatio;
const b = bL.y / imageResizeRatio;
const M = cv.matFromArray(2, 3, cv.CV_64FC1, [1, 0, -l, 0, 1, -t]);
// let dsize = new cv.Size(dst.rows - t - b, dst.cols - l - r);
// const dsize = new cv.Size(dst.rows - l - r, dst.cols - t - b);
const dsize = new cv.Size(dst.cols - l - (dst.cols - r), dst.rows - t - (dst.rows - b));
cv.warpAffine(dst, dst, M, dsize, cv.INTER_LINEAR, cv.BORDER_CONSTANT, new cv.Scalar());
// setTimeout(async () => {
// cv.imshow(canvasRef, dst);
// }, 0);
// dst.delete();
M.delete();
};
/**
*
* @param state RomaineState
* @param payload HistoryAction["payload"]
* @todo
* 1) reduce rotation to one command if previous history is also rotation
*/
const history = (state, payload) => {
switch (payload.cmd) {
case "CLEAR": {
// overwrite with empty array
return Object.assign(Object.assign({}, state), { history: { commands: [], pointer: 0 } });
}
case "PUSH": {
if (state.mode === "undo" || state.mode === "redo" || state.mode === null)
return Object.assign({}, state);
const pointer = state.history.pointer;
let newCommands = state.history.commands;
if (pointer < newCommands.length) {
newCommands = state.history.commands.reduce((a, c, i) => (pointer > i ? [...a, c] : a), []);
}
return Object.assign(Object.assign({}, state), { history: Object.assign(Object.assign({}, state.history), { commands: [...newCommands, getHistoryFromState(state)], pointer: pointer + 1 }) });
}
case "UNDO": {
const pointer = state.history.pointer;
return Object.assign(Object.assign({}, state), { history: Object.assign(Object.assign({}, state.history), { pointer: pointer ? pointer - 1 : 0 }) });
}
case "REDO": {
const pointer = state.history.pointer;
return Object.assign(Object.assign({}, state), { history: Object.assign(Object.assign({}, state.history), { pointer: pointer ? pointer - 1 : 0 }) });
}
default:
return Object.assign({}, state);
}
};
const getHistoryFromState = ({ mode, angle, scale, cropPoints, }) => {
switch (mode) {
case "rotate-left":
return { cmd: mode, payload: angle % 360 };
case "rotate-right":
return { cmd: mode, payload: (360 - angle) % 360 };
case "flip-horizontal":
return { cmd: mode, payload: null };
case "flip-vertical":
return { cmd: mode, payload: null };
case "crop":
console.warn("need to add crop points to history");
return { cmd: mode, payload: cropPoints };
case "perspective-crop":
console.warn("need to add crop points to history");
return { cmd: mode, payload: cropPoints };
case "scale":
return { cmd: mode, payload: scale };
case "full-reset":
throw new Error('error: action "full-reset" should not call `history`.`PUSH`').stack;
case null:
// handles null
throw new Error("error: action of type null should not call `history`.`PUSH`").stack;
default:
// handles fall through mode
throw new Error(`error: action of type ${mode} is not defined in switch \`history\`.\`PUSH\``).stack;
}
};
const romaineReducer = (state, action) => {
switch (action.type) {
case "JOIN_PAYLOAD":
return Object.assign(Object.assign({}, state), action.payload);
case "MODE":
if (state.mode === action.payload)
return Object.assign(Object.assign({}, state), { mode: null });
return Object.assign(Object.assign({}, state), { mode: action.payload });
case "ANGLE":
return Object.assign(Object.assign({}, state), { angle: action.payload });
case "SCALE":
return Object.assign(Object.assign({}, state), { scale: action.payload });
case "CROP_POINTS":
if (!action.payload)
return Object.assign({}, state);
return Object.assign(Object.assign({}, state), { cropPoints: action.payload });
case "HISTORY":
return Object.assign({}, history(state, action.payload));
case "IMAGE-UPDATE":
return Object.assign(Object.assign({}, state), { image: action.payload });
default:
return Object.assign({}, state);
}
};
const initialRomaineState = {
mode: null,
angle: 90,
scale: {
width: 0,
height: 0,
},
image: {
width: 0,
height: 0,
id: null,
},
history: { commands: [], pointer: 0 },
cropPoints: {
"left-top": { x: 0, y: 0 },
"left-bottom": { x: 0, y: 0 },
"right-bottom": { x: 0, y: 0 },
"right-top": { x: 0, y: 0 },
},
};
const moduleConfig = {
wasmBinaryFile: "opencv_js.wasm",
usingWasm: true,
};
const OpenCvContext = createContext({
loaded: false,
romaine: initialRomaineState,
setCropPoints: null,
undo: null,
redo: null,
});
const { Consumer: OpenCvConsumer, Provider } = OpenCvContext;
const scriptId = "openCvScriptTag";
/**
* a romaine context for use in getting openCV and the canvas ref element
* @todo
* 1) Add ref to provider
* 2) See if nonce is really required here
*/
const Romaine = ({ openCvPath, children, onLoad, angle = 90, }) => {
const [loaded, setLoaded] = useState(false);
const [_image, setImage] = useState(null);
const handleOnLoad = useCallback(() => {
onLoad && onLoad(window.cv);
setLoaded(true);
}, [onLoad, setLoaded]);
const generateOpenCvScriptTag = useMemo(() => {
// make sure we are in the browser
if (typeof window !== "undefined") {
if (!document.getElementById(scriptId) && !window.cv) {
const js = document.createElement("script");
// append to head
js.id = scriptId;
js.nonce = "8IBTHwOdqNKAWeKl7plt8g==";
js.defer = true;
js.async = true;
js.src = openCvPath || "https://docs.opencv.org/3.4.13/opencv.js";
return js;
}
else if (document.getElementById(scriptId) && !window.cv) {
return document.getElementById(scriptId);
}
}
}, [openCvPath]);
useEffect(() => {
if (window.cv) {
handleOnLoad();
return;
}
// https://docs.opencv.org/3.4/dc/de6/tutorial_js_nodejs.html
// https://medium.com/code-divoire/integrating-opencv-js-with-an-angular-application-20ae11c7e217
// https://stackoverflow.com/questions/56671436/cv-mat-is-not-a-constructor-opencv
moduleConfig.onRuntimeInitialized = handleOnLoad;
window.Module = moduleConfig;
// if (!document.getElementById(scriptId))
if (generateOpenCvScriptTag && !document.getElementById(scriptId))
document.body.appendChild(generateOpenCvScriptTag);
// else handleOnLoad();
}, [openCvPath, handleOnLoad, generateOpenCvScriptTag]);
const [romaine, dispatchRomaine] = useReducer(romaineReducer, initialRomaineState);
const setMode = useCallback((mode) => {
dispatchRomaine({ type: "MODE", payload: mode });
}, [dispatchRomaine]);
const setAngle = useCallback((angle) => {
dispatchRomaine({ type: "ANGLE", payload: angle });
}, [dispatchRomaine]);
const setScale = useCallback((scale) => {
dispatchRomaine({ type: "SCALE", payload: scale });
}, [dispatchRomaine]);
const updateImageInformation = useCallback((image) => {
dispatchRomaine({
type: "SCALE",
payload: {
width: image.width,
height: image.height,
},
});
dispatchRomaine({ type: "IMAGE-UPDATE", payload: image });
}, [dispatchRomaine]);
const { cropPoints, history: { pointer }, } = romaine;
const setCropPoints = useCallback((payload) => {
if (typeof payload === "function")
payload = payload(cropPoints);
dispatchRomaine({ type: "CROP_POINTS", payload });
}, [dispatchRomaine, cropPoints]);
const pushHistory = useCallback(() => {
dispatchRomaine({ type: "HISTORY", payload: { cmd: "PUSH" } });
}, [dispatchRomaine]);
const clearHistory = useCallback(() => {
dispatchRomaine({ type: "HISTORY", payload: { cmd: "CLEAR" } });
}, [dispatchRomaine]);
const moveHistory = useCallback((direction) => {
if (direction)
return () => {
dispatchRomaine({ type: "MODE", payload: null });
dispatchRomaine({ type: "HISTORY", payload: { cmd: "UNDO" } });
};
else
return () => {
dispatchRomaine({ type: "MODE", payload: null });
dispatchRomaine({ type: "HISTORY", payload: { cmd: "REDO" } });
};
}, [dispatchRomaine]);
useEffect(() => {
setAngle(angle);
}, [angle, setAngle]);
const memoizedProviderValue = useMemo(() => ({
loaded,
cv: typeof window !== "undefined"
? window === null || window === void 0 ? void 0 : window.cv
: null,
romaine: Object.assign(Object.assign({}, romaine), { history: Object.assign({}, romaine.history), clearHistory }),
setImage,
setMode,
setAngle,
setScale,
updateImageInformation,
pushHistory,
setCropPoints,
undo: moveHistory(true),
redo: moveHistory(false),
}), [
loaded,
romaine,
pointer,
setMode,
setAngle,
setScale,
pushHistory,
moveHistory,
clearHistory,
setCropPoints,
updateImageInformation,
]);
// const { canvasRef } = useCanvas({ image });
// usePreview({ cv: memoizedProviderValue, canvasRef });
return React.createElement(Provider, { value: memoizedProviderValue }, children);
};
Romaine.propTypes = {
openCvPath: T.string,
children: T.node,
onLoad: T.func,
angle: T.number,
};
const useRomaine = () => useContext(OpenCvContext);
/**
* Add default styles to the points
* Makes sure position absolute cannot be overwritten
* @param cropPointStyles
*/
const buildCropPointStyle = (cropPointStyles = {}) => (Object.assign(Object.assign({ backgroundColor: "transparent", border: "4px solid #3cabe2", zIndex: 1001, borderRadius: "100%", cursor: "move" }, cropPointStyles), { position: "absolute" }));
/**
* @returns A crop point to use during cropping and image rotation
*/
const CropPoint = ({ pointSize, pointArea, defaultPosition, onStop: externalOnStop, onDrag: externalOnDrag, bounds, cropPointStyles = {}, }) => {
const { romaine: { cropPoints }, } = useRomaine();
const cropPointStyle = useMemo(() => {
if (cropPointStyles.width !== pointSize ||
cropPointStyles.height !== pointSize) {
console.warn("");
}
cropPointStyles.width = pointSize;
cropPointStyles.height = pointSize;
return buildCropPointStyle(cropPointStyles);
}, [cropPointStyles, pointSize]);
const onDrag = useCallback((_, position) => {
externalOnDrag(Object.assign(Object.assign({}, position), { x: position.x + pointSize / 2, y: position.y + pointSize / 2 }), pointArea);
}, [externalOnDrag]);
const onStop = useCallback((_, position) => {
externalOnStop(Object.assign(Object.assign({}, position), { x: position.x + pointSize / 2, y: position.y + pointSize / 2 }), pointArea, cropPoints);
}, [externalOnDrag, cropPoints]);
return (React.createElement(Draggable, { bounds: bounds, defaultPosition: defaultPosition, position: {
x: cropPoints[pointArea].x - pointSize / 2,
y: cropPoints[pointArea].y - pointSize / 2,
}, onDrag: onDrag, onStop: onStop },
React.createElement("div", { style: cropPointStyle })));
};
const CropPoints = (_a) => {
var { previewDims } = _a, otherProps = __rest(_a, ["previewDims"]);
return (React.createElement(React.Fragment, null,
React.createElement(CropPoint, Object.assign({ pointArea: "left-top", defaultPosition: { x: 0, y: 0 } }, otherProps)),
React.createElement(CropPoint, Object.assign({ pointArea: "right-top", defaultPosition: { x: previewDims.width, y: 0 } }, otherProps)),
React.createElement(CropPoint, Object.assign({ pointArea: "right-bottom", defaultPosition: { x: 0, y: previewDims.height } }, otherProps)),
React.createElement(CropPoint, Object.assign({ pointArea: "left-bottom", defaultPosition: {
x: previewDims.width,
y: previewDims.height,
} }, otherProps))));
};
CropPoints.propTypes = {
previewDims: T.shape({
ratio: T.number,
width: T.number,
height: T.number,
}),
};
/**
* Create the lines for the cropper utility
*/
const CropPointsDelimiters = (_a) => {
var {
// romaineRef,
crop, cropPoints, previewDims, lineWidth = 3, lineColor = "#3cabe2", pointSize } = _a, props = __rest(_a, ["crop", "cropPoints", "previewDims", "lineWidth", "lineColor", "pointSize"]);
const canvas = useRef(null);
const clearCanvas = useCallback(() => {
if (canvas.current) {
const ctx = canvas.current.getContext("2d");
ctx && ctx.clearRect(0, 0, previewDims.width, previewDims.height);
}
}, [previewDims]);
/** This needs to do a better job. See Issue #2
* @link https://github.com/DanielBailey-web/romaine/issues/2
*
*/
const sortPoints = useCallback(() => {
const sortOrder = [
"left-top",
"right-top",
"right-bottom",
"left-bottom",
];
return sortOrder.reduce((acc, pointPos) => [...acc, cropPoints[pointPos]], []);
}, [cropPoints]);
const drawShape = useCallback(([point1, point2, point3, point4]) => {
const ctx = canvas.current && canvas.current.getContext("2d");
if (ctx) {
ctx.lineWidth = lineWidth;
ctx.strokeStyle = lineColor;
ctx.beginPath();
ctx.moveTo(point1.x + pointSize / 2, point1.y);
ctx.lineTo(point2.x - pointSize / 2, point2.y);
ctx.moveTo(point2.x, point2.y + pointSize / 2);
ctx.lineTo(point3.x, point3.y - pointSize / 2);
ctx.moveTo(point3.x - pointSize / 2, point3.y);
ctx.lineTo(point4.x + pointSize / 2, point4.y);
ctx.moveTo(point4.x, point4.y - pointSize / 2);
ctx.lineTo(point1.x, point1.y + pointSize / 2);
ctx.closePath();
ctx.stroke();
}
}, [lineColor, lineWidth, pointSize]);
useEffect(() => {
if (cropPoints && canvas.current) {
clearCanvas();
const sortedPoints = sortPoints();
drawShape(sortedPoints);
}
}, [cropPoints, drawShape, clearCanvas, sortPoints]);
/**
* Takes in a `CoordinateXY` and makes sure that it is inside the `ContourCoordinates`
*
* @returns boolean
*
* @todo Should use point slope formula for when using perspective cropper
*/
const xyInPoints = ({ x, y }) => {
if (x > cropPoints["left-top"].x &&
y > cropPoints["left-top"].y &&
x < cropPoints["right-top"].x &&
y > cropPoints["right-top"].y &&
x < cropPoints["right-bottom"].x &&
y < cropPoints["right-bottom"].y &&
x > cropPoints["left-bottom"].x &&
y < cropPoints["left-bottom"].y)
return true;
return false;
};
const getCursorPosition = (event) => {
var _a, _b, _c;
if ((_a = canvas === null || canvas === void 0 ? void 0 : canvas.current) === null || _a === void 0 ? void 0 : _a.getBoundingClientRect) {
const x = event.clientX - ((_b = canvas === null || canvas === void 0 ? void 0 : canvas.current) === null || _b === void 0 ? void 0 : _b.getBoundingClientRect().left);
const y = event.clientY - ((_c = canvas === null || canvas === void 0 ? void 0 : canvas.current) === null || _c === void 0 ? void 0 : _c.getBoundingClientRect().top);
if (xyInPoints({ x, y }))
return "inside";
else
return "outside";
}
};
const [cursor, setCursor] = useState("default");
const handleMouseMove = (e) => {
const cursorPosition = getCursorPosition(e);
if (cursorPosition === "inside" && cursor !== "crosshair")
setCursor("crosshair");
else if (cursorPosition === "outside" && cursor !== "default")
setCursor("default");
};
const handleClick = (e) => {
const cursorPosition = getCursorPosition(e);
if (cursorPosition === "inside")
crop({
preview: true,
filterCvParams: {
grayScale: false,
th: false,
},
image: {
quality: 1,
type: "image/jpeg",
},
});
};
return (React.createElement("canvas", { id: `${props.saltId ? props.saltId + "-" : ""}crop-point-delimiters`, ref: canvas, style: {
position: "absolute",
top: 0,
right: 0,
left: 0,
bottom: 0,
zIndex: 5,
cursor,
}, width: previewDims.width, height: previewDims.height, onClick: handleClick, onMouseMove: handleMouseMove, onMouseLeave: () => setCursor("default") }));
};
CropPointsDelimiters.propTypes = {
previewDims: T.shape({
ratio: T.number,
width: T.number,
height: T.number,
}),
cropPoints: T.shape({
"left-top": T.shape({ x: T.number, y: T.number }).isRequired,
"right-top": T.shape({ x: T.number, y: T.number }).isRequired,
"right-bottom": T.shape({ x: T.number, y: T.number }).isRequired,
"left-bottom": T.shape({ x: T.number, y: T.number }).isRequired,
}),
lineColor: T.string,
lineWidth: T.number,
pointSize: T.number,
saltId: T.string,
};
const CroppingCanvas = ({ image, onDragStop, onChange, previewCanvasRef, setCropFunc, pointSize = 30, lineWidth, lineColor, previewDims, imageResizeRatio, showPreview, setPreviewPaneDimensions, saltId, canvasPtr, }) => {
var _a, _b, _c, _d, _e, _f;
const { loaded: cvLoaded, cv, romaine: { mode, cropPoints }, setMode, pushHistory, setCropPoints, } = useRomaine();
// const [_worker, setWorker] = useState<Worker | null>();
// useEffect(() => {
// if (window.Worker) {
// const worker = new Worker("./test.js");
// worker.onmessage = function (e) {
// console.log(e.data);
// console.log("Message received from worker");
// };
// worker.postMessage([20, 30]);
// setWorker(worker);
// }
// }, []);
const [loading, setLoading] = useState(false);
const cropCB = useCallback((opts = {}) => __awaiter(void 0, void 0, void 0, function* () {
// need to figure out how to not need this and still render
setLoading(true);
// push can be moved to mode
pushHistory === null || pushHistory === void 0 ? void 0 : pushHistory();
return new Promise((resolve) => {
if (!opts.cropPoints)
opts.cropPoints = cropPoints;
if (!opts.imageResizeRatio)
opts.imageResizeRatio = imageResizeRatio;
if (!opts.mode)
opts.mode = mode;
if (opts.cropPoints) {
if (opts.mode === "crop")
cropOpenCV(cv, canvasPtr.current, opts.cropPoints, opts.imageResizeRatio
// canvasRef.current,
);
else if (opts.mode === "perspective-crop")
warpPerspective(cv, canvasPtr.current, opts.cropPoints, opts.imageResizeRatio);
else
return setLoading(false);
// @todo implement this somewhere
// applyFilter(cv, canvasRef.current, opts.filterCvParams);
if (opts.preview) {
const dims = canvasPtr.current.size();
const irr = setPreviewPaneDimensions(dims);
showPreview(irr, canvasPtr.current, false);
setMode === null || setMode === void 0 ? void 0 : setMode(null);
}
setLoading(false);
return resolve();
}
});
}),
// disabling because eslint doesn't know that canvasRef is a ref
// eslint-disable-next-line react-hooks/exhaustive-deps
[
cv,
cropPoints,
mode,
pushHistory,
setLoading,
setMode,
setPreviewPaneDimensions,
imageResizeRatio,
]);
useEffect(() => {
setCropFunc(() => cropCB);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [cropCB]);
const detectContours = () => {
/*
* warning, this function will alter the canvasPtr
*
* Remember to copy pointer to new variable if you want to keep it
* Make sure to clean up variable also
*/
const dst = new cv.Mat();
canvasPtr.current.copyTo(dst);
const ksize = new cv.Size(5, 5);
// convert the image to grayscale, blur it, and find edges in the image
cv.cvtColor(dst, dst, cv.COLOR_RGBA2GRAY, 0);
cv.GaussianBlur(dst, dst, ksize, 0, 0, cv.BORDER_DEFAULT);
cv.Canny(dst, dst, 75, 200);
// find contours
cv.threshold(dst, dst, 120, 200, cv.THRESH_BINARY);
const contours = new cv.MatVector();
const hierarchy = new cv.Mat();
cv.findContours(dst, contours, hierarchy, cv.RETR_CCOMP, cv.CHAIN_APPROX_SIMPLE);
const rect = cv.boundingRect(dst);
dst.delete();
hierarchy.delete();
contours.delete();
// transform the rectangle into a set of points
Object.keys(rect).forEach((key) => {
rect[key] = rect[key] * imageResizeRatio;
});
const contourCoordinates = {
"left-top": { x: rect.x, y: rect.y },
"right-top": { x: rect.x + rect.width, y: rect.y },
"right-bottom": {
x: rect.x + rect.width,
y: rect.y + rect.height,
},
"left-bottom": { x: rect.x, y: rect.y + rect.height },
};
setCropPoints(contourCoordinates);
};
useEffect(() => {
if (onChange && cropPoints) {
onChange(Object.assign(Object.assign({}, cropPoints), { loading }));
}
}, [cropPoints, loading]);
/**
* @todo
* 1) Need to make sure that this is valid to never run the other code
* if it is, then need to remove the code in the else block
*/
const bootstrap = () => __awaiter(void 0, void 0, void 0, function* () {
detectContours();
setLoading(false);
// else {
// // imread is SLOW
// const src = cv.imread(previewCanvasRef.current);
// const contourCoordinates = {
// "left-top": { x: 0, y: 0 },
// "right-top": { x: src.cols, y: 0 },
// "right-bottom": {
// x: src.cols,
// y: src.rows,
// },
// "left-bottom": { x: 0, y: src.rows },
// };
// setCropPoints(contourCoordinates);
// }
});
useEffect(() => {
if (image &&
previewCanvasRef.current &&
cvLoaded &&
(mode === "crop" || mode === "perspective-crop")) {
bootstrap();
}
else {
setLoading(true);
}
}, [image, cvLoaded, mode]);
const handleNormalCornerMove = useCallback((position, area, cPs) => {
const { x, y } = position;
if (cPs) {
switch (area) {
case "left-bottom":
return Object.assign(Object.assign({}, cPs), { [area]: { x, y }, "left-top": { x, y: cPs["left-top"].y }, "right-bottom": { x: cPs["right-bottom"].x, y } });
case "right-bottom":
return Object.assign(Object.assign({}, cPs), { [area]: { x, y }, "right-top": { x, y: cPs["right-top"].y }, "left-bottom": { x: cPs["left-bottom"].x, y } });
case "right-top":
return Object.assign(Object.assign({}, cPs), { [area]: { x, y }, "left-top": { x: cPs["left-top"].x, y }, "right-bottom": { x, y: cPs["right-bottom"].y } });
case "left-top":
return Object.assign(Object.assign({}, cPs), { [area]: { x, y }, "left-bottom": { x, y: cPs["left-bottom"].y }, "right-top": { x: cPs["right-top"].x, y } });
}
}
}, []);
const onCornerDrag = useCallback((position, area) => {
// if (magnifierCanvasRef.current && previewCanvasRef.current) {
// const { x, y } = position;
// const magnCtx = magnifierCanvasRef.current.getContext("2d");
// clearCanvasByRef(magnifierCanvasRef);
// TODO we should make those 5, 10 and 20 values proportionate
// to the point size
// magnCtx &&
// magnCtx.drawImage(
// previewCanvasRef.current,
// x - (pointSize - 10),
// y - (pointSize - 10),
// pointSize + 5,
// pointSize + 5,
// x + 10,
// y - 90,
// pointSize + 20,
// pointSize + 20
// );
setCropPoints((cPs) => {
if (cPs && mode === "perspective-crop")
return Object.assign(Object.assign({}, cPs), { [area]: position });
return handleNormalCornerMove(position, area, cPs);
});
// }
}, [mode, setCropPoints, handleNormalCornerMove]);
const onCornerStop = useCallback((position, area, cropPoints) => {
const { x, y } = position;
// clearCanvasByRef(magnifierCanvasRef);
setCropPoints((cPs) => {
if (cPs && mode === "perspective-crop")
return Object.assign(Object.assign({}, cPs), { [area]: { x, y } });
return handleNormalCornerMove(position, area, cPs);
});
onDragStop === null || onDragStop === void 0 ? void 0 : onDragStop(Object.assign(Object.assign({}, cropPoints), { [area]: { x, y }, loading }));
}, [mode, setCropPoints, handleNormalCornerMove]);
return (React.createElement(React.Fragment, null, previewDims &&
(mode === "crop" || mode === "perspective-crop") &&
cropPoints && (React.createElement(React.Fragment, null,
React.createElement(CropPoints, { pointSize: pointSize, previewDims: previewDims, onDrag: onCornerDrag, onStop: onCornerStop, bounds: {
left: (((_a = previewCanvasRef === null || previewCanvasRef === void 0 ? void 0 : previewCanvasRef.current) === null || _a === void 0 ? void 0 : _a.offsetLeft) || 0) - pointSize / 2,
top: (((_b = previewCanvasRef === null || previewCanvasRef === void 0 ? void 0 : previewCanvasRef.current) === null || _b === void 0 ? void 0 : _b.offsetTop) || 0) - pointSize / 2,
right: (((_c = previewCanvasRef === null || previewCanvasRef === void 0 ? void 0 : previewCanvasRef.current) === null || _c === void 0 ? void 0 : _c.offsetLeft) || 0) -
pointSize / 2 +
(((_d = previewCanvasRef === null || previewCanvasRef === void 0 ? void 0 : previewCanvasRef.current) === null || _d === void 0 ? void 0 : _d.offsetWidth) || 0),
bottom: (((_e = previewCanvasRef === null || previewCanvasRef === void 0 ? void 0 : previewCanvasRef.current) === null || _e === void 0 ? void 0 : _e.offsetTop) || 0) -
pointSize / 2 +
(((_f = previewCanvasRef === null || previewCanvasRef === void 0 ? void 0 : previewCanvasRef.current) === null || _f === void 0 ? void 0 : _f.offsetHeight) || 0),
} }),
React.createElement(CropPointsDelimiters
// romaineRef={romaineRef as React.RefObject<RomaineRef>}
, {
// romaineRef={romaineRef as React.RefObject<RomaineRef>}
crop: cropCB, previewDims: previewDims, cropPoints: cropPoints, lineWidth: lineWidth, lineColor: lineColor, pointSize: pointSize, saltId: saltId })))));
};
const buildImgContainerStyle = (previewDims) => ({
width: previewDims.width,
height: previewDims.height,
});
const usePreview = ({ canvasRef, maxDims, originalImageDims, }) => {
const { maxHeight, maxWidth } = maxDims;
const { cv } = useRomaine();
const previewRef = useRef(null);
const [imageResizeRatio, setImageResizeRatio] = useState(1);
const [previewDims, setPreviewDims] = useState({
height: maxHeight,
width: maxWidth,
ratio: 1,
});
const createPreview = (resizeRatio = imageResizeRatio, source, cleanup = true) => {
if (!source && canvasRef.current)
source == cv.imread(canvasRef.current);
if (!(source === null || source === void 0 ? void 0 : source.$$.ptr))
return;
if (cv && previewRef.current) {
const dst = new cv.Mat();
const dsize = new cv.Size(0, 0);
cv.resize(source, dst, dsize, resizeRatio, resizeRatio, cv.INTER_AREA);
cv.imshow(previewRef.current, dst);
if (cleanup)
source.delete();
dst.delete();
}
};
const setPreviewPaneDimensions = useCallback((dims = originalImageDims) => {
if (dims && (previewRef === null || previewRef === void 0 ? void 0 : previewRef.current)) {
const newPreviewDims = calcDims(dims.width, dims.height, maxWidth, maxHeight);
setPreviewDims(newPreviewDims);
previewRef.current.width = newPreviewDims.width;
previewRef.current.height = newPreviewDims.height;
const irr = newPreviewDims.width / dims.width;
setImageResizeRatio(irr);
return irr;
}
}, [maxHeight, maxWidth]);
return {
previewRef,
createPreview,
imageResizeRatio,
setPreviewPaneDimensions,
previewDims,
};
};
const useCanvas = ({ image, saltId }) => {
const { cv, romaine: { mode }, updateImageInformation, } = useRomaine();
const [loaded, setLoaded] = useState(false);
const [originalImageDims, setOriginalImageDims] = useState({
width: 0,
height: 0,
});
const canvasRef = useRef(document.createElement("canvas"));
const canvasPtr = useRef();
const getFile = useCallback((image) => __awaiter(void 0, void 0, void 0, function* () { return yield readFile(image); }), []);
const createCanvas = useCallback(() => new Promise((resolve, reject) => __awaiter(void 0, void 0, void 0, function* () {
if (!image)
return reject("Image source is invalid");
const src = yield getFile(image);
try {
const img = document.createElement("img");
img.style.display = "none";
img.id = `${saltId ? saltId + "-" : ""}working-image`;
img.onload = () => __awaiter(void 0, void 0, void 0, function* () {
// set edited image canvas and dimensions
canvasRef.current = document.createElement("canvas");
canvasRef.current.style.display = "none";
canvasRef.current.id = `${saltId ? saltId + "-" : ""}working-canvas`;
canvasRef.current.width = img.width;
canvasRef.current.height = img.height;
setOriginalImageDims({ height: img.height, width: img.width });
updateImageInformation === null || updateImageInformation === void 0 ? void 0 : updateImageInformation({
height: img.height,
width: img.width,
id: img.id,
});
const ctx = canvasRef.current.getContext("2d");
if (ctx) {
ctx.fillStyle = "#fff0"; // transparent
ctx.fillRect(0, 0, img.width, img.height);
ctx.drawImage(img, 0, 0);
canvasPtr.current = cv.imread(canvasRef.current);
setLoaded(true);
return resolve();
}
return reject();
});
if (isCrossOriginURL(src))
img.crossOrigin = "anonymous";
img.src = src;
}
catch (err) {
console.error("Error in create canvas: ", err);
reject("unknown error while creating canvas");
}
})), [image]);
const resetImage = useCallback(() => {
var _a;
// set loaded to false before we destroy the pointer to avoid race conditions
setLoaded(false);
(_a = canvasPtr.current) === null || _a === void 0 ? void 0 : _a.delete();
createCanvas();
}, [image]);
useEffect(() => {
if (image)
resetImage();
}, [image]);
useEffect(() => {
if (mode === "undo") {
resetImage();
}
}, [mode]);
return {
canvasRef,
createCanvas,
originalImageDims,
canvasPtr,
resetImage,
/** loaded: Is the image loaded onto the canvas and does the pointer exist */
loaded,
};
};
const flip = (cv, canvas, src, orientation) => __awaiter(void 0, void 0, void 0, function* () {
if (orientation === "horizontal") {
cv.flip(src, src, 0);
}
else if (orientation === "vertical") {
cv.flip(src, src, 1);
}
else {
throw new Error("Invalid orientation");
}
cv.imshow(canvas, src);
});
const rotate = (cv, canvas, mode, setPreviewPaneDimensions, showPreview, opts, cvCanvas) => __awaiter(void 0, void 0, void 0, function* () {
const { preview, angle, cleanup } = Object.assign({ preview: false, cleanup: true }, opts);
let src = cvCanvas;
if (!canvas)
return;
if (!cvCanvas)
src = cv.imread(canvas);
// const dst = new cv.Mat();
const center = new cv.Point(src.cols / 2, src.rows / 2);
const M1_temp = cv.getRotationMatrix2D(center, angle, 1);
const a = [...M1_temp.data64F];
M1_temp.delete();
const cos = Math.abs(a[0]);
const sin = Math.abs(a[3]);
// compute the new bounding dimensions of the image
const newWidth = ~~(src.rows * sin + src.cols * cos);
const newHeight = ~~(src.rows * cos + src.cols * sin);
/**
* Col 3 Row 1 is horizontal transform (numerical position away from y axis)
*
* Col 3 Row 2 is vertical transform ("" y axis)
*
* @description
* This code is a modified version of rotate_bound found in python package imutils
* @link
* https://github.com/jrosebr1/imutils/blob/c12f15391fcc945d0d644b85194b8c044a392e0a/imutils/convenience.py#L41
*/
const M1 = [
[a[0], a[1], a[2] + newWidth / 2 - center.x],
[a[3], a[4], a[5] + newHeight / 2 - center.y],
];
const oneDimensionalArray = [].concat.apply([], M1);
const M = cv.matFromArray(2, 3, cv.CV_64FC1, oneDimensionalArray);
const height = newHeight, width = newWidth;
canvas.height = height;
canvas.width = width;
const irr = setPreviewPaneDimensions({
height,
width,
});
// this is the slowest step
cv.warpAffine(src, src, M, { height, width }, cv.INTER_LINEAR, cv.BORDER_CONSTANT, new cv.Scalar());
// if (mode !== "undo" && mode !== "redo") {
// showPreview(irr, dst, false);
// setMode?.(null);
// }
// setTimeout(() => {
// cv.imshow(canvas, dst);
// dst.delete();
// }, 0);
if (mode !== "undo" && mode !== "redo")
showPreview(irr, src, false);
setTimeout(() => {
// show the real preview first so it works faster for user
// due to this we must cleanup dst ourselves
// imshow is being called in showPreview, so for preview this would be redundant
if (!preview)
cv.imshow(canvas, src);
if (cleanup)
src.delete();
// finished, set the mode back to null
M.delete();
}, 0);
});
const scale = (cv, canvas, src, dims) => __awaiter(void 0, void 0, void 0, function* () {
const dsize = new cv.Size(dims.width, dims.height);
cv.resize(src, src, dsize, 0, 0, cv.INTER_AREA);
cv.imshow(canvas, src);
});
const handleModeChange = ({ romaine: { cv, setMode, romaine: { mode, clearHistory, angle, history, scale: newScale }, pushHistory, undo, }, _canvas: { canvasRef, canvasPtr, resetImage }, _preview: { previewRef, createPreview, setPreviewPaneDimensions }, }) => {
var _a, _b, _c, _d;
switch (mode) {
case "full-reset": {
clearHistory();
resetImage();
createPreview();
setMode === null || setMode === void 0 ? void 0 : setMode(null);
break;
}
case "undo": {
let waitingOnPointer = true;
if (history.pointer === 1) {
undo();
setMode === null || setMode === void 0 ? void 0 : setMode("preview");
return;
}
// if history.pointer -1 is 0, we're at the beginning of the history
if (history.pointer - 1 === 0) {
resetImage();
setMode === null || setMode === void 0 ? void 0 : setMode(null);
return;
}
for (let i = 0; i < history.pointer - 1; i++) {
if (!((_a = canvasPtr.current) === null || _a === void 0 ? void 0 : _a.$$.ptr)) {
continue;
}
waitingOnPointer = false;
switch (history.commands[i].cmd) {
case "rotate-left":
rotate(cv, canvasRef.current, mode, setPreviewPaneDimensions, createPreview, {
angle: history.commands[i].payload,
preview: false,
cleanup: false,
}, canvasPtr.current);
break;
case "rotate-right": {
rotate(cv, canvasRef.current, mode, setPreviewPaneDimensions, createPreview, {
angle: history.commands[i].payload,
preview: false,
cleanup: false,
}, canvasPtr.current);
break;
}
case "crop": {
cropOpenCV(cv, canvasPtr.current, history.commands[i].payload, setPreviewPaneDimensions({
width: canvasPtr.current.cols,
height: canvasPtr.current.rows,
}));
break;
}
case "perspective-crop":
warpPerspective(cv, canvasPtr.current, history.commands[i].payload, setPreviewPaneDimensions({
width: canvasPtr.current.cols,
height: canvasPtr.current.rows,
}));
break;
case "flip-horizontal":
flip(cv, canvasRef.current, canvasPtr.current, "horizontal");
break;
case "flip-vertical":
flip(cv, canvasRef.current, canvasPtr.current, "vertical");
break;
case "scale":
scale(cv, canvasRef.current, canvasPtr.current, newScale);
break;
}
}
if (!waitingOnPointer) {
undo();
setMode === null || setMode === void 0 ? void 0 : setMode("preview");
}
return;
}
case "crop": {
// cropping modes are handled in CroppingCanvas.tsx
break;
}
case "perspective-crop": {
// cropping modes are handled in CroppingCanvas.tsx
break;
}
case "flip-horizontal": {
if (canvasPtr.current) {
flip(cv, canvasRef.current, canvasPtr.current, "horizontal");