Pinch/drag library photo inside polaroid & wall frame windows with export parity
The framed windows (polaroid card / wall frame opening) now take the same pinch + double-tap gestures as plain library, clamped so the photo keeps covering the mat opening, and the visible sub-rectangle is handed to the export engine so the file matches the preview.
Wall frame export also stopped crashing: on Android release an image asset resolves to a drawable *name* ('wallframe') and is flagged downloaded, so Asset.downloadAsync short-circuited and readAsStringAsync threw 'Unsupported scheme for location wallframe'. Clearing that state lets the native module copy the drawable into the cache.
This commit is contained in:
+105
-36
@@ -101,6 +101,15 @@ export interface ViewfinderHandle {
|
||||
* hardware bias change re-locks AE on some devices and flashes the preview).
|
||||
*/
|
||||
flushExposureBias(): Promise<void>;
|
||||
/**
|
||||
* Current interactive photo reposition inside a framed library window
|
||||
* (RETRO POLAROID / WALL FRAME): scale s (>=1) and the visible window-center
|
||||
* as 0..1 fractions of the photo's cover plane (the area that fills the
|
||||
* window at 1x). Null when no framed window is showing or the photo is
|
||||
* unzoomed. App passes it to the export engine so the saved file shows
|
||||
* exactly the crop the user framed after pinch/drag.
|
||||
*/
|
||||
getFrameWindowZoom(): { s: number; u: number; v: number } | null;
|
||||
}
|
||||
|
||||
const noiseEffect = Skia.RuntimeEffect.Make(`
|
||||
@@ -460,6 +469,11 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
||||
}
|
||||
}
|
||||
},
|
||||
// Library frame window (polaroid/wall) zoom, for export parity. Read
|
||||
// live through the ref — the handle is captured once with deps [ref].
|
||||
getFrameWindowZoom() {
|
||||
return frameZoomRef.current;
|
||||
},
|
||||
}),
|
||||
[ref]
|
||||
);
|
||||
@@ -864,6 +878,29 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
||||
if (selectedFrame !== 'wallframe') return null;
|
||||
return wallframeLayout(vw, vh, 'contain');
|
||||
}, [selectedFrame, vw, vh]);
|
||||
// Framed photo window (library only) whose content the user may pinch/drag:
|
||||
// polaroid card window or wall-frame opening. Null for plain frames (their
|
||||
// whole composition region is the photo) and for the live camera.
|
||||
const zoomWindowRect = (): { x: number; y: number; w: number; h: number } | null => {
|
||||
if (mode !== 'library') return null;
|
||||
if (polaroidGeo) return polaroidGeo.window;
|
||||
if (selectedFrame === 'wallframe' && wallGeo && wallImage) return wallGeo.window;
|
||||
return null;
|
||||
};
|
||||
// Clamp pan/zoom so the photo always keeps COVERING the framed window (never
|
||||
// drags far enough to reveal the card/mat through the transparent opening).
|
||||
// Transform is P' = s*P + t in canvas coords; with the photo drawn over the
|
||||
// window rect, covering means t in [(x+w)(1-s), x(1-s)] (y likewise).
|
||||
const clampZoomToWindow = (s: number, tx: number, ty: number) => {
|
||||
const win = zoomWindowRect();
|
||||
if (!win) return { s, tx, ty }; // plain library/camera: unchanged
|
||||
if (s <= 1.0001) return { s, tx: 0, ty: 0 };
|
||||
return {
|
||||
s,
|
||||
tx: Math.min(win.x * (1 - s), Math.max((win.x + win.w) * (1 - s), tx)),
|
||||
ty: Math.min(win.y * (1 - s), Math.max((win.y + win.h) * (1 - s), ty)),
|
||||
};
|
||||
};
|
||||
// Live feed band rect for the CAMERA preview: the photo-area that must show
|
||||
// the export-equivalent sensor crop — the ratio band region (plain frames),
|
||||
// the polaroid photo window or the wall-frame window. When present the frame
|
||||
@@ -980,27 +1017,11 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
||||
if (wmDragPos) onWmPositionChange(wmDragPos.x, wmDragPos.y);
|
||||
setWmDragPos(null);
|
||||
};
|
||||
// Library polaroid/wall returns have no pinch responder of their own — this
|
||||
// fullscreen drag layer carries the watermark gesture there (drag = move,
|
||||
// tap = place, lift = commit). Plain-library and camera modes reuse their
|
||||
// existing gesture layers via the wm branches above.
|
||||
const wmDragOverlay = (
|
||||
<View
|
||||
className="absolute inset-0"
|
||||
onStartShouldSetResponder={() => true}
|
||||
onResponderGrant={(e: any) => {
|
||||
const t = e?.nativeEvent?.touches?.[0] ?? e?.nativeEvent;
|
||||
placeWm(t?.pageX ?? t?.locationX ?? 0, t?.pageY ?? t?.locationY ?? 0);
|
||||
}}
|
||||
onResponderMove={(e: any) => {
|
||||
const t = e?.nativeEvent?.touches?.[0] ?? e?.nativeEvent;
|
||||
placeWm(t?.pageX ?? t?.locationX ?? 0, t?.pageY ?? t?.locationY ?? 0);
|
||||
}}
|
||||
onResponderRelease={() => commitWm()}
|
||||
onResponderTerminate={() => commitWm()}
|
||||
/>
|
||||
);
|
||||
|
||||
// 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, pinch + double-tap repositions the photo
|
||||
// inside the frame window (watermark stays armed when zoomed so detail can
|
||||
// be inspected without accidentally moving text — matching plain library).
|
||||
const onLibTouchStart = (e: any) => {
|
||||
const ts = e.nativeEvent.touches;
|
||||
if (ts.length >= 2) {
|
||||
@@ -1047,11 +1068,12 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
||||
const d = Math.hypot(a.locationX - b.locationX, a.locationY - b.locationY);
|
||||
const s = clampZoom((p.s * d) / Math.max(p.d, 1));
|
||||
const k = s / p.s;
|
||||
setLibZoom({
|
||||
const next = clampZoomToWindow(
|
||||
s,
|
||||
tx: p.fx - k * (p.fx - p.tx),
|
||||
ty: p.fy - k * (p.fy - p.ty),
|
||||
});
|
||||
p.fx - k * (p.fx - p.tx),
|
||||
p.fy - k * (p.fy - p.ty)
|
||||
);
|
||||
setLibZoom(next);
|
||||
}
|
||||
};
|
||||
const onLibTouchEnd = (e: any) => {
|
||||
@@ -1075,20 +1097,26 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
||||
lastTapRef.current = null;
|
||||
setLibZoom((z) => {
|
||||
if (z.s > 1.01) return { s: 1, tx: 0, ty: 0 }; // double-tap again -> 100%
|
||||
// Clamp the focal point into the photo's display rect (composition
|
||||
// region under a ratio band, contain rect otherwise) so a double-tap
|
||||
// that lands in the black letterbox cannot fling the photo away.
|
||||
// Clamp the focal point into the photo's display rect — the framed
|
||||
// window when one is showing (polaroid/wall), the composition region
|
||||
// under a ratio band, or the contain rect — so a double-tap that lands
|
||||
// in the black letterbox cannot fling the photo away.
|
||||
let fx = x;
|
||||
let fy = y;
|
||||
const fit = regionBand
|
||||
? { dx: region.x, dy: region.y, dw: region.w, dh: region.h }
|
||||
: imageFitRect;
|
||||
const win = zoomWindowRect();
|
||||
const fit = win
|
||||
? { dx: win.x, dy: win.y, dw: win.w, dh: win.h }
|
||||
: regionBand
|
||||
? { dx: region.x, dy: region.y, dw: region.w, dh: region.h }
|
||||
: imageFitRect;
|
||||
if (fit) {
|
||||
fx = Math.min(Math.max(fx, fit.dx), fit.dx + fit.dw);
|
||||
fy = Math.min(Math.max(fy, fit.dy), fit.dy + fit.dh);
|
||||
}
|
||||
const s = 2.5;
|
||||
return { s, tx: fx - s * fx, ty: fy - s * fy }; // zoom into the tapped point
|
||||
// zoom into the tapped point, then clamp so the photo keeps covering
|
||||
// the framed window (plain library keeps the raw focal transform).
|
||||
return clampZoomToWindow(s, fx - s * fx, fy - s * fy);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -1105,6 +1133,25 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
||||
[libZoom]
|
||||
);
|
||||
|
||||
// Export-facing summary of the current framed-window photo reposition. The
|
||||
// gesture state (libZoom) is canvas-absolute; u/v convert it to the fraction
|
||||
// of the photo's cover plane sitting at the WINDOW CENTER — layout units the
|
||||
// export engine can apply at its own pixel size (the Skia render draws the
|
||||
// source sub-rectangle that stays visible through the frame's opening). The
|
||||
// handle method reads this ref because useImperativeHandle captures once.
|
||||
const frameZoomRef = useRef<{ s: number; u: number; v: number } | null>(null);
|
||||
frameZoomRef.current = (() => {
|
||||
const win = zoomWindowRect();
|
||||
if (!win || libZoom.s <= 1.001) return null;
|
||||
const tlx = libZoom.tx + (libZoom.s - 1) * win.x;
|
||||
const tly = libZoom.ty + (libZoom.s - 1) * win.y;
|
||||
return {
|
||||
s: libZoom.s,
|
||||
u: (win.w / 2 - tlx) / (libZoom.s * win.w),
|
||||
v: (win.h / 2 - tly) / (libZoom.s * win.h),
|
||||
};
|
||||
})();
|
||||
|
||||
// Border/bars scale with the composition region so framed exports stay
|
||||
// proportional to the cropped photo (region == fullscreen when no ratio).
|
||||
const borderWidth = Math.min(region.w, region.h) * 0.05;
|
||||
@@ -1485,7 +1532,10 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
||||
<View className="flex-1 relative overflow-hidden bg-black items-center justify-center">
|
||||
<Canvas style={StyleSheet.absoluteFill}>
|
||||
<Rect x={card.x} y={card.y} width={card.w} height={card.h} color="#faf9f6" />
|
||||
<Group>
|
||||
{/* Photo is clipped to the window and repositioned by pinch/drag;
|
||||
the card, seam, grain and watermarks stay put. */}
|
||||
<Group clip={winRect}>
|
||||
<Group matrix={libZoomMatrix}>
|
||||
<ColorMatrix matrix={colorMatrix} />
|
||||
{useShaderPass ? (
|
||||
<Rect x={win.x} y={win.y} width={win.w} height={win.h}>
|
||||
@@ -1515,6 +1565,7 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
||||
<SkiaImage image={skiaImage} fit={polaroidFit} rect={winRect} />
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
{grainOpacity > 0 && noiseEffect && (
|
||||
<Group blendMode="overlay">
|
||||
<Rect x={win.x} y={win.y} width={win.w} height={win.h} opacity={grainOpacity}>
|
||||
@@ -1535,7 +1586,14 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
||||
{renderGPSWatermark()}
|
||||
{renderCustomWatermark()}
|
||||
</Canvas>
|
||||
{wmArmed && wmDragOverlay}
|
||||
<View
|
||||
className="absolute inset-0"
|
||||
onStartShouldSetResponder={() => true}
|
||||
onMoveShouldSetResponder={() => true}
|
||||
onResponderGrant={onLibTouchStart}
|
||||
onResponderMove={onLibTouchMove}
|
||||
onResponderRelease={onLibTouchEnd}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -1553,7 +1611,10 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
||||
<View className="flex-1 relative overflow-hidden bg-black items-center justify-center">
|
||||
<Canvas style={StyleSheet.absoluteFill}>
|
||||
<Rect x={0} y={0} width={vw} height={vh} color="#000000" />
|
||||
<Group>
|
||||
{/* Photo is clipped to the mat window and repositioned by pinch/
|
||||
drag; the artwork, grain and watermarks stay put. */}
|
||||
<Group clip={winRect}>
|
||||
<Group matrix={libZoomMatrix}>
|
||||
<ColorMatrix matrix={colorMatrix} />
|
||||
{useShaderPass ? (
|
||||
<Rect x={win.x} y={win.y} width={win.w} height={win.h}>
|
||||
@@ -1583,6 +1644,7 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
||||
<SkiaImage image={skiaImage} fit="cover" rect={winRect} />
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
{grainOpacity > 0 && noiseEffect && (
|
||||
<Group blendMode="overlay">
|
||||
<Rect x={win.x} y={win.y} width={win.w} height={win.h} opacity={grainOpacity}>
|
||||
@@ -1599,7 +1661,14 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
||||
{renderGPSWatermark()}
|
||||
{renderCustomWatermark()}
|
||||
</Canvas>
|
||||
{wmArmed && wmDragOverlay}
|
||||
<View
|
||||
className="absolute inset-0"
|
||||
onStartShouldSetResponder={() => true}
|
||||
onMoveShouldSetResponder={() => true}
|
||||
onResponderGrant={onLibTouchStart}
|
||||
onResponderMove={onLibTouchMove}
|
||||
onResponderRelease={onLibTouchEnd}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user