Zoom the FRAME preview instead of the photo, and save to DCIM/Camera
- FRAME tab now pinches the whole view (frame + photo + watermark) in both camera and library; one finger pans that view. Preview only: the exported file is identical at 1x and while the view is magnified. - Watermark pinch/drag stay relative and keep priority while the WATERMARK row is open, so they still work after returning from the FRAME tab. - Exports go through a native MediaStore insert into DCIM/Camera, falling back to expo-media-library when the native save fails.
This commit is contained in:
+289
-69
@@ -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<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
||||
onWmSizeChange,
|
||||
onWmEdit,
|
||||
wmEditing = false,
|
||||
frameTabActive = false,
|
||||
photoRotation = 0,
|
||||
photoStraighten = 0,
|
||||
wmRotation = 0,
|
||||
@@ -640,13 +646,15 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(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<ViewfinderHandle, ViewfinderProps>(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<ViewfinderHandle, ViewfinderProps>(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<ViewfinderHandle, ViewfinderProps>(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<ViewfinderHandle, ViewfinderProps>(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<ViewfinderHandle, ViewfinderProps>(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<ViewfinderHandle, ViewfinderProps>(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<ViewfinderHandle, ViewfinderProps>(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<ViewfinderHandle, ViewfinderProps>(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<ViewfinderHandle, ViewfinderProps>(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<ViewfinderHandle, ViewfinderProps>(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<ViewfinderHandle, ViewfinderProps>(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<ViewfinderHandle, ViewfinderProps>(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<ViewfinderHandle, ViewfinderProps>(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<ViewfinderHandle, ViewfinderProps>(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<ViewfinderHandle, ViewfinderProps>(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<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
||||
return (
|
||||
<>
|
||||
<Canvas style={StyleSheet.absoluteFill} pointerEvents="none">
|
||||
<Group matrix={viewZoomMatrix}>
|
||||
{/* 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<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
||||
{renderFrameOverlay()}
|
||||
{renderGPSWatermark()}
|
||||
{renderCustomWatermark()}
|
||||
</Group>
|
||||
</Canvas>
|
||||
|
||||
{/* Rule-of-thirds guide, clipped to the composition region (fullscreen
|
||||
@@ -2126,6 +2333,10 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
||||
|
||||
return (
|
||||
<View className="flex-1 relative overflow-hidden bg-black">
|
||||
{/* 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. */}
|
||||
<View style={[StyleSheet.absoluteFill, touchZoomStyle(viewZoom)]}>
|
||||
<SkiaCamera
|
||||
key={'cam-' + facing + '-' + cameraMountNonce}
|
||||
ref={skiaCameraRef}
|
||||
@@ -2151,6 +2362,7 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
||||
onResponderRelease={onImageDragEnd}
|
||||
onResponderTerminate={onImageDragEnd}
|
||||
/>
|
||||
</View>
|
||||
{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<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
||||
return (
|
||||
<View className="flex-1 relative overflow-hidden bg-black items-center justify-center">
|
||||
<Canvas style={StyleSheet.absoluteFill}>
|
||||
<Group matrix={viewZoomMatrix}>
|
||||
<Rect x={card.x} y={card.y} width={card.w} height={card.h} color="#faf9f6" />
|
||||
{/* 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<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
||||
/>
|
||||
{renderGPSWatermark()}
|
||||
{renderCustomWatermark()}
|
||||
</Group>
|
||||
</Canvas>
|
||||
<View
|
||||
className="absolute inset-0"
|
||||
style={libCropTouchStyle}
|
||||
onStartShouldSetResponder={() => true}
|
||||
onMoveShouldSetResponder={() => true}
|
||||
onResponderGrant={onLibTouchStart}
|
||||
@@ -2350,6 +2565,7 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
||||
return (
|
||||
<View className="flex-1 relative overflow-hidden bg-black items-center justify-center">
|
||||
<Canvas style={StyleSheet.absoluteFill}>
|
||||
<Group matrix={viewZoomMatrix}>
|
||||
<Rect x={0} y={0} width={vw} height={vh} color="#000000" />
|
||||
{/* 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<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
||||
</Group>
|
||||
{renderGPSWatermark()}
|
||||
{renderCustomWatermark()}
|
||||
</Group>
|
||||
</Canvas>
|
||||
<View
|
||||
className="absolute inset-0"
|
||||
style={libCropTouchStyle}
|
||||
onStartShouldSetResponder={() => true}
|
||||
onMoveShouldSetResponder={() => true}
|
||||
onResponderGrant={onLibTouchStart}
|
||||
@@ -2419,6 +2637,7 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
||||
{libraryImageUri && skiaImage ? (
|
||||
<>
|
||||
<Canvas style={StyleSheet.absoluteFill}>
|
||||
<Group matrix={viewZoomMatrix}>
|
||||
<Group matrix={libViewMatrix}>
|
||||
<ColorMatrix matrix={colorMatrix} />
|
||||
{useShaderPass ? (
|
||||
@@ -2548,6 +2767,7 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
||||
)}
|
||||
</Group>
|
||||
)}
|
||||
</Group>
|
||||
</Canvas>
|
||||
<View
|
||||
className="absolute inset-0"
|
||||
|
||||
@@ -11,6 +11,9 @@ import { drawFrameOnCanvas, polaroidLayout, POLAROID_CARD, POLAROID_WIN_W, wallf
|
||||
import { patchJpegDpi } from './jpegDpi';
|
||||
import { writeJpegExif } from './exifWrite';
|
||||
import { nextPhotoPath, readPhotoBytes, getDeviceIdentity } from './photoMeta';
|
||||
import RecipescamExport from '../../modules/recipescam-export';
|
||||
|
||||
const stripScheme = (uri: string) => 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 };
|
||||
|
||||
Reference in New Issue
Block a user