diff --git a/App.tsx b/App.tsx index 4f71175..5931ad0 100644 --- a/App.tsx +++ b/App.tsx @@ -83,6 +83,9 @@ 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({ ...DEFAULT_CUSTOM_WM }); + // WATERMARK tab ROTATE chip: clockwise degrees, applied to the GPS lines and + // the custom text in both the preview and the exported file. + const [wmRotation, setWmRotation] = useState(0); // 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). @@ -774,6 +777,7 @@ export default function App() { evFromCamera: true, aspect: aspectRatio === 'full' ? undefined : aspectRatio, watermark: wmForExport(), + watermarkRotation: wmRotation, }; return NATIVE_EXPORT ? processAndExportPhotoNative(sourceUri, recipeArgs, selectedFrame, useGeotag, captureGps, options) @@ -841,6 +845,7 @@ export default function App() { { aspect: libraryAspect, watermark: wmForExport(), + watermarkRotation: wmRotation, } ) : await processAndExportPhoto( @@ -859,6 +864,7 @@ export default function App() { { aspect: libraryAspect, watermark: wmForExport(), + watermarkRotation: wmRotation, // 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, @@ -1052,6 +1058,7 @@ export default function App() { meteringAdjustments={meteringAdj} imageAdjustTarget={imageAdjustTarget} customWm={customWm} + wmRotation={wmRotation} // The mark only captures image touches while its panel is open, so a // watermark left ON cannot block tap-to-focus or pinch/zoom. wmEditing={openTab === 'watermark'} @@ -1108,6 +1115,8 @@ export default function App() { onAcceptCustomWm={handleAcceptCustomWm} onCancelCustomWm={handleCancelCustomWm} wmEditNonce={wmEditNonce} + wmRotation={wmRotation} + onRotateWm={() => setWmRotation((r) => (r + 90) % 360)} /> )} diff --git a/src/components/AdjustmentPanel.tsx b/src/components/AdjustmentPanel.tsx index 316e3ac..6a370fe 100644 --- a/src/components/AdjustmentPanel.tsx +++ b/src/components/AdjustmentPanel.tsx @@ -64,6 +64,10 @@ interface AdjustmentPanelProps { onCancelCustomWm: () => void; // Bumped by a double-tap on the mark: put the caret back in the text input. wmEditNonce: number; + // WATERMARK tab ROTATE chip: clockwise degrees (0/90/180/270) applied to both + // the GPS lines and the custom text, in the preview and in the export. + wmRotation: number; + onRotateWm: () => void; } interface ChipDef { @@ -146,6 +150,8 @@ export default function AdjustmentPanel({ onCancelCustomWm, wmEditNonce, onToggleRotation, + wmRotation, + onRotateWm, }: 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. @@ -524,6 +530,13 @@ export default function AdjustmentPanel({ active: customWm.enabled, onPress: () => onUpdateCustomWm({ ...customWm, enabled: !customWm.enabled }), })} + {(useGeotag || customWm.enabled) && + renderChip({ + key: 'wm-rotate', + label: `ROTATE ${wmRotation}°`, + active: wmRotation !== 0, + onPress: onRotateWm, + })} {customWm.enabled && renderChip({ key: 'custom-wm-accept', diff --git a/src/components/Viewfinder.tsx b/src/components/Viewfinder.tsx index b235584..9acb5b2 100644 --- a/src/components/Viewfinder.tsx +++ b/src/components/Viewfinder.tsx @@ -105,6 +105,10 @@ interface ViewfinderProps { y: number; }; onWmPositionChange?: (x: number, y: 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. + wmRotation?: number; // True while the WATERMARK panel is open — that panel IS the mark's edit // session. The mark draws whenever the CUSTOM chip is ON with text, but it // only CAPTURES image touches (placement drags in camera mode, click-through @@ -196,6 +200,7 @@ const Viewfinder = forwardRef(function Viewfi onWmEdit, wmEditing = false, photoRotation = 0, + wmRotation = 0, }: ViewfinderProps, ref ) { @@ -1113,6 +1118,19 @@ const Viewfinder = forwardRef(function Viewfi if (mode === 'library' && imageFitRect) return imageFitRect; return { dx: 0, dy: 0, dw: vw, dh: vh }; }; + // The drawn mark's baseline origin. Everything that has to agree with the + // glyphs — the render, the double-tap box, the export — goes through here, + // and the ROTATE chip spins around this same point. + const wmAnchor = () => { + const area = wmArea(); + const fracX = wmDragPos?.x ?? customWm.x; + const fracY = wmDragPos?.y ?? customWm.y; + return { + ax: area.dx + fracX * area.dw, + ay: area.dy + fracY * area.dh + Math.round(wmFontSize * 0.35), + }; + }; + const wmRad = (wmRotation * Math.PI) / 180; 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 @@ -1154,24 +1172,31 @@ const Viewfinder = forwardRef(function Viewfi // so a double-tap ON the text re-opens the editor instead of moving it. const wmBox = () => { if (!wmVisible || !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 { ax, ay } = wmAnchor(); 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) }; + return { x: ax, y: ay - 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; + ) => { + 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; + } + return ( + 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, and otherwise DRAG the photo inside the @@ -1464,15 +1489,13 @@ const Viewfinder = forwardRef(function Viewfi const renderCustomWatermark = () => { if (!wmVisible || !wmFont) return null; - const area = wmArea(); - const fracX = wmDragPos?.x ?? customWm.x; - const fracY = wmDragPos?.y ?? customWm.y; // Baseline = dragged anchor + ~0.35em drop (the grabbed anchor reads as the // text's visual center). Must match exportEngine step 8's formula. - const tx = area.dx + fracX * area.dw; - const ty = area.dy + fracY * area.dh + Math.round(wmFontSize * 0.35); + const { ax, ay } = wmAnchor(); return ( - + + + ); }; @@ -1530,7 +1553,10 @@ const Viewfinder = forwardRef(function Viewfi const camW = iconFont.measureText('📷'); return ( - + {/* Icons white so they read as glyphs, not part of the amber text */} diff --git a/src/utils/exportEngine.ts b/src/utils/exportEngine.ts index 67aff2e..49c908f 100644 --- a/src/utils/exportEngine.ts +++ b/src/utils/exportEngine.ts @@ -46,6 +46,10 @@ export interface ExportOptions { size?: number; font?: string | null; } | null; + // WATERMARK tab ROTATE chip: clockwise degrees (0/90/180/270) applied to + // BOTH the GPS lines and the custom text, each around its own anchor (the + // same anchor Viewfinder rotates around, so the preview matches the file). + watermarkRotation?: number; // Library framed-window photo reposition (Viewfinder.getFrameWindowZoom): // after pinch/drag inside a polaroid card window or a wall-frame opening, // s = scale and u/v = the fraction of the photo's cover plane sitting at the @@ -596,10 +600,16 @@ export async function processAndExportPhoto( baseY = stampH - fontSize * 1.5; } + // Spin the whole two-line block around its top-left (the anchor the + // viewfinder uses). 0 = the historical upright look. + const wmRot = options?.watermarkRotation ?? 0; + stampCanvas.save(); + if (wmRot) stampCanvas.rotate(wmRot, baseX, baseY - Math.round(fontSize * 2.4)); stampCanvas.drawText('📍', baseX, baseY - fontSize * 1.4, iconPaint, iconFont); stampCanvas.drawText(locationName, baseX + gap, baseY - fontSize * 1.4, textPaint, font); stampCanvas.drawText('📷', baseX, baseY, iconPaint, iconFont); stampCanvas.drawText(timestampStr, baseX + gap, baseY, textPaint, font); + stampCanvas.restore(); } // Custom watermark: baseline at the dragged fraction of the photo/window @@ -623,7 +633,11 @@ export async function processAndExportPhoto( wmPaint.setColor(Skia.Color(customWm.color ?? '#f59e0b')); const tx = area.dx + clamp01(customWm.x) * area.dw; const ty = area.dy + clamp01(customWm.y) * area.dh + Math.round(wmSize * 0.35); + const wmRot = options?.watermarkRotation ?? 0; + stampCanvas.save(); + if (wmRot) stampCanvas.rotate(wmRot, tx, ty); stampCanvas.drawText(customWm.text, tx, ty, wmPaint, wmFont); + stampCanvas.restore(); } } }