diff --git a/assets/images/crop.svg b/assets/images/crop.svg
new file mode 100644
index 000000000000..d9638c22851f
--- /dev/null
+++ b/assets/images/crop.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/cspell.json b/cspell.json
index a438d9c338e7..f9fe50a6e5c1 100644
--- a/cspell.json
+++ b/cspell.json
@@ -829,6 +829,8 @@
"Wooo",
"Splittable",
"pgrep",
+ "Nesw",
+ "nesw",
"skia",
"canvaskit",
"Invoicify",
diff --git a/src/components/Button/index.tsx b/src/components/Button/index.tsx
index 70d239973d2a..89fa7f739f18 100644
--- a/src/components/Button/index.tsx
+++ b/src/components/Button/index.tsx
@@ -360,7 +360,7 @@ function Button({
{!!icon && (
-
+
void;
+
+ /** Initial crop rectangle (optional) */
+ initialCrop?: CropRect;
+
+ isAuthTokenRequired?: boolean;
+};
+
+type CornerPosition = 'topLeft' | 'topRight' | 'bottomLeft' | 'bottomRight';
+type EdgePosition = 'top' | 'bottom' | 'left' | 'right';
+
+function ReceiptCropView({imageUri, onCropChange, initialCrop, isAuthTokenRequired}: ReceiptCropViewProps) {
+ const styles = useThemeStyles();
+ const StyleUtils = useStyleUtils();
+
+ // Image dimensions
+ const [imageSize, setImageSize] = useState({width: 0, height: 0});
+ const [hasImageDimensions, setHasImageDimensions] = useState(false);
+
+ // Container dimensions (using shared values for worklet access)
+ const containerWidthSV = useSharedValue(0);
+ const containerHeightSV = useSharedValue(0);
+ const displayWidthSV = useSharedValue(0);
+ const displayHeightSV = useSharedValue(0);
+ const imageOffsetXSV = useSharedValue(0);
+ const imageOffsetYSV = useSharedValue(0);
+ const [containerSize, setContainerSize] = useState({width: 0, height: 0});
+
+ // Crop rectangle in display coordinates (relative to container)
+ const cropX = useSharedValue(0);
+ const cropY = useSharedValue(0);
+ const cropWidth = useSharedValue(0);
+ const cropHeight = useSharedValue(0);
+
+ // Track previous values to detect changes and recalculate crop on resize
+ const prevDisplayValuesRef = useRef<{
+ displayWidth: number;
+ displayHeight: number;
+ scaleX: number;
+ scaleY: number;
+ imageOffsetX: number;
+ imageOffsetY: number;
+ } | null>(null);
+
+ const isCropInitialized = imageSize.width > 0 && imageSize.height > 0 && containerSize.width > 0 && containerSize.height > 0;
+
+ // Calculate scale factors to convert display coordinates to image coordinates
+ const {scaleX, scaleY, displayWidth, displayHeight, imageOffsetX, imageOffsetY} = useMemo(() => {
+ if (!containerSize.width || !containerSize.height || !imageSize.width || !imageSize.height) {
+ return {scaleX: 1, scaleY: 1, displayWidth: 0, displayHeight: 0, imageOffsetX: 0, imageOffsetY: 0};
+ }
+
+ // Calculate how the image is scaled to fit the container
+ const imageAspectRatio = imageSize.width / imageSize.height;
+ const containerAspectRatio = containerSize.width / containerSize.height;
+
+ let calculatedDisplayWidth: number;
+ let calculatedDisplayHeight: number;
+
+ if (imageAspectRatio > containerAspectRatio) {
+ // Image is wider - fit to width
+ calculatedDisplayWidth = containerSize.width;
+ calculatedDisplayHeight = containerSize.width / imageAspectRatio;
+ } else {
+ // Image is taller - fit to height
+ calculatedDisplayHeight = containerSize.height;
+ calculatedDisplayWidth = containerSize.height * imageAspectRatio;
+ }
+
+ // Calculate the image's offset within the container (centered)
+ const calculatedImageOffsetX = (containerSize.width - calculatedDisplayWidth) / 2;
+ const calculatedImageOffsetY = (containerSize.height - calculatedDisplayHeight) / 2;
+
+ // Calculate scale factors to convert display coordinates to image coordinates
+ const calculatedScaleX = imageSize.width / calculatedDisplayWidth;
+ const calculatedScaleY = imageSize.height / calculatedDisplayHeight;
+
+ // Update shared values for worklet access
+ displayWidthSV.set(calculatedDisplayWidth);
+ displayHeightSV.set(calculatedDisplayHeight);
+ imageOffsetXSV.set(calculatedImageOffsetX);
+ imageOffsetYSV.set(calculatedImageOffsetY);
+
+ return {
+ scaleX: calculatedScaleX,
+ scaleY: calculatedScaleY,
+ displayWidth: calculatedDisplayWidth,
+ displayHeight: calculatedDisplayHeight,
+ imageOffsetX: calculatedImageOffsetX,
+ imageOffsetY: calculatedImageOffsetY,
+ };
+ }, [containerSize.width, containerSize.height, imageSize.width, imageSize.height, displayWidthSV, displayHeightSV, imageOffsetXSV, imageOffsetYSV]);
+
+ // Initialize crop rectangle when dimensions are available
+ useEffect(() => {
+ if (!displayWidth || !displayHeight || !scaleX || !scaleY) {
+ return;
+ }
+
+ // Only initialize if crop values are still at 0 (not yet initialized)
+ if (cropWidth.get() === 0 && cropHeight.get() === 0) {
+ if (initialCrop) {
+ // Convert image coordinates to display coordinates and add image offset
+ cropX.set(imageOffsetX + initialCrop.x / scaleX);
+ cropY.set(imageOffsetY + initialCrop.y / scaleY);
+ cropWidth.set(initialCrop.width / scaleX);
+ cropHeight.set(initialCrop.height / scaleY);
+ } else {
+ // Default: crop the entire image area (no padding)
+ // Crop rectangle is relative to the image position (centered in container)
+ cropX.set(imageOffsetX);
+ cropY.set(imageOffsetY);
+ cropWidth.set(displayWidth);
+ cropHeight.set(displayHeight);
+ }
+ // Store initial values
+ prevDisplayValuesRef.current = {
+ displayWidth,
+ displayHeight,
+ scaleX,
+ scaleY,
+ imageOffsetX,
+ imageOffsetY,
+ };
+ }
+ }, [displayWidth, displayHeight, scaleX, scaleY, imageOffsetX, imageOffsetY, initialCrop, cropX, cropY, cropWidth, cropHeight]);
+
+ // Update crop rectangle when container/image dimensions change (e.g., window resize)
+ useEffect(() => {
+ if (!displayWidth || !displayHeight || !scaleX || !scaleY) {
+ return;
+ }
+
+ // Only update if crop is already initialized and dimensions have changed
+ if (cropWidth.get() === 0 && cropHeight.get() === 0) {
+ return;
+ }
+
+ const prev = prevDisplayValuesRef.current;
+ if (!prev) {
+ return;
+ }
+
+ // Check if dimensions actually changed
+ const hasChanged =
+ prev.displayWidth !== displayWidth ||
+ prev.displayHeight !== displayHeight ||
+ prev.scaleX !== scaleX ||
+ prev.scaleY !== scaleY ||
+ prev.imageOffsetX !== imageOffsetX ||
+ prev.imageOffsetY !== imageOffsetY;
+
+ if (!hasChanged) {
+ return;
+ }
+
+ // Get current crop rectangle in display coordinates
+ const currentCropX = cropX.get();
+ const currentCropY = cropY.get();
+ const currentCropWidth = cropWidth.get();
+ const currentCropHeight = cropHeight.get();
+
+ // Convert current crop from old display coordinates to image coordinates
+ const imageX = (currentCropX - prev.imageOffsetX) * prev.scaleX;
+ const imageY = (currentCropY - prev.imageOffsetY) * prev.scaleY;
+ const imageWidth = currentCropWidth * prev.scaleX;
+ const imageHeight = currentCropHeight * prev.scaleY;
+
+ // Convert back to new display coordinates
+ const newCropX = imageOffsetX + imageX / scaleX;
+ const newCropY = imageOffsetY + imageY / scaleY;
+ const newCropWidth = imageWidth / scaleX;
+ const newCropHeight = imageHeight / scaleY;
+
+ // Update crop rectangle
+ cropX.set(newCropX);
+ cropY.set(newCropY);
+ cropWidth.set(newCropWidth);
+ cropHeight.set(newCropHeight);
+
+ // Update stored values
+ prevDisplayValuesRef.current = {
+ displayWidth,
+ displayHeight,
+ scaleX,
+ scaleY,
+ imageOffsetX,
+ imageOffsetY,
+ };
+
+ // Notify parent of the updated crop (in image coordinates)
+ const crop: CropRect = {
+ x: imageX,
+ y: imageY,
+ width: imageWidth,
+ height: imageHeight,
+ };
+ onCropChange?.(crop);
+ }, [displayWidth, displayHeight, scaleX, scaleY, imageOffsetX, imageOffsetY, cropX, cropY, cropWidth, cropHeight, onCropChange]);
+
+ const onContainerLayout = useCallback(
+ (event: LayoutChangeEvent) => {
+ const {width, height} = event.nativeEvent.layout;
+ setContainerSize({width, height});
+ containerWidthSV.set(width);
+ containerHeightSV.set(height);
+ },
+ [containerWidthSV, containerHeightSV],
+ );
+
+ const onImageLoad = useCallback(
+ (event: {nativeEvent: {width: number; height: number}}) => {
+ const {width, height} = event.nativeEvent;
+ if (!width || !height || (imageSize.width === width && imageSize.height === height)) {
+ return;
+ }
+ if (!hasImageDimensions) {
+ setHasImageDimensions(true);
+ }
+ setImageSize({width, height});
+ },
+ [hasImageDimensions, imageSize.width, imageSize.height],
+ );
+
+ /**
+ * Clamp a value between min and max
+ */
+ const clamp = useCallback((value: number, min: number, max: number) => {
+ 'worklet';
+
+ return Math.max(min, Math.min(max, value));
+ }, []);
+
+ const applyCropBoundsAndNotify = useCallback(
+ (newX: number, newY: number, newWidth: number, newHeight: number, imageLeft: number, imageRight: number, imageTop: number, imageBottom: number) => {
+ // clamp to image bounds
+ let x = newX;
+ let y = newY;
+ let w = newWidth;
+ let h = newHeight;
+
+ if (x < imageLeft) {
+ const diff = imageLeft - x;
+ x = imageLeft;
+ w -= diff;
+ }
+ if (x + w > imageRight) {
+ w = imageRight - x;
+ }
+ if (y < imageTop) {
+ const diff = imageTop - y;
+ y = imageTop;
+ h -= diff;
+ }
+ if (y + h > imageBottom) {
+ h = imageBottom - y;
+ }
+
+ cropX.set(x);
+ cropY.set(y);
+ cropWidth.set(w);
+ cropHeight.set(h);
+
+ onCropChange?.({
+ x: (x - imageOffsetX) * scaleX,
+ y: (y - imageOffsetY) * scaleY,
+ width: w * scaleX,
+ height: h * scaleY,
+ });
+ },
+ [cropX, cropY, cropWidth, cropHeight, imageOffsetX, scaleX, imageOffsetY, scaleY, onCropChange],
+ );
+
+ /**
+ * Create gesture handler for an edge
+ */
+ const createEdgeGesture = useCallback(
+ (edge: EdgePosition) => {
+ return Gesture.Pan()
+ .runOnJS(true)
+ .onChange((event: GestureUpdateEvent) => {
+ const currentX = cropX.get();
+ const currentY = cropY.get();
+ const currentWidth = cropWidth.get();
+ const currentHeight = cropHeight.get();
+
+ let newX = currentX;
+ let newY = currentY;
+ let newWidth = currentWidth;
+ let newHeight = currentHeight;
+
+ const minSize = variables.cornerHandleSize * 2;
+ const imgDisplayWidth = displayWidthSV.get();
+ const imgDisplayHeight = displayHeightSV.get();
+ const imageLeft = imageOffsetXSV.get();
+ const imageRight = imageLeft + imgDisplayWidth;
+ const imageTop = imageOffsetYSV.get();
+ const imageBottom = imageTop + imgDisplayHeight;
+
+ switch (edge) {
+ case 'top':
+ newY = clamp(currentY + event.changeY, imageTop, currentY + currentHeight - minSize);
+ newHeight = currentHeight - (newY - currentY);
+ break;
+ case 'bottom':
+ newHeight = clamp(currentHeight + event.changeY, minSize, imageBottom - currentY);
+ break;
+ case 'left':
+ newX = clamp(currentX + event.changeX, imageLeft, currentX + currentWidth - minSize);
+ newWidth = currentWidth - (newX - currentX);
+ break;
+ case 'right':
+ newWidth = clamp(currentWidth + event.changeX, minSize, imageRight - currentX);
+ break;
+ default:
+ break;
+ }
+
+ applyCropBoundsAndNotify(newX, newY, newWidth, newHeight, imageLeft, imageRight, imageTop, imageBottom);
+ });
+ },
+ [cropX, cropY, cropWidth, cropHeight, displayWidthSV, displayHeightSV, imageOffsetXSV, imageOffsetYSV, applyCropBoundsAndNotify, clamp],
+ );
+
+ /**
+ * Create gesture handler for a corner
+ */
+ const createCornerGesture = useCallback(
+ (corner: CornerPosition) => {
+ return Gesture.Pan()
+ .runOnJS(true)
+ .onChange((event: GestureUpdateEvent) => {
+ const currentX = cropX.get();
+ const currentY = cropY.get();
+ const currentWidth = cropWidth.get();
+ const currentHeight = cropHeight.get();
+
+ let newX = currentX;
+ let newY = currentY;
+ let newWidth = currentWidth;
+ let newHeight = currentHeight;
+
+ const minSize = variables.cornerHandleSize * 2;
+ const imgDisplayWidth = displayWidthSV.get();
+ const imgDisplayHeight = displayHeightSV.get();
+ const imageLeft = imageOffsetXSV.get();
+ const imageRight = imageLeft + imgDisplayWidth;
+ const imageTop = imageOffsetYSV.get();
+ const imageBottom = imageTop + imgDisplayHeight;
+
+ switch (corner) {
+ case 'topLeft':
+ newX = clamp(currentX + event.changeX, imageLeft, currentX + currentWidth - minSize);
+ newY = clamp(currentY + event.changeY, imageTop, currentY + currentHeight - minSize);
+ newWidth = currentWidth - (newX - currentX);
+ newHeight = currentHeight - (newY - currentY);
+ break;
+ case 'topRight':
+ newY = clamp(currentY + event.changeY, imageTop, currentY + currentHeight - minSize);
+ newWidth = clamp(currentWidth + event.changeX, minSize, imageRight - currentX);
+ newHeight = currentHeight - (newY - currentY);
+ break;
+ case 'bottomLeft':
+ newX = clamp(currentX + event.changeX, imageLeft, currentX + currentWidth - minSize);
+ newWidth = currentWidth - (newX - currentX);
+ newHeight = clamp(currentHeight + event.changeY, minSize, imageBottom - currentY);
+ break;
+ case 'bottomRight':
+ newWidth = clamp(currentWidth + event.changeX, minSize, imageRight - currentX);
+ newHeight = clamp(currentHeight + event.changeY, minSize, imageBottom - currentY);
+ break;
+ default:
+ break;
+ }
+
+ applyCropBoundsAndNotify(newX, newY, newWidth, newHeight, imageLeft, imageRight, imageTop, imageBottom);
+ });
+ },
+ [cropX, cropY, cropWidth, cropHeight, displayWidthSV, displayHeightSV, imageOffsetXSV, imageOffsetYSV, applyCropBoundsAndNotify, clamp],
+ );
+
+ const borderStyle = useAnimatedStyle(() => {
+ 'worklet';
+
+ return StyleUtils.getCropViewStyle('border', {
+ cropX: cropX.get(),
+ cropY: cropY.get(),
+ cropWidth: cropWidth.get(),
+ cropHeight: cropHeight.get(),
+ });
+ });
+
+ const topLeftCornerStyle = useAnimatedStyle(() => {
+ 'worklet';
+
+ return StyleUtils.getCropViewStyle('cornerTopLeft', {
+ cropX: cropX.get(),
+ cropY: cropY.get(),
+ cropWidth: cropWidth.get(),
+ cropHeight: cropHeight.get(),
+ });
+ });
+
+ const topRightCornerStyle = useAnimatedStyle(() => {
+ 'worklet';
+
+ return StyleUtils.getCropViewStyle('cornerTopRight', {
+ cropX: cropX.get(),
+ cropY: cropY.get(),
+ cropWidth: cropWidth.get(),
+ cropHeight: cropHeight.get(),
+ });
+ });
+
+ const bottomLeftCornerStyle = useAnimatedStyle(() => {
+ 'worklet';
+
+ return StyleUtils.getCropViewStyle('cornerBottomLeft', {
+ cropX: cropX.get(),
+ cropY: cropY.get(),
+ cropWidth: cropWidth.get(),
+ cropHeight: cropHeight.get(),
+ });
+ });
+
+ const bottomRightCornerStyle = useAnimatedStyle(() => {
+ 'worklet';
+
+ return StyleUtils.getCropViewStyle('cornerBottomRight', {
+ cropX: cropX.get(),
+ cropY: cropY.get(),
+ cropWidth: cropWidth.get(),
+ cropHeight: cropHeight.get(),
+ });
+ });
+
+ // Edge handle styles - thicker tap target while keeping visual size
+ const topEdgeStyle = useAnimatedStyle(() => {
+ 'worklet';
+
+ return StyleUtils.getCropViewStyle('edgeTop', {
+ cropX: cropX.get(),
+ cropY: cropY.get(),
+ cropWidth: cropWidth.get(),
+ cropHeight: cropHeight.get(),
+ });
+ });
+
+ const bottomEdgeStyle = useAnimatedStyle(() => {
+ 'worklet';
+
+ return StyleUtils.getCropViewStyle('edgeBottom', {
+ cropX: cropX.get(),
+ cropY: cropY.get(),
+ cropWidth: cropWidth.get(),
+ cropHeight: cropHeight.get(),
+ });
+ });
+
+ const leftEdgeStyle = useAnimatedStyle(() => {
+ 'worklet';
+
+ return StyleUtils.getCropViewStyle('edgeLeft', {
+ cropX: cropX.get(),
+ cropY: cropY.get(),
+ cropWidth: cropWidth.get(),
+ cropHeight: cropHeight.get(),
+ });
+ });
+
+ const rightEdgeStyle = useAnimatedStyle(() => {
+ 'worklet';
+
+ return StyleUtils.getCropViewStyle('edgeRight', {
+ cropX: cropX.get(),
+ cropY: cropY.get(),
+ cropWidth: cropWidth.get(),
+ cropHeight: cropHeight.get(),
+ });
+ });
+
+ const overlayTopStyle = useAnimatedStyle(() => {
+ 'worklet';
+
+ const imageLeft = imageOffsetXSV.get();
+ const imageTop = imageOffsetYSV.get();
+ const imgDisplayWidth = displayWidthSV.get();
+ const cropTop = cropY.get();
+
+ return StyleUtils.getCropViewStyle('overlayTop', {
+ imageLeft,
+ imageTop,
+ imgDisplayWidth,
+ cropTop,
+ });
+ });
+
+ const overlayBottomStyle = useAnimatedStyle(() => {
+ 'worklet';
+
+ const imageLeft = imageOffsetXSV.get();
+ const imageTop = imageOffsetYSV.get();
+ const imgDisplayWidth = displayWidthSV.get();
+ const imgDisplayHeight = displayHeightSV.get();
+ const cropBottom = cropY.get() + cropHeight.get();
+ const imageBottom = imageTop + imgDisplayHeight;
+
+ return StyleUtils.getCropViewStyle('overlayBottom', {
+ imageLeft,
+ imageTop,
+ imgDisplayWidth,
+ cropBottom,
+ imageBottom,
+ });
+ });
+
+ const overlayLeftStyle = useAnimatedStyle(() => {
+ 'worklet';
+
+ const imageLeft = imageOffsetXSV.get();
+ const cropLeft = cropX.get();
+ const cropTop = cropY.get();
+ const cropHeightValue = cropHeight.get();
+
+ return StyleUtils.getCropViewStyle('overlayLeft', {
+ imageLeft,
+ cropLeft,
+ cropTop,
+ cropHeight: cropHeightValue,
+ });
+ });
+
+ const overlayRightStyle = useAnimatedStyle(() => {
+ 'worklet';
+
+ const imageLeft = imageOffsetXSV.get();
+ const imgDisplayWidth = displayWidthSV.get();
+ const cropRight = cropX.get() + cropWidth.get();
+ const imageRight = imageLeft + imgDisplayWidth;
+ const cropTop = cropY.get();
+ const cropHeightValue = cropHeight.get();
+
+ return StyleUtils.getCropViewStyle('overlayRight', {
+ imageLeft,
+ imgDisplayWidth,
+ cropRight,
+ imageRight,
+ cropTop,
+ cropHeight: cropHeightValue,
+ });
+ });
+
+ const cornerVisualStyle = StyleUtils.getCropViewStyle('cornerVisual');
+
+ return (
+
+ ControlSelection.blockElement(el as HTMLElement | null)}
+ >
+
+
+
+
+ {isCropInitialized && (
+ <>
+
+
+
+
+
+
+
+ {/* Edge handles */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Corner handles */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ )}
+ {!hasImageDimensions && }
+
+
+ );
+}
+
+ReceiptCropView.displayName = 'ReceiptCropView';
+
+export default ReceiptCropView;
+export type {CropRect};
diff --git a/src/languages/de.ts b/src/languages/de.ts
index cdc2e5526b00..897d7ed64908 100644
--- a/src/languages/de.ts
+++ b/src/languages/de.ts
@@ -1083,6 +1083,7 @@ const translations: TranslationDeepObject = {
deleteConfirmation: 'Sind Sie sicher, dass Sie diesen Beleg löschen möchten?',
addReceipt: 'Beleg hinzufügen',
scanFailed: 'Der Beleg konnte nicht gescannt werden, da Händler, Datum oder Betrag fehlen.',
+ crop: 'Zuschneiden',
addAReceipt: {
phrase1: 'Beleg hinzufügen',
phrase2: 'oder ziehe eine hierher und lege sie ab',
diff --git a/src/languages/en.ts b/src/languages/en.ts
index fe10986c45d5..97be62ab6255 100644
--- a/src/languages/en.ts
+++ b/src/languages/en.ts
@@ -1105,6 +1105,7 @@ const translations = {
deleteConfirmation: 'Are you sure you want to delete this receipt?',
addReceipt: 'Add receipt',
scanFailed: "The receipt couldn't be scanned, as it's missing a merchant, date, or amount.",
+ crop: 'Crop',
addAReceipt: {
phrase1: 'Add a receipt',
phrase2: 'or drag and drop one here',
diff --git a/src/languages/es.ts b/src/languages/es.ts
index 308af6fe1d71..e972a488ecbb 100644
--- a/src/languages/es.ts
+++ b/src/languages/es.ts
@@ -936,6 +936,7 @@ const translations: TranslationDeepObject = {
deleteConfirmation: '¿Estás seguro de que quieres borrar este recibo?',
addReceipt: 'Añadir recibo',
scanFailed: 'El recibo no pudo ser escaneado, ya que falta el comerciante, la fecha o el monto.',
+ crop: 'Recortar',
addAReceipt: {
phrase1: 'Añade un recibo',
phrase2: 'o arrastra y suelta uno aquí',
diff --git a/src/languages/fr.ts b/src/languages/fr.ts
index e2f830bb90bd..a6b7967a2dbf 100644
--- a/src/languages/fr.ts
+++ b/src/languages/fr.ts
@@ -1087,6 +1087,7 @@ const translations: TranslationDeepObject = {
deleteConfirmation: 'Voulez-vous vraiment supprimer ce reçu ?',
addReceipt: 'Ajouter un reçu',
scanFailed: 'Le reçu n’a pas pu être scanné, car il lui manque un commerçant, une date ou un montant.',
+ crop: 'Recadrer',
addAReceipt: {
phrase1: 'Ajouter un reçu',
phrase2: 'ou faites-en glisser un ici',
diff --git a/src/languages/it.ts b/src/languages/it.ts
index 71a693672a3a..72b4a0b126e0 100644
--- a/src/languages/it.ts
+++ b/src/languages/it.ts
@@ -1081,6 +1081,7 @@ const translations: TranslationDeepObject = {
deleteConfirmation: 'Sei sicuro di voler eliminare questa ricevuta?',
addReceipt: 'Aggiungi ricevuta',
scanFailed: 'La ricevuta non può essere acquisita perché manca il nome dell’esercente, la data o l’importo.',
+ crop: 'Ritaglia',
addAReceipt: {
phrase1: 'Aggiungi una ricevuta',
phrase2: 'o trascinalo qui',
diff --git a/src/languages/ja.ts b/src/languages/ja.ts
index 78da63366d2c..8f0ace2df696 100644
--- a/src/languages/ja.ts
+++ b/src/languages/ja.ts
@@ -1073,6 +1073,7 @@ const translations: TranslationDeepObject = {
deleteConfirmation: 'この領収書を削除してもよろしいですか?',
addReceipt: '領収書を追加',
scanFailed: 'このレシートは、店舗名、日付、または金額が不足しているためスキャンできませんでした。',
+ crop: 'トリミング',
addAReceipt: {
phrase1: '領収書を追加',
phrase2: 'または、ここにファイルをドラッグ&ドロップしてください',
diff --git a/src/languages/nl.ts b/src/languages/nl.ts
index fb7319713ee0..270fb8041331 100644
--- a/src/languages/nl.ts
+++ b/src/languages/nl.ts
@@ -1080,6 +1080,7 @@ const translations: TranslationDeepObject = {
deleteConfirmation: 'Weet je zeker dat je deze bon wilt verwijderen?',
addReceipt: 'Bon toevoegen',
scanFailed: 'De bon is niet gescand, omdat er een handelaar, datum of bedrag ontbreekt.',
+ crop: 'Bijsnijden',
addAReceipt: {
phrase1: 'Voeg een bon toe',
phrase2: 'of sleep ze hier naartoe',
diff --git a/src/languages/pl.ts b/src/languages/pl.ts
index 78200c1d5e0b..d7f9879de14d 100644
--- a/src/languages/pl.ts
+++ b/src/languages/pl.ts
@@ -1080,6 +1080,7 @@ const translations: TranslationDeepObject = {
deleteConfirmation: 'Czy na pewno chcesz usunąć ten paragon?',
addReceipt: 'Dodaj paragon',
scanFailed: 'Nie można było zeskanować paragonu, ponieważ brakuje na nim sprzedawcy, daty lub kwoty.',
+ crop: 'Przytnij',
addAReceipt: {
phrase1: 'Dodaj paragon',
phrase2: 'lub przeciągnij i upuść tutaj',
diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts
index caba5c45cf8a..89005bfca02c 100644
--- a/src/languages/pt-BR.ts
+++ b/src/languages/pt-BR.ts
@@ -1079,6 +1079,7 @@ const translations: TranslationDeepObject = {
deleteConfirmation: 'Tem certeza de que deseja excluir este recibo?',
addReceipt: 'Adicionar recibo',
scanFailed: 'O recibo não pôde ser digitalizado porque está faltando o comerciante, a data ou o valor.',
+ crop: 'Cortar',
addAReceipt: {
phrase1: 'Adicionar um recibo',
phrase2: 'ou arraste e solte um aqui',
diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts
index ebb52f8a4baf..a684bc7ea822 100644
--- a/src/languages/zh-hans.ts
+++ b/src/languages/zh-hans.ts
@@ -1060,6 +1060,7 @@ const translations: TranslationDeepObject = {
deleteConfirmation: '确定要删除这张收据吗?',
addReceipt: '添加收据',
scanFailed: '无法扫描此收据,因为缺少商家、日期或金额。',
+ crop: '裁剪',
addAReceipt: {
phrase1: '添加收据',
phrase2: '或将文件拖放到此处',
diff --git a/src/pages/media/AttachmentModalScreen/AttachmentModalBaseContent/index.tsx b/src/pages/media/AttachmentModalScreen/AttachmentModalBaseContent/index.tsx
index 5ea9b770d2ca..72c272305de0 100644
--- a/src/pages/media/AttachmentModalScreen/AttachmentModalBaseContent/index.tsx
+++ b/src/pages/media/AttachmentModalScreen/AttachmentModalBaseContent/index.tsx
@@ -67,6 +67,9 @@ function AttachmentModalBaseContent({
onCarouselAttachmentChange = () => {},
transaction: transactionProp,
shouldCloseOnSwipeDown = false,
+ footerActionButtons,
+ customAttachmentContent,
+ attachmentViewContainerStyles,
}: AttachmentModalBaseContentProps) {
const styles = useThemeStyles();
const {shouldUseNarrowLayout} = useResponsiveLayout();
@@ -326,7 +329,7 @@ function AttachmentModalBaseContent({
shouldOverlayDots
subTitleLink={currentAttachmentLink ?? ''}
/>
-
+
{isLoading && }
{shouldShowNotFoundPage && !isLoading && (
)}
- {shouldDisplayContent && Content}
+ {shouldDisplayContent && (customAttachmentContent ?? Content)}
+ {!!footerActionButtons && (
+
+
+ {footerActionButtons}
+
+
+ )}
{/* If we have an onConfirm method show a confirmation button */}
{!!onConfirm && !isConfirmButtonDisabled && (
diff --git a/src/pages/media/AttachmentModalScreen/AttachmentModalBaseContent/types.ts b/src/pages/media/AttachmentModalScreen/AttachmentModalBaseContent/types.ts
index 15d2c2bddf21..94d737d12ce9 100644
--- a/src/pages/media/AttachmentModalScreen/AttachmentModalBaseContent/types.ts
+++ b/src/pages/media/AttachmentModalScreen/AttachmentModalBaseContent/types.ts
@@ -1,5 +1,5 @@
import type {RefObject} from 'react';
-import type {View} from 'react-native';
+import type {StyleProp, View, ViewStyle} from 'react-native';
import type {OnyxEntry} from 'react-native-onyx';
import type {ValueOf} from 'type-fest';
import type {Attachment} from '@components/Attachments/types';
@@ -133,6 +133,15 @@ type AttachmentModalBaseContentProps = {
/** Allows users to swipe down to close the modal */
shouldCloseOnSwipeDown?: boolean;
+
+ /** Footer action buttons to display below the image */
+ footerActionButtons?: React.ReactNode;
+
+ /** Custom content to render instead of the default attachment view (e.g., crop view) */
+ customAttachmentContent?: React.ReactNode;
+
+ /** Extra styles to pass for the attachment view container */
+ attachmentViewContainerStyles?: StyleProp;
};
export type {
diff --git a/src/pages/media/AttachmentModalScreen/routes/TransactionReceiptModalContent.tsx b/src/pages/media/AttachmentModalScreen/routes/TransactionReceiptModalContent.tsx
index 72eaf7b7d772..e74c61f6c441 100644
--- a/src/pages/media/AttachmentModalScreen/routes/TransactionReceiptModalContent.tsx
+++ b/src/pages/media/AttachmentModalScreen/routes/TransactionReceiptModalContent.tsx
@@ -1,12 +1,17 @@
import {Str} from 'expensify-common';
import React, {useCallback, useEffect, useMemo, useState} from 'react';
+import {View} from 'react-native';
+import Button from '@components/Button';
import ConfirmModal from '@components/ConfirmModal';
+import ReceiptCropView from '@components/ReceiptCropView';
+import type {CropRect} from '@components/ReceiptCropView';
import useAllTransactions from '@hooks/useAllTransactions';
import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset';
import useLocalize from '@hooks/useLocalize';
import useNetwork from '@hooks/useNetwork';
import useOnyx from '@hooks/useOnyx';
import usePolicy from '@hooks/usePolicy';
+import useThemeStyles from '@hooks/useThemeStyles';
import {detachReceipt, navigateToStartStepIfScanFileCannotBeRead, removeMoneyRequestOdometerImage, replaceReceipt, setMoneyRequestReceipt} from '@libs/actions/IOU';
import {openReport} from '@libs/actions/Report';
import cropOrRotateImage from '@libs/cropOrRotateImage';
@@ -32,10 +37,9 @@ import useDownloadAttachment from './hooks/useDownloadAttachment';
function TransactionReceiptModalContent({navigation, route}: AttachmentModalScreenProps) {
const {reportID, transactionID, action, iouType: iouTypeParam, readonly: readonlyParam, mergeTransactionID, imageType} = route.params;
- const icons = useMemoizedLazyExpensifyIcons(['Download', 'Trashcan'] as const);
const {translate} = useLocalize();
const {isOffline} = useNetwork();
- const expensifyIcons = useMemoizedLazyExpensifyIcons(['Camera']);
+ const expensifyIcons = useMemoizedLazyExpensifyIcons(['Camera', 'Download', 'Crop', 'Trashcan', 'Rotate', 'Close', 'Checkmark']);
const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`);
const allTransactions = useAllTransactions();
@@ -134,6 +138,10 @@ function TransactionReceiptModalContent({navigation, route}: AttachmentModalScre
const [isDeleteReceiptConfirmModalVisible, setIsDeleteReceiptConfirmModalVisible] = useState(false);
const [isRotating, setIsRotating] = useState(false);
+ const [isCropping, setIsCropping] = useState(false);
+ const [isCropSaving, setIsCropSaving] = useState(false);
+ const [cropRect, setCropRect] = useState(null);
+ const styles = useThemeStyles();
useEffect(() => {
if ((!!report && !!transaction) || isDraftTransaction) {
@@ -243,9 +251,6 @@ function TransactionReceiptModalContent({navigation, route}: AttachmentModalScre
const allowDownload = !isEReceipt;
- /**
- * Rotate the receipt image 90 degrees and save it automatically.
- */
const rotateReceipt = useCallback(() => {
if (!transaction?.transactionID || !sourceUri || !isImage) {
return;
@@ -265,7 +270,6 @@ function TransactionReceiptModalContent({navigation, route}: AttachmentModalScre
return;
}
- // Both web and native return objects with uri property
const imageUriResult = 'uri' in rotatedImage && rotatedImage.uri ? rotatedImage.uri : undefined;
if (!imageUriResult) {
setIsRotating(false);
@@ -276,7 +280,6 @@ function TransactionReceiptModalContent({navigation, route}: AttachmentModalScre
const rotatedFilename = file.name ?? receiptFilename;
if (isDraftTransaction) {
- // Update the transaction immediately so the modal displays the rotated image right away
setMoneyRequestReceipt(transaction.transactionID, imageUriResult, rotatedFilename, isDraftTransaction, receiptType);
} else {
replaceReceipt({
@@ -294,7 +297,7 @@ function TransactionReceiptModalContent({navigation, route}: AttachmentModalScre
});
}, [transaction?.transactionID, isDraftTransaction, sourceUri, isImage, receiptFilename, policyCategories, transaction?.receipt?.type, policy]);
- const shouldShowRotateReceiptButton = useMemo(
+ const shouldShowRotateAndCropReceiptButton = useMemo(
() =>
shouldShowReplaceReceiptButton &&
transaction &&
@@ -305,35 +308,87 @@ function TransactionReceiptModalContent({navigation, route}: AttachmentModalScre
[shouldShowReplaceReceiptButton, transaction, isEReceipt, receiptFilename],
);
+ const enterCropMode = useCallback(() => {
+ setIsCropping(true);
+ setCropRect(null);
+ }, []);
+
+ const exitCropMode = useCallback(() => {
+ setIsCropping(false);
+ setCropRect(null);
+ }, []);
+
+ const handleCropChange = useCallback((crop: CropRect) => {
+ setCropRect(crop);
+ }, []);
+
+ const saveCrop = useCallback(() => {
+ if (!transaction?.transactionID || !sourceUri || !isImage || !cropRect) {
+ return;
+ }
+
+ const receiptType = transaction?.receipt?.type ?? CONST.IMAGE_FILE_FORMAT.JPEG;
+
+ setIsCropSaving(true);
+ cropOrRotateImage(
+ sourceUri as string,
+ [
+ {
+ crop: {
+ originX: cropRect.x,
+ originY: cropRect.y,
+ width: cropRect.width,
+ height: cropRect.height,
+ },
+ },
+ ],
+ {
+ compress: 1,
+ name: receiptFilename,
+ type: receiptType,
+ },
+ )
+ .then((croppedImage) => {
+ if (!croppedImage) {
+ setIsCropSaving(false);
+ return;
+ }
+
+ const imageUriResult = 'uri' in croppedImage && croppedImage.uri ? croppedImage.uri : undefined;
+ if (!imageUriResult) {
+ setIsCropSaving(false);
+ return;
+ }
+
+ const file = croppedImage as File;
+ const croppedFilename = file.name ?? receiptFilename;
+
+ if (isDraftTransaction) {
+ setMoneyRequestReceipt(transaction.transactionID, imageUriResult, croppedFilename, isDraftTransaction, receiptType);
+ } else {
+ replaceReceipt({
+ transactionID: transaction.transactionID,
+ file,
+ source: imageUriResult,
+ transactionPolicyCategories: policyCategories,
+ transactionPolicy: policy,
+ });
+ }
+ setIsCropSaving(false);
+ setIsCropping(false);
+ setCropRect(null);
+ })
+ .catch(() => {
+ setIsCropSaving(false);
+ });
+ }, [transaction?.transactionID, isDraftTransaction, sourceUri, isImage, cropRect, receiptFilename, policyCategories, transaction?.receipt?.type, policy]);
+
const threeDotsMenuItems: ThreeDotsMenuItemFactory = useCallback(
({file, source: innerSource, isLocalSource}) => {
const menuItems = [];
- if (shouldShowReplaceReceiptButton || isOdometerImage) {
- menuItems.push({
- icon: expensifyIcons.Camera,
- text: translate('common.replace'),
- onSelected: () => {
- Navigation.dismissModal({
- callback: () =>
- Navigation.navigate(
- isOdometerImage
- ? ROUTES.ODOMETER_IMAGE.getRoute(action ?? CONST.IOU.ACTION.CREATE, iouType, transactionID, reportID, imageType)
- : ROUTES.MONEY_REQUEST_STEP_SCAN.getRoute(
- action ?? CONST.IOU.ACTION.EDIT,
- iouType,
- draftTransactionID ?? transaction?.transactionID,
- report?.reportID,
- Navigation.getActiveRoute(),
- ),
- ),
- });
- },
- sentryLabel: CONST.SENTRY_LABEL.RECEIPT_MODAL.REPLACE_RECEIPT,
- });
- }
if ((!isOffline && allowDownload && !isLocalSource) || !!draftTransactionID) {
menuItems.push({
- icon: icons.Download,
+ icon: expensifyIcons.Download,
text: translate('common.download'),
onSelected: () => onDownloadAttachment({source: innerSource, file}),
sentryLabel: CONST.SENTRY_LABEL.RECEIPT_MODAL.DOWNLOAD_RECEIPT,
@@ -350,7 +405,7 @@ function TransactionReceiptModalContent({navigation, route}: AttachmentModalScre
const isDraftOdometer = isOdometerImage && isDraftTransaction;
if (isDeletableReceipt || isDraftOdometer) {
menuItems.push({
- icon: icons.Trashcan,
+ icon: expensifyIcons.Trashcan,
text: isOdometerImage ? translate('distance.odometer.deleteOdometerPhoto') : translate('receipt.deleteReceipt'),
onSelected: () => setIsDeleteReceiptConfirmModalVisible?.(true),
shouldCallAfterModalHide: true,
@@ -360,7 +415,6 @@ function TransactionReceiptModalContent({navigation, route}: AttachmentModalScre
return menuItems;
},
[
- shouldShowReplaceReceiptButton,
isOdometerImage,
isOffline,
allowDownload,
@@ -369,16 +423,8 @@ function TransactionReceiptModalContent({navigation, route}: AttachmentModalScre
shouldShowDeleteReceiptButton,
transactionReport,
isDraftTransaction,
- expensifyIcons.Camera,
translate,
- action,
- iouType,
- transactionID,
- reportID,
- imageType,
- report?.reportID,
- icons.Download,
- icons.Trashcan,
+ expensifyIcons,
onDownloadAttachment,
],
);
@@ -406,6 +452,126 @@ function TransactionReceiptModalContent({navigation, route}: AttachmentModalScre
[deleteReceiptAndClose, deleteOdometerImageAndClose, isDeleteReceiptConfirmModalVisible, isOdometerImage, translate],
);
+ const footerActionButtons = useMemo(() => {
+ if (isCropping) {
+ return (
+
+
+
+
+ );
+ }
+
+ if (!shouldShowRotateAndCropReceiptButton && !shouldShowReplaceReceiptButton && !isOdometerImage) {
+ return null;
+ }
+
+ return (
+
+ {!!shouldShowRotateAndCropReceiptButton && (
+
+ )}
+ {!!shouldShowRotateAndCropReceiptButton && (
+
+ )}
+ {(shouldShowReplaceReceiptButton || isOdometerImage) && (
+
+ );
+ }, [
+ isCropping,
+ shouldShowRotateAndCropReceiptButton,
+ shouldShowReplaceReceiptButton,
+ isOdometerImage,
+ styles.flexRow,
+ styles.gap2,
+ styles.ph5,
+ styles.pb5,
+ styles.justifyContentCenter,
+ styles.transactionReceiptButton,
+ expensifyIcons.Rotate,
+ expensifyIcons.Crop,
+ expensifyIcons.Camera,
+ expensifyIcons.Close,
+ expensifyIcons.Checkmark,
+ rotateReceipt,
+ translate,
+ isRotating,
+ enterCropMode,
+ exitCropMode,
+ saveCrop,
+ isCropSaving,
+ cropRect,
+ action,
+ iouType,
+ transactionID,
+ reportID,
+ imageType,
+ draftTransactionID,
+ transaction?.transactionID,
+ report?.reportID,
+ ]);
+
+ const customAttachmentContent = useMemo(() => {
+ if (!isCropping || !source) {
+ return null;
+ }
+
+ return (
+
+ );
+ }, [isCropping, source, handleCropChange, isAuthTokenRequired]);
+
const contentProps = useMemo(
() => ({
source,
@@ -418,29 +584,31 @@ function TransactionReceiptModalContent({navigation, route}: AttachmentModalScre
isLoading: !transaction && reportMetadata?.isLoadingInitialReportActions,
shouldShowNotFoundPage,
shouldShowCarousel: false,
- shouldShowRotateButton: shouldShowRotateReceiptButton,
- onRotateButtonPress: rotateReceipt,
- isRotating,
+ shouldShowRotateButton: false,
onDownloadAttachment: allowDownload ? undefined : onDownloadAttachment,
transaction,
shouldMinimizeMenuButton: false,
+ footerActionButtons,
+ customAttachmentContent,
+ attachmentViewContainerStyles: [styles.pv5, styles.ph2],
}),
[
- allowDownload,
+ source,
+ originalFileName,
+ report,
headerTitle,
+ threeDotsMenuItems,
isAuthTokenRequired,
isTrackExpenseActionValue,
- onDownloadAttachment,
- originalFileName,
- report,
+ transaction,
reportMetadata?.isLoadingInitialReportActions,
shouldShowNotFoundPage,
- shouldShowRotateReceiptButton,
- rotateReceipt,
- isRotating,
- source,
- threeDotsMenuItems,
- transaction,
+ allowDownload,
+ onDownloadAttachment,
+ footerActionButtons,
+ customAttachmentContent,
+ styles.pv5,
+ styles.ph2,
],
);
diff --git a/src/styles/index.ts b/src/styles/index.ts
index 0ab688145bbf..61cc9664e02d 100644
--- a/src/styles/index.ts
+++ b/src/styles/index.ts
@@ -5790,6 +5790,9 @@ const staticStyles = (theme: ThemeColors) =>
paymentMethodErrorRow: {
paddingHorizontal: variables.iconSizeMenuItem + variables.iconSizeNormal / 2,
},
+ transactionReceiptButton: {
+ width: variables.transactionReceiptButtonWidth,
+ },
chartHeader: {
flexDirection: 'row',
alignItems: 'center',
diff --git a/src/styles/utils/cursor/index.native.ts b/src/styles/utils/cursor/index.native.ts
index 74b9b72bd1d0..a12cf4ecf0f9 100644
--- a/src/styles/utils/cursor/index.native.ts
+++ b/src/styles/utils/cursor/index.native.ts
@@ -12,6 +12,10 @@ const cursor: CursorStyles = {
cursorZoomOut: {},
cursorInitial: {},
cursorText: {},
+ cursorEwResize: {},
+ cursorNsResize: {},
+ cursorNeswResize: {},
+ cursorNwseResize: {},
};
export default cursor;
diff --git a/src/styles/utils/cursor/index.ts b/src/styles/utils/cursor/index.ts
index 514571522faa..ad8abc23cdff 100644
--- a/src/styles/utils/cursor/index.ts
+++ b/src/styles/utils/cursor/index.ts
@@ -39,6 +39,18 @@ const cursor: CursorStyles = {
cursorText: {
cursor: 'text' as ViewStyle['cursor'],
},
+ cursorEwResize: {
+ cursor: 'ew-resize' as ViewStyle['cursor'],
+ },
+ cursorNsResize: {
+ cursor: 'ns-resize' as ViewStyle['cursor'],
+ },
+ cursorNeswResize: {
+ cursor: 'nesw-resize' as ViewStyle['cursor'],
+ },
+ cursorNwseResize: {
+ cursor: 'nwse-resize' as ViewStyle['cursor'],
+ },
};
export default cursor;
diff --git a/src/styles/utils/cursor/types.ts b/src/styles/utils/cursor/types.ts
index 7ffac48612fb..cc990268ceed 100644
--- a/src/styles/utils/cursor/types.ts
+++ b/src/styles/utils/cursor/types.ts
@@ -11,7 +11,11 @@ type CursorStylesKeys =
| 'cursorGrabbing'
| 'cursorZoomOut'
| 'cursorInitial'
- | 'cursorText';
+ | 'cursorText'
+ | 'cursorEwResize'
+ | 'cursorNsResize'
+ | 'cursorNeswResize'
+ | 'cursorNwseResize';
type CursorStyles = Record>;
diff --git a/src/styles/utils/index.ts b/src/styles/utils/index.ts
index 8933b41e1e65..edbd9febe17a 100644
--- a/src/styles/utils/index.ts
+++ b/src/styles/utils/index.ts
@@ -1994,6 +1994,195 @@ const createStyleUtils = (theme: ThemeColors, styles: ThemeStyles) => ({
containerStyle: {paddingBottom},
};
},
+
+ /**
+ * Returns a single crop view style by key. Use this from useAnimatedStyle to avoid building all 14 styles per frame.
+ * @param params - Object containing dynamic crop values (same as getCropViewStyles)
+ * @param key - Which style to return
+ */
+ getCropViewStyle: (
+ key:
+ | 'cornerVisual'
+ | 'border'
+ | 'cornerTopLeft'
+ | 'cornerTopRight'
+ | 'cornerBottomLeft'
+ | 'cornerBottomRight'
+ | 'edgeTop'
+ | 'edgeBottom'
+ | 'edgeLeft'
+ | 'edgeRight'
+ | 'overlayTop'
+ | 'overlayBottom'
+ | 'overlayLeft'
+ | 'overlayRight',
+ params?: {
+ cropX?: number;
+ cropY?: number;
+ cropWidth?: number;
+ cropHeight?: number;
+ imageLeft?: number;
+ imageTop?: number;
+ imgDisplayWidth?: number;
+ cropLeft?: number;
+ cropRight?: number;
+ cropTop?: number;
+ cropBottom?: number;
+ imageRight?: number;
+ imageBottom?: number;
+ },
+ ) => {
+ 'worklet';
+
+ const {
+ cropX = 0,
+ cropY = 0,
+ cropWidth = 0,
+ cropHeight = 0,
+ imageLeft = 0,
+ imageTop = 0,
+ imgDisplayWidth = 0,
+ cropLeft = 0,
+ cropRight = 0,
+ cropTop = 0,
+ cropBottom = 0,
+ imageRight = 0,
+ imageBottom = 0,
+ } = params ?? {};
+
+ switch (key) {
+ case 'cornerVisual':
+ return {
+ position: 'absolute' as const,
+ left: (variables.cornerTapTargetSize - variables.cornerHandleSize) / 2,
+ top: (variables.cornerTapTargetSize - variables.cornerHandleSize) / 2,
+ width: variables.cornerHandleSize,
+ height: variables.cornerHandleSize,
+ borderRadius: variables.cornerHandleSize / 2,
+ backgroundColor: theme.success,
+ };
+ case 'border':
+ return {
+ ...styles.pAbsolute,
+ left: cropX,
+ top: cropY,
+ width: cropWidth,
+ height: cropHeight,
+ borderWidth: variables.cropBorderWidth,
+ borderColor: theme.success,
+ };
+ case 'cornerTopLeft':
+ return {
+ ...styles.pAbsolute,
+ left: cropX - variables.cornerTapTargetSize / 2,
+ top: cropY - variables.cornerTapTargetSize / 2,
+ width: variables.cornerTapTargetSize,
+ height: variables.cornerTapTargetSize,
+ ...styles.cursorNwseResize,
+ };
+ case 'cornerTopRight':
+ return {
+ ...styles.pAbsolute,
+ left: cropX + cropWidth - variables.cornerTapTargetSize / 2,
+ top: cropY - variables.cornerTapTargetSize / 2,
+ width: variables.cornerTapTargetSize,
+ height: variables.cornerTapTargetSize,
+ ...styles.cursorNeswResize,
+ };
+ case 'cornerBottomLeft':
+ return {
+ ...styles.pAbsolute,
+ left: cropX - variables.cornerTapTargetSize / 2,
+ top: cropY + cropHeight - variables.cornerTapTargetSize / 2,
+ width: variables.cornerTapTargetSize,
+ height: variables.cornerTapTargetSize,
+ ...styles.cursorNeswResize,
+ };
+ case 'cornerBottomRight':
+ return {
+ ...styles.pAbsolute,
+ left: cropX + cropWidth - variables.cornerTapTargetSize / 2,
+ top: cropY + cropHeight - variables.cornerTapTargetSize / 2,
+ width: variables.cornerTapTargetSize,
+ height: variables.cornerTapTargetSize,
+ ...styles.cursorNwseResize,
+ };
+ case 'edgeTop':
+ return {
+ ...styles.pAbsolute,
+ left: cropX,
+ top: cropY - variables.edgeHandleTapTargetThickness / 2,
+ width: cropWidth,
+ height: variables.edgeHandleTapTargetThickness,
+ ...styles.cursorNsResize,
+ };
+ case 'edgeBottom':
+ return {
+ ...styles.pAbsolute,
+ left: cropX,
+ top: cropY + cropHeight - variables.edgeHandleTapTargetThickness / 2,
+ width: cropWidth,
+ height: variables.edgeHandleTapTargetThickness,
+ ...styles.cursorNsResize,
+ };
+ case 'edgeLeft':
+ return {
+ ...styles.pAbsolute,
+ left: cropX - variables.edgeHandleTapTargetThickness / 2,
+ top: cropY,
+ width: variables.edgeHandleTapTargetThickness,
+ height: cropHeight,
+ ...styles.cursorEwResize,
+ };
+ case 'edgeRight':
+ return {
+ ...styles.pAbsolute,
+ left: cropX + cropWidth - variables.edgeHandleTapTargetThickness / 2,
+ top: cropY,
+ width: variables.edgeHandleTapTargetThickness,
+ height: cropHeight,
+ ...styles.cursorEwResize,
+ };
+ case 'overlayTop':
+ return {
+ ...styles.pAbsolute,
+ left: imageLeft,
+ top: imageTop,
+ width: imgDisplayWidth,
+ height: Math.max(0, cropTop - imageTop),
+ backgroundColor: theme.transparentWhite,
+ };
+ case 'overlayBottom':
+ return {
+ ...styles.pAbsolute,
+ left: imageLeft,
+ top: cropBottom,
+ width: imgDisplayWidth,
+ height: Math.max(0, imageBottom - cropBottom),
+ backgroundColor: theme.transparentWhite,
+ };
+ case 'overlayLeft':
+ return {
+ ...styles.pAbsolute,
+ left: imageLeft,
+ top: cropTop,
+ width: Math.max(0, cropLeft - imageLeft),
+ height: cropHeight,
+ backgroundColor: theme.transparentWhite,
+ };
+ case 'overlayRight':
+ return {
+ ...styles.pAbsolute,
+ left: cropRight,
+ top: cropTop,
+ width: Math.max(0, imageRight - cropRight),
+ height: cropHeight,
+ backgroundColor: theme.transparentWhite,
+ };
+ default:
+ return {};
+ }
+ },
});
type StyleUtilsType = ReturnType;
diff --git a/src/styles/variables.ts b/src/styles/variables.ts
index e3596ccf859e..08ef5dbfd8bb 100644
--- a/src/styles/variables.ts
+++ b/src/styles/variables.ts
@@ -147,8 +147,12 @@ export default {
checkboxLabelActiveOpacity: 0.7,
checkboxLabelHoverOpacity: 1,
avatarChatSpacing: 12,
+ cornerHandleSize: 12,
+ cornerTapTargetSize: 40,
+ edgeHandleTapTargetThickness: 12,
chatInputSpacing: 52, // 40 + avatarChatSpacing
borderTopWidth: 1,
+ cropBorderWidth: 1,
emptyLHNIconWidth: 24, // iconSizeSmall + 4*2 horizontal margin
emptyLHNIconHeight: 16,
emptySelectionListIconWidth: 120,
@@ -245,6 +249,7 @@ export default {
changePolicyEducationModalWidth: 400,
changePolicyEducationModalIconWidth: 147.69,
changePolicyEducationModalIconHeight: 180,
+ transactionReceiptButtonWidth: 100,
fontSizeToWidthRatio: getValueUsingPixelRatio(0.8, 1),