camera and gallery image crop
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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 { 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;
|
||||
|
||||
@@ -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 @@
|
||||
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<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 {
|
||||
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<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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<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 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<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;
|
||||
return await encodeImage(bitmap);
|
||||
} finally {
|
||||
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. */
|
||||
export function onBackButton(handler: () => boolean): () => void {
|
||||
if (!isNative) return () => undefined;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
Reference in New Issue
Block a user