camera and gallery image crop
This commit is contained in:
@@ -206,7 +206,7 @@ React + Vite + TS; PWA и Android-приложение через Capacitor. PWA
|
|||||||
- **Экран карт**: список локально хранящихся карт (цветные плашки с лого или, если лого нет, с аббревиатурой названия на цветном фоне), сортировка по давности использования, поиск по подстроке (от 1 символа, без учёта регистра) в названии, тегах и комментарии, кнопки «обновить» и «добавить».
|
- **Экран карт**: список локально хранящихся карт (цветные плашки с лого или, если лого нет, с аббревиатурой названия на цветном фоне), сортировка по давности использования, поиск по подстроке (от 1 символа, без учёта регистра) в названии, тегах и комментарии, кнопки «обновить» и «добавить».
|
||||||
- **«Обновить»**: скачивает список карт с сервера; при успехе заменяет локальный список (карты, пропавшие с сервера, удаляются локально вместе с картинками; даты использования сохраняются по ID карты). Затем для карт, у которых изменился `imagesVersion`, асинхронно скачиваются лого и картинки — не больше 5 запросов одновременно. Докачиваются и файлы, которых нет локально (например, после сбоя); версия картинок карты запоминается только когда скачаны все её файлы.
|
- **«Обновить»**: скачивает список карт с сервера; при успехе заменяет локальный список (карты, пропавшие с сервера, удаляются локально вместе с картинками; даты использования сохраняются по ID карты). Затем для карт, у которых изменился `imagesVersion`, асинхронно скачиваются лого и картинки — не больше 5 запросов одновременно. Докачиваются и файлы, которых нет локально (например, после сбоя); версия картинок карты запоминается только когда скачаны все её файлы.
|
||||||
- **Экран карты**: название, код (отрисовка `bwip-js`), теги, комментарий (с переносами строк), локальные картинки. На Android яркость окна поднимается до максимума (собственный плагин `ScreenBrightness` в `android/`) и возвращается к системной при уходе с экрана. Открытие экрана считается использованием карты (сохраняются дата и время). Кнопки «редактировать» и «удалить» (с подтверждением).
|
- **Экран карты**: название, код (отрисовка `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» повторяет только оставшиеся.
|
- После успешного добавления или редактирования локальная копия карты сразу обновляется ответом сервера; загруженные картинки сохраняются локально без повторного скачивания. Если сохранение прервалось на середине (например, карта создана, а фото не загрузились), выполненные шаги сохраняются и «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 сбрасывает скачанные версии.
|
- **Обновления 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.
|
- **Обновление самого 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.
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|||||||
import { parseLoginDeepLink, type Card } from '@cardy/shared';
|
import { parseLoginDeepLink, type Card } from '@cardy/shared';
|
||||||
import { createApi } from './api.js';
|
import { createApi } from './api.js';
|
||||||
import { useNavigation, type LoginPrefill } from './navigation.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 { cardImageIds, images, storage, type PinRecord, type Session, type UsageMap } from './storage.js';
|
||||||
import { refreshCardList, syncImages } from './sync.js';
|
import { refreshCardList, syncImages } from './sync.js';
|
||||||
import {
|
import {
|
||||||
@@ -233,6 +233,7 @@ function Main({ boot, justUpdated }: { boot: Boot; justUpdated: boolean }) {
|
|||||||
useEffect(
|
useEffect(
|
||||||
() =>
|
() =>
|
||||||
onBackButton(() => {
|
onBackButton(() => {
|
||||||
|
if (runOverlayBack()) return true;
|
||||||
const { locked: isLocked, lockedLogin: overLock } = lockedRef.current;
|
const { locked: isLocked, lockedLogin: overLock } = lockedRef.current;
|
||||||
if (isLocked && !overLock) return false;
|
if (isLocked && !overLock) return false;
|
||||||
return back();
|
return back();
|
||||||
|
|||||||
@@ -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<ImageBitmap>();
|
||||||
|
const [rect, setRect] = useState<CropRect>();
|
||||||
|
const [busy, setBusy] = useState(true);
|
||||||
|
const [error, setError] = useState<string>();
|
||||||
|
const [area, setArea] = useState({ width: 0, height: 0 });
|
||||||
|
const areaRef = useRef<HTMLDivElement>(null);
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const drag = useRef<Drag | undefined>(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<HTMLElement>) => {
|
||||||
|
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<HTMLElement>) => {
|
||||||
|
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<HTMLElement>) => {
|
||||||
|
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 (
|
||||||
|
<div className="cropper" role="dialog" aria-modal="true" aria-label={title}>
|
||||||
|
<header className="cropper-bar">
|
||||||
|
<button className="text-button" onClick={onCancel}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<h1 className="appbar-title">{title}</h1>
|
||||||
|
<button className="text-button strong" onClick={done} disabled={busy || !rect}>
|
||||||
|
Done
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="cropper-area" ref={areaRef}>
|
||||||
|
{error && <p className="cropper-message">{error}</p>}
|
||||||
|
{!error && busy && !bitmap && <p className="cropper-message">Opening…</p>}
|
||||||
|
{bitmap && scale > 0 && (
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
className="cropper-image"
|
||||||
|
style={{ left: offsetX, top: offsetY, width: bitmap.width * scale, height: bitmap.height * scale }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{bitmap && rect && scale > 0 && (
|
||||||
|
<div
|
||||||
|
className="cropper-frame"
|
||||||
|
style={{
|
||||||
|
left: offsetX + rect.x * scale,
|
||||||
|
top: offsetY + rect.y * scale,
|
||||||
|
width: rect.width * scale,
|
||||||
|
height: rect.height * scale,
|
||||||
|
}}
|
||||||
|
onPointerDown={startDrag('move')}
|
||||||
|
{...handlers}
|
||||||
|
>
|
||||||
|
{(['nw', 'ne', 'sw', 'se'] as const).map((corner) => (
|
||||||
|
<span key={corner} className={`cropper-handle ${corner}`} onPointerDown={startDrag(corner)} {...handlers} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer className="cropper-bar bottom">
|
||||||
|
<button className="button secondary" onClick={rotate} disabled={busy || !bitmap}>
|
||||||
|
<Icon name="rotate" size={18} /> Rotate
|
||||||
|
</button>
|
||||||
|
{rect && bitmap && (
|
||||||
|
<button className="text-button" onClick={() => setRect(initialCrop(bitmap.width, bitmap.height, square))} disabled={busy}>
|
||||||
|
Reset
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { pushOverlayBack } from './platform.js';
|
||||||
import type { ScanResult } from './scanner.js';
|
import type { ScanResult } from './scanner.js';
|
||||||
import { Icon } from './ui.js';
|
import { Icon } from './ui.js';
|
||||||
|
|
||||||
@@ -10,6 +11,10 @@ export function ScannerView({ formats, hint, onResult, onClose }: { formats: For
|
|||||||
const [error, setError] = useState<string>();
|
const [error, setError] = useState<string>();
|
||||||
const onResultRef = useRef(onResult);
|
const onResultRef = useRef(onResult);
|
||||||
onResultRef.current = 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(() => {
|
useEffect(() => {
|
||||||
let stopped = false;
|
let stopped = false;
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,27 +1,69 @@
|
|||||||
export const MAX_IMAGE_SIDE = 1000;
|
import type { CropRect } from './cropMath.js';
|
||||||
|
|
||||||
/**
|
/** Longer side of uploaded logos and photos. */
|
||||||
* Re-encodes a picked image to WebP (JPEG where WebP encoding is unsupported), at most
|
export const MAX_IMAGE_SIDE = 1000;
|
||||||
* MAX_IMAGE_SIDE px on the longer side. Re-encoding drops EXIF; orientation is applied first.
|
/** Longer side kept while cropping: enough detail for a crop, bounded memory for camera photos. */
|
||||||
*/
|
const WORKING_MAX_SIDE = 2500;
|
||||||
export async function compressImage(file: Blob): Promise<Blob> {
|
|
||||||
const bitmap = await createImageBitmap(file, { imageOrientation: 'from-image' });
|
/** 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<ImageBitmap> {
|
||||||
|
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 {
|
try {
|
||||||
const scale = Math.min(1, MAX_IMAGE_SIDE / Math.max(bitmap.width, bitmap.height));
|
return await createImageBitmap(original, {
|
||||||
const width = Math.max(1, Math.round(bitmap.width * scale));
|
resizeWidth: Math.max(1, Math.round(original.width * scale)),
|
||||||
const height = Math.max(1, Math.round(bitmap.height * 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');
|
const canvas = document.createElement('canvas');
|
||||||
canvas.width = width;
|
canvas.width = width;
|
||||||
canvas.height = height;
|
canvas.height = height;
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d');
|
||||||
if (!ctx) throw new Error('Canvas is not available');
|
if (!ctx) throw new Error('Canvas is not available');
|
||||||
ctx.drawImage(bitmap, 0, 0, width, height);
|
return { canvas, ctx };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The image turned 90° clockwise. */
|
||||||
|
export async function rotateImage(bitmap: ImageBitmap): Promise<ImageBitmap> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<Blob> {
|
||||||
|
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<Blob | null>((resolve) => canvas.toBlob(resolve, type, 0.85));
|
const encode = (type: string) => new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, type, 0.85));
|
||||||
const webp = await encode('image/webp');
|
const webp = await encode('image/webp');
|
||||||
if (webp?.type === 'image/webp') return webp;
|
if (webp?.type === 'image/webp') return webp;
|
||||||
const jpeg = await encode('image/jpeg');
|
const jpeg = await encode('image/jpeg');
|
||||||
if (!jpeg) throw new Error('Could not encode the image');
|
if (!jpeg) throw new Error('Could not encode the image');
|
||||||
return jpeg;
|
return jpeg;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whole picked image, re-encoded (no crop). */
|
||||||
|
export async function compressImage(file: Blob): Promise<Blob> {
|
||||||
|
const bitmap = await loadImage(file);
|
||||||
|
try {
|
||||||
|
return await encodeImage(bitmap);
|
||||||
} finally {
|
} finally {
|
||||||
bitmap.close();
|
bitmap.close();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,27 @@ export async function setMaxBrightness(on: boolean): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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. */
|
/** Android back button; return true when handled, otherwise the app exits. */
|
||||||
export function onBackButton(handler: () => boolean): () => void {
|
export function onBackButton(handler: () => boolean): () => void {
|
||||||
if (!isNative) return () => undefined;
|
if (!isNative) return () => undefined;
|
||||||
|
|||||||
@@ -13,12 +13,18 @@ import {
|
|||||||
type ImageRef,
|
type ImageRef,
|
||||||
} from '@cardy/shared';
|
} from '@cardy/shared';
|
||||||
import { ApiError, type Api } from '../api.js';
|
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 { ScannerView, scanImageFile } from '../ScannerView.js';
|
||||||
import { images } from '../storage.js';
|
import { images } from '../storage.js';
|
||||||
import { Banner, CardBadge, CodeImage, Icon, IconButton, Screen, TagList, useBlobUrl, useStoredImage } from '../ui.js';
|
import { Banner, CardBadge, CodeImage, Icon, IconButton, Screen, TagList, useBlobUrl, useStoredImage } from '../ui.js';
|
||||||
|
|
||||||
type LogoChange = { kind: 'keep' } | { kind: 'remove' } | { kind: 'new'; blob: Blob };
|
type LogoChange = { kind: 'keep' } | { kind: 'remove' } | { kind: 'new'; blob: Blob };
|
||||||
|
interface CropJob {
|
||||||
|
id: number;
|
||||||
|
file: File;
|
||||||
|
target: 'logo' | 'photo';
|
||||||
|
}
|
||||||
interface NewImage {
|
interface NewImage {
|
||||||
tempId: string;
|
tempId: string;
|
||||||
blob: Blob;
|
blob: Blob;
|
||||||
@@ -80,14 +86,21 @@ export function EditScreen({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const pickLogo = (file: File) =>
|
// Picked images go through the cropper one by one (Cancel skips an image).
|
||||||
withBusy('Processing image…', async () => setLogo({ kind: 'new', blob: await compressImage(file) }));
|
const [cropQueue, setCropQueue] = useState<CropJob[]>([]);
|
||||||
|
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[]) =>
|
const pickPhotos = (files: File[]) =>
|
||||||
withBusy('Processing images…', async () => {
|
setCropQueue((q) => [...q, ...files.map((file) => ({ id: ++tempCounter, file, target: 'photo' as const }))]);
|
||||||
const blobs = await Promise.all(files.map(compressImage));
|
|
||||||
setAdded((a) => [...a, ...blobs.map((blob) => ({ tempId: `new-${++tempCounter}`, blob }))]);
|
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) =>
|
const scanFromImage = (file: File) =>
|
||||||
withBusy('Looking for a code…', async () => {
|
withBusy('Looking for a code…', async () => {
|
||||||
@@ -316,6 +329,23 @@ export function EditScreen({
|
|||||||
<input ref={photosInput} type="file" accept="image/*" multiple hidden onChange={(e) => onFiles(e, pickPhotos)} />
|
<input ref={photosInput} type="file" accept="image/*" multiple hidden onChange={(e) => onFiles(e, pickPhotos)} />
|
||||||
<input ref={photosCamera} type="file" accept="image/*" capture="environment" hidden onChange={(e) => onFiles(e, pickPhotos)} />
|
<input ref={photosCamera} type="file" accept="image/*" capture="environment" hidden onChange={(e) => onFiles(e, pickPhotos)} />
|
||||||
<input ref={scanInput} type="file" accept="image/*" hidden onChange={(e) => onFiles(e, (f) => f[0] && scanFromImage(f[0]))} />
|
<input ref={scanInput} type="file" accept="image/*" hidden onChange={(e) => onFiles(e, (f) => f[0] && scanFromImage(f[0]))} />
|
||||||
|
|
||||||
|
{cropping && (
|
||||||
|
<CropScreen
|
||||||
|
key={cropping.id}
|
||||||
|
file={cropping.file}
|
||||||
|
square={cropping.target === 'logo'}
|
||||||
|
title={
|
||||||
|
cropping.target === 'logo'
|
||||||
|
? 'Crop logo'
|
||||||
|
: photoJobs.length > 1
|
||||||
|
? `Crop photo (${photoJobs.length} left)`
|
||||||
|
: 'Crop photo'
|
||||||
|
}
|
||||||
|
onDone={(blob) => cropDone(cropping, blob)}
|
||||||
|
onCancel={() => cropSkip(cropping)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Screen>
|
</Screen>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -863,6 +863,110 @@ textarea:focus {
|
|||||||
text-shadow: 0 1px 4px #000;
|
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 ---- */
|
||||||
|
|
||||||
.dialog-backdrop {
|
.dialog-backdrop {
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ const paths = {
|
|||||||
close: 'M6 6l12 12M18 6L6 18',
|
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',
|
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',
|
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;
|
} as const;
|
||||||
|
|
||||||
export function Icon({ name, size = 22 }: { name: keyof typeof paths; size?: number }) {
|
export function Icon({ name, size = 22 }: { name: keyof typeof paths; size?: number }) {
|
||||||
|
|||||||
Reference in New Issue
Block a user