Freehand photo editor cropping mode (#1538)

This commit is contained in:
Vishnu Mohandas 2024-01-22 23:49:31 +05:30 committed by GitHub
commit 3b271768de
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 571 additions and 60 deletions

View file

@ -624,5 +624,8 @@
"FASTER_UPLOAD_DESCRIPTION": "Route uploads through nearby servers",
"MAGIC_SEARCH_STATUS": "Magic Search Status",
"INDEXED_ITEMS": "Indexed items",
"CACHE_DIRECTORY": "Cache folder"
"CACHE_DIRECTORY": "Cache folder",
"FREEHAND": "Freehand",
"APPLY_CROP": "Apply Crop",
"PHOTO_EDIT_REQUIRED_TO_SAVE": "At least one transformation or color adjustment must be performed before saving."
}

View file

@ -0,0 +1,133 @@
import { EnteMenuItem } from 'components/Menu/EnteMenuItem';
import { MenuItemGroup } from 'components/Menu/MenuItemGroup';
import MenuSectionTitle from 'components/Menu/MenuSectionTitle';
import { useContext } from 'react';
import { ImageEditorOverlayContext } from './';
import { CropBoxProps } from './';
import type { MutableRefObject } from 'react';
import { t } from 'i18next';
import CropIcon from '@mui/icons-material/Crop';
interface IProps {
previewScale: number;
cropBoxProps: CropBoxProps;
cropBoxRef: MutableRefObject<HTMLDivElement>;
resetCropBox: () => void;
}
export const cropRegionOfCanvas = (
canvas: HTMLCanvasElement,
topLeftX: number,
topLeftY: number,
bottomRightX: number,
bottomRightY: number,
scale: number = 1
) => {
const context = canvas.getContext('2d');
if (!context || !canvas) return;
context.imageSmoothingEnabled = false;
const width = (bottomRightX - topLeftX) * scale;
const height = (bottomRightY - topLeftY) * scale;
const img = new Image();
img.src = canvas.toDataURL();
img.onload = () => {
context.clearRect(0, 0, canvas.width, canvas.height);
canvas.width = width;
canvas.height = height;
context.drawImage(
img,
topLeftX,
topLeftY,
width,
height,
0,
0,
width,
height
);
};
};
export const getCropRegionArgs = (
cropBoxEle: HTMLDivElement,
canvasEle: HTMLCanvasElement
) => {
// get the bounding rectangle of the crop box
const cropBoxRect = cropBoxEle.getBoundingClientRect();
// Get the bounding rectangle of the canvas
const canvasRect = canvasEle.getBoundingClientRect();
// calculate the scale of the canvas display relative to its actual dimensions
const displayScale = canvasEle.width / canvasRect.width;
// calculate the coordinates of the crop box relative to the canvas and adjust for any scrolling by adding scroll offsets
const x1 =
(cropBoxRect.left - canvasRect.left + window.scrollX) * displayScale;
const y1 =
(cropBoxRect.top - canvasRect.top + window.scrollY) * displayScale;
const x2 = x1 + cropBoxRect.width * displayScale;
const y2 = y1 + cropBoxRect.height * displayScale;
return {
x1,
x2,
y1,
y2,
};
};
const CropMenu = (props: IProps) => {
const {
canvasRef,
originalSizeCanvasRef,
canvasLoading,
setCanvasLoading,
setTransformationPerformed,
setCurrentTab,
} = useContext(ImageEditorOverlayContext);
return (
<>
<MenuSectionTitle title={t('FREEHAND')} />
<MenuItemGroup
style={{
marginBottom: '0.5rem',
}}>
<EnteMenuItem
disabled={canvasLoading}
startIcon={<CropIcon />}
onClick={() => {
if (!props.cropBoxRef.current || !canvasRef.current)
return;
const { x1, x2, y1, y2 } = getCropRegionArgs(
props.cropBoxRef.current,
canvasRef.current
);
setCanvasLoading(true);
setTransformationPerformed(true);
cropRegionOfCanvas(canvasRef.current, x1, y1, x2, y2);
cropRegionOfCanvas(
originalSizeCanvasRef.current,
x1 / props.previewScale,
y1 / props.previewScale,
x2 / props.previewScale,
y2 / props.previewScale
);
props.resetCropBox();
setCanvasLoading(false);
setCurrentTab('transform');
}}
label={t('APPLY_CROP')}
/>
</MenuItemGroup>
</>
);
};
export default CropMenu;

View file

@ -0,0 +1,118 @@
import { CropBoxProps } from './';
import type { Ref, Dispatch, SetStateAction, CSSProperties } from 'react';
import { forwardRef } from 'react';
const handleStyle: CSSProperties = {
position: 'absolute',
height: '10px',
width: '10px',
backgroundColor: 'white',
border: '1px solid black',
};
const seHandleStyle: CSSProperties = {
...handleStyle,
right: '-5px',
bottom: '-5px',
cursor: 'se-resize',
};
interface IProps {
cropBox: CropBoxProps;
setIsDragging: Dispatch<SetStateAction<boolean>>;
}
const FreehandCropRegion = forwardRef(
(props: IProps, ref: Ref<HTMLDivElement>) => {
return (
<>
{/* Top overlay */}
<div
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: props.cropBox.y + 'px', // height up to the top of the crop box
backgroundColor: 'rgba(0,0,0,0.5)',
pointerEvents: 'none',
}}></div>
{/* Bottom overlay */}
<div
style={{
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
height: `calc(100% - ${
props.cropBox.y + props.cropBox.height
}px)`, // height from the bottom of the crop box to the bottom of the canvas
backgroundColor: 'rgba(0,0,0,0.5)',
pointerEvents: 'none',
}}></div>
{/* Left overlay */}
<div
style={{
position: 'absolute',
top: props.cropBox.y + 'px',
left: 0,
width: props.cropBox.x + 'px', // width up to the left side of the crop box
height: props.cropBox.height + 'px', // same height as the crop box
backgroundColor: 'rgba(0,0,0,0.5)',
pointerEvents: 'none',
}}></div>
{/* Right overlay */}
<div
style={{
position: 'absolute',
top: props.cropBox.y + 'px',
right: 0,
width: `calc(100% - ${
props.cropBox.x + props.cropBox.width
}px)`, // width from the right side of the crop box to the right side of the canvas
height: props.cropBox.height + 'px', // same height as the crop box
backgroundColor: 'rgba(0,0,0,0.5)',
pointerEvents: 'none',
}}></div>
<div
style={{
display: 'grid',
position: 'absolute',
left: props.cropBox.x + 'px',
top: props.cropBox.y + 'px',
width: props.cropBox.width + 'px',
height: props.cropBox.height + 'px',
border: '1px solid white',
gridTemplateColumns: '1fr 1fr 1fr',
gridTemplateRows: '1fr 1fr 1fr',
gap: '0px',
zIndex: 30, // make sure the crop box is above the overlays
}}
ref={ref}>
{Array.from({ length: 9 }).map((_, index) => (
<div
key={index}
style={{
border: '1px solid white',
boxSizing: 'border-box',
pointerEvents: 'none',
}}></div>
))}
<div
style={seHandleStyle}
onMouseDown={(e) => {
e.preventDefault();
props.setIsDragging(true);
}}></div>
</div>
</>
);
}
);
export default FreehandCropRegion;

View file

@ -89,6 +89,7 @@ const TransformMenu = () => {
);
};
};
const flipCanvas = (
canvas: HTMLCanvasElement,
direction: 'vertical' | 'horizontal'

View file

@ -29,6 +29,7 @@ import mime from 'mime-types';
import CloseIcon from '@mui/icons-material/Close';
import { HorizontalFlex } from '@ente/shared/components/Container';
import TransformMenu from './TransformMenu';
import CropMenu from './CropMenu';
import ColoursMenu from './ColoursMenu';
import { FileWithCollection } from 'types/upload';
import uploadManager from 'services/upload/uploadManager';
@ -44,6 +45,12 @@ import { getEditorCloseConfirmationMessage } from 'utils/ui';
import { logError } from '@ente/shared/sentry';
import { getFileType } from 'services/typeDetectionService';
import { downloadUsingAnchor } from '@ente/shared/utils';
import { CORNER_THRESHOLD, FILTER_DEFAULT_VALUES } from 'constants/photoEditor';
import FreehandCropRegion from './FreehandCropRegion';
import EnteButton from '@ente/shared/components/EnteButton';
import { CenteredFlex } from '@ente/shared/components/Container';
import CropIcon from '@mui/icons-material/Crop';
import { cropRegionOfCanvas, getCropRegionArgs } from './CropMenu';
interface IProps {
file: EnteFile;
@ -59,16 +66,11 @@ export const ImageEditorOverlayContext = createContext(
setTransformationPerformed: Dispatch<SetStateAction<boolean>>;
setCanvasLoading: Dispatch<SetStateAction<boolean>>;
canvasLoading: boolean;
setCurrentTab: Dispatch<SetStateAction<OperationTab>>;
}
);
const filterDefaultValues = {
brightness: 100,
contrast: 100,
blur: 0,
saturation: 100,
invert: false,
};
type OperationTab = 'crop' | 'transform' | 'colours';
const getEditedFileName = (fileName: string) => {
const fileNameParts = fileName.split('.');
@ -77,6 +79,13 @@ const getEditedFileName = (fileName: string) => {
return editedFileName;
};
export interface CropBoxProps {
x: number;
y: number;
width: number;
height: number;
}
const ImageEditorOverlay = (props: IProps) => {
const appContext = useContext(AppContext);
@ -88,19 +97,17 @@ const ImageEditorOverlay = (props: IProps) => {
const [currentRotationAngle, setCurrentRotationAngle] = useState(0);
const [currentTab, setCurrentTab] = useState<'transform' | 'colours'>(
'transform'
);
const [currentTab, setCurrentTab] = useState<OperationTab>('transform');
const [brightness, setBrightness] = useState(
filterDefaultValues.brightness
FILTER_DEFAULT_VALUES.brightness
);
const [contrast, setContrast] = useState(filterDefaultValues.contrast);
const [blur, setBlur] = useState(filterDefaultValues.blur);
const [contrast, setContrast] = useState(FILTER_DEFAULT_VALUES.contrast);
const [blur, setBlur] = useState(FILTER_DEFAULT_VALUES.blur);
const [saturation, setSaturation] = useState(
filterDefaultValues.saturation
FILTER_DEFAULT_VALUES.saturation
);
const [invert, setInvert] = useState(filterDefaultValues.invert);
const [invert, setInvert] = useState(FILTER_DEFAULT_VALUES.invert);
const [transformationPerformed, setTransformationPerformed] =
useState(false);
@ -110,6 +117,149 @@ const ImageEditorOverlay = (props: IProps) => {
const [showControlsDrawer, setShowControlsDrawer] = useState(true);
const [previewCanvasScale, setPreviewCanvasScale] = useState(0);
const [cropBox, setCropBox] = useState<CropBoxProps>({
x: 0,
y: 0,
width: 100,
height: 100,
});
const [startX, setStartX] = useState(0);
const [startY, setStartY] = useState(0);
const [beforeGrowthHeight, setBeforeGrowthHeight] = useState(0);
const [beforeGrowthWidth, setBeforeGrowthWidth] = useState(0);
const [isDragging, setIsDragging] = useState(false);
const [isGrowing, setIsGrowing] = useState(false);
const cropBoxRef = useRef<HTMLDivElement>(null);
const getCanvasBoundsOffsets = () => {
const canvasBounds = {
height: canvasRef.current.height,
width: canvasRef.current.width,
};
const parentBounds = parentRef.current.getBoundingClientRect();
// calculate the offset created by centering the canvas in its parent
const offsetX = (parentBounds.width - canvasBounds.width) / 2;
const offsetY = (parentBounds.height - canvasBounds.height) / 2;
return {
offsetY,
offsetX,
canvasBounds,
parentBounds,
};
};
const handleDragStart = (e) => {
if (currentTab !== 'crop') return;
const rect = cropBoxRef.current.getBoundingClientRect();
const offsetX = e.pageX - rect.left - rect.width / 2;
const offsetY = e.pageY - rect.top - rect.height / 2;
// check if the cursor is near the corners of the box
const isNearLeftOrRightEdge =
e.pageX < rect.left + CORNER_THRESHOLD ||
e.pageX > rect.right - CORNER_THRESHOLD;
const isNearTopOrBottomEdge =
e.pageY < rect.top + CORNER_THRESHOLD ||
e.pageY > rect.bottom - CORNER_THRESHOLD;
if (isNearLeftOrRightEdge && isNearTopOrBottomEdge) {
// cursor is near a corner, do not initiate dragging
setIsGrowing(true);
setStartX(e.pageX);
setStartY(e.pageY);
setBeforeGrowthWidth(cropBox.width);
setBeforeGrowthHeight(cropBox.height);
return;
}
setIsDragging(true);
setStartX(e.pageX - offsetX);
setStartY(e.pageY - offsetY);
};
const handleDrag = (e) => {
if (!isDragging && !isGrowing) return;
// d- variables are the delta change between start and now
const dx = e.pageX - startX;
const dy = e.pageY - startY;
const { offsetX, offsetY, canvasBounds } = getCanvasBoundsOffsets();
if (isGrowing) {
setCropBox((prev) => {
const newWidth = Math.min(
beforeGrowthWidth + dx,
canvasBounds.width - prev.x + offsetX
);
const newHeight = Math.min(
beforeGrowthHeight + dy,
canvasBounds.height - prev.y + offsetY
);
return {
...prev,
width: newWidth,
height: newHeight,
};
});
} else {
setCropBox((prev) => {
let newX = prev.x + dx;
let newY = prev.y + dy;
// constrain the new position to the canvas boundaries, accounting for the offset
newX = Math.max(
offsetX,
Math.min(newX, offsetX + canvasBounds.width - prev.width)
);
newY = Math.max(
offsetY,
Math.min(newY, offsetY + canvasBounds.height - prev.height)
);
return {
...prev,
x: newX,
y: newY,
};
});
setStartX(e.pageX);
setStartY(e.pageY);
}
};
const handleDragEnd = () => {
setStartX(0);
setStartY(0);
setIsGrowing(false);
setIsDragging(false);
};
const resetCropBox = () => {
setCropBox((prev) => {
const { offsetX, offsetY, canvasBounds } = getCanvasBoundsOffsets();
return {
...prev,
x: offsetX,
y: offsetY,
height: canvasBounds.height,
width: canvasBounds.width,
};
});
};
useEffect(() => {
if (!canvasRef.current) {
return;
@ -117,17 +267,23 @@ const ImageEditorOverlay = (props: IProps) => {
try {
applyFilters([canvasRef.current, originalSizeCanvasRef.current]);
setColoursAdjusted(
brightness !== filterDefaultValues.brightness ||
contrast !== filterDefaultValues.contrast ||
blur !== filterDefaultValues.blur ||
saturation !== filterDefaultValues.saturation ||
invert !== filterDefaultValues.invert
brightness !== FILTER_DEFAULT_VALUES.brightness ||
contrast !== FILTER_DEFAULT_VALUES.contrast ||
blur !== FILTER_DEFAULT_VALUES.blur ||
saturation !== FILTER_DEFAULT_VALUES.saturation ||
invert !== FILTER_DEFAULT_VALUES.invert
);
} catch (e) {
logError(e, 'Error applying filters');
}
}, [brightness, contrast, blur, saturation, invert, canvasRef, fileURL]);
useEffect(() => {
if (currentTab !== 'crop') return;
resetCropBox();
setShowControlsDrawer(false);
}, [currentTab]);
const applyFilters = async (canvases: HTMLCanvasElement[]) => {
try {
for (const canvas of canvases) {
@ -203,6 +359,7 @@ const ImageEditorOverlay = (props: IProps) => {
}
setCanvasLoading(true);
resetFilters();
setCurrentRotationAngle(0);
@ -226,6 +383,7 @@ const ImageEditorOverlay = (props: IProps) => {
parentRef.current.clientWidth / img.width,
parentRef.current.clientHeight / img.height
);
setPreviewCanvasScale(scale);
const width = img.width * scale;
const height = img.height * scale;
@ -246,6 +404,13 @@ const ImageEditorOverlay = (props: IProps) => {
setColoursAdjusted(false);
setCanvasLoading(false);
resetCropBox();
setStartX(0);
setStartY(0);
setIsDragging(false);
setIsGrowing(false);
resolve(true);
} catch (e) {
reject(e);
@ -387,35 +552,97 @@ const ImageEditorOverlay = (props: IProps) => {
boxSizing={'border-box'}
display="flex"
alignItems="center"
justifyContent="center">
justifyContent="center"
position="relative"
onMouseUp={handleDragEnd}
onMouseMove={isDragging ? handleDrag : null}
onMouseDown={handleDragStart}>
<Box
height="90%"
width="100%"
ref={parentRef}
display="flex"
alignItems="center"
justifyContent="center">
{(fileURL === null || canvasLoading) && (
<CircularProgress />
)}
style={{
position: 'relative',
width: '100%',
height: '100%',
}}>
<Box
height="90%"
width="100%"
ref={parentRef}
display="flex"
alignItems="center"
justifyContent="center"
position="relative">
{(fileURL === null || canvasLoading) && (
<CircularProgress />
)}
<canvas
ref={canvasRef}
style={{
objectFit: 'contain',
display:
fileURL === null || canvasLoading
? 'none'
: 'block',
position: 'absolute',
}}
/>
<canvas
ref={originalSizeCanvasRef}
style={{
display: 'none',
}}
/>
<canvas
ref={canvasRef}
style={{
objectFit: 'contain',
display:
fileURL === null || canvasLoading
? 'none'
: 'block',
position: 'absolute',
}}
/>
<canvas
ref={originalSizeCanvasRef}
style={{
display: 'none',
}}
/>
{currentTab === 'crop' && (
<FreehandCropRegion
cropBox={cropBox}
ref={cropBoxRef}
setIsDragging={setIsDragging}
/>
)}
</Box>
{currentTab === 'crop' && (
<CenteredFlex marginTop="1rem">
<EnteButton
color="accent"
startIcon={<CropIcon />}
onClick={() => {
if (
!cropBoxRef.current ||
!canvasRef.current
)
return;
const { x1, x2, y1, y2 } =
getCropRegionArgs(
cropBoxRef.current,
canvasRef.current
);
setCanvasLoading(true);
setTransformationPerformed(true);
cropRegionOfCanvas(
canvasRef.current,
x1,
y1,
x2,
y2
);
cropRegionOfCanvas(
originalSizeCanvasRef.current,
x1 / previewCanvasScale,
y1 / previewCanvasScale,
x2 / previewCanvasScale,
y2 / previewCanvasScale
);
resetCropBox();
setCanvasLoading(false);
setCurrentTab('transform');
}}>
{t('APPLY_CROP')}
</EnteButton>
</CenteredFlex>
)}
</Box>
</Box>
</Box>
@ -441,6 +668,7 @@ const ImageEditorOverlay = (props: IProps) => {
onChange={(_, value) => {
setCurrentTab(value);
}}>
<Tab label={t('CROP')} value="crop" />
<Tab label={t('TRANSFORM')} value="transform" />
<Tab
label={t('COLORS')}
@ -463,18 +691,25 @@ const ImageEditorOverlay = (props: IProps) => {
label={t('RESTORE_ORIGINAL')}
/>
</MenuItemGroup>
{currentTab === 'transform' && (
<ImageEditorOverlayContext.Provider
value={{
originalSizeCanvasRef,
canvasRef,
setCanvasLoading,
canvasLoading,
setTransformationPerformed,
}}>
<TransformMenu />
</ImageEditorOverlayContext.Provider>
)}
<ImageEditorOverlayContext.Provider
value={{
originalSizeCanvasRef,
canvasRef,
setCanvasLoading,
canvasLoading,
setTransformationPerformed,
setCurrentTab,
}}>
{currentTab === 'crop' && (
<CropMenu
previewScale={previewCanvasScale}
cropBoxProps={cropBox}
cropBoxRef={cropBoxRef}
resetCropBox={resetCropBox}
/>
)}
{currentTab === 'transform' && <TransformMenu />}
</ImageEditorOverlayContext.Provider>
{currentTab === 'colours' && (
<ColoursMenu
brightness={brightness}
@ -495,14 +730,25 @@ const ImageEditorOverlay = (props: IProps) => {
startIcon={<DownloadIcon />}
onClick={downloadEditedPhoto}
label={t('DOWNLOAD_EDITED')}
disabled={
!transformationPerformed && !coloursAdjusted
}
/>
<MenuItemDivider />
<EnteMenuItem
startIcon={<CloudUploadIcon />}
onClick={saveCopyToEnte}
label={t('SAVE_A_COPY_TO_ENTE')}
disabled={
!transformationPerformed && !coloursAdjusted
}
/>
</MenuItemGroup>
{!transformationPerformed && !coloursAdjusted && (
<MenuSectionTitle
title={t('PHOTO_EDIT_REQUIRED_TO_SAVE')}
/>
)}
</EnteDrawer>
</Backdrop>
</>

View file

@ -0,0 +1,10 @@
export const FILTER_DEFAULT_VALUES = {
brightness: 100,
contrast: 100,
blur: 0,
saturation: 100,
invert: false,
};
// CORNER_THRESHOLD defines the threshold near the corners of the crop box in which dragging is assumed as not the intention
export const CORNER_THRESHOLD = 20;