Merge branch 'main' into file-download-location
This commit is contained in:
commit
edfdc5dd1e
39 changed files with 1121 additions and 331 deletions
|
@ -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)
|
||||
|
|
|
@ -206,6 +206,7 @@
|
|||
"SEARCH_TYPE": {
|
||||
"COLLECTION": "Album",
|
||||
"LOCATION": "Location",
|
||||
"CITY": "Location",
|
||||
"DATE": "Date",
|
||||
"FILE_NAME": "File name",
|
||||
"THING": "Content",
|
||||
|
|
|
@ -624,5 +624,8 @@
|
|||
"FASTER_UPLOAD_DESCRIPTION": "",
|
||||
"MAGIC_SEARCH_STATUS": "",
|
||||
"INDEXED_ITEMS": "",
|
||||
"CACHE_DIRECTORY": ""
|
||||
"CACHE_DIRECTORY": "",
|
||||
"FREEHAND": "",
|
||||
"APPLY_CROP": "",
|
||||
"PHOTO_EDIT_REQUIRED_TO_SAVE": ""
|
||||
}
|
||||
|
|
|
@ -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."
|
||||
}
|
||||
|
|
|
@ -624,5 +624,8 @@
|
|||
"FASTER_UPLOAD_DESCRIPTION": "",
|
||||
"MAGIC_SEARCH_STATUS": "",
|
||||
"INDEXED_ITEMS": "",
|
||||
"CACHE_DIRECTORY": ""
|
||||
"CACHE_DIRECTORY": "",
|
||||
"FREEHAND": "",
|
||||
"APPLY_CROP": "",
|
||||
"PHOTO_EDIT_REQUIRED_TO_SAVE": ""
|
||||
}
|
||||
|
|
|
@ -624,5 +624,8 @@
|
|||
"FASTER_UPLOAD_DESCRIPTION": "",
|
||||
"MAGIC_SEARCH_STATUS": "",
|
||||
"INDEXED_ITEMS": "",
|
||||
"CACHE_DIRECTORY": ""
|
||||
"CACHE_DIRECTORY": "",
|
||||
"FREEHAND": "",
|
||||
"APPLY_CROP": "",
|
||||
"PHOTO_EDIT_REQUIRED_TO_SAVE": ""
|
||||
}
|
||||
|
|
|
@ -624,5 +624,8 @@
|
|||
"FASTER_UPLOAD_DESCRIPTION": "",
|
||||
"MAGIC_SEARCH_STATUS": "",
|
||||
"INDEXED_ITEMS": "",
|
||||
"CACHE_DIRECTORY": ""
|
||||
"CACHE_DIRECTORY": "",
|
||||
"FREEHAND": "",
|
||||
"APPLY_CROP": "",
|
||||
"PHOTO_EDIT_REQUIRED_TO_SAVE": ""
|
||||
}
|
||||
|
|
|
@ -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": ""
|
||||
}
|
||||
|
|
|
@ -624,5 +624,8 @@
|
|||
"FASTER_UPLOAD_DESCRIPTION": "",
|
||||
"MAGIC_SEARCH_STATUS": "",
|
||||
"INDEXED_ITEMS": "",
|
||||
"CACHE_DIRECTORY": ""
|
||||
"CACHE_DIRECTORY": "",
|
||||
"FREEHAND": "",
|
||||
"APPLY_CROP": "",
|
||||
"PHOTO_EDIT_REQUIRED_TO_SAVE": ""
|
||||
}
|
||||
|
|
|
@ -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": ""
|
||||
}
|
||||
|
|
|
@ -624,5 +624,8 @@
|
|||
"FASTER_UPLOAD_DESCRIPTION": "",
|
||||
"MAGIC_SEARCH_STATUS": "",
|
||||
"INDEXED_ITEMS": "",
|
||||
"CACHE_DIRECTORY": ""
|
||||
"CACHE_DIRECTORY": "",
|
||||
"FREEHAND": "",
|
||||
"APPLY_CROP": "",
|
||||
"PHOTO_EDIT_REQUIRED_TO_SAVE": ""
|
||||
}
|
||||
|
|
|
@ -624,5 +624,8 @@
|
|||
"FASTER_UPLOAD_DESCRIPTION": "",
|
||||
"MAGIC_SEARCH_STATUS": "",
|
||||
"INDEXED_ITEMS": "",
|
||||
"CACHE_DIRECTORY": ""
|
||||
"CACHE_DIRECTORY": "",
|
||||
"FREEHAND": "",
|
||||
"APPLY_CROP": "",
|
||||
"PHOTO_EDIT_REQUIRED_TO_SAVE": ""
|
||||
}
|
||||
|
|
|
@ -624,5 +624,8 @@
|
|||
"FASTER_UPLOAD_DESCRIPTION": "",
|
||||
"MAGIC_SEARCH_STATUS": "",
|
||||
"INDEXED_ITEMS": "",
|
||||
"CACHE_DIRECTORY": ""
|
||||
"CACHE_DIRECTORY": "",
|
||||
"FREEHAND": "",
|
||||
"APPLY_CROP": "",
|
||||
"PHOTO_EDIT_REQUIRED_TO_SAVE": ""
|
||||
}
|
||||
|
|
|
@ -624,5 +624,8 @@
|
|||
"FASTER_UPLOAD_DESCRIPTION": "通过附近的服务器路由上传",
|
||||
"MAGIC_SEARCH_STATUS": "魔法搜索状态",
|
||||
"INDEXED_ITEMS": "索引项目",
|
||||
"CACHE_DIRECTORY": "缓存文件夹"
|
||||
"CACHE_DIRECTORY": "缓存文件夹",
|
||||
"FREEHAND": "手画",
|
||||
"APPLY_CROP": "应用裁剪",
|
||||
"PHOTO_EDIT_REQUIRED_TO_SAVE": "保存之前必须至少执行一项转换或颜色调整。"
|
||||
}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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;
|
|
@ -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;
|
|
@ -89,6 +89,7 @@ const TransformMenu = () => {
|
|||
);
|
||||
};
|
||||
};
|
||||
|
||||
const flipCanvas = (
|
||||
canvas: HTMLCanvasElement,
|
||||
direction: 'vertical' | 'horizontal'
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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={
|
||||
|
|
|
@ -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 />;
|
||||
|
|
|
@ -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[] =
|
||||
|
|
10
apps/photos/src/constants/photoEditor.ts
Normal file
10
apps/photos/src/constants/photoEditor.ts
Normal 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;
|
|
@ -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}
|
||||
|
|
|
@ -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(
|
||||
|
|
97
apps/photos/src/services/locationSearchService.ts
Normal file
97
apps/photos/src/services/locationSearchService.ts
Normal 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));
|
||||
}
|
|
@ -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 };
|
||||
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -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) => ({
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
|
30
apps/photos/src/utils/comlink/ComlinkSearchWorker.ts
Normal file
30
apps/photos/src/utils/comlink/ComlinkSearchWorker.ts
Normal 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();
|
|
@ -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;
|
||||
}
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
75
apps/photos/src/worker/search.worker.ts
Normal file
75
apps/photos/src/worker/search.worker.ts
Normal 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;
|
||||
}
|
|
@ -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';
|
||||
|
|
33
packages/shared/hooks/useEffectSingleThreaded.tsx
Normal file
33
packages/shared/hooks/useEffectSingleThreaded.tsx
Normal 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);
|
||||
}
|
|
@ -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';
|
||||
}
|
||||
|
|
Loading…
Add table
Reference in a new issue