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
+94 -7
View File
@@ -1,8 +1,9 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { View, Text, TextInput, TouchableOpacity, ScrollView } from 'react-native';
import Slider from '@react-native-community/slider';
import * as Haptics from 'expo-haptics';
import { Trash2 } from 'lucide-react-native';
import { Skia } from '@shopify/react-native-skia';
import { Recipe, ColorAdjustments, CustomWatermark, FrameId, BaseFilter } from '../types';
import { FRAMES } from '../utils/frameUtils';
import { PARAM_DEFS, ContinuousParamDef } from '../utils/paramDefs';
@@ -18,7 +19,7 @@ import RecipeCreateModal from './RecipeCreateModal';
type TabId = 'recipes' | 'iq' | 'wb' | 'filters' | 'frame' | 'watermark';
// Choice-group keys that open a strip above the chip row: color chrome groups
// (wb tab) and the D.RANGE selector (iq tab).
type GroupKey = 'cx' | 'cxb' | 'dr';
type GroupKey = 'cx' | 'cxb' | 'dr' | 'wmColor' | 'wmSize' | 'wmFont';
interface AdjustmentPanelProps {
activeTab: TabId;
@@ -43,6 +44,10 @@ interface AdjustmentPanelProps {
onSelectRecipe: (recipe: Recipe) => void;
onUpdateAdjustments: (adjustments: Partial<ColorAdjustments>) => void;
onUpdateFrame: (frameId: FrameId) => void;
// FRAME tab: turn the inserted library photo 90deg (landscape <-> portrait)
// so it fits the chosen frame better.
photoRotation: 0 | 90;
onToggleRotation: () => void;
onToggleGeotag: (enabled: boolean) => void;
onSaveRecipe: (name: string) => void;
onDeleteRecipe: (id: string) => void;
@@ -50,6 +55,11 @@ interface AdjustmentPanelProps {
// input and the export options all read the same object.
customWm: CustomWatermark;
onUpdateCustomWm: (wm: CustomWatermark) => void;
// ACCEPT: copy the live draft into the mark the export burns in. Edits stay a
// draft (viewfinder preview only) until this is pressed.
onAcceptCustomWm: () => void;
// Bumped by a double-tap on the mark: put the caret back in the text input.
wmEditNonce: number;
}
interface ChipDef {
@@ -90,6 +100,25 @@ const WB_PAIRS: Record<string, { t: number; ti: number }> = {
fluorescent: { t: 4000, ti: 3 },
};
// WATERMARK tab styling choices: swatches for TEXT COLOR, multipliers on the
// base 3.2%-of-frame-width size for TEXT SIZE.
const WM_DEFAULT_COLOR = '#f59e0b';
const WM_COLORS: { v: string; d: string }[] = [
{ v: '#f59e0b', d: 'AMBER' },
{ v: '#ffffff', d: 'WHITE' },
{ v: '#111111', d: 'BLACK' },
{ v: '#ef4444', d: 'RED' },
{ v: '#3b82f6', d: 'BLUE' },
{ v: '#22c55e', d: 'GREEN' },
{ v: '#a1a1aa', d: 'GREY' },
];
const WM_SIZES: { v: string; d: string }[] = [
{ v: '0.6', d: 'S' },
{ v: '1', d: 'M' },
{ v: '1.6', d: 'L' },
{ v: '2.5', d: 'XL' },
];
export default function AdjustmentPanel({
activeTab,
recipes,
@@ -104,11 +133,28 @@ export default function AdjustmentPanel({
onSelectRecipe,
onUpdateAdjustments,
onUpdateFrame,
photoRotation,
onToggleGeotag,
onDeleteRecipe,
customWm,
onUpdateCustomWm,
onAcceptCustomWm,
wmEditNonce,
onToggleRotation,
}: AdjustmentPanelProps) {
// Double-tap on the mark in the viewfinder bumps wmEditNonce -> focus the
// input again so the text can be retyped after the keyboard was dismissed.
const wmInputRef = useRef<TextInput>(null);
useEffect(() => {
if (wmEditNonce > 0) {
// BACK hides the IME but leaves the input focused, so a bare focus() is a
// no-op and the keyboard never returns. Blur first so the re-focus is a
// real focus change the IME reacts to.
wmInputRef.current?.blur();
const id = setTimeout(() => wmInputRef.current?.focus(), 60);
return () => clearTimeout(id);
}
}, [wmEditNonce]);
// The single choice-group whose strip (OFF/WEAK/STRONG, or the DR ladder) is
// open above the chips (same one-row-at-a-time pattern as openParam — which
// lives in App now, since an open row also arms image-drag adjustment).
@@ -280,6 +326,21 @@ export default function AdjustmentPanel({
{ v: '200', d: 'DR200' },
{ v: '400', d: 'DR400' },
];
// WATERMARK tab styling strips. FONT lists every family the PLATFORM font
// manager exposes (the device's own system fonts); DEFAULT keeps the face
// the mark shipped with.
const wmFontOptions = useMemo(() => {
const mgr = Skia.FontMgr.System();
const names = new Set<string>();
for (let i = 0; i < mgr.countFamilies() && names.size < 120; i++) {
const n = mgr.getFamilyName(i);
if (n && !/fallback/i.test(n)) names.add(n);
}
return [
{ v: 'DEFAULT', d: 'DEFAULT' },
...[...names].sort().map((n) => ({ v: n, d: n })),
];
}, []);
const groupDefs: Record<GroupKey, { label: string; off: string; value: string; options: { v: string; d: string }[]; onPick: (v: string) => void }> = {
cx: {
label: 'COLOR CHROME', off: 'none', value: a.colorChrome || 'none', options: chromeOptions,
@@ -296,6 +357,20 @@ export default function AdjustmentPanel({
dynamicRange: (v === 'auto' ? 'auto' : parseInt(v, 10)) as ColorAdjustments['dynamicRange'],
}),
},
wmColor: {
label: 'TEXT COLOR', off: WM_DEFAULT_COLOR,
value: customWm.color ?? WM_DEFAULT_COLOR, options: WM_COLORS,
onPick: (v) => onUpdateCustomWm({ ...customWm, color: v }),
},
wmSize: {
label: 'TEXT SIZE', off: '1', value: String(customWm.size ?? 1), options: WM_SIZES,
onPick: (v) => onUpdateCustomWm({ ...customWm, size: Number(v) }),
},
wmFont: {
label: 'TEXT FONT', off: 'DEFAULT', value: customWm.font ?? 'DEFAULT',
options: wmFontOptions,
onPick: (v) => onUpdateCustomWm({ ...customWm, font: v === 'DEFAULT' ? null : v }),
},
};
const groupChip = (g: GroupKey): ChipDef => ({
key: `grp-${g}`,
@@ -416,6 +491,12 @@ export default function AdjustmentPanel({
selectedFrame,
(val) => onUpdateFrame(val as FrameId)
)}
{renderChip({
key: 'rotate',
label: photoRotation === 0 ? 'ROTATE 90' : 'ROTATE 0',
active: photoRotation !== 0,
onPress: onToggleRotation,
})}
</ScrollView>
);
@@ -438,6 +519,16 @@ export default function AdjustmentPanel({
active: customWm.enabled,
onPress: () => onUpdateCustomWm({ ...customWm, enabled: !customWm.enabled }),
})}
{customWm.enabled &&
renderChip({
key: 'custom-wm-accept',
label: 'ACCEPT',
active: true,
onPress: onAcceptCustomWm,
})}
{customWm.enabled && renderChip(groupChip('wmColor'))}
{customWm.enabled && renderChip(groupChip('wmSize'))}
{customWm.enabled && renderChip(groupChip('wmFont'))}
</ScrollView>
);
@@ -542,6 +633,7 @@ export default function AdjustmentPanel({
{activeTab === 'watermark' && customWm.enabled && (
<View className="border-b border-amber-500/30 bg-amber-500/5 px-3 py-2">
<TextInput
ref={wmInputRef}
value={customWm.text}
onChangeText={(text) => onUpdateCustomWm({ ...customWm, text })}
placeholder="TYPE WATERMARK TEXT"
@@ -549,11 +641,6 @@ export default function AdjustmentPanel({
className="rounded-md border border-amber-500/70 bg-black/40 px-3 py-2 font-mono text-sm font-bold text-amber-300"
maxLength={80}
/>
<Text className="mt-1 font-mono text-[9px] font-bold tracking-widest text-amber-400/80">
{customWm.text.trim().length > 0
? 'DRAG ON IMAGE TO MOVE — TAP TO PLACE'
: 'TYPE TEXT ABOVE, THEN DRAG ON IMAGE TO PLACE'}
</Text>
</View>
)}
{chipsRow}