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
+60 -8
View File
@@ -54,6 +54,13 @@ export default function App() {
// the bottom deck stays under the keyboard. Track the IME height ourselves and
// lift the deck by it (covers the custom-watermark text input).
const [keyboardHeight, setKeyboardHeight] = useState(0);
// Top edge of the bottom deck (its own onLayout) -> the height left for the
// photo. Library mode letterboxes the image above it so no watermark
// position hides under an open panel. Null until the first layout pass.
const [deckTop, setDeckTop] = useState<number | null>(null);
// Library photo rotation (FRAME tab), 0 or 90deg clockwise; feeds the
// preview source and the export alike.
const [photoRotation, setPhotoRotation] = useState<0 | 90>(0);
useEffect(() => {
const show = Keyboard.addListener('keyboardDidShow', (e) => setKeyboardHeight(e.endCoordinates.height));
const hide = Keyboard.addListener('keyboardDidHide', () => setKeyboardHeight(0));
@@ -76,6 +83,20 @@ export default function App() {
// live Viewfinder overlay, the panel input and every export share one state.
const DEFAULT_CUSTOM_WM: CustomWatermark = { enabled: false, text: '', x: 0.5, y: 0.5 };
const [customWm, setCustomWm] = useState<CustomWatermark>({ ...DEFAULT_CUSTOM_WM });
// The mark the EXPORT burns in. The chip above is the live draft (typing +
// dragging update it); only ACCEPT in the WATERMARK tab copies the draft in
// here — until then the file keeps the previous stamp (or none).
const [customWmApplied, setCustomWmApplied] = useState<CustomWatermark>({
...DEFAULT_CUSTOM_WM,
});
// Bumped by a double-tap on the mark: the panel re-focuses the text input.
const [wmEditNonce, setWmEditNonce] = useState(0);
// Draft edit: toggling the chip OFF also drops any accepted stamp, so a
// switched-off watermark can never come back in an export.
const handleUpdateCustomWm = (wm: CustomWatermark) => {
setCustomWm(wm);
if (!wm.enabled) setCustomWmApplied({ ...DEFAULT_CUSTOM_WM });
};
// Composition aspect-ratio (FULL / 4:3 / 3:2 upright) + RAW DNG sidecar.
const [aspectRatio, setAspectRatio] = useState<AspectRatio>('full');
const [rawEnabled, setRawEnabled] = useState(false);
@@ -320,6 +341,9 @@ export default function App() {
text: typeof raw.text === 'string' ? raw.text : '',
x: clamp01(raw.x, 0.5),
y: clamp01(raw.y, 0.5),
color: typeof raw.color === 'string' ? raw.color : undefined,
size: typeof raw.size === 'number' && Number.isFinite(raw.size) ? raw.size : undefined,
font: typeof raw.font === 'string' ? raw.font : null,
});
}
if (session.selectedRecipeId) {
@@ -549,6 +573,7 @@ export default function App() {
if (!result.canceled && result.assets && result.assets.length > 0) {
const asset = result.assets[0];
setLibraryImageUri(asset.uri);
setPhotoRotation(0);
setMode('library');
// Clear old GPS — will be replaced if the photo (or device) has location data
setGpsInfo(null);
@@ -583,12 +608,27 @@ export default function App() {
// Thumbnail/preview target for the camera-mode right button: newest photo.
const lastPhotoUri = photoHistory.length > 0 ? photoHistory[photoHistory.length - 1].uri : null;
// Watermark handed to the export engines (Skia + native): null while disabled
// or empty, else trimmed text + position. Trimming here keeps the stamped
// text identical to what the preview drew.
const wmForExport = (): { text: string; x: number; y: number } | null =>
customWm.enabled && customWm.text.trim().length > 0
? { text: customWm.text.trim(), x: customWm.x, y: customWm.y }
// Watermark handed to the export engines (Skia + native): the ACCEPTED mark
// (not the live draft), null while disabled or empty, else trimmed text +
// position. Trimming here keeps the stamped text identical to what the
// preview drew.
const wmForExport = (): {
text: string;
x: number;
y: number;
color?: string;
size?: number;
font?: string | null;
} | null =>
customWmApplied.enabled && customWmApplied.text.trim().length > 0
? {
text: customWmApplied.text.trim(),
x: customWmApplied.x,
y: customWmApplied.y,
color: customWmApplied.color,
size: customWmApplied.size,
font: customWmApplied.font,
}
: null;
const handleCapture = async () => {
@@ -751,6 +791,7 @@ export default function App() {
// Pinch/drag inside a polaroid card window or wall-frame opening:
// save the same crop the preview showed (null = centered 1x).
frameWindowZoom: viewfinderRef.current?.getFrameWindowZoom?.() ?? null,
photoRotation,
}
);
@@ -920,6 +961,8 @@ export default function App() {
useGeotag={useGeotag}
gpsInfo={gpsInfo}
libraryImageUri={libraryImageUri}
availableHeight={deckTop}
photoRotation={photoRotation}
cameraPermissionGranted={cameraPermissionGranted}
onRequestCameraPermission={requestCameraPermission}
photoOutput={photoOutput}
@@ -933,6 +976,7 @@ export default function App() {
imageAdjustTarget={imageAdjustTarget}
customWm={customWm}
onWmPositionChange={(x, y) => setCustomWm((p) => ({ ...p, x, y }))}
onWmEdit={() => setWmEditNonce((n) => n + 1)}
/>
</View>
@@ -940,7 +984,11 @@ export default function App() {
<TopBar mode={mode} recipeName={activeRecipe.name} onSaveRecipe={handleSaveRecipe} />
{/* Bottom deck: chip panel (progressive disclosure) -> tool rail -> shutter */}
<View className="absolute left-0 right-0 bottom-0 z-10" style={{ paddingBottom: keyboardHeight }}>
<View
className="absolute left-0 right-0 bottom-0 z-10"
style={{ paddingBottom: keyboardHeight }}
onLayout={(e) => setDeckTop(e.nativeEvent.layout.y)}
>
{openTab !== null && (
<View
style={{
@@ -956,6 +1004,8 @@ export default function App() {
currentRecipeId={selectedRecipe?.id || ''}
adjustments={adjustments}
selectedFrame={selectedFrame}
photoRotation={photoRotation}
onToggleRotation={() => setPhotoRotation((r) => (r === 0 ? 90 : 0))}
useGeotag={useGeotag}
openParam={openParamKey}
onOpenParamChange={setOpenParamKey}
@@ -968,7 +1018,9 @@ export default function App() {
onSaveRecipe={handleSaveRecipe}
onDeleteRecipe={handleDeleteRecipe}
customWm={customWm}
onUpdateCustomWm={setCustomWm}
onUpdateCustomWm={handleUpdateCustomWm}
onAcceptCustomWm={() => setCustomWmApplied({ ...customWm })}
wmEditNonce={wmEditNonce}
/>
</View>
)}