From 45158cde38bd04cc3bd13d093930fc13b98a3c7f Mon Sep 17 00:00:00 2001 From: "azykov@mail.ru" Date: Tue, 29 Sep 2026 20:04:58 +0300 Subject: [PATCH] camera and gallery image crop --- development specification-v2.md | 2 +- mobile-client/src/App.tsx | 3 +- mobile-client/src/CropScreen.tsx | 192 +++++++++++++++++++++++ mobile-client/src/ScannerView.tsx | 5 + mobile-client/src/cropMath.test.ts | 45 ++++++ mobile-client/src/cropMath.ts | 85 ++++++++++ mobile-client/src/imageTools.ts | 78 ++++++--- mobile-client/src/platform.ts | 21 +++ mobile-client/src/screens/EditScreen.tsx | 44 +++++- mobile-client/src/styles.css | 104 ++++++++++++ mobile-client/src/ui.tsx | 1 + 11 files changed, 553 insertions(+), 27 deletions(-) create mode 100644 mobile-client/src/CropScreen.tsx create mode 100644 mobile-client/src/cropMath.test.ts create mode 100644 mobile-client/src/cropMath.ts diff --git a/development specification-v2.md b/development specification-v2.md index 68673f0..3083f2a 100644 --- a/development specification-v2.md +++ b/development specification-v2.md @@ -206,7 +206,7 @@ React + Vite + TS; PWA и Android-приложение через Capacitor. PWA - **Экран карт**: список локально хранящихся карт (цветные плашки с лого или, если лого нет, с аббревиатурой названия на цветном фоне), сортировка по давности использования, поиск по подстроке (от 1 символа, без учёта регистра) в названии, тегах и комментарии, кнопки «обновить» и «добавить». - **«Обновить»**: скачивает список карт с сервера; при успехе заменяет локальный список (карты, пропавшие с сервера, удаляются локально вместе с картинками; даты использования сохраняются по ID карты). Затем для карт, у которых изменился `imagesVersion`, асинхронно скачиваются лого и картинки — не больше 5 запросов одновременно. Докачиваются и файлы, которых нет локально (например, после сбоя); версия картинок карты запоминается только когда скачаны все её файлы. - **Экран карты**: название, код (отрисовка `bwip-js`), теги, комментарий (с переносами строк), локальные картинки. На Android яркость окна поднимается до максимума (собственный плагин `ScreenBrightness` в `android/`) и возвращается к системной при уходе с экрана. Открытие экрана считается использованием карты (сохраняются дата и время). Кнопки «редактировать» и «удалить» (с подтверждением). -- **Экран добавления и редактирования**: название, комментарий (многострочное поле), теги (через запятую), цвет (по умолчанию случайный из палитры цветов, контрастных с белым текстом), лого, картинки, код и тип кода — вручную или распознаванием с камеры или из загруженной картинки (`barcode-detector` на `zxing-wasm` и в PWA, и в Android WebView; wasm-модуль входит в сборку). Лого и картинки можно снять камерой (кнопка «Camera», поле выбора файла с `capture`) или выбрать из галереи («Gallery», для фото — сразу несколько). Лого и картинки перед отправкой пережимаются в WebP до 1000px по большей стороне, EXIF удаляется. +- **Экран добавления и редактирования**: название, комментарий (многострочное поле), теги (через запятую), цвет (по умолчанию случайный из палитры цветов, контрастных с белым текстом), лого, картинки, код и тип кода — вручную или распознаванием с камеры или из загруженной картинки (`barcode-detector` на `zxing-wasm` и в PWA, и в Android WebView; wasm-модуль входит в сборку). После съёмки или выбора каждое изображение открывается на экране кадрирования: рамку можно двигать и менять за углы (для лого рамка квадратная, для фото свободная и по умолчанию охватывает всё изображение), изображение можно повернуть на 90°; «Cancel» пропускает изображение, несколько фото из галереи кадрируются по очереди; системная кнопка «назад» Android закрывает кадрирование (и сканер камеры), не уходя с экрана редактирования. Лого и картинки можно снять камерой (кнопка «Camera», поле выбора файла с `capture`) или выбрать из галереи («Gallery», для фото — сразу несколько). Лого и картинки перед отправкой пережимаются в WebP до 1000px по большей стороне, EXIF удаляется. - После успешного добавления или редактирования локальная копия карты сразу обновляется ответом сервера; загруженные картинки сохраняются локально без повторного скачивания. Если сохранение прервалось на середине (например, карта создана, а фото не загрузились), выполненные шаги сохраняются и «Save» повторяет только оставшиеся. - **Обновления Android-приложения без переустановки APK** (`@capgo/capacitor-updater` в ручном режиме, без облака Capgo, телеметрия отключена). APK содержит веб-сборку и полностью работает офлайн. `pnpm publish:android` собирает новую версию (номер — время сборки в UTC, `ГГГГ.МДД.Ччммсс`), кладёт zip и `version.json` (версия, SHA-256, минимальный `versionCode` APK) в `mobile-client/updates/android`; сервер `mobile-client` раздаёт их по `/m/android/`. При запуске и каждом возврате в приложение (не чаще раза в минуту, только при наличии связи) приложение проверяет `version.json` и скачивает более новую совместимую версию в фоне. На экране списка карт (разблокировано, без идущей загрузки картинок) она применяется сразу — перезагрузкой без повторного PIN; на экране карты, в редакторе, на экране входа или PIN — откладывается (`next()`: применяется при уходе в фон или при возврате к списку). Версия, не подтвердившая запуск за 10 секунд, откатывается к предыдущей. Установка нового APK сбрасывает скачанные версии. - **Обновление самого APK** (при изменениях нативной части: плагины, манифест, Java). `pnpm publish:android-apk` собирает релизный APK, подписанный ключом из `android/keystore.properties` (не в git; тем же ключом подписываются и отладочные сборки), с `versionCode` из времени сборки (`ггДДДЧЧмм`, UTC) и `versionName` = версия веб-сборки, и кладёт его вместе с `apk.json` (versionCode, versionName, SHA-256, размер) в `mobile-client/updates/android`. При запуске и возврате в приложение (не чаще раза в минуту) приложение сверяет `apk.json` со своим `versionCode` и при наличии более нового APK показывает на экране списка карт баннер «A new version of the app is available» с кнопкой Install: APK скачивается с прогрессом, проверяется SHA-256 и передаётся системному установщику Android (`PackageInstaller`). В первый раз нужно разрешить Cardy «Установку неизвестных приложений» (приложение открывает этот экран настроек), затем подтвердить системный диалог; на Android 12+ последующие обновления могут устанавливаться без подтверждения, если это разрешит система. Веб-обновления, опубликованные после выпуска APK, требуют этот APK или новее (`minNativeBuild`), старые установки получают предложение обновить APK. diff --git a/mobile-client/src/App.tsx b/mobile-client/src/App.tsx index d113d33..9f312bf 100644 --- a/mobile-client/src/App.tsx +++ b/mobile-client/src/App.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { parseLoginDeepLink, type Card } from '@cardy/shared'; import { createApi } from './api.js'; import { useNavigation, type LoginPrefill } from './navigation.js'; -import { onBackButton, onDeepLink, onResumeAfter } from './platform.js'; +import { onBackButton, onDeepLink, onResumeAfter, runOverlayBack } from './platform.js'; import { cardImageIds, images, storage, type PinRecord, type Session, type UsageMap } from './storage.js'; import { refreshCardList, syncImages } from './sync.js'; import { @@ -233,6 +233,7 @@ function Main({ boot, justUpdated }: { boot: Boot; justUpdated: boolean }) { useEffect( () => onBackButton(() => { + if (runOverlayBack()) return true; const { locked: isLocked, lockedLogin: overLock } = lockedRef.current; if (isLocked && !overLock) return false; return back(); diff --git a/mobile-client/src/CropScreen.tsx b/mobile-client/src/CropScreen.tsx new file mode 100644 index 0000000..d102913 --- /dev/null +++ b/mobile-client/src/CropScreen.tsx @@ -0,0 +1,192 @@ +import { useEffect, useLayoutEffect, useRef, useState, type PointerEvent as ReactPointerEvent } from 'react'; +import { dragCrop, initialCrop, roundCrop, type CropRect, type DragMode } from './cropMath.js'; +import { encodeImage, loadImage, rotateImage } from './imageTools.js'; +import { pushOverlayBack } from './platform.js'; +import { Icon } from './ui.js'; + +interface Drag { + mode: DragMode; + pointerId: number; + startX: number; + startY: number; + startRect: CropRect; +} + +/** + * Full-screen cropper for a picked logo or photo: drag the frame, resize it by the corners, rotate + * by 90°. `square` locks the frame to a square (logos). Resolves with the encoded image (WebP, ≤1000px). + */ +export function CropScreen({ + file, + square, + title, + onDone, + onCancel, +}: { + file: Blob; + square: boolean; + title: string; + onDone: (image: Blob) => void; + onCancel: () => void; +}) { + const [bitmap, setBitmap] = useState(); + const [rect, setRect] = useState(); + const [busy, setBusy] = useState(true); + const [error, setError] = useState(); + const [area, setArea] = useState({ width: 0, height: 0 }); + const areaRef = useRef(null); + const canvasRef = useRef(null); + const drag = useRef(undefined); + + // Android back button closes the cropper (skips this image), not the editor underneath. + const cancelRef = useRef(onCancel); + cancelRef.current = onCancel; + useEffect(() => pushOverlayBack(cancelRef), []); + + useEffect(() => { + let alive = true; + let loaded: ImageBitmap | undefined; + setBusy(true); + loadImage(file) + .then((b) => { + loaded = b; + if (!alive) return b.close(); + setBitmap(b); + setRect(initialCrop(b.width, b.height, square)); + }) + .catch(() => alive && setError('This image cannot be opened')) + .finally(() => alive && setBusy(false)); + return () => { + alive = false; + loaded?.close(); + }; + }, [file, square]); + + useLayoutEffect(() => { + const el = areaRef.current; + if (!el) return; + // Measure right away (ResizeObserver only reports on the next rendered frame), then follow resizes. + const measure = () => setArea({ width: el.clientWidth, height: el.clientHeight }); + measure(); + const observer = new ResizeObserver(measure); + observer.observe(el); + return () => observer.disconnect(); + }, []); + + // Free the decoded image when the cropper closes. + const bitmapRef = useRef(bitmap); + bitmapRef.current = bitmap; + useEffect(() => () => bitmapRef.current?.close(), []); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas || !bitmap) return; + canvas.width = bitmap.width; + canvas.height = bitmap.height; + canvas.getContext('2d')?.drawImage(bitmap, 0, 0); + }, [bitmap]); + + const scale = bitmap && area.width ? Math.min(area.width / bitmap.width, area.height / bitmap.height) : 0; + const offsetX = bitmap ? (area.width - bitmap.width * scale) / 2 : 0; + const offsetY = bitmap ? (area.height - bitmap.height * scale) / 2 : 0; + + const startDrag = (mode: DragMode) => (e: ReactPointerEvent) => { + if (!rect || busy) return; + e.preventDefault(); + e.stopPropagation(); + e.currentTarget.setPointerCapture(e.pointerId); + drag.current = { mode, pointerId: e.pointerId, startX: e.clientX, startY: e.clientY, startRect: rect }; + }; + const moveDrag = (e: ReactPointerEvent) => { + const d = drag.current; + if (!d || d.pointerId !== e.pointerId || !bitmap || !scale) return; + const dx = (e.clientX - d.startX) / scale; + const dy = (e.clientY - d.startY) / scale; + setRect(dragCrop(d.mode, d.startRect, dx, dy, bitmap.width, bitmap.height, square)); + }; + const endDrag = (e: ReactPointerEvent) => { + if (drag.current?.pointerId === e.pointerId) drag.current = undefined; + }; + + const rotate = async () => { + if (!bitmap) return; + setBusy(true); + try { + const rotated = await rotateImage(bitmap); + bitmap.close(); + setBitmap(rotated); + setRect(initialCrop(rotated.width, rotated.height, square)); + } catch { + setError('Could not rotate the image'); + } finally { + setBusy(false); + } + }; + + const done = async () => { + if (!bitmap || !rect) return; + setBusy(true); + try { + onDone(await encodeImage(bitmap, roundCrop(rect, bitmap.width, bitmap.height))); + } catch (e) { + setError((e as Error).message); + setBusy(false); + } + }; + + const handlers = { onPointerMove: moveDrag, onPointerUp: endDrag, onPointerCancel: endDrag }; + + return ( +
+
+ +

{title}

+ +
+ +
+ {error &&

{error}

} + {!error && busy && !bitmap &&

Opening…

} + {bitmap && scale > 0 && ( + + )} + {bitmap && rect && scale > 0 && ( +
+ {(['nw', 'ne', 'sw', 'se'] as const).map((corner) => ( + + ))} +
+ )} +
+ +
+ + {rect && bitmap && ( + + )} +
+
+ ); +} diff --git a/mobile-client/src/ScannerView.tsx b/mobile-client/src/ScannerView.tsx index 113caa5..fb775ab 100644 --- a/mobile-client/src/ScannerView.tsx +++ b/mobile-client/src/ScannerView.tsx @@ -1,4 +1,5 @@ import { useEffect, useRef, useState } from 'react'; +import { pushOverlayBack } from './platform.js'; import type { ScanResult } from './scanner.js'; import { Icon } from './ui.js'; @@ -10,6 +11,10 @@ export function ScannerView({ formats, hint, onResult, onClose }: { formats: For const [error, setError] = useState(); const onResultRef = useRef(onResult); onResultRef.current = onResult; + // Android back button closes the scanner, not the screen underneath. + const closeRef = useRef(onClose); + closeRef.current = onClose; + useEffect(() => pushOverlayBack(closeRef), []); useEffect(() => { let stopped = false; diff --git a/mobile-client/src/cropMath.test.ts b/mobile-client/src/cropMath.test.ts new file mode 100644 index 0000000..03a53ea --- /dev/null +++ b/mobile-client/src/cropMath.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest'; +import { dragCrop, initialCrop, roundCrop } from './cropMath.js'; + +const W = 1000; +const H = 600; + +describe('crop geometry', () => { + it('starts with the whole image, or a centered square', () => { + expect(initialCrop(W, H, false)).toEqual({ x: 0, y: 0, width: 1000, height: 600 }); + expect(initialCrop(W, H, true)).toEqual({ x: 230, y: 30, width: 540, height: 540 }); + }); + + it('moves inside the image', () => { + const start = { x: 100, y: 100, width: 200, height: 200 }; + expect(dragCrop('move', start, 50, -20, W, H, false)).toEqual({ x: 150, y: 80, width: 200, height: 200 }); + expect(dragCrop('move', start, -500, 900, W, H, false)).toEqual({ x: 0, y: 400, width: 200, height: 200 }); + }); + + it('resizes around the opposite corner and stays inside', () => { + const start = { x: 100, y: 100, width: 200, height: 200 }; + expect(dragCrop('se', start, 50, 30, W, H, false)).toEqual({ x: 100, y: 100, width: 250, height: 230 }); + expect(dragCrop('nw', start, 50, 30, W, H, false)).toEqual({ x: 150, y: 130, width: 150, height: 170 }); + expect(dragCrop('ne', start, 2000, -2000, W, H, false)).toEqual({ x: 100, y: 0, width: 900, height: 300 }); + expect(dragCrop('sw', start, -2000, 2000, W, H, false)).toEqual({ x: 0, y: 100, width: 300, height: 500 }); + }); + + it('keeps a minimum size', () => { + const start = { x: 100, y: 100, width: 200, height: 200 }; + const shrunk = dragCrop('se', start, -1000, -1000, W, H, false, 48); + expect(shrunk).toEqual({ x: 100, y: 100, width: 48, height: 48 }); + }); + + it('keeps square crops square, limited by the nearer edge', () => { + const start = { x: 100, y: 100, width: 200, height: 200 }; + expect(dragCrop('se', start, 80, 10, W, H, true)).toEqual({ x: 100, y: 100, width: 280, height: 280 }); + const big = dragCrop('se', start, 5000, 5000, W, H, true); + expect(big).toEqual({ x: 100, y: 100, width: 500, height: 500 }); + const nw = dragCrop('nw', start, -500, -10, W, H, true); + expect(nw).toEqual({ x: 0, y: 0, width: 300, height: 300 }); + }); + + it('rounds to whole pixels inside the image', () => { + expect(roundCrop({ x: 10.4, y: -0.2, width: 99.6, height: 700 }, W, H)).toEqual({ x: 10, y: 0, width: 100, height: 600 }); + }); +}); diff --git a/mobile-client/src/cropMath.ts b/mobile-client/src/cropMath.ts new file mode 100644 index 0000000..0107d07 --- /dev/null +++ b/mobile-client/src/cropMath.ts @@ -0,0 +1,85 @@ +/** Crop rectangle in image pixels. */ +export interface CropRect { + x: number; + y: number; + width: number; + height: number; +} + +/** What is being dragged: the whole rectangle or one of its corners. */ +export type DragMode = 'move' | 'nw' | 'ne' | 'sw' | 'se'; + +const clamp = (v: number, lo: number, hi: number) => Math.min(Math.max(v, lo), hi); + +/** Smallest crop side, in image pixels. */ +export function minCropSide(imageWidth: number, imageHeight: number): number { + return Math.max(16, Math.round(Math.min(imageWidth, imageHeight) * 0.08)); +} + +/** Starting rectangle: the whole image, or the largest centered square (inset a little) for square crops. */ +export function initialCrop(imageWidth: number, imageHeight: number, square: boolean): CropRect { + if (!square) return { x: 0, y: 0, width: imageWidth, height: imageHeight }; + const side = Math.round(Math.min(imageWidth, imageHeight) * 0.9); + return { x: Math.round((imageWidth - side) / 2), y: Math.round((imageHeight - side) / 2), width: side, height: side }; +} + +/** + * Rectangle after dragging by (dx, dy) image pixels from `start`, kept inside the image, + * at least `minSide` on each side and square when `square` is set. Corners resize around the + * opposite corner, which stays in place. + */ +export function dragCrop( + mode: DragMode, + start: CropRect, + dx: number, + dy: number, + imageWidth: number, + imageHeight: number, + square: boolean, + minSide = minCropSide(imageWidth, imageHeight), +): CropRect { + if (mode === 'move') { + return { + ...start, + x: clamp(start.x + dx, 0, imageWidth - start.width), + y: clamp(start.y + dy, 0, imageHeight - start.height), + }; + } + + const west = mode === 'nw' || mode === 'sw'; + const north = mode === 'nw' || mode === 'ne'; + // The corner opposite to the dragged one stays fixed. + const anchorX = west ? start.x + start.width : start.x; + const anchorY = north ? start.y + start.height : start.y; + const maxWidth = west ? anchorX : imageWidth - anchorX; + const maxHeight = north ? anchorY : imageHeight - anchorY; + + let width = start.width + (west ? -dx : dx); + let height = start.height + (north ? -dy : dy); + if (square) { + const side = clamp(Math.max(width, height), minSide, Math.min(maxWidth, maxHeight)); + width = side; + height = side; + } else { + width = clamp(width, minSide, maxWidth); + height = clamp(height, minSide, maxHeight); + } + return { + x: west ? anchorX - width : anchorX, + y: north ? anchorY - height : anchorY, + width, + height, + }; +} + +/** Whole pixels, still inside the image. */ +export function roundCrop(rect: CropRect, imageWidth: number, imageHeight: number): CropRect { + const x = clamp(Math.round(rect.x), 0, imageWidth - 1); + const y = clamp(Math.round(rect.y), 0, imageHeight - 1); + return { + x, + y, + width: clamp(Math.round(rect.width), 1, imageWidth - x), + height: clamp(Math.round(rect.height), 1, imageHeight - y), + }; +} diff --git a/mobile-client/src/imageTools.ts b/mobile-client/src/imageTools.ts index 02ebdc5..42e75cd 100644 --- a/mobile-client/src/imageTools.ts +++ b/mobile-client/src/imageTools.ts @@ -1,27 +1,69 @@ +import type { CropRect } from './cropMath.js'; + +/** Longer side of uploaded logos and photos. */ export const MAX_IMAGE_SIDE = 1000; +/** Longer side kept while cropping: enough detail for a crop, bounded memory for camera photos. */ +const WORKING_MAX_SIDE = 2500; + +/** Decodes a picked file with its EXIF orientation applied, scaled down to a working size. */ +export async function loadImage(file: Blob, maxSide = WORKING_MAX_SIDE): Promise { + const original = await createImageBitmap(file, { imageOrientation: 'from-image' }); + const scale = Math.min(1, maxSide / Math.max(original.width, original.height)); + if (scale === 1) return original; + try { + return await createImageBitmap(original, { + resizeWidth: Math.max(1, Math.round(original.width * scale)), + resizeHeight: Math.max(1, Math.round(original.height * scale)), + resizeQuality: 'high', + }); + } finally { + original.close(); + } +} + +function canvas2d(width: number, height: number) { + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + const ctx = canvas.getContext('2d'); + if (!ctx) throw new Error('Canvas is not available'); + return { canvas, ctx }; +} + +/** The image turned 90° clockwise. */ +export async function rotateImage(bitmap: ImageBitmap): Promise { + const { canvas, ctx } = canvas2d(bitmap.height, bitmap.width); + ctx.translate(bitmap.height, 0); + ctx.rotate(Math.PI / 2); + ctx.drawImage(bitmap, 0, 0); + return createImageBitmap(canvas); +} /** - * Re-encodes a picked image to WebP (JPEG where WebP encoding is unsupported), at most - * MAX_IMAGE_SIDE px on the longer side. Re-encoding drops EXIF; orientation is applied first. + * Encodes `crop` of the image (whole image by default) as WebP (JPEG where WebP encoding is + * unsupported), at most MAX_IMAGE_SIDE px on the longer side. Re-encoding drops EXIF. */ +export async function encodeImage(bitmap: ImageBitmap, crop?: CropRect): Promise { + const area = crop ?? { x: 0, y: 0, width: bitmap.width, height: bitmap.height }; + const scale = Math.min(1, MAX_IMAGE_SIDE / Math.max(area.width, area.height)); + const width = Math.max(1, Math.round(area.width * scale)); + const height = Math.max(1, Math.round(area.height * scale)); + const { canvas, ctx } = canvas2d(width, height); + ctx.imageSmoothingQuality = 'high'; + ctx.drawImage(bitmap, area.x, area.y, area.width, area.height, 0, 0, width, height); + const encode = (type: string) => new Promise((resolve) => canvas.toBlob(resolve, type, 0.85)); + const webp = await encode('image/webp'); + if (webp?.type === 'image/webp') return webp; + const jpeg = await encode('image/jpeg'); + if (!jpeg) throw new Error('Could not encode the image'); + return jpeg; +} + +/** Whole picked image, re-encoded (no crop). */ export async function compressImage(file: Blob): Promise { - const bitmap = await createImageBitmap(file, { imageOrientation: 'from-image' }); + const bitmap = await loadImage(file); try { - const scale = Math.min(1, MAX_IMAGE_SIDE / Math.max(bitmap.width, bitmap.height)); - const width = Math.max(1, Math.round(bitmap.width * scale)); - const height = Math.max(1, Math.round(bitmap.height * scale)); - const canvas = document.createElement('canvas'); - canvas.width = width; - canvas.height = height; - const ctx = canvas.getContext('2d'); - if (!ctx) throw new Error('Canvas is not available'); - ctx.drawImage(bitmap, 0, 0, width, height); - const encode = (type: string) => new Promise((resolve) => canvas.toBlob(resolve, type, 0.85)); - const webp = await encode('image/webp'); - if (webp?.type === 'image/webp') return webp; - const jpeg = await encode('image/jpeg'); - if (!jpeg) throw new Error('Could not encode the image'); - return jpeg; + return await encodeImage(bitmap); } finally { bitmap.close(); } diff --git a/mobile-client/src/platform.ts b/mobile-client/src/platform.ts index 6bb4c0c..bfee999 100644 --- a/mobile-client/src/platform.ts +++ b/mobile-client/src/platform.ts @@ -15,6 +15,27 @@ export async function setMaxBrightness(on: boolean): Promise { } } +// Full-screen overlays inside a screen (cropper, camera scanner) close on the Android back button +// instead of leaving the screen underneath. +const overlayBackHandlers: Array<{ current: () => void }> = []; + +/** Registers an overlay's back action while it is shown; returns the cleanup. */ +export function pushOverlayBack(handler: { current: () => void }): () => void { + overlayBackHandlers.push(handler); + return () => { + const index = overlayBackHandlers.lastIndexOf(handler); + if (index >= 0) overlayBackHandlers.splice(index, 1); + }; +} + +/** Runs the topmost overlay's back action; false when no overlay is shown. */ +export function runOverlayBack(): boolean { + const top = overlayBackHandlers[overlayBackHandlers.length - 1]; + if (!top) return false; + top.current(); + return true; +} + /** Android back button; return true when handled, otherwise the app exits. */ export function onBackButton(handler: () => boolean): () => void { if (!isNative) return () => undefined; diff --git a/mobile-client/src/screens/EditScreen.tsx b/mobile-client/src/screens/EditScreen.tsx index ed7c5b4..70b67e3 100644 --- a/mobile-client/src/screens/EditScreen.tsx +++ b/mobile-client/src/screens/EditScreen.tsx @@ -13,12 +13,18 @@ import { type ImageRef, } from '@cardy/shared'; import { ApiError, type Api } from '../api.js'; -import { compressImage, fileNameFor } from '../imageTools.js'; +import { CropScreen } from '../CropScreen.js'; +import { fileNameFor } from '../imageTools.js'; import { ScannerView, scanImageFile } from '../ScannerView.js'; import { images } from '../storage.js'; import { Banner, CardBadge, CodeImage, Icon, IconButton, Screen, TagList, useBlobUrl, useStoredImage } from '../ui.js'; type LogoChange = { kind: 'keep' } | { kind: 'remove' } | { kind: 'new'; blob: Blob }; +interface CropJob { + id: number; + file: File; + target: 'logo' | 'photo'; +} interface NewImage { tempId: string; blob: Blob; @@ -80,14 +86,21 @@ export function EditScreen({ } }; - const pickLogo = (file: File) => - withBusy('Processing image…', async () => setLogo({ kind: 'new', blob: await compressImage(file) })); + // Picked images go through the cropper one by one (Cancel skips an image). + const [cropQueue, setCropQueue] = useState([]); + const cropping = cropQueue[0]; + const photoJobs = cropQueue.filter((j) => j.target === 'photo'); + const pickLogo = (file: File) => setCropQueue((q) => [...q, { id: ++tempCounter, file, target: 'logo' }]); const pickPhotos = (files: File[]) => - withBusy('Processing images…', async () => { - const blobs = await Promise.all(files.map(compressImage)); - setAdded((a) => [...a, ...blobs.map((blob) => ({ tempId: `new-${++tempCounter}`, blob }))]); - }); + setCropQueue((q) => [...q, ...files.map((file) => ({ id: ++tempCounter, file, target: 'photo' as const }))]); + + const cropDone = (job: CropJob, blob: Blob) => { + if (job.target === 'logo') setLogo({ kind: 'new', blob }); + else setAdded((a) => [...a, { tempId: `new-${++tempCounter}`, blob }]); + setCropQueue((q) => q.filter((j) => j !== job)); + }; + const cropSkip = (job: CropJob) => setCropQueue((q) => q.filter((j) => j !== job)); const scanFromImage = (file: File) => withBusy('Looking for a code…', async () => { @@ -316,6 +329,23 @@ export function EditScreen({ onFiles(e, pickPhotos)} /> onFiles(e, pickPhotos)} /> onFiles(e, (f) => f[0] && scanFromImage(f[0]))} /> + + {cropping && ( + 1 + ? `Crop photo (${photoJobs.length} left)` + : 'Crop photo' + } + onDone={(blob) => cropDone(cropping, blob)} + onCancel={() => cropSkip(cropping)} + /> + )} ); } diff --git a/mobile-client/src/styles.css b/mobile-client/src/styles.css index 29f8d1d..3270126 100644 --- a/mobile-client/src/styles.css +++ b/mobile-client/src/styles.css @@ -863,6 +863,110 @@ textarea:focus { text-shadow: 0 1px 4px #000; } +/* ---- cropper ---- */ + +.cropper { + position: fixed; + inset: 0; + z-index: 45; + background: #000; + display: flex; + flex-direction: column; + user-select: none; +} + +.cropper-bar { + display: grid; + grid-template-columns: auto 1fr auto; + align-items: center; + gap: 8px; + padding: calc(6px + var(--safe-top)) 8px 6px; + background: var(--bg); +} + +.cropper-bar.bottom { + display: flex; + justify-content: center; + gap: 16px; + padding: 10px 16px calc(10px + var(--safe-bottom)); +} + +.cropper-area { + position: relative; + flex: 1; + margin: 24px; + touch-action: none; +} + +.cropper-message { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + color: var(--muted); +} + +.cropper-image { + position: absolute; +} + +.cropper-frame { + position: absolute; + border: 2px solid #fff; + box-shadow: 0 0 0 100vmax rgb(0 0 0 / 0.55); + cursor: move; + touch-action: none; + /* rule-of-thirds guides */ + background-image: + linear-gradient(to right, transparent calc(33.33% - 0.5px), rgb(255 255 255 / 0.35) calc(33.33% - 0.5px), rgb(255 255 255 / 0.35) calc(33.33% + 0.5px), transparent calc(33.33% + 0.5px), transparent calc(66.66% - 0.5px), rgb(255 255 255 / 0.35) calc(66.66% - 0.5px), rgb(255 255 255 / 0.35) calc(66.66% + 0.5px), transparent calc(66.66% + 0.5px)), + linear-gradient(to bottom, transparent calc(33.33% - 0.5px), rgb(255 255 255 / 0.35) calc(33.33% - 0.5px), rgb(255 255 255 / 0.35) calc(33.33% + 0.5px), transparent calc(33.33% + 0.5px), transparent calc(66.66% - 0.5px), rgb(255 255 255 / 0.35) calc(66.66% - 0.5px), rgb(255 255 255 / 0.35) calc(66.66% + 0.5px), transparent calc(66.66% + 0.5px)); +} + +/* Large invisible touch area around a small visible corner dot. */ +.cropper-handle { + position: absolute; + width: 44px; + height: 44px; + touch-action: none; +} + +.cropper-handle::after { + content: ''; + position: absolute; + left: 13px; + top: 13px; + width: 18px; + height: 18px; + border-radius: 50%; + background: #fff; + box-shadow: 0 0 0 2px rgb(0 0 0 / 0.4); +} + +.cropper-handle.nw { + left: -22px; + top: -22px; + cursor: nwse-resize; +} + +.cropper-handle.ne { + right: -22px; + top: -22px; + cursor: nesw-resize; +} + +.cropper-handle.sw { + left: -22px; + bottom: -22px; + cursor: nesw-resize; +} + +.cropper-handle.se { + right: -22px; + bottom: -22px; + cursor: nwse-resize; +} + /* ---- dialog ---- */ .dialog-backdrop { diff --git a/mobile-client/src/ui.tsx b/mobile-client/src/ui.tsx index 17acb84..6bc3fb1 100644 --- a/mobile-client/src/ui.tsx +++ b/mobile-client/src/ui.tsx @@ -23,6 +23,7 @@ const paths = { close: 'M6 6l12 12M18 6L6 18', key: 'M14 10a4 4 0 1 0-3.5 3.97L10 14v3H8v2H5v-3l5.03-5.03A4 4 0 0 0 14 10zM15.5 8.5h.01', backspace: 'M21 5H8l-6 7 6 7h13zM11 9l6 6M17 9l-6 6', + rotate: 'M20 11a8 8 0 1 0-2.3 5.7M20 4v7h-7', } as const; export function Icon({ name, size = 22 }: { name: keyof typeof paths; size?: number }) {