Edit the watermark in place, rotate the frame photo, place a custom watermark

Watermark editing no longer covers the photo: the preview letterboxes the
image inside its fit rect while the deck is open, and the idle hint is gone.

The FRAME tab can rotate a library photo 90 degrees clockwise. Rotation
swaps the pixel dimensions instead of the frame, so the polaroid/wall window
keeps its aspect and every consumer (cover crop, watermark area, export) picks
up the new orientation from width()/height() with no extra layout branch. The
rotate surface is CPU-backed on purpose - a GPU snapshot loses its pixels
before the next frame and renders the photo black.

Custom watermark text can now be dragged anywhere on the photo, double-tapped
to re-edit, and is only baked into the file once ACCEPT is pressed; the export
engine takes the same position. It also gained COLOR/SIZE/FONT chips, with the
font list read from the OS via Skia's FontMgr so any installed family can be
used.
This commit is contained in:
2026-09-10 09:26:55 +07:00
parent ff06045e02
commit 3a8b110c1b
6 changed files with 347 additions and 42 deletions
+110 -16
View File
@@ -22,6 +22,7 @@ import {
TileMode,
FilterMode,
MipmapMode,
FontStyle,
} from '@shopify/react-native-skia';
import { Lock } from 'lucide-react-native';
import { Recipe, GPSInfo, FrameId, AspectRatio, ColorAdjustments, ASPECT_RATIO_W_H } from '../types';
@@ -29,6 +30,7 @@ import { getSkiaColorMatrix, applyExposureGain } from '../utils/colorUtils';
import { TONE_SKSL, getToneUniforms } from '../utils/toneShader';
import { CINEMA_SKSL, getCinemaUniforms, cinemaOffFlat } from '../utils/cinemaShader';
import { polaroidLayout, wallframeLayout, WALLFRAME_PNG_W, WALLFRAME_PNG_H } from '../utils/frameUtils';
import { rotateSkImage90 } from '../utils/skiaImage';
interface ViewfinderProps {
mode: 'camera' | 'library';
@@ -40,6 +42,16 @@ interface ViewfinderProps {
useGeotag: boolean;
gpsInfo: GPSInfo | null;
libraryImageUri: string | null;
// Library only: screen height still visible above the bottom deck. The whole
// viewfinder geometry is letterboxed into it so no part of the photo (and no
// custom-watermark position) hides under an open panel. Camera ignores this:
// its feed rects are normalized against the real window.
availableHeight?: number | null;
// Library only: user rotation of the inserted photo, 0 or 90deg clockwise.
photoRotation?: 0 | 90;
// WATERMARK tab: double-tap ON the mark re-opens the text editor (App bumps
// a nonce so the panel puts the caret back in its input).
onWmEdit?: () => void;
cameraPermissionGranted: boolean;
onRequestCameraPermission: () => void;
photoOutput: CameraPhotoOutput;
@@ -159,6 +171,7 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
useGeotag,
gpsInfo,
libraryImageUri,
availableHeight,
cameraPermissionGranted,
onRequestCameraPermission,
photoOutput,
@@ -172,12 +185,17 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
imageAdjustTarget,
customWm = { enabled: false, text: '', x: 0.5, y: 0.5 },
onWmPositionChange,
onWmEdit,
photoRotation = 0,
}: ViewfinderProps,
ref
) {
// Fullscreen hero: the viewfinder fills the entire screen, every overlay
// rect derives from window size.
const { width: vw, height: vh } = useWindowDimensions();
const { width: vw, height: winH } = useWindowDimensions();
// Library: everything below this line is the bottom deck, so the photo is
// letterboxed above it (top-anchored: same fractions, same export).
const vh = mode === 'library' && availableHeight && availableHeight > 0 ? availableHeight : winH;
const insets = useSafeAreaInsets();
// Composition ratio band. With a ratio active the photo area is a centered
@@ -251,7 +269,14 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
}
}, [device]);
const skiaImage = useImage(libraryImageUri || '');
const loadedImage = useImage(libraryImageUri || '');
// FRAME-tab rotation: pre-rotate the source, so the width()/height() swap
// flows through every derived rect (polaroid window aspect, cover crop,
// watermark area) and the export matches the preview for free.
const skiaImage = useMemo(
() => (photoRotation === 90 ? rotateSkImage90(loadedImage) : loadedImage),
[loadedImage, photoRotation]
);
// Wall-frame PNG artwork (project root). Loaded lazily so other frames pay
// no asset cost; source switches to '' when unselected (useImage → null).
const wallImage = useImage(selectedFrame === 'wallframe' ? require('../../wallframe.png') : '');
@@ -859,14 +884,24 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
// pole-like, whereas the real print is a shorter 3:4 card letterboxed on
// screen). ponytail: verified on Xiaomi quality-mode captures (4:3); if the
// session negotiates 16:9 photos, change to 9/16.
// The frame keys off the ORIGINAL (pre-rotation) source: rotating the print
// inside the frame must never resize the frame itself.
const framePhotoAspect =
loadedImage && loadedImage.width() > 0 && loadedImage.height() > 0
? loadedImage.width() / loadedImage.height()
: 0;
const polaroidAspect =
ratioWH ??
(mode === 'library' && skiaImage && skiaImage.width() > 0 && skiaImage.height() > 0
? skiaImage.width() / skiaImage.height()
: 3 / 4);
ratioWH ?? (mode === 'library' && framePhotoAspect > 0 ? framePhotoAspect : 3 / 4);
// Photo draw fit inside the polaroid window: cover when the window aspect
// (ratio) differs from the source photo, fill otherwise (aspects match).
const polaroidFit: 'cover' | 'fill' = ratioWH ? 'cover' : 'fill';
// (ratio) differs from the source photo, fill otherwise (aspects match). A
// print rotated inside the frame no longer matches the (un-rotated) window
// aspect, so it is contain-fitted — letterboxed inside the window, never
// stretched, and the card keeps its dimensions.
const polaroidFit: 'cover' | 'contain' | 'fill' = ratioWH
? 'cover'
: photoRotation === 90
? 'contain'
: 'fill';
const polaroidGeo = useMemo(() => {
if (selectedFrame !== 'polaroid') return null;
return polaroidLayout(vw, vh, polaroidAspect, 'contain');
@@ -992,13 +1027,27 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
// Photo/window area the 0..1 fractions map onto — identical rule to
// renderGPSWatermark so both marks share the user-visible coordinate space.
const wmArea = () => {
const win = polaroidGeo?.window ?? wallGeo?.window ?? null;
if (win) return { dx: win.x, dy: win.y, dw: win.w, dh: win.h };
// The FRAME, not just the photo window: the mark may be dragged anywhere
// on the card / frame (the flat bottom deck is where a caption belongs).
// A framed export's canvas IS that frame, so the same 0..1 fractions
// carry over to the burn-in.
const box = polaroidGeo?.card ?? wallGeo?.frame ?? null;
if (box) return { dx: box.x, dy: box.y, dw: box.w, dh: box.h };
if (regionBand) return { dx: region.x, dy: region.y, dw: region.w, dh: region.h };
if (mode === 'library' && imageFitRect) return imageFitRect;
return { dx: 0, dy: 0, dw: vw, dh: vh };
};
const wmFontSize = Math.round(vw * 0.032); // matches customFont/iconFont size
const wmFontSize = Math.round(vw * 0.032 * (customWm.size ?? 1));
const wmColor = customWm.color ?? '#f59e0b';
// FONT chip: an OS family resolved through the platform font manager; no
// family picked -> the bundled face customFont was built from, re-sized to
// the current SIZE so one code path serves both.
const wmFont = useMemo(() => {
const face = customWm.font
? Skia.FontMgr.System().matchFamilyStyle(customWm.font, FontStyle.Normal)
: customFont?.getTypeface() ?? null;
return face ? Skia.Font(face, wmFontSize) : null;
}, [customWm.font, wmFontSize, customFont]);
const wmFracFromXY = (x: number, y: number) => {
const area = wmArea();
const dw = area.dw || 1;
@@ -1008,15 +1057,45 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
y: Math.max(0, Math.min(1, (y - area.dy) / dh)),
};
};
// placeWm/commitWm are called back-to-back inside one gesture handler, so
// reading the wmDragPos state would commit the PREVIOUS render's value (a
// tap with no move events committed nothing at all). Mirror it in a ref.
const wmDragPosRef = useRef<{ x: number; y: number } | null>(null);
const placeWm = (x: number, y: number) => {
if (!wmArmed) return;
setWmDragPos(wmFracFromXY(x, y));
const pos = wmFracFromXY(x, y);
wmDragPosRef.current = pos;
setWmDragPos(pos);
};
const commitWm = () => {
if (!wmArmed || !onWmPositionChange) return;
if (wmDragPos) onWmPositionChange(wmDragPos.x, wmDragPos.y);
const pos = wmDragPosRef.current;
wmDragPosRef.current = null;
setWmDragPos(null);
if (!wmArmed || !onWmPositionChange || !pos) return;
onWmPositionChange(pos.x, pos.y);
};
// Bounding box of the drawn mark (same anchor maths as renderCustomWatermark)
// so a double-tap ON the text re-opens the editor instead of moving it.
const wmBox = () => {
if (!wmArmed || !wmFont) return null;
const area = wmArea();
const fracX = wmDragPos?.x ?? customWm.x;
const fracY = wmDragPos?.y ?? customWm.y;
const tx = area.dx + fracX * area.dw;
const ty = area.dy + fracY * area.dh + Math.round(wmFontSize * 0.35);
const w = Math.max(wmFont.measureText(customWm.text.trim()).width, wmFontSize * 2);
return { x: tx, y: ty - wmFontSize, w, h: Math.round(wmFontSize * 1.4) };
};
const wmBoxHit = (
box: { x: number; y: number; w: number; h: number } | null,
x: number,
y: number
) =>
!!box &&
x >= box.x - 8 &&
x <= box.x + box.w + 8 &&
y >= box.y - 8 &&
y <= box.y + box.h + 8;
// 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
@@ -1086,6 +1165,21 @@ 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 && libZoom.s <= 1.01) {
const now = Date.now();
const prev = lastTapRef.current;
lastTapRef.current = { x, y, t: now };
// Second tap ON the mark (keyboard hidden, still placing): back to the
// text editor — the mark is only embedded by ACCEPT in the panel.
if (
prev &&
now - prev.t < 350 &&
Math.hypot(x - prev.x, y - prev.y) < 50 &&
wmBoxHit(wmBox(), x, y)
) {
lastTapRef.current = null;
onWmEdit?.();
return;
}
placeWm(x, y);
commitWm();
return;
@@ -1250,7 +1344,7 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
};
const renderCustomWatermark = () => {
if (!wmArmed || !customFont) return null;
if (!wmArmed || !wmFont) return null;
const area = wmArea();
const fracX = wmDragPos?.x ?? customWm.x;
const fracY = wmDragPos?.y ?? customWm.y;
@@ -1259,7 +1353,7 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
const tx = area.dx + fracX * area.dw;
const ty = area.dy + fracY * area.dh + Math.round(wmFontSize * 0.35);
return (
<SkiaText x={tx} y={ty} text={customWm.text.trim()} font={customFont} color="#f59e0b" />
<SkiaText x={tx} y={ty} text={customWm.text.trim()} font={wmFont} color={wmColor} />
);
};