camera and gallery image crop

This commit is contained in:
azykov@mail.ru
2026-09-29 20:04:58 +03:00
parent b6fb2fe40f
commit 45158cde38
11 changed files with 553 additions and 27 deletions
+1 -1
View File
@@ -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.
+2 -1
View File
@@ -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();
+192
View File
@@ -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>
);
}
+5
View File
@@ -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<string>();
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;
+45
View File
@@ -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 });
});
});
+85
View File
@@ -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),
};
}
+53 -11
View File
@@ -1,27 +1,69 @@
export const MAX_IMAGE_SIDE = 1000;
import type { CropRect } from './cropMath.js';
/**
* 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.
*/
export async function compressImage(file: Blob): Promise<Blob> {
const bitmap = await createImageBitmap(file, { imageOrientation: 'from-image' });
/** 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<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 {
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));
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');
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 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<Blob> {
const bitmap = await loadImage(file);
try {
return await encodeImage(bitmap);
} finally {
bitmap.close();
}
+21
View File
@@ -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. */
export function onBackButton(handler: () => boolean): () => void {
if (!isNative) return () => undefined;
+37 -7
View File
@@ -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<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[]) =>
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({
<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={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>
);
}
+104
View File
@@ -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 {
+1
View File
@@ -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 }) {