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:
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user