diff --git a/modules/recipescam-export/android/src/main/java/com/locphamtran/recipescamera/export/RecipescamExportModule.kt b/modules/recipescam-export/android/src/main/java/com/locphamtran/recipescamera/export/RecipescamExportModule.kt index 40c897e..564a968 100644 --- a/modules/recipescam-export/android/src/main/java/com/locphamtran/recipescamera/export/RecipescamExportModule.kt +++ b/modules/recipescam-export/android/src/main/java/com/locphamtran/recipescamera/export/RecipescamExportModule.kt @@ -1,5 +1,6 @@ package com.locphamtran.recipescamera.export +import android.content.ContentValues import android.content.Context import android.graphics.Bitmap import android.graphics.BitmapFactory @@ -10,6 +11,8 @@ import android.graphics.Paint import android.graphics.Rect import android.graphics.RectF import android.graphics.Typeface +import android.os.Environment +import android.provider.MediaStore import android.util.Base64 import android.util.Log import expo.modules.kotlin.functions.Coroutine @@ -74,6 +77,44 @@ class RecipescamExportModule : Module() { Name("RecipesCamExport") Log.i(TAG, "module defined RecipesCamExport") + // Save an encoded JPEG/PNG into DCIM/Camera. expo-media-library's JS API + // only ever inserts into DCIM (root) or Pictures/Camera, so a camera-roll + // export lands where the stock camera puts its shots by inserting the + // MediaStore row ourselves with an explicit RELATIVE_PATH. The file is + // staged IS_PENDING, copied from the cache path, then published. Returns the + // MediaStore _ID (same shape as expo's Asset.id, usable with + // deleteAssetsAsync), or "" when the insert/copy fails so JS can fall back. + AsyncFunction("saveToCameraRollAsync") Coroutine { srcPath: String, mimeType: String -> + val ctx = appContext.reactContext ?: error("react context lost") + withContext(Dispatchers.IO) { + val src = File(srcPath) + if (!src.exists()) return@withContext "" + val resolver = ctx.contentResolver + val values = ContentValues().apply { + put(MediaStore.MediaColumns.DISPLAY_NAME, src.name) + put(MediaStore.MediaColumns.MIME_TYPE, mimeType) + put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DCIM + "/Camera") + put(MediaStore.MediaColumns.IS_PENDING, 1) + } + val uri = try { + resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values) + } catch (e: Throwable) { + Log.w(TAG, "saveToCameraRollAsync insert failed: ${e.message}") + null + } ?: return@withContext "" + try { + resolver.openOutputStream(uri)?.use { out -> src.inputStream().use { it.copyTo(out) } } + ?: error("openOutputStream returned null") + } catch (e: Throwable) { + Log.w(TAG, "saveToCameraRollAsync copy failed: ${e.message}") + resolver.delete(uri, null, null) + return@withContext "" + } + resolver.update(uri, ContentValues().apply { put(MediaStore.MediaColumns.IS_PENDING, 0) }, null, null) + uri.lastPathSegment ?: "" + } + } + // P0 spike: decode (BitmapFactory) + JPEG encode + file write, all off the // JS/main threads. Returns per-stage ms so JS can prove the JS thread is // never blocked while native rendering runs. diff --git a/modules/recipescam-export/index.ts b/modules/recipescam-export/index.ts index b1b53a9..a53bdfb 100644 --- a/modules/recipescam-export/index.ts +++ b/modules/recipescam-export/index.ts @@ -91,6 +91,12 @@ export interface RecipescamExportModule { processPhotoAsync(srcPath: string, dstPath: string, matrix: number[], crop: CropRect | null, adjust?: PhotoAdjust): Promise; /** P1 alias: matrix + crop, no tone/cinema/enhance passes. */ processColorAsync(srcPath: string, dstPath: string, matrix: number[], crop: CropRect | null, quality: number): Promise; + /** + * Insert an encoded image into DCIM/Camera (MediaStore RELATIVE_PATH) — where + * the stock camera writes. Returns the MediaStore _ID (expo Asset.id shape), + * or '' when the insert/copy fails, so JS falls back to MediaLibrary. + */ + saveToCameraRollAsync(srcPath: string, mimeType: string): Promise; /** * Raw device identity the stock Xiaomi camera folds into EXIF 0x889e (the * watermark blob HyperOS Gallery reads) but CameraX/HAL never supplies: diff --git a/src/components/Viewfinder.tsx b/src/components/Viewfinder.tsx index a31c522..de7a4d1 100644 --- a/src/components/Viewfinder.tsx +++ b/src/components/Viewfinder.tsx @@ -133,6 +133,11 @@ interface ViewfinderProps { // to focus / zoom) while this is set, so a watermark left ON can never // swallow the camera's tap-to-focus or the library's pinch/double-tap. wmEditing?: boolean; + // True while the FRAME tab is the open deck tab. Pinching then magnifies the + // WHOLE preview — frame, photo and watermarks together — instead of the + // photo: a preview-only loupe for placing the mark. The saved file never + // changes, because the export reads the photo zoom, not this view zoom. + frameTabActive?: boolean; // WALL FRAME hung landscape (4000x3117) instead of portrait. wallLandscape?: boolean; } @@ -233,6 +238,7 @@ const Viewfinder = forwardRef(function Viewfi onWmSizeChange, onWmEdit, wmEditing = false, + frameTabActive = false, photoRotation = 0, photoStraighten = 0, wmRotation = 0, @@ -640,13 +646,15 @@ const Viewfinder = forwardRef(function Viewfi v0 = t.value; param = { min: t.min, max: t.max, step: t.step, onChange: t.onChange }; } else if (wmArmed || gpsWmArmed) { - // Watermark armed: every touch drags/places the mark under it (an open - // param slider keeps priority; the AE-lock EV drag yields while armed). + // Watermark armed: every touch drags the mark (an open param slider keeps + // priority; the AE-lock EV drag yields while armed). Anchor the drag — + // the mark only MOVES once the finger does, it never jumps under it. kind = 'wm'; const t = e?.nativeEvent?.touches?.[0] ?? e?.nativeEvent; - const tgt = wmTargetAt(t?.locationX ?? 0, t?.locationY ?? 0) ?? (wmArmed ? 'custom' : 'gps'); + const tp = { x: t?.locationX ?? 0, y: t?.locationY ?? 0 }; + const tgt = wmTargetAt(tp.x, tp.y) ?? (wmArmed ? 'custom' : 'gps'); wmDragTgtRef.current = tgt; - placeWm(t?.pageX ?? t?.locationX ?? 0, t?.pageY ?? t?.locationY ?? 0, tgt); + beginWmDrag(tgt, tp.x, tp.y); } else if (focusPoint && onExposureChange) { kind = 'ev'; v0 = evStops; @@ -656,7 +664,14 @@ const Viewfinder = forwardRef(function Viewfi const gts = e?.nativeEvent?.touches ?? []; panRef.current = gts.length === 1 - ? { x: gts[0].locationX, y: gts[0].locationY, tx: libZoom.tx, ty: libZoom.ty } + ? { + x: gts[0].locationX, + y: gts[0].locationY, + px: gts[0].pageX, + py: gts[0].pageY, + tx: viewZoomed ? viewZoom.tx : libZoom.tx, + ty: viewZoomed ? viewZoom.ty : libZoom.ty, + } : null; panUsedRef.current = false; if (gts.length < 2) pinchRef.current = null; @@ -669,6 +684,49 @@ const Viewfinder = forwardRef(function Viewfi // the export takes (frameWindowZoom). One finger drags it too, but only a // touch that no parameter row and no AE-lock EV drag has claimed. const wts = e?.nativeEvent?.touches; + // Two fingers: a mark under them resizes (the WATERMARK panel's own pinch); + // anything else on the FRAME tab magnifies the whole view. + if (wts && wts.length >= 2) { + const mx = (wts[0].locationX + wts[1].locationX) / 2; + const my = (wts[0].locationY + wts[1].locationY) / 2; + const d = Math.hypot( + wts[0].locationX - wts[1].locationX, + wts[0].locationY - wts[1].locationY + ); + const mi = { x: mx, y: my }; + const mark = pinchMarkAt(mi.x, mi.y) ?? wmPinchRef.current?.tgt ?? null; + if (mark) { + const p = wmPinchRef.current; + if (!p) wmPinchRef.current = { tgt: mark, d, size: wmSizeOf(mark) }; + else { + applyWmScale(p.tgt, (p.size * d) / Math.max(p.d, 1)); + if (g) g.moved = true; + } + return; + } + if (frameTabActive) { + applyViewPinch(wts); + if (g) g.moved = true; + return; + } + } + // FRAME tab, magnified view: one finger drags the picture inside the + // viewport (preview only, like the pinch). An armed mark keeps the touch. + if (viewZoomed && wts?.length === 1 && g?.kind === 'focus' && !pinchRef.current) { + const pan0 = panRef.current; + if (pan0) { + // pageX/pageY: absolute screen dp, immune to the view transform, so the + // picture tracks the finger 1:1 (locationX is scaled by the matrix). + const dx = wts[0].pageX - pan0.px; + const dy = wts[0].pageY - pan0.py; + if (Math.abs(dx) > TAP_SLOP || Math.abs(dy) > TAP_SLOP) panUsedRef.current = true; + if (panUsedRef.current) { + setViewZoom(clampViewZoom({ s: viewZoom.s, tx: pan0.tx + dx, ty: pan0.ty + dy })); + g.moved = true; + } + } + return; + } if (g && g.kind !== 'wm' && !wmArmed && zoomWindowRect()) { if (wts && wts.length >= 2) { const [a, b] = wts; @@ -707,32 +765,20 @@ const Viewfinder = forwardRef(function Viewfi } if (!g || g.kind === 'focus') return; if (g.kind === 'wm') { - // Watermark drag: follow the finger; the commit happens on release below. + // Watermark drag: the mark keeps its place under the finger and travels + // by the finger's DELTA — it must never jump to the touch point. The + // second finger (pinch, above) resizes it instead; the commit is below. const ts = e?.nativeEvent?.touches ?? []; - if (ts.length >= 2 && wmEditing) { - // A second finger turns placement into a pinch: it scales the mark - // (the camera frame behind it never changes). - const d = Math.hypot( - ts[0].locationX - ts[1].locationX, - ts[0].locationY - ts[1].locationY - ); - const p = wmPinchRef.current; - if (!p) { - const tgt = wmDragTgtRef.current ?? (wmArmed ? 'custom' : 'gps'); - wmPinchRef.current = { tgt, d, size: wmSizeOf(tgt) }; - } else { - applyWmScale(p.tgt, (p.size * d) / Math.max(p.d, 1)); - g.moved = true; - } - return; - } const t = ts[0] ?? e?.nativeEvent; - placeWm( - t?.pageX ?? t?.locationX ?? 0, - t?.pageY ?? t?.locationY ?? 0, - wmDragTgtRef.current ?? (wmArmed ? 'custom' : 'gps') - ); - g.moved = true; + const { x, y } = { x: t?.locationX ?? 0, y: t?.locationY ?? 0 }; + const d0 = wmDragRef.current; + if (d0 && (Math.abs(x - d0.px) > TAP_SLOP || Math.abs(y - d0.py) > TAP_SLOP)) { + d0.moved = true; + } + if (d0?.moved) { + moveWmDrag(x, y); + g.moved = true; + } return; } const dy = g.y0 - pageYOf(e); // up = positive / increase @@ -764,16 +810,18 @@ const Viewfinder = forwardRef(function Viewfi imgDragRef.current = null; wmPinchRef.current = null; wmDragTgtRef.current = null; + wmDragRef.current = null; + viewPinchRef.current = null; pinchRef.current = null; panRef.current = null; panUsedRef.current = false; if (!g) return; if (g.kind === 'wm') { - // A tap places the text at the tapped point; a drag already moved it. - // Never lock AE/AF while the watermark is armed. + // A tap (never a drag) drops the text at the tapped point; a drag has + // already moved it. Never lock AE/AF while the watermark is armed. if (!g.moved) { const t = e?.nativeEvent?.touches?.[0] ?? e?.nativeEvent; - placeWm(t?.pageX ?? t?.locationX ?? 0, t?.pageY ?? t?.locationY ?? 0); + placeWm(t?.locationX ?? 0, t?.locationY ?? 0); } commitWm(); return; @@ -1052,12 +1100,33 @@ const Viewfinder = forwardRef(function Viewfi useEffect(() => { if (libCropActive) setLibZoom({ s: 1, tx: 0, ty: 0 }); }, [libCropActive, cropRatio]); + // FRAME-tab PREVIEW zoom: while that tab is open two fingers magnify the + // whole view (frame + photo + marks together) so a watermark can be placed + // and sized against a big picture. Preview only — the export reads libZoom, + // never this — and leaving the tab (a mode switch closes it) drops it to 1x. + const [viewZoom, setViewZoom] = useState({ s: 1, tx: 0, ty: 0 }); + // Magnified preview (FRAME tab only): one finger then drags the picture + // inside the viewport, so it is the pan that pairs with the pinch above. + const viewZoomed = frameTabActive && viewZoom.s > 1.01; + // Keep the scaled view covering the viewport: no edge may pull inside it. + const clampViewZoom = (z: { s: number; tx: number; ty: number }) => { + const s = clampZoom(z.s); + return { + s, + tx: Math.min(0, Math.max(-(s - 1) * vw, z.tx)), + ty: Math.min(0, Math.max(-(s - 1) * vh, z.ty)), + }; + }; + const viewPinchRef = useRef<{ d: number; s: number; tx: number; ty: number; fx: number; fy: number } | null>(null); + useEffect(() => { + if (!frameTabActive) setViewZoom({ s: 1, tx: 0, ty: 0 }); + }, [frameTabActive]); const pinchRef = useRef<{ d: number; s: number; tx: number; ty: number; fx: number; fy: number } | null>(null); const pinchUsedRef = useRef(false); // Framed-window drag: anchor at touch-down (finger + transform origin), so a // move never accumulates rounding across a slow drag. - const panRef = useRef<{ x: number; y: number; tx: number; ty: number } | null>(null); + const panRef = useRef<{ x: number; y: number; px: number; py: number; tx: number; ty: number } | null>(null); const panUsedRef = useRef(false); const lastTapRef = useRef<{ x: number; y: number; t: number } | null>(null); const clampZoom = (s: number) => Math.max(1, Math.min(6, s)); @@ -1424,17 +1493,52 @@ const Viewfinder = forwardRef(function Viewfi // The mark a running gesture grabbed, decided at grant time (the box under // the first touch) so a drag keeps hold of the mark it started on. const wmDragTgtRef = useRef<'custom' | 'gps' | null>(null); - const placeWm = (x: number, y: number, tgt: 'custom' | 'gps') => { - const pos = wmFracFromXY(x, y); + const setWmFrac = (frac: { x: number; y: number }, tgt: 'custom' | 'gps') => { if (tgt === 'custom') { if (!wmArmed) return; - wmDragPosRef.current = pos; - setWmDragPos(pos); + wmDragPosRef.current = frac; + setWmDragPos(frac); return; } if (!gpsWmArmed) return; - gpsDragPosRef.current = pos; - setGpsDragPos(pos); + gpsDragPosRef.current = frac; + setGpsDragPos(frac); + }; + const placeWm = (x: number, y: number, tgt: 'custom' | 'gps') => + setWmFrac(wmFracFromXY(x, y), tgt); + // The mark drags RELATIVELY: it keeps its spot when the finger lands and only + // travels by the finger's delta. Placing it AT the touch (the old behavior) + // yanked the mark under the finger, so a pinch that started on it threw it + // across the frame. Anchor its fraction + the touch-down point here. + const wmDragRef = useRef<{ + tgt: 'custom' | 'gps'; + fx: number; + fy: number; + px: number; + py: number; + moved: boolean; + } | null>(null); + const beginWmDrag = (tgt: 'custom' | 'gps', x: number, y: number) => { + const frac = + tgt === 'custom' + ? { x: wmDragPosRef.current?.x ?? customWm.x, y: wmDragPosRef.current?.y ?? customWm.y } + : { + x: gpsDragPosRef.current?.x ?? gpsWm?.x ?? 0.05, + y: gpsDragPosRef.current?.y ?? gpsWm?.y ?? 0.1, + }; + wmDragRef.current = { tgt, fx: frac.x, fy: frac.y, px: x, py: y, moved: false }; + }; + const moveWmDrag = (x: number, y: number) => { + const d = wmDragRef.current; + if (!d) return; + const area = wmArea(); + setWmFrac( + { + x: Math.max(0, Math.min(1, d.fx + (x - d.px) / (area.dw || 1))), + y: Math.max(0, Math.min(1, d.fy + (y - d.py) / (area.dh || 1))), + }, + d.tgt + ); }; const commitWm = () => { const pos = wmDragPosRef.current; @@ -1507,6 +1611,35 @@ const Viewfinder = forwardRef(function Viewfi if (gpsWmArmed) return 'gps'; return null; }; + // Touch coordinates are CANVAS coordinates: RN hands locationX/Y back with the + // touch layer's own transform undone, and that layer now carries the very same + // matrix as the Skia canvas (see touchZoomStyle), so a mark hit-test or a drag + // line reads the touch straight off — no inverse at any view zoom. + // The armed mark a two-finger pinch is ON. Boxes only: a pinch on empty space + // must not steal the gesture from the FRAME tab's view zoom. + const pinchMarkAt = (x: number, y: number): 'custom' | 'gps' | null => { + if (!wmEditing) return null; + if (wmArmed && wmBoxHit(wmBox(), x, y)) return 'custom'; + if (gpsWmArmed && gpsBoxHit(x, y)) return 'gps'; + return null; + }; + // Two fingers on the FRAME tab magnify the whole VIEW about their midpoint + // (the photo zoom's maths, aimed at the view instead). + const applyViewPinch = (ts: { locationX: number; locationY: number }[]) => { + const [a, b] = ts; + const d = Math.hypot(a.locationX - b.locationX, a.locationY - b.locationY); + const mx = (a.locationX + b.locationX) / 2; + const my = (a.locationY + b.locationY) / 2; + const p = viewPinchRef.current; + if (!p) { + viewPinchRef.current = { d, s: viewZoom.s, tx: viewZoom.tx, ty: viewZoom.ty, fx: mx, fy: my }; + return; + } + const s = clampZoom((p.s * d) / Math.max(p.d, 1)); + // f* is the canvas point the fingers pinch on: keep s*f + t constant with + // the OLD s/t (at 1x this is the old fx*(1-k), now correct when zoomed too). + setViewZoom(clampViewZoom({ s, tx: p.tx + p.fx * (p.s - s), ty: p.ty + p.fy * (p.s - s) })); + }; // Library framed (polaroid/wall) returns reuse the SAME responder handlers as // plain library (see the framed JSX below): single-finger drags place/move // the text watermark while armed, and otherwise DRAG the photo inside the @@ -1578,16 +1711,26 @@ const Viewfinder = forwardRef(function Viewfi } const ts = e.nativeEvent.touches; panRef.current = - ts.length === 1 ? { x: ts[0].locationX, y: ts[0].locationY, tx: libZoom.tx, ty: libZoom.ty } : null; + ts.length === 1 + ? { + x: ts[0].locationX, + y: ts[0].locationY, + px: ts[0].pageX, + py: ts[0].pageY, + tx: viewZoomed ? viewZoom.tx : libZoom.tx, + ty: viewZoomed ? viewZoom.ty : libZoom.ty, + } + : null; panUsedRef.current = false; if (ts.length === 1 && (wmArmed || gpsWmArmed)) { - const x = ts[0].locationX; - const y = ts[0].locationY; + const { x, y } = { x: ts[0].locationX, y: ts[0].locationY }; const boxHit = (wmArmed && wmBoxHit(wmBox(), x, y)) || (gpsWmArmed && gpsBoxHit(x, y)); - // Zoomed photo: only a touch ON a mark takes the gesture, the rest keeps - // panning the framed photo. At 1x the lone armed mark takes any touch so - // a plain tap still places it. - wmDragTgtRef.current = libZoom.s <= 1.01 || boxHit ? wmTargetAt(x, y) : null; + // Anything zoomed (photo or view): only a touch ON a mark takes the + // gesture, the rest keeps panning / magnifying. At 1x the lone armed mark + // takes any touch so a plain tap still places it. + wmDragTgtRef.current = + (libZoom.s <= 1.01 && viewZoom.s <= 1.01) || boxHit ? wmTargetAt(x, y) : null; + if (wmDragTgtRef.current) beginWmDrag(wmDragTgtRef.current, x, y); } else wmDragTgtRef.current = null; if (ts.length >= 2) { const [a, b] = ts; @@ -1597,14 +1740,21 @@ const Viewfinder = forwardRef(function Viewfi pinchUsedRef.current = true; lastTapRef.current = null; if (wmEditing) { - // WATERMARK tab open: a two-finger gesture belongs to the marks. It - // scales the one under the fingers and NEVER zooms the photo — pinch - // zoom comes back the moment the panel closes. - const tgt = wmTargetAt(mx, my); + // WATERMARK tab open: a two-finger gesture ON a mark scales it. One + // that lands off the marks falls through to the FRAME tab's view zoom + // (seeded in the move handler), so it never zooms the photo either. + const mi = { x: mx, y: my }; + const tgt = pinchMarkAt(mi.x, mi.y); wmPinchRef.current = tgt ? { tgt, d, size: wmSizeOf(tgt) } : null; pinchRef.current = null; return; } + // FRAME tab open: the pinch magnifies the VIEW, not the photo — the + // export keeps the framing the user sees at 1x. + if (frameTabActive) { + pinchRef.current = null; + return; + } pinchRef.current = { d, s: libZoom.s, tx: libZoom.tx, ty: libZoom.ty, fx: mx, fy: my }; } }; @@ -1623,10 +1773,11 @@ const Viewfinder = forwardRef(function Viewfi // editor. Seed the baseline the same way. let p = wmPinchRef.current; if (!p) { - const tgt = wmTargetAt( - (ts[0].locationX + ts[1].locationX) / 2, - (ts[0].locationY + ts[1].locationY) / 2 - ); + const mi = { + x: (ts[0].locationX + ts[1].locationX) / 2, + y: (ts[0].locationY + ts[1].locationY) / 2, + }; + const tgt = pinchMarkAt(mi.x, mi.y); if (tgt) { p = { tgt, @@ -1639,15 +1790,45 @@ const Viewfinder = forwardRef(function Viewfi if (p) { const d = Math.hypot(ts[0].locationX - ts[1].locationX, ts[0].locationY - ts[1].locationY); applyWmScale(p.tgt, (p.size * d) / Math.max(p.d, 1)); + pinchUsedRef.current = true; + return; } + // Off the marks on the FRAME tab: fall through to the view zoom below. + } + // FRAME tab: two fingers magnify the whole view — frame, photo and marks + // together — so a watermark can be dragged against a big picture. Preview + // only; nothing export-facing reads viewZoom. + if (ts.length >= 2 && frameTabActive) { + applyViewPinch(ts); pinchUsedRef.current = true; return; } // Watermark armed + one finger + unzoomed: the drag moves the mark (not the // photo). Zoomed-in keeps pinch/pan so the user can inspect detail first. if (ts.length === 1 && wmDragTgtRef.current) { + const t = { x: ts[0].locationX, y: ts[0].locationY }; + const d = wmDragRef.current; + if (d && (Math.abs(t.x - d.px) > 6 || Math.abs(t.y - d.py) > 6)) { + d.moved = true; + } + if (d?.moved) moveWmDrag(t.x, t.y); + return; + } + // FRAME tab, magnified view: one finger drags the picture inside the + // viewport instead of the framed photo (preview only). + if (ts.length === 1 && !pinchRef.current && viewZoomed) { const t = ts[0]; - placeWm(t.pageX ?? t.locationX, t.pageY ?? t.locationY, wmDragTgtRef.current); + const pan0 = panRef.current; + if (pan0) { + // pageX/pageY: absolute screen dp, immune to the view transform, so the + // picture tracks the finger 1:1 (locationX is scaled by the matrix). + const dx = t.pageX - pan0.px; + const dy = t.pageY - pan0.py; + if (Math.abs(dx) > 6 || Math.abs(dy) > 6) panUsedRef.current = true; + if (panUsedRef.current) { + setViewZoom(clampViewZoom({ s: viewZoom.s, tx: pan0.tx + dx, ty: pan0.ty + dy })); + } + } return; } // One finger, no pinch running: drag the photo inside the framed window @@ -1704,14 +1885,17 @@ const Viewfinder = forwardRef(function Viewfi const wasPan = panUsedRef.current; // Decided at touch-down; the reset below would lose it. const wmTgt = wmDragTgtRef.current; + const wmMoved = !!wmDragRef.current?.moved; pinchRef.current = null; wmPinchRef.current = null; wmDragTgtRef.current = null; + wmDragRef.current = null; + viewPinchRef.current = null; pinchUsedRef.current = false; panRef.current = null; panUsedRef.current = false; // A drag is neither a tap nor the first half of a double-tap. - if (wasPan) lastTapRef.current = null; + if (wasPan || wmMoved) lastTapRef.current = null; if (wasPinch || wasPan) return; // a double-tap is always a single finger const x = e.nativeEvent.locationX; const y = e.nativeEvent.locationY; @@ -1734,7 +1918,7 @@ const Viewfinder = forwardRef(function Viewfi } // Watermark armed: a tap places + commits the text and skips the double-tap // zoom bookkeeping (double-tap zoom is disabled while placing). - if ((wmArmed || gpsWmArmed) && (libZoom.s <= 1.01 || wmTgt)) { + if ((wmArmed || gpsWmArmed) && ((libZoom.s <= 1.01 && viewZoom.s <= 1.01) || wmTgt)) { const now = Date.now(); const prev = lastTapRef.current; lastTapRef.current = { x, y, t: now }; @@ -1750,7 +1934,9 @@ const Viewfinder = forwardRef(function Viewfi onWmEdit?.(); return; } - placeWm(x, y, wmTgt ?? wmTargetAt(x, y) ?? 'custom'); + // A drag has already moved the mark where the finger left it; only a + // plain tap drops it at the tapped point. + if (!wmMoved) placeWm(x, y, wmTgt ?? wmTargetAt(x, y) ?? 'custom'); commitWm(); return; } @@ -1816,17 +2002,36 @@ const Viewfinder = forwardRef(function Viewfi : libZoomMatrix, [libCropView, libZoom, libZoomMatrix] ); - // Same transform on the touch layer. RN scales about the view's centre, so - // the translation carries a centre correction ((k-1)·centre). - const libCropTouchStyle = libCropView - ? { - transform: [ - { translateX: libCropView.ox + (vw / 2) * (libCropView.k - 1) }, - { translateY: libCropView.oy + (vh / 2) * (libCropView.k - 1) }, - { scale: libCropView.k }, - ], - } - : undefined; + // FRAME-tab view zoom as a Skia matrix, drawn as an OUTER group around a + // branch's whole canvas. Preview only: nothing export-facing reads it. + const viewZoomMatrix = useMemo( + () => [viewZoom.s, 0, viewZoom.tx, 0, viewZoom.s, viewZoom.ty, 0, 0, 1], + [viewZoom] + ); + // Same transform on the touch layer, and that is the point of it: RN scales + // about the view's CENTRE by default, which dragged the layer's local space + // off the canvas — and every mark hit-test with it — as soon as the view was + // zoomed. Pinning the origin to the top left makes the layer map exactly like + // the Skia matrix the canvas draws with (P' = s·P + t), which is also what + // lets the gesture handlers use their coordinates as canvas coordinates. + const touchZoomStyle = (z: { s: number; tx: number; ty: number }) => + z.s === 1 && z.tx === 0 && z.ty === 0 + ? undefined + : { + transformOrigin: 'top left', + transform: [{ translateX: z.tx }, { translateY: z.ty }, { scale: z.s }], + }; + // The view zoom composes OVER the committed crop (outer·inner), exactly like + // the canvas draws it. + const libCropTouchStyle = touchZoomStyle( + libCropView + ? { + s: viewZoom.s * libCropView.k, + tx: viewZoom.s * libCropView.ox + viewZoom.tx, + ty: viewZoom.s * libCropView.oy + viewZoom.ty, + } + : viewZoom + ); // Export-facing summary of the current framed-window photo reposition. The // gesture state (libZoom) is canvas-absolute; u/v convert it to the fraction @@ -2043,6 +2248,7 @@ const Viewfinder = forwardRef(function Viewfi return ( <> + {/* Aspect-ratio band: black out the live feed outside the composition region. Frames with their own geometry (polaroid/wall) draw their own bars later, so this only fires for the plain frames. */} @@ -2078,6 +2284,7 @@ const Viewfinder = forwardRef(function Viewfi {renderFrameOverlay()} {renderGPSWatermark()} {renderCustomWatermark()} + {/* Rule-of-thirds guide, clipped to the composition region (fullscreen @@ -2126,6 +2333,10 @@ const Viewfinder = forwardRef(function Viewfi return ( + {/* FRAME-tab view zoom: the feed and its gesture layer magnify together + (preview only — a capture is untouched) so the watermark can be + dragged against a big picture. The overlays mirror the same matrix. */} + (function Viewfi onResponderRelease={onImageDragEnd} onResponderTerminate={onImageDragEnd} /> + {focusPoint && ( /* The locked-AE/AF reticle IS the unlock control: the old "AE/AF LOCKED — TAP TO UNLOCK" banner was removed from the preview @@ -2270,6 +2482,7 @@ const Viewfinder = forwardRef(function Viewfi return ( + {/* Photo is clipped to the window and repositioned by pinch/drag; the card, seam, grain and watermarks stay put. */} @@ -2325,9 +2538,11 @@ const Viewfinder = forwardRef(function Viewfi /> {renderGPSWatermark()} {renderCustomWatermark()} + true} onMoveShouldSetResponder={() => true} onResponderGrant={onLibTouchStart} @@ -2350,6 +2565,7 @@ const Viewfinder = forwardRef(function Viewfi return ( + {/* Photo is clipped to the mat window and repositioned by pinch/ drag; the artwork, grain and watermarks stay put. */} @@ -2401,9 +2617,11 @@ const Viewfinder = forwardRef(function Viewfi {renderGPSWatermark()} {renderCustomWatermark()} + true} onMoveShouldSetResponder={() => true} onResponderGrant={onLibTouchStart} @@ -2419,6 +2637,7 @@ const Viewfinder = forwardRef(function Viewfi {libraryImageUri && skiaImage ? ( <> + {useShaderPass ? ( @@ -2548,6 +2767,7 @@ const Viewfinder = forwardRef(function Viewfi )} )} + uri.replace(/^file:\/\//, ''); import { applyPhotoRotation } from './skiaImage'; export interface ExportResult { @@ -746,6 +749,15 @@ export async function processAndExportPhoto( // viewer; the OS cache purge reclaims them eventually. const mediaPermission = await MediaLibrary.requestPermissionsAsync(); if (mediaPermission.granted) { + // DCIM/Camera (stock camera location) first: expo-media-library can only + // reach DCIM (root) or Pictures/Camera, so the native module does the + // MediaStore insert. Falls back to the plain expo save if that fails. + try { + const assetId = await RecipescamExport.saveToCameraRollAsync(stripScheme(exportFile), 'image/jpeg'); + if (assetId) return { uri: exportFile, savedToLibrary: true, assetId }; + } catch (e) { + console.warn('Native DCIM/Camera save failed, falling back to MediaLibrary:', e); + } try { const asset = await MediaLibrary.createAssetAsync(exportFile); return { uri: exportFile, savedToLibrary: true, assetId: asset.id };