Merge branch 'main' into file-download-location

This commit is contained in:
Abhinav 2024-01-24 13:52:44 +05:30
commit edfdc5dd1e
39 changed files with 1121 additions and 331 deletions

View file

@ -82,7 +82,9 @@ An important part of our journey is to build better software by consistently lis
<br/>
---
## 🙇 Attributions
Cross-browser testing provided by
- Cross-browser testing provided by
[<img src="https://d98b8t1nnulk5.cloudfront.net/production/images/layout/logo-header.png?1469004780" width="115" height="25">](https://www.browserstack.com/open-source)
- Location search powered by [Simple Maps](https://simplemaps.com/data/world-cities)

View file

@ -206,6 +206,7 @@
"SEARCH_TYPE": {
"COLLECTION": "Album",
"LOCATION": "Location",
"CITY": "Location",
"DATE": "Date",
"FILE_NAME": "File name",
"THING": "Content",

View file

@ -624,5 +624,8 @@
"FASTER_UPLOAD_DESCRIPTION": "",
"MAGIC_SEARCH_STATUS": "",
"INDEXED_ITEMS": "",
"CACHE_DIRECTORY": ""
"CACHE_DIRECTORY": "",
"FREEHAND": "",
"APPLY_CROP": "",
"PHOTO_EDIT_REQUIRED_TO_SAVE": ""
}

View file

@ -210,6 +210,7 @@
"SEARCH_TYPE": {
"COLLECTION": "Album",
"LOCATION": "Location",
"CITY": "Location",
"DATE": "Date",
"FILE_NAME": "File name",
"THING": "Content",
@ -624,5 +625,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

@ -624,5 +624,8 @@
"FASTER_UPLOAD_DESCRIPTION": "",
"MAGIC_SEARCH_STATUS": "",
"INDEXED_ITEMS": "",
"CACHE_DIRECTORY": ""
"CACHE_DIRECTORY": "",
"FREEHAND": "",
"APPLY_CROP": "",
"PHOTO_EDIT_REQUIRED_TO_SAVE": ""
}

View file

@ -624,5 +624,8 @@
"FASTER_UPLOAD_DESCRIPTION": "",
"MAGIC_SEARCH_STATUS": "",
"INDEXED_ITEMS": "",
"CACHE_DIRECTORY": ""
"CACHE_DIRECTORY": "",
"FREEHAND": "",
"APPLY_CROP": "",
"PHOTO_EDIT_REQUIRED_TO_SAVE": ""
}

View file

@ -624,5 +624,8 @@
"FASTER_UPLOAD_DESCRIPTION": "",
"MAGIC_SEARCH_STATUS": "",
"INDEXED_ITEMS": "",
"CACHE_DIRECTORY": ""
"CACHE_DIRECTORY": "",
"FREEHAND": "",
"APPLY_CROP": "",
"PHOTO_EDIT_REQUIRED_TO_SAVE": ""
}

View file

@ -624,5 +624,8 @@
"FASTER_UPLOAD_DESCRIPTION": "Router les chargements vers les serveurs à proximité",
"MAGIC_SEARCH_STATUS": "",
"INDEXED_ITEMS": "Éléments indexés",
"CACHE_DIRECTORY": ""
"CACHE_DIRECTORY": "",
"FREEHAND": "",
"APPLY_CROP": "",
"PHOTO_EDIT_REQUIRED_TO_SAVE": ""
}

View file

@ -624,5 +624,8 @@
"FASTER_UPLOAD_DESCRIPTION": "",
"MAGIC_SEARCH_STATUS": "",
"INDEXED_ITEMS": "",
"CACHE_DIRECTORY": ""
"CACHE_DIRECTORY": "",
"FREEHAND": "",
"APPLY_CROP": "",
"PHOTO_EDIT_REQUIRED_TO_SAVE": ""
}

View file

@ -624,5 +624,8 @@
"FASTER_UPLOAD_DESCRIPTION": "Uploaden door nabije servers",
"MAGIC_SEARCH_STATUS": "Magische Zoekfunctie Status",
"INDEXED_ITEMS": "Geïndexeerde bestanden",
"CACHE_DIRECTORY": "Cache map"
"CACHE_DIRECTORY": "Cache map",
"FREEHAND": "",
"APPLY_CROP": "",
"PHOTO_EDIT_REQUIRED_TO_SAVE": ""
}

View file

@ -624,5 +624,8 @@
"FASTER_UPLOAD_DESCRIPTION": "",
"MAGIC_SEARCH_STATUS": "",
"INDEXED_ITEMS": "",
"CACHE_DIRECTORY": ""
"CACHE_DIRECTORY": "",
"FREEHAND": "",
"APPLY_CROP": "",
"PHOTO_EDIT_REQUIRED_TO_SAVE": ""
}

View file

@ -624,5 +624,8 @@
"FASTER_UPLOAD_DESCRIPTION": "",
"MAGIC_SEARCH_STATUS": "",
"INDEXED_ITEMS": "",
"CACHE_DIRECTORY": ""
"CACHE_DIRECTORY": "",
"FREEHAND": "",
"APPLY_CROP": "",
"PHOTO_EDIT_REQUIRED_TO_SAVE": ""
}

View file

@ -624,5 +624,8 @@
"FASTER_UPLOAD_DESCRIPTION": "",
"MAGIC_SEARCH_STATUS": "",
"INDEXED_ITEMS": "",
"CACHE_DIRECTORY": ""
"CACHE_DIRECTORY": "",
"FREEHAND": "",
"APPLY_CROP": "",
"PHOTO_EDIT_REQUIRED_TO_SAVE": ""
}

View file

@ -624,5 +624,8 @@
"FASTER_UPLOAD_DESCRIPTION": "通过附近的服务器路由上传",
"MAGIC_SEARCH_STATUS": "魔法搜索状态",
"INDEXED_ITEMS": "索引项目",
"CACHE_DIRECTORY": "缓存文件夹"
"CACHE_DIRECTORY": "缓存文件夹",
"FREEHAND": "手画",
"APPLY_CROP": "应用裁剪",
"PHOTO_EDIT_REQUIRED_TO_SAVE": "保存之前必须至少执行一项转换或颜色调整。"
}

View file

@ -55,8 +55,8 @@ interface Props {
selected: SelectedState | ((selected: SelectedState) => SelectedState)
) => void;
selected: SelectedState;
deletedFileIds?: Set<number>;
setDeletedFileIds?: (value: Set<number>) => void;
tempDeletedFileIds?: Set<number>;
setTempDeletedFileIds?: (value: Set<number>) => void;
activeCollectionID: number;
enableDownload?: boolean;
fileToCollectionsMap: Map<number, number[]>;
@ -75,8 +75,8 @@ const PhotoFrame = ({
favItemIds,
setSelected,
selected,
deletedFileIds,
setDeletedFileIds,
tempDeletedFileIds,
setTempDeletedFileIds,
activeCollectionID,
enableDownload,
fileToCollectionsMap,
@ -599,8 +599,8 @@ const PhotoFrame = ({
gettingData={getSlideData}
getConvertedItem={getConvertedItem}
favItemIds={favItemIds}
deletedFileIds={deletedFileIds}
setDeletedFileIds={setDeletedFileIds}
tempDeletedFileIds={tempDeletedFileIds}
setTempDeletedFileIds={setTempDeletedFileIds}
isTrashCollection={activeCollectionID === TRASH_SECTION}
isInHiddenSection={isInHiddenSection}
enableDownload={enableDownload}

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

@ -86,8 +86,8 @@ interface Iprops {
id?: string;
className?: string;
favItemIds: Set<number>;
deletedFileIds: Set<number>;
setDeletedFileIds?: (value: Set<number>) => void;
tempDeletedFileIds: Set<number>;
setTempDeletedFileIds?: (value: Set<number>) => void;
isTrashCollection: boolean;
isInHiddenSection: boolean;
enableDownload: boolean;
@ -493,13 +493,13 @@ function PhotoViewer(props: Iprops) {
};
const trashFile = async (file: EnteFile) => {
const { deletedFileIds, setDeletedFileIds } = props;
const { tempDeletedFileIds, setTempDeletedFileIds } = props;
try {
appContext.startLoading();
await trashFiles([file]);
appContext.finishLoading();
deletedFileIds.add(file.id);
setDeletedFileIds(new Set(deletedFileIds));
tempDeletedFileIds.add(file.id);
setTempDeletedFileIds(new Set(tempDeletedFileIds));
updateItems(props.items.filter((item) => item.id !== file.id));
needUpdate.current = true;
} catch (e) {

View file

@ -1,13 +1,7 @@
import { IconButton } from '@mui/material';
import pDebounce from 'p-debounce';
import { AppContext } from 'pages/_app';
import React, {
useCallback,
useContext,
useEffect,
useRef,
useState,
} from 'react';
import { useCallback, useContext, useEffect, useRef, useState } from 'react';
import {
getAutoCompleteSuggestions,
getDefaultOptions,
@ -34,6 +28,9 @@ import { t } from 'i18next';
import memoize from 'memoize-one';
import { LocationTagData } from 'types/entity';
import { FILE_TYPE } from 'constants/file';
import { InputActionMeta } from 'react-select/src/types';
import { components } from 'react-select';
import { City } from 'services/locationSearchService';
interface Iprops {
isOpen: boolean;
@ -43,20 +40,33 @@ interface Iprops {
collections: Collection[];
}
const createComponents = memoize((Option, ValueContainer, Menu) => ({
const createComponents = memoize((Option, ValueContainer, Menu, Input) => ({
Option,
ValueContainer,
Menu,
Input,
}));
const VisibleInput = (props) => (
<components.Input {...props} isHidden={false} />
);
export default function SearchInput(props: Iprops) {
const selectRef = useRef(null);
const [value, setValue] = useState<SearchOption>(null);
const appContext = useContext(AppContext);
const handleChange = (value: SearchOption) => {
setValue(value);
setQuery(value.label);
blur();
};
const handleInputChange = (value: string, actionMeta: InputActionMeta) => {
if (actionMeta.action === 'input-change') {
setQuery(value);
}
};
const [defaultOptions, setDefaultOptions] = useState([]);
const [query, setQuery] = useState('');
useEffect(() => {
search(value);
@ -69,7 +79,7 @@ export default function SearchInput(props: Iprops) {
}, []);
async function refreshDefaultOptions() {
const defaultOptions = await getDefaultOptions(props.files);
const defaultOptions = await getDefaultOptions();
setDefaultOptions(defaultOptions);
}
@ -82,14 +92,22 @@ export default function SearchInput(props: Iprops) {
}, 10);
props.setIsOpen(false);
setValue(null);
setQuery('');
}
};
const getOptions = pDebounce(
getAutoCompleteSuggestions(props.files, props.collections),
250
const getOptions = useCallback(
pDebounce(
getAutoCompleteSuggestions(props.files, props.collections),
250
),
[props.files, props.collections]
);
const blur = () => {
selectRef.current?.blur();
};
const search = (selectedOption: SearchOption) => {
if (!selectedOption) {
return;
@ -108,9 +126,16 @@ export default function SearchInput(props: Iprops) {
};
props.setIsOpen(true);
break;
case SuggestionType.CITY:
search = {
city: selectedOption.value as City,
};
props.setIsOpen(true);
break;
case SuggestionType.COLLECTION:
search = { collection: selectedOption.value as number };
setValue(null);
setQuery('');
break;
case SuggestionType.FILE_NAME:
search = { files: selectedOption.value as number[] };
@ -161,7 +186,8 @@ export default function SearchInput(props: Iprops) {
const components = createComponents(
OptionWithInfo,
ValueContainerWithIcon,
MemoizedMenuWithPeople
MemoizedMenuWithPeople,
VisibleInput
);
return (
@ -175,6 +201,8 @@ export default function SearchInput(props: Iprops) {
onChange={handleChange}
onFocus={handleOnFocus}
isClearable
inputValue={query}
onInputChange={handleInputChange}
escapeClearsValue
styles={SelectStyles}
defaultOptions={

View file

@ -17,6 +17,7 @@ const getIconByType = (type: SuggestionType) => {
case SuggestionType.DATE:
return <CalendarIcon />;
case SuggestionType.LOCATION:
case SuggestionType.CITY:
return <LocationIcon />;
case SuggestionType.COLLECTION:
return <FolderIcon />;

View file

@ -391,7 +391,7 @@ export default function Uploader(props: Props) {
) => {
try {
addLogLine(
`upload file to an existing collection - "${collection.name}"`
`upload file to an existing collection name:${collection.name}, collectionID:${collection.id}`
);
await preCollectionCreationAction();
const filesWithCollectionToUpload: FileWithCollection[] =

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;

View file

@ -122,7 +122,6 @@ import GalleryEmptyState from 'components/GalleryEmptyState';
import AuthenticateUserModal from 'components/AuthenticateUserModal';
import useMemoSingleThreaded from '@ente/shared/hooks/useMemoSingleThreaded';
import { isArchivedFile } from 'utils/magicMetadata';
import { isSameDayAnyYear, isInsideLocationTag } from 'utils/search';
import { getSessionExpiredMessage } from 'utils/ui';
import { syncEntities } from 'services/entityService';
import { constructUserIDToEmailMap } from 'services/collectionService';
@ -137,6 +136,9 @@ import {
FilesDownloadProgress,
FilesDownloadProgressAttributes,
} from 'components/FilesDownloadProgress';
import locationSearchService from 'services/locationSearchService';
import ComlinkSearchWorker from 'utils/comlink/ComlinkSearchWorker';
import useEffectSingleThreaded from '@ente/shared/hooks/useEffectSingleThreaded';
export const DeadCenter = styled('div')`
flex: 1;
@ -231,10 +233,11 @@ export default function Gallery() {
const syncInProgress = useRef(true);
const syncInterval = useRef<NodeJS.Timeout>();
const resync = useRef<{ force: boolean; silent: boolean }>();
const [deletedFileIds, setDeletedFileIds] = useState<Set<number>>(
// tempDeletedFileIds and tempHiddenFileIds are used to keep track of files that are deleted/hidden in the current session but not yet synced with the server.
const [tempDeletedFileIds, setTempDeletedFileIds] = useState<Set<number>>(
new Set<number>()
);
const [hiddenFileIds, setHiddenFileIds] = useState<Set<number>>(
const [tempHiddenFileIds, setTempHiddenFileIds] = useState<Set<number>>(
new Set<number>()
);
const { startLoading, finishLoading, setDialogMessage, ...appContext } =
@ -248,6 +251,9 @@ export default function Gallery() {
const [emailList, setEmailList] = useState<string[]>(null);
const [activeCollectionID, setActiveCollectionID] =
useState<number>(undefined);
const [hiddenFileIds, setHiddenFileIds] = useState<Set<number>>(
new Set<number>()
);
const [fixCreationTimeView, setFixCreationTimeView] = useState(false);
const [fixCreationTimeAttributes, setFixCreationTimeAttributes] =
useState<FixCreationTimeAttributes>(null);
@ -352,6 +358,7 @@ export default function Gallery() {
setIsFirstLoad(false);
setJustSignedUp(false);
setIsFirstFetch(false);
locationSearchService.loadCities();
syncInterval.current = setInterval(() => {
syncWithRemote(false, true);
}, SYNC_INTERVAL_IN_MICROSECONDS);
@ -372,6 +379,14 @@ export default function Gallery() {
};
}, []);
useEffectSingleThreaded(
async ([files]: [files: EnteFile[]]) => {
const searchWorker = await ComlinkSearchWorker.getInstance();
await searchWorker.setFiles(files);
},
[files]
);
useEffect(() => {
if (!user || !files || !collections || !hiddenFiles || !trashedFiles) {
return;
@ -477,7 +492,9 @@ export default function Gallery() {
);
}, [collections, activeCollectionID]);
const filteredData = useMemoSingleThreaded((): EnteFile[] => {
const filteredData = useMemoSingleThreaded(async (): Promise<
EnteFile[]
> => {
if (
!files ||
!user ||
@ -491,117 +508,74 @@ export default function Gallery() {
if (activeCollectionID === TRASH_SECTION && !isInSearchMode) {
return getUniqueFiles([
...trashedFiles,
...files.filter((file) => deletedFileIds?.has(file.id)),
...files.filter((file) => tempDeletedFileIds?.has(file.id)),
]);
}
const filteredFiles = getUniqueFiles(
(isInHiddenSection ? hiddenFiles : files).filter((item) => {
if (deletedFileIds?.has(item.id)) {
return false;
}
const searchWorker = await ComlinkSearchWorker.getInstance();
if (!isInHiddenSection && hiddenFileIds?.has(item.id)) {
return false;
}
let filteredFiles: EnteFile[] = [];
if (isInSearchMode) {
filteredFiles = getUniqueFiles(await searchWorker.search(search));
} else {
filteredFiles = getUniqueFiles(
(isInHiddenSection ? hiddenFiles : files).filter((item) => {
if (tempDeletedFileIds?.has(item.id)) {
return false;
}
// SEARCH MODE
if (isInSearchMode) {
if (
search?.date &&
!isSameDayAnyYear(search.date)(
new Date(item.metadata.creationTime / 1000)
)
) {
if (!isInHiddenSection && tempHiddenFileIds?.has(item.id)) {
return false;
}
if (
search?.location &&
!isInsideLocationTag(
{
latitude: item.metadata.latitude,
longitude: item.metadata.longitude,
},
search.location
)
) {
return false;
}
if (
search?.person &&
search.person.files.indexOf(item.id) === -1
) {
return false;
}
if (
search?.thing &&
search.thing.files.indexOf(item.id) === -1
) {
return false;
}
if (
search?.text &&
search.text.files.indexOf(item.id) === -1
) {
return false;
}
if (search?.files && search.files.indexOf(item.id) === -1) {
return false;
}
if (
typeof search?.fileType !== 'undefined' &&
search.fileType !== item.metadata.fileType
) {
return false;
}
if (search?.clip && search.clip.has(item.id) === false) {
return false;
}
return true;
}
// archived collections files can only be seen in their respective collection
if (archivedCollections.has(item.collectionID)) {
// archived collections files can only be seen in their respective collection
if (archivedCollections.has(item.collectionID)) {
if (activeCollectionID === item.collectionID) {
return true;
} else {
return false;
}
}
// HIDDEN ITEMS SECTION - show all individual hidden files
if (
activeCollectionID === HIDDEN_ITEMS_SECTION &&
defaultHiddenCollectionIDs.has(item.collectionID)
) {
return true;
}
// Archived files can only be seen in archive section or their respective collection
if (isArchivedFile(item)) {
if (
activeCollectionID === ARCHIVE_SECTION ||
activeCollectionID === item.collectionID
) {
return true;
} else {
return false;
}
}
// ALL SECTION - show all files
if (activeCollectionID === ALL_SECTION) {
// show all files except the ones in hidden collections
if (hiddenFileIds.has(item.id)) {
return false;
} else {
return true;
}
}
// COLLECTION SECTION - show files in the active collection
if (activeCollectionID === item.collectionID) {
return true;
} else {
return false;
}
}
// HIDDEN ITEMS SECTION - show all individual hidden files
if (
activeCollectionID === HIDDEN_ITEMS_SECTION &&
defaultHiddenCollectionIDs.has(item.collectionID)
) {
return true;
}
// Archived files can only be seen in archive section or their respective collection
if (isArchivedFile(item)) {
if (
activeCollectionID === ARCHIVE_SECTION ||
activeCollectionID === item.collectionID
) {
return true;
} else {
return false;
}
}
// ALL SECTION - show all files
if (activeCollectionID === ALL_SECTION) {
return true;
}
// COLLECTION SECTION - show files in the active collection
if (activeCollectionID === item.collectionID) {
return true;
} else {
return false;
}
})
);
})
);
}
if (search?.clip) {
return filteredFiles.sort((a, b) => {
return search.clip.get(b.id) - search.clip.get(a.id);
@ -617,7 +591,8 @@ export default function Gallery() {
files,
trashedFiles,
hiddenFiles,
deletedFileIds,
tempDeletedFileIds,
tempHiddenFileIds,
hiddenFileIds,
search,
activeCollectionID,
@ -748,8 +723,8 @@ export default function Gallery() {
logError(e, 'syncWithRemote failed');
}
} finally {
setDeletedFileIds(new Set());
setHiddenFileIds(new Set());
setTempDeletedFileIds(new Set());
setTempHiddenFileIds(new Set());
!silent && finishLoading();
}
syncInProgress.current = false;
@ -794,6 +769,8 @@ export default function Gallery() {
const defaultHiddenCollectionIDs =
getDefaultHiddenCollectionIDs(hiddenCollections);
setDefaultHiddenCollectionIDs(defaultHiddenCollectionIDs);
const hiddenFileIds = new Set<number>(hiddenFiles.map((f) => f.id));
setHiddenFileIds(hiddenFileIds);
const collectionSummaries = getCollectionSummaries(
user,
collections,
@ -881,13 +858,20 @@ export default function Gallery() {
selected.collectionID
);
}
if (selected?.ownCount === filteredData?.length) {
if (
ops === COLLECTION_OPS_TYPE.REMOVE ||
ops === COLLECTION_OPS_TYPE.RESTORE ||
ops === COLLECTION_OPS_TYPE.MOVE
) {
// redirect to all section when no items are left in the current collection.
setActiveCollectionID(ALL_SECTION);
} else if (ops === COLLECTION_OPS_TYPE.UNHIDE) {
exitHiddenSection();
}
}
clearSelection();
await syncWithRemote(false, true);
if (isInHiddenSection && ops === COLLECTION_OPS_TYPE.UNHIDE) {
exitHiddenSection();
}
setActiveCollectionID(collection.id);
} catch (e) {
logError(e, 'collection ops failed', { ops });
setDialogMessage({
@ -917,12 +901,20 @@ export default function Gallery() {
await handleFileOps(
ops,
toProcessFiles,
setDeletedFileIds,
setHiddenFileIds,
setTempDeletedFileIds,
setTempHiddenFileIds,
setFixCreationTimeAttributes,
setFilesDownloadProgressAttributesCreator
);
}
if (
selected?.ownCount === filteredData?.length &&
ops !== FILE_OPS_TYPE.ARCHIVE &&
ops !== FILE_OPS_TYPE.DOWNLOAD &&
ops !== FILE_OPS_TYPE.FIX_TIME
) {
setActiveCollectionID(ALL_SECTION);
}
clearSelection();
await syncWithRemote(false, true);
} catch (e) {
@ -1154,8 +1146,8 @@ export default function Gallery() {
favItemIds={favItemIds}
setSelected={setSelected}
selected={selected}
deletedFileIds={deletedFileIds}
setDeletedFileIds={setDeletedFileIds}
tempDeletedFileIds={tempDeletedFileIds}
setTempDeletedFileIds={setTempDeletedFileIds}
setIsPhotoSwipeOpen={setIsPhotoSwipeOpen}
activeCollectionID={activeCollectionID}
enableDownload={true}

View file

@ -268,6 +268,16 @@ class ClipServiceImpl {
);
return;
}
const extension = enteFile.metadata.title.split('.').pop();
if (!extension || !['jpg', 'jpeg'].includes(extension)) {
addLogLine(
`skipping non jpg file for clip embedding extraction file: ${enteFile.metadata.title} fileID: ${enteFile.id}`
);
return;
}
addLogLine(
`queuing up for local clip embedding extraction for file: ${enteFile.metadata.title} fileID: ${enteFile.id}`
);
try {
await this.liveEmbeddingExtractionQueue.add(async () => {
const embedding = await this.extractLocalFileClipImageEmbedding(

View file

@ -0,0 +1,97 @@
import { CITIES_URL } from '@ente/shared/constants/urls';
import { logError } from '@ente/shared/sentry';
import { LocationTagData } from 'types/entity';
import { Location } from 'types/upload';
export interface City {
city: string;
country: string;
lat: number;
lng: number;
}
const DEFAULT_CITY_RADIUS = 10;
const KMS_PER_DEGREE = 111.16;
class LocationSearchService {
private cities: Array<City> = [];
private citiesPromise: Promise<void>;
async loadCities() {
try {
if (this.citiesPromise) {
return;
}
this.citiesPromise = fetch(CITIES_URL).then((response) => {
return response.json().then((data) => {
this.cities = data['data'];
});
});
await this.citiesPromise;
} catch (e) {
logError(e, 'LocationSearchService loadCities failed');
this.citiesPromise = null;
}
}
async searchCities(searchTerm: string) {
try {
if (!this.citiesPromise) {
this.loadCities();
}
await this.citiesPromise;
return this.cities.filter((city) => {
return city.city
.toLowerCase()
.startsWith(searchTerm.toLowerCase());
});
} catch (e) {
logError(e, 'LocationSearchService searchCities failed');
throw e;
}
}
}
export default new LocationSearchService();
export function isInsideLocationTag(
location: Location,
locationTag: LocationTagData
) {
return isLocationCloseToPoint(
location,
locationTag.centerPoint,
locationTag.radius
);
}
export function isInsideCity(location: Location, city: City) {
return isLocationCloseToPoint(
{ latitude: city.lat, longitude: city.lng },
location,
DEFAULT_CITY_RADIUS
);
}
function isLocationCloseToPoint(
centerPoint: Location,
location: Location,
radius: number
) {
const a = (radius * _scaleFactor(centerPoint.latitude)) / KMS_PER_DEGREE;
const b = radius / KMS_PER_DEGREE;
const x = centerPoint.latitude - location.latitude;
const y = centerPoint.longitude - location.longitude;
if ((x * x) / (a * a) + (y * y) / (b * b) <= 1) {
return true;
}
return false;
}
///The area bounded by the location tag becomes more elliptical with increase
///in the magnitude of the latitude on the caritesian plane. When latitude is
///0 degrees, the ellipse is a circle with a = b = r. When latitude incrases,
///the major axis (a) has to be scaled by the secant of the latitude.
function _scaleFactor(lat: number) {
return 1 / Math.cos(lat * (Math.PI / 180));
}

View file

@ -16,11 +16,7 @@ import {
ClipSearchScores,
} from 'types/search';
import ObjectService from './machineLearning/objectService';
import {
getFormattedDate,
isInsideLocationTag,
isSameDayAnyYear,
} from 'utils/search';
import { getFormattedDate } from 'utils/search';
import { Person, Thing } from 'types/machineLearning';
import { getUniqueFiles } from 'utils/file';
import { getLatestEntities } from './entityService';
@ -31,15 +27,17 @@ import { ClipService, computeClipMatchScore } from './clipService';
import { CustomError } from '@ente/shared/error';
import { Model } from 'types/embedding';
import { getLocalEmbeddings } from './embeddingService';
import locationSearchService, { City } from './locationSearchService';
import ComlinkSearchWorker from 'utils/comlink/ComlinkSearchWorker';
const DIGITS = new Set(['0', '1', '2', '3', '4', '5', '6', '7', '8', '9']);
const CLIP_SCORE_THRESHOLD = 0.23;
export const getDefaultOptions = async (files: EnteFile[]) => {
export const getDefaultOptions = async () => {
return [
await getIndexStatusSuggestion(),
...convertSuggestionsToOptions(await getAllPeopleSuggestion(), files),
...(await convertSuggestionsToOptions(await getAllPeopleSuggestion())),
].filter((t) => !!t);
};
@ -60,47 +58,42 @@ export const getAutoCompleteSuggestions =
...getCollectionSuggestion(searchPhrase, collections),
getFileNameSuggestion(searchPhrase, files),
getFileCaptionSuggestion(searchPhrase, files),
...(await getLocationTagSuggestions(searchPhrase)),
...(await getLocationSuggestions(searchPhrase)),
...(await getThingSuggestion(searchPhrase)),
].filter((suggestion) => !!suggestion);
return convertSuggestionsToOptions(suggestions, files);
return convertSuggestionsToOptions(suggestions);
} catch (e) {
logError(e, 'getAutoCompleteSuggestions failed');
return [];
}
};
function convertSuggestionsToOptions(
suggestions: Suggestion[],
files: EnteFile[]
) {
const previewImageAppendedOptions: SearchOption[] = suggestions
.map((suggestion) => ({
suggestion,
searchQuery: convertSuggestionToSearchQuery(suggestion),
}))
.map(({ suggestion, searchQuery }) => {
const resultFiles = getUniqueFiles(
files.filter((file) => isSearchedFile(file, searchQuery))
);
if (searchQuery?.clip) {
resultFiles.sort((a, b) => {
const aScore = searchQuery.clip.get(a.id);
const bScore = searchQuery.clip.get(b.id);
return bScore - aScore;
});
}
return {
async function convertSuggestionsToOptions(
suggestions: Suggestion[]
): Promise<SearchOption[]> {
const searchWorker = await ComlinkSearchWorker.getInstance();
const previewImageAppendedOptions: SearchOption[] = [];
for (const suggestion of suggestions) {
const searchQuery = convertSuggestionToSearchQuery(suggestion);
const resultFiles = getUniqueFiles(
await searchWorker.search(searchQuery)
);
if (searchQuery?.clip) {
resultFiles.sort((a, b) => {
const aScore = searchQuery.clip.get(a.id);
const bScore = searchQuery.clip.get(b.id);
return bScore - aScore;
});
}
if (resultFiles.length) {
previewImageAppendedOptions.push({
...suggestion,
fileCount: resultFiles.length,
previewFiles: resultFiles.slice(0, 3),
};
})
.filter((option) => option.fileCount);
});
}
}
return previewImageAppendedOptions;
}
function getFileTypeSuggestion(searchPhrase: string): Suggestion[] {
@ -266,10 +259,9 @@ function getFileCaptionSuggestion(
};
}
async function getLocationTagSuggestions(searchPhrase: string) {
const searchResults = await searchLocationTag(searchPhrase);
return searchResults.map(
async function getLocationSuggestions(searchPhrase: string) {
const locationTagResults = await searchLocationTag(searchPhrase);
const locationTagSuggestions = locationTagResults.map(
(locationTag) =>
({
type: SuggestionType.LOCATION,
@ -277,6 +269,28 @@ async function getLocationTagSuggestions(searchPhrase: string) {
label: locationTag.data.name,
} as Suggestion)
);
const locationTagNames = new Set(
locationTagSuggestions.map((result) => result.label)
);
const citySearchResults = await locationSearchService.searchCities(
searchPhrase
);
const nonConflictingCityResult = citySearchResults.filter(
(city) => !locationTagNames.has(city.city)
);
const citySearchSuggestions = nonConflictingCityResult.map(
(city) =>
({
type: SuggestionType.CITY,
value: city,
label: city.city,
} as Suggestion)
);
return [...locationTagSuggestions, ...citySearchSuggestions];
}
async function getThingSuggestion(searchPhrase: string): Promise<Suggestion[]> {
@ -406,48 +420,6 @@ async function searchClip(searchPhrase: string): Promise<ClipSearchScores> {
return clipSearchResult;
}
function isSearchedFile(file: EnteFile, search: Search) {
if (search?.collection) {
return search.collection === file.collectionID;
}
if (search?.date) {
return isSameDayAnyYear(search.date)(
new Date(file.metadata.creationTime / 1000)
);
}
if (search?.location) {
return isInsideLocationTag(
{
latitude: file.metadata.latitude,
longitude: file.metadata.longitude,
},
search.location
);
}
if (search?.files) {
return search.files.indexOf(file.id) !== -1;
}
if (search?.person) {
return search.person.files.indexOf(file.id) !== -1;
}
if (search?.thing) {
return search.thing.files.indexOf(file.id) !== -1;
}
if (search?.text) {
return search.text.files.indexOf(file.id) !== -1;
}
if (typeof search?.fileType !== 'undefined') {
return search.fileType === file.metadata.fileType;
}
if (typeof search?.clip !== 'undefined') {
return search.clip.has(file.id);
}
return false;
}
function convertSuggestionToSearchQuery(option: Suggestion): Search {
switch (option.type) {
case SuggestionType.DATE:
@ -460,6 +432,9 @@ function convertSuggestionToSearchQuery(option: Suggestion): Search {
location: option.value as LocationTagData,
};
case SuggestionType.CITY:
return { city: option.value as City };
case SuggestionType.COLLECTION:
return { collection: option.value as number };

View file

@ -361,7 +361,9 @@ class UploadManager {
try {
eventBus.emit(Events.FILE_UPLOADED, {
enteFile: decryptedFile,
localFile: fileWithCollection.file,
localFile:
fileWithCollection.file ??
fileWithCollection.livePhotoAssets.image,
});
} catch (e) {
logError(e, 'Error in fileUploaded handlers');

View file

@ -49,11 +49,10 @@ export const SelectStyles = {
...style,
display: 'none',
}),
singleValue: (style, state) => ({
singleValue: (style) => ({
...style,
backgroundColor: 'transparent',
color: '#d1d1d1',
display: state.selectProps.menuIsOpen ? 'none' : 'block',
marginLeft: '36px',
}),
placeholder: (style) => ({

View file

@ -3,6 +3,7 @@ import { IndexStatus } from 'types/machineLearning/ui';
import { EnteFile } from 'types/file';
import { LocationTagData } from 'types/entity';
import { FILE_TYPE } from 'constants/file';
import { City } from 'services/locationSearchService';
export enum SuggestionType {
DATE = 'DATE',
@ -16,6 +17,7 @@ export enum SuggestionType {
FILE_CAPTION = 'FILE_CAPTION',
FILE_TYPE = 'FILE_TYPE',
CLIP = 'CLIP',
CITY = 'CITY',
}
export interface DateValue {
@ -35,6 +37,7 @@ export interface Suggestion {
| Thing
| WordGroup
| LocationTagData
| City
| FILE_TYPE
| ClipSearchScores;
hide?: boolean;
@ -43,6 +46,7 @@ export interface Suggestion {
export type Search = {
date?: DateValue;
location?: LocationTagData;
city?: City;
collection?: number;
files?: number[];
person?: Person;

View file

@ -50,6 +50,7 @@ import {
getUniqueCollectionExportName,
} from 'utils/export';
import exportService from 'services/export';
import { addLogLine } from '@ente/shared/logging';
export enum COLLECTION_OPS_TYPE {
ADD,
@ -477,7 +478,8 @@ export function isValidReplacementAlbum(
return (
collection.name === wantedCollectionName &&
(collection.type === CollectionType.album ||
collection.type === CollectionType.folder) &&
collection.type === CollectionType.folder ||
collection.type === CollectionType.uncategorized) &&
!isHiddenCollection(collection) &&
!isQuickLinkCollection(collection) &&
!isIncomingShare(collection, user)
@ -566,8 +568,13 @@ export const getOrCreateAlbum = async (
}
for (const collection of existingCollections) {
if (isValidReplacementAlbum(collection, user, albumName)) {
addLogLine(
`Found existing album ${albumName} with id ${collection.id}`
);
return collection;
}
}
return createAlbum(albumName);
const album = await createAlbum(albumName);
addLogLine(`Created new album ${albumName} with id ${album.id}`);
return album;
};

View file

@ -0,0 +1,30 @@
import { Remote } from 'comlink';
import { runningInBrowser } from 'utils/common';
import { ComlinkWorker } from '@ente/shared/worker/comlinkWorker';
import { DedicatedSearchWorker } from 'worker/search.worker';
class ComlinkSearchWorker {
private comlinkWorkerInstance: Remote<DedicatedSearchWorker>;
async getInstance() {
if (!this.comlinkWorkerInstance) {
this.comlinkWorkerInstance = await getDedicatedSearchWorker()
.remote;
}
return this.comlinkWorkerInstance;
}
}
export const getDedicatedSearchWorker = () => {
if (runningInBrowser()) {
const cryptoComlinkWorker = new ComlinkWorker<
typeof DedicatedSearchWorker
>(
'ente-search-worker',
new Worker(new URL('worker/search.worker.ts', import.meta.url))
);
return cryptoComlinkWorker;
}
};
export default new ComlinkSearchWorker();

View file

@ -949,11 +949,11 @@ export const shouldShowAvatar = (file: EnteFile, user: User) => {
export const handleFileOps = async (
ops: FILE_OPS_TYPE,
files: EnteFile[],
setDeletedFileIds: (
deletedFileIds: Set<number> | ((prev: Set<number>) => Set<number>)
setTempDeletedFileIds: (
tempDeletedFileIds: Set<number> | ((prev: Set<number>) => Set<number>)
) => void,
setHiddenFileIds: (
hiddenFileIds: Set<number> | ((prev: Set<number>) => Set<number>)
setTempHiddenFileIds: (
tempHiddenFileIds: Set<number> | ((prev: Set<number>) => Set<number>)
) => void,
setFixCreationTimeAttributes: (
fixCreationTimeAttributes:
@ -966,13 +966,13 @@ export const handleFileOps = async (
) => {
switch (ops) {
case FILE_OPS_TYPE.TRASH:
await deleteFileHelper(files, false, setDeletedFileIds);
await deleteFileHelper(files, false, setTempDeletedFileIds);
break;
case FILE_OPS_TYPE.DELETE_PERMANENTLY:
await deleteFileHelper(files, true, setDeletedFileIds);
await deleteFileHelper(files, true, setTempDeletedFileIds);
break;
case FILE_OPS_TYPE.HIDE:
await hideFilesHelper(files, setHiddenFileIds);
await hideFilesHelper(files, setTempHiddenFileIds);
break;
case FILE_OPS_TYPE.DOWNLOAD: {
const setSelectedFileDownloadProgressAttributes =
@ -1000,12 +1000,12 @@ export const handleFileOps = async (
const deleteFileHelper = async (
selectedFiles: EnteFile[],
permanent: boolean,
setDeletedFileIds: (
deletedFileIds: Set<number> | ((prev: Set<number>) => Set<number>)
setTempDeletedFileIds: (
tempDeletedFileIds: Set<number> | ((prev: Set<number>) => Set<number>)
) => void
) => {
try {
setDeletedFileIds((deletedFileIds) => {
setTempDeletedFileIds((deletedFileIds) => {
selectedFiles.forEach((file) => deletedFileIds.add(file.id));
return new Set(deletedFileIds);
});
@ -1015,25 +1015,25 @@ const deleteFileHelper = async (
await trashFiles(selectedFiles);
}
} catch (e) {
setDeletedFileIds(new Set());
setTempDeletedFileIds(new Set());
throw e;
}
};
const hideFilesHelper = async (
selectedFiles: EnteFile[],
setHiddenFileIds: (
hiddenFileIds: Set<number> | ((prev: Set<number>) => Set<number>)
setTempHiddenFileIds: (
tempHiddenFileIds: Set<number> | ((prev: Set<number>) => Set<number>)
) => void
) => {
try {
setHiddenFileIds((hiddenFileIds) => {
setTempHiddenFileIds((hiddenFileIds) => {
selectedFiles.forEach((file) => hiddenFileIds.add(file.id));
return new Set(hiddenFileIds);
});
await moveToHiddenCollection(selectedFiles);
} catch (e) {
setHiddenFileIds(new Set());
setTempHiddenFileIds(new Set());
throw e;
}
};

View file

@ -1,6 +1,4 @@
import { LocationTagData } from 'types/entity';
import { DateValue } from 'types/search';
import { Location } from 'types/upload';
export const isSameDayAnyYear =
(baseDate: DateValue) => (compareDate: Date) => {
@ -28,18 +26,3 @@ export function getFormattedDate(date: DateValue) {
new Date(date.year ?? 1, date.month ?? 1, date.date ?? 1)
);
}
export function isInsideLocationTag(
location: Location,
locationTag: LocationTagData
) {
const { centerPoint, aSquare, bSquare } = locationTag;
const { latitude, longitude } = location;
const x = Math.abs(centerPoint.latitude - latitude);
const y = Math.abs(centerPoint.longitude - longitude);
if ((x * x) / aSquare + (y * y) / bSquare <= 1) {
return true;
} else {
return false;
}
}

View file

@ -0,0 +1,75 @@
import * as Comlink from 'comlink';
import {
isInsideLocationTag,
isInsideCity,
} from 'services/locationSearchService';
import { EnteFile } from 'types/file';
import { isSameDayAnyYear } from 'utils/search';
import { Search } from 'types/search';
export class DedicatedSearchWorker {
private files: EnteFile[] = [];
setFiles(files: EnteFile[]) {
this.files = files;
}
search(search: Search) {
return this.files.filter((file) => {
return isSearchedFile(file, search);
});
}
}
Comlink.expose(DedicatedSearchWorker, self);
function isSearchedFile(file: EnteFile, search: Search) {
if (search?.collection) {
return search.collection === file.collectionID;
}
if (search?.date) {
return isSameDayAnyYear(search.date)(
new Date(file.metadata.creationTime / 1000)
);
}
if (search?.location) {
return isInsideLocationTag(
{
latitude: file.metadata.latitude,
longitude: file.metadata.longitude,
},
search.location
);
}
if (search?.city) {
return isInsideCity(
{
latitude: file.metadata.latitude,
longitude: file.metadata.longitude,
},
search.city
);
}
if (search?.files) {
return search.files.indexOf(file.id) !== -1;
}
if (search?.person) {
return search.person.files.indexOf(file.id) !== -1;
}
if (search?.thing) {
return search.thing.files.indexOf(file.id) !== -1;
}
if (search?.text) {
return search.text.files.indexOf(file.id) !== -1;
}
if (typeof search?.fileType !== 'undefined') {
return search.fileType === file.metadata.fileType;
}
if (typeof search?.clip !== 'undefined') {
return search.clip.has(file.id);
}
return false;
}

View file

@ -17,3 +17,5 @@ export const WEB_ROADMAP_URL = 'https://github.com/ente-io/photos-web/issues';
export const DESKTOP_ROADMAP_URL =
'https://github.com/ente-io/photos-desktop/issues';
export const CITIES_URL = 'https://static.ente.io/world_cities.json';

View file

@ -0,0 +1,33 @@
import { useEffect, useRef } from 'react';
import { isPromise } from '../utils';
// useEffectSingleThreaded is a useEffect that will only run one at a time, and will
// caches the latest deps of requests that come in while it is running, and will
// run that after the current run is complete.
export default function useEffectSingleThreaded(
fn: (deps) => void | Promise<void>,
deps: any[]
): void {
const updateInProgress = useRef(false);
const nextRequestDepsRef = useRef<any[]>(null);
useEffect(() => {
const main = async (deps) => {
if (updateInProgress.current) {
nextRequestDepsRef.current = deps;
return;
}
updateInProgress.current = true;
const result = fn(deps);
if (isPromise(result)) {
await result;
}
updateInProgress.current = false;
if (nextRequestDepsRef.current) {
const deps = nextRequestDepsRef.current;
nextRequestDepsRef.current = null;
setTimeout(() => main(deps), 0);
}
};
main(deps);
}, deps);
}

View file

@ -22,3 +22,7 @@ export function downloadUsingAnchor(link: string, name: string) {
URL.revokeObjectURL(link);
a.remove();
}
export function isPromise<T>(obj: T | Promise<T>): obj is Promise<T> {
return obj && typeof (obj as any).then === 'function';
}