Let the GPS watermark be placed, sized and hung landscape

Three WATERMARK/FRAME behaviours that share the stamp geometry:

* GPS mark: drag it anywhere on the frame/photo area and pinch it bigger or
  smaller. The 0..1 anchor plus the size multiplier ride along in the export
  options, so a framed file (whose canvas IS the card/frame) burns the mark in
  at exactly the fraction the preview showed.
* The photo's own pinch zoom stays available while the WATERMARK editor is
  closed, so a two-finger zoom is never swallowed by the mark while its panel
  is open.
* WALL FRAME gains a WALL PORTRAIT / WALL LANDSCAPE chip: the artwork hangs
  in its own 4000x3117 orientation instead of the default 90-degree rotation.
This commit is contained in:
2026-09-10 16:35:35 +07:00
parent 47e424789d
commit 11ed786758
6 changed files with 313 additions and 116 deletions
+13
View File
@@ -48,6 +48,10 @@ interface AdjustmentPanelProps {
// so it fits the chosen frame better.
photoRotation: 0 | 90;
onToggleRotation: () => void;
// FRAME tab (WALL FRAME only): hang the artwork landscape instead of the
// default portrait rotation.
wallLandscape: boolean;
onToggleWallLandscape: () => void;
onToggleGeotag: (enabled: boolean) => void;
onSaveRecipe: (name: string) => void;
onDeleteRecipe: (id: string) => void;
@@ -142,6 +146,8 @@ export default function AdjustmentPanel({
onUpdateAdjustments,
onUpdateFrame,
photoRotation,
wallLandscape,
onToggleWallLandscape,
onToggleGeotag,
onDeleteRecipe,
customWm,
@@ -508,6 +514,13 @@ export default function AdjustmentPanel({
active: photoRotation !== 0,
onPress: onToggleRotation,
})}
{selectedFrame === 'wallframe' &&
renderChip({
key: 'wall-orient',
label: wallLandscape ? 'WALL LANDSCAPE' : 'WALL PORTRAIT',
active: wallLandscape,
onPress: onToggleWallLandscape,
})}
</ScrollView>
);
+195 -71
View File
@@ -105,6 +105,13 @@ interface ViewfinderProps {
y: number;
};
onWmPositionChange?: (x: number, y: number) => void;
// GPS mark placement (WATERMARK tab drag + pinch): the same 0..1 fractions
// of the same area customWm uses, TOP-LEFT anchored; size multiplies the
// base 3.2%-of-width size. Absent = the historical fixed top-left look.
gpsWm?: { x: number; y: number; size: number };
onGpsWmChange?: (x: number, y: number, size: number) => void;
// Pinch on the custom mark: its SIZE, exactly like the panel's SIZE chip.
onWmSizeChange?: (size: number) => void;
// WATERMARK tab ROTATE chip: clockwise degrees (0/90/180/270). Rotates the
// custom text AND the GPS lines around their own anchors — same angle the
// export burns in.
@@ -115,6 +122,8 @@ 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;
// WALL FRAME hung landscape (4000x3117) instead of portrait.
wallLandscape?: boolean;
}
export interface ViewfinderHandle {
@@ -197,10 +206,14 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
imageAdjustTarget,
customWm = { enabled: false, text: '', x: 0.5, y: 0.5 },
onWmPositionChange,
gpsWm,
onGpsWmChange,
onWmSizeChange,
onWmEdit,
wmEditing = false,
photoRotation = 0,
wmRotation = 0,
wallLandscape = false,
}: ViewfinderProps,
ref
) {
@@ -305,6 +318,17 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
require('../../assets/NotoEmoji-GPS.ttf'),
Math.round(vw * 0.032)
);
// GPS mark SIZE (pinch while placing it): the same two faces, re-sized, so
// the preview grows with the multiplier the export burns in.
const gpsIconSize = Math.round(vw * 0.032 * (gpsWm?.size ?? 1));
const gpsFont = useMemo(
() => (customFont ? Skia.Font(customFont.getTypeface(), gpsIconSize) : null),
[customFont, gpsIconSize]
);
const gpsIconFont = useMemo(
() => (iconFont ? Skia.Font(iconFont.getTypeface(), gpsIconSize) : null),
[iconFont, gpsIconSize]
);
const adjustments = meteringAdjustments
? { ...recipe.adjustments, ...meteringAdjustments }
@@ -629,12 +653,14 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
kind = 'param';
v0 = t.value;
param = { min: t.min, max: t.max, step: t.step, onChange: t.onChange };
} else if (wmArmed) {
// Watermark armed: every touch drags/places the text (an open param
// slider keeps priority; the AE-lock EV drag yields while armed).
} 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).
kind = 'wm';
const t = e?.nativeEvent?.touches?.[0] ?? e?.nativeEvent;
placeWm(t?.pageX ?? t?.locationX ?? 0, t?.pageY ?? t?.locationY ?? 0);
const tgt = wmTargetAt(t?.locationX ?? 0, t?.locationY ?? 0) ?? (wmArmed ? 'custom' : 'gps');
wmDragTgtRef.current = tgt;
placeWm(t?.pageX ?? t?.locationX ?? 0, t?.pageY ?? t?.locationY ?? 0, tgt);
} else if (focusPoint && onExposureChange) {
kind = 'ev';
v0 = evStops;
@@ -646,8 +672,30 @@ 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.
const t = e?.nativeEvent?.touches?.[0] ?? e?.nativeEvent;
placeWm(t?.pageX ?? t?.locationX ?? 0, t?.pageY ?? t?.locationY ?? 0);
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;
return;
}
@@ -678,6 +726,8 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
const onImageDragEnd = (e: any) => {
const g = imgDragRef.current;
imgDragRef.current = null;
wmPinchRef.current = null;
wmDragTgtRef.current = null;
if (!g) return;
if (g.kind === 'wm') {
// A tap places the text at the tapped point; a drag already moved it.
@@ -993,8 +1043,8 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
// is cover-cropped by the mat opening (never letterboxed inside the window).
const wallGeo = useMemo(() => {
if (selectedFrame !== 'wallframe') return null;
return wallframeLayout(vw, vh, 'contain');
}, [selectedFrame, vw, vh]);
return wallframeLayout(vw, vh, 'contain', wallLandscape);
}, [selectedFrame, vw, vh, wallLandscape]);
// 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.
@@ -1105,6 +1155,11 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
const wmVisible = !!(customWm.enabled && customWm.text.trim().length > 0);
const wmArmed = !!(wmVisible && wmEditing && onWmPositionChange);
const [wmDragPos, setWmDragPos] = useState<{ x: number; y: number } | null>(null);
// GPS lines: same deal, but they have no text editor — armed whenever the
// panel is open and the mark is drawn.
const gpsWmVisible = !!(useGeotag && gpsInfo && customFont && iconFont);
const gpsWmArmed = !!(gpsWmVisible && wmEditing && onGpsWmChange);
const [gpsDragPos, setGpsDragPos] = useState<{ x: number; y: number } | null>(null);
// Photo/window area the 0..1 fractions map onto — identical rule to
// renderGPSWatermark so both marks share the user-visible coordinate space.
const wmArea = () => {
@@ -1130,6 +1185,19 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
ay: area.dy + fracY * area.dh + Math.round(wmFontSize * 0.35),
};
};
// The GPS block's TOP-LEFT corner (the two lines grow right and down from
// there) — the same 0..1 fractions the export burns in, so preview and file
// agree. Defaults are the old fixed top-left look.
const gpsAnchor = () => {
const area = wmArea();
const fracX = gpsDragPos?.x ?? gpsWm?.x ?? 0.05;
const fracY = gpsDragPos?.y ?? gpsWm?.y ?? 0.1;
return {
ax: area.dx + fracX * area.dw,
ay: area.dy + fracY * area.dh,
iconSize: gpsIconSize,
};
};
const wmRad = (wmRotation * Math.PI) / 180;
const wmFontSize = Math.round(vw * 0.032 * (customWm.size ?? 1));
const wmColor = customWm.color ?? '#f59e0b';
@@ -1155,18 +1223,41 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
// 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;
const gpsDragPosRef = useRef<{ x: number; y: number } | null>(null);
// 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);
wmDragPosRef.current = pos;
setWmDragPos(pos);
if (tgt === 'custom') {
if (!wmArmed) return;
wmDragPosRef.current = pos;
setWmDragPos(pos);
return;
}
if (!gpsWmArmed) return;
gpsDragPosRef.current = pos;
setGpsDragPos(pos);
};
const commitWm = () => {
const pos = wmDragPosRef.current;
wmDragPosRef.current = null;
setWmDragPos(null);
if (!wmArmed || !onWmPositionChange || !pos) return;
onWmPositionChange(pos.x, pos.y);
if (wmArmed && onWmPositionChange && pos) onWmPositionChange(pos.x, pos.y);
const gpos = gpsDragPosRef.current;
gpsDragPosRef.current = null;
setGpsDragPos(null);
if (gpsWmArmed && onGpsWmChange && gpos) onGpsWmChange(gpos.x, gpos.y, gpsWm?.size ?? 1);
};
// Two fingers on a mark: scale THAT mark (custom SIZE / GPS mark size)
// instead of zooming the photo. 0.4..4x keeps it on the card.
const wmPinchRef = useRef<{ tgt: 'custom' | 'gps'; d: number; size: number } | null>(null);
const wmSizeOf = (tgt: 'custom' | 'gps') =>
tgt === 'custom' ? customWm.size ?? 1 : gpsWm?.size ?? 1;
const applyWmScale = (tgt: 'custom' | 'gps', size: number) => {
const s = Math.max(0.4, Math.min(4, size));
if (tgt === 'custom') onWmSizeChange?.(s);
else onGpsWmChange?.(gpsWm?.x ?? 0.05, gpsWm?.y ?? 0.1, s);
};
// 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.
@@ -1176,27 +1267,49 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
const w = Math.max(wmFont.measureText(customWm.text.trim()).width, wmFontSize * 2);
return { x: ax, y: ay - wmFontSize, w, h: Math.round(wmFontSize * 1.4) };
};
const wmBoxHit = (
// A rotated mark needs the tap un-spun around the same anchor, otherwise
// the box stops covering the visible glyphs.
const boxHitAt = (
box: { x: number; y: number; w: number; h: number } | null,
x: number,
y: number
y: number,
origin: { x: number; y: number }
) => {
if (!box) return false;
// A rotated mark needs the tap un-spun around the same anchor, otherwise
// the box stops covering the visible glyphs.
if (wmRotation) {
const { ax, ay } = wmAnchor();
const c = Math.cos(-wmRad);
const s = Math.sin(-wmRad);
const dx = x - ax;
const dy = y - ay;
x = ax + dx * c - dy * s;
y = ay + dx * s + dy * c;
const dx = x - origin.x;
const dy = y - origin.y;
x = origin.x + dx * c - dy * s;
y = origin.y + dx * s + dy * c;
}
return (
x >= box.x - 8 && x <= box.x + box.w + 8 && y >= box.y - 8 && y <= box.y + box.h + 8
);
};
const wmBoxHit = (
box: { x: number; y: number; w: number; h: number } | null,
x: number,
y: number
) => boxHitAt(box, x, y, wmAnchor());
// The GPS block covers both lines out to the widest one (the timestamp).
const gpsBox = () => {
const { ax, ay, iconSize } = gpsAnchor();
return { x: ax, y: ay, w: Math.round(iconSize * 8.5), h: Math.round(iconSize * 2.6) };
};
const gpsBoxHit = (x: number, y: number) => boxHitAt(gpsBox(), x, y, gpsAnchor());
// Which mark a touch is on. A box hit always wins; with neither box hit the
// lone armed mark takes the touch, so a tap anywhere still places it.
const wmTargetAt = (x: number, y: number): 'custom' | 'gps' | null => {
const custom = wmArmed && wmBoxHit(wmBox(), x, y);
const gps = gpsWmArmed && gpsBoxHit(x, y);
if (custom) return 'custom';
if (gps) return 'gps';
if (wmArmed) return 'custom';
if (gpsWmArmed) return 'gps';
return null;
};
// 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
@@ -1209,27 +1322,50 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
panRef.current =
ts.length === 1 ? { x: ts[0].locationX, y: ts[0].locationY, tx: libZoom.tx, ty: libZoom.ty } : null;
panUsedRef.current = false;
if (ts.length === 1 && (wmArmed || gpsWmArmed) && libZoom.s <= 1.01) {
wmDragTgtRef.current = wmTargetAt(ts[0].locationX, ts[0].locationY);
}
if (ts.length >= 2) {
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;
pinchUsedRef.current = true;
lastTapRef.current = null;
pinchRef.current = {
d: Math.hypot(a.locationX - b.locationX, a.locationY - b.locationY),
s: libZoom.s,
tx: libZoom.tx,
ty: libZoom.ty,
fx: (a.locationX + b.locationX) / 2,
fy: (a.locationY + b.locationY) / 2,
};
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);
wmPinchRef.current = tgt ? { tgt, d, size: wmSizeOf(tgt) } : null;
pinchRef.current = null;
return;
}
pinchRef.current = { d, s: libZoom.s, tx: libZoom.tx, ty: libZoom.ty, fx: mx, fy: my };
}
};
const onLibTouchMove = (e: any) => {
const ts = e.nativeEvent.touches;
// Watermark armed + one finger + unzoomed: the drag moves the text (not the
// WATERMARK tab open: two fingers scale the mark under them (or do
// nothing) — the photo itself only zooms once the editor is closed.
if (ts.length >= 2 && wmEditing) {
const p = wmPinchRef.current;
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;
}
// 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 (wmArmed && ts.length === 1 && libZoom.s <= 1.01) {
if ((wmArmed || gpsWmArmed) && ts.length === 1 && libZoom.s <= 1.01) {
const t = ts[0];
placeWm(t.pageX ?? t.locationX, t.pageY ?? t.locationY);
placeWm(
t.pageX ?? t.locationX,
t.pageY ?? t.locationY,
wmDragTgtRef.current ?? (wmArmed ? 'custom' : 'gps')
);
return;
}
// One finger, no pinch running: drag the photo inside the framed window
@@ -1281,6 +1417,8 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
const wasPinch = pinchUsedRef.current;
const wasPan = panUsedRef.current;
pinchRef.current = null;
wmPinchRef.current = null;
wmDragTgtRef.current = null;
pinchUsedRef.current = false;
panRef.current = null;
panUsedRef.current = false;
@@ -1293,13 +1431,13 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
// editor (and the placement layer with it). A single tap is NOT consumed —
// this block records nothing, so the viewer rules below still run and the
// photo keeps pinch / double-tap zoom / pan.
if (!wmArmed && wmVisible && libZoom.s <= 1.01) {
if (!wmArmed && !gpsWmArmed && (wmVisible || gpsWmVisible) && libZoom.s <= 1.01) {
const prev = lastTapRef.current;
if (
prev &&
Date.now() - prev.t < 350 &&
Math.hypot(x - prev.x, y - prev.y) < 50 &&
wmBoxHit(wmBox(), x, y)
(wmBoxHit(wmBox(), x, y) || gpsBoxHit(x, y))
) {
lastTapRef.current = null;
onWmEdit?.();
@@ -1308,23 +1446,23 @@ 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) {
if ((wmArmed || gpsWmArmed) && 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.
// Second tap ON a mark (keyboard hidden, still placing): back to the
// 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)
(wmBoxHit(wmBox(), x, y) || gpsBoxHit(x, y))
) {
lastTapRef.current = null;
onWmEdit?.();
return;
}
placeWm(x, y);
placeWm(x, y, wmTargetAt(x, y) ?? 'custom');
commitWm();
return;
}
@@ -1522,46 +1660,32 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
};
const renderGPSWatermark = () => {
if (!useGeotag || !gpsInfo || !customFont || !iconFont) return null;
if (!gpsWmVisible || !gpsFont || !gpsIconFont) return null;
const locationName = gpsInfo.locality || 'STREET VIEW';
const d = new Date(gpsInfo.timestamp);
const pad = (n: number) => String(n).padStart(2, '0');
const timestampStr = `${pad(d.getDate())}/${pad(d.getMonth() + 1)}/${d.getFullYear()} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
// Anchor to the PHOTO's top-left, not the screen. Bottom anchoring put the
// mark under the floating chip rows (open WB/FRAME panel covered it), so it
// now lives at the top of the photo/window: the bottom sheet can never reach
// it. In camera mode the photo fills the screen; in library mode it is
// contain-fitted with letterbox — use imageFitRect so the mark sits on the
// photo corner. With a polaroid frame the photo lives inside the card
// window — anchor to that. Always clear the floating TopBar (recipe name +
// SAVE), which ends around insets.top + 56 dp.
const win = polaroidGeo?.window ?? wallGeo?.window ?? null;
const area = wmArea();
const iconSize = Math.round(vw * 0.032); // matches customFont/iconFont size
const topBarBottom = insets.top + 56;
const topBase = Math.max(area.dy + Math.round(iconSize * 0.6), topBarBottom);
const xOffset =
area.dx + (win ? area.dw * 0.05 : Math.min(16, area.dw * 0.04));
// Two stacked lines (pin/location above camera/timestamp).
const row1Y = topBase + Math.round(iconSize * 1.1);
const row2Y = topBase + Math.round(iconSize * 2.3);
// One clear space (0.6em ≈ a mono space) between each icon and its text.
// TOP-LEFT anchored two-line block at the dragged fraction of the same area
// the custom mark uses (frame card / photo window / screen). A framed
// export's canvas IS that frame, so the fractions carry straight to the
// burn-in — exportEngine step 8 draws with the identical formula.
const { ax, ay, iconSize } = gpsAnchor();
const row1Y = ay + Math.round(iconSize * 1.1);
const row2Y = ay + Math.round(iconSize * 2.3);
// One clear space (0.6em ~ a mono space) between each icon and its text.
const gap = (w: number) => Math.round(Math.max(w, iconSize) + iconSize * 0.6);
const pinW = iconFont.measureText('📍');
const camW = iconFont.measureText('📷');
const pinW = gpsIconFont.measureText('📍');
const camW = gpsIconFont.measureText('📷');
return (
<Group
origin={{ x: xOffset, y: topBase }}
transform={wmRotation ? [{ rotate: wmRad }] : undefined}
>
<Group origin={{ x: ax, y: ay }} transform={wmRotation ? [{ rotate: wmRad }] : undefined}>
{/* Icons white so they read as glyphs, not part of the amber text */}
<SkiaText x={xOffset} y={row1Y} text="📍" font={iconFont} color="#ffffff" />
<SkiaText x={xOffset + gap(pinW.width)} y={row1Y} text={locationName} font={customFont} color="#f59e0b" />
<SkiaText x={xOffset} y={row2Y} text="📷" font={iconFont} color="#ffffff" />
<SkiaText x={xOffset + gap(camW.width)} y={row2Y} text={timestampStr} font={customFont} color="#f59e0b" />
<SkiaText x={ax} y={row1Y} text="📍" font={gpsIconFont} color="#ffffff" />
<SkiaText x={ax + gap(pinW.width)} y={row1Y} text={locationName} font={gpsFont} color="#f59e0b" />
<SkiaText x={ax} y={row2Y} text="📷" font={gpsIconFont} color="#ffffff" />
<SkiaText x={ax + gap(camW.width)} y={row2Y} text={timestampStr} font={gpsFont} color="#f59e0b" />
</Group>
);
};