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:
@@ -16,7 +16,7 @@ import ToolRail, { TabId } from './src/components/ToolRail';
|
||||
import SettingsModal from './src/components/SettingsModal';
|
||||
import PhotoViewerModal from './src/components/PhotoViewerModal';
|
||||
|
||||
import { Recipe, ColorAdjustments, CustomWatermark, FrameId, GPSInfo, AspectRatio, MeterMode, PhotoEntry, ShutterSound, StartupMode } from './src/types';
|
||||
import { Recipe, ColorAdjustments, CustomWatermark, GpsWatermark, FrameId, GPSInfo, AspectRatio, MeterMode, PhotoEntry, ShutterSound, StartupMode } from './src/types';
|
||||
import { getAllRecipes, saveCustomRecipe, deleteCustomRecipe } from './src/utils/storageUtils';
|
||||
import { DEFAULT_ADJUSTMENTS, FILM_SIMS, filmSimToRecipe } from './src/utils/defaultRecipes';
|
||||
import { getCurrentGPS, requestLocationPermissions } from './src/utils/locationUtils';
|
||||
@@ -86,6 +86,14 @@ export default function App() {
|
||||
// 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);
|
||||
// GPS mark placement (WATERMARK tab drag + pinch): fractions of the frame /
|
||||
// photo area plus a size multiplier, same space as the custom mark. There is
|
||||
// nothing to accept — the panel being open IS the edit session.
|
||||
const DEFAULT_GPS_WM: GpsWatermark = { x: 0.05, y: 0.1, size: 1 };
|
||||
const [gpsWm, setGpsWm] = useState<GpsWatermark>({ ...DEFAULT_GPS_WM });
|
||||
// FRAME tab ORIENTATION chip (WALL FRAME only): hang the artwork landscape
|
||||
// (the PNG's own 4000x3117) instead of the default portrait rotation.
|
||||
const [wallLandscape, setWallLandscape] = useState(false);
|
||||
// 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).
|
||||
@@ -442,6 +450,7 @@ export default function App() {
|
||||
}
|
||||
setCustomWm({ ...DEFAULT_CUSTOM_WM });
|
||||
setCustomWmApplied({ ...DEFAULT_CUSTOM_WM });
|
||||
setGpsWm({ ...DEFAULT_GPS_WM });
|
||||
};
|
||||
|
||||
const handleUpdateAdjustments = (updates: Partial<ColorAdjustments>) => {
|
||||
@@ -778,6 +787,8 @@ export default function App() {
|
||||
aspect: aspectRatio === 'full' ? undefined : aspectRatio,
|
||||
watermark: wmForExport(),
|
||||
watermarkRotation: wmRotation,
|
||||
gpsWatermark: gpsWm,
|
||||
wallframeLandscape: wallLandscape,
|
||||
};
|
||||
return NATIVE_EXPORT
|
||||
? processAndExportPhotoNative(sourceUri, recipeArgs, selectedFrame, useGeotag, captureGps, options)
|
||||
@@ -846,6 +857,8 @@ export default function App() {
|
||||
aspect: libraryAspect,
|
||||
watermark: wmForExport(),
|
||||
watermarkRotation: wmRotation,
|
||||
gpsWatermark: gpsWm,
|
||||
wallframeLandscape: wallLandscape,
|
||||
}
|
||||
)
|
||||
: await processAndExportPhoto(
|
||||
@@ -865,6 +878,8 @@ export default function App() {
|
||||
aspect: libraryAspect,
|
||||
watermark: wmForExport(),
|
||||
watermarkRotation: wmRotation,
|
||||
gpsWatermark: gpsWm,
|
||||
wallframeLandscape: wallLandscape,
|
||||
// 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,
|
||||
@@ -1059,6 +1074,10 @@ export default function App() {
|
||||
imageAdjustTarget={imageAdjustTarget}
|
||||
customWm={customWm}
|
||||
wmRotation={wmRotation}
|
||||
gpsWm={gpsWm}
|
||||
onGpsWmChange={(x, y, size) => setGpsWm({ x, y, size })}
|
||||
onWmSizeChange={(size) => setCustomWm((p) => ({ ...p, size }))}
|
||||
wallLandscape={wallLandscape}
|
||||
// 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'}
|
||||
@@ -1099,6 +1118,8 @@ export default function App() {
|
||||
selectedFrame={selectedFrame}
|
||||
photoRotation={photoRotation}
|
||||
onToggleRotation={() => setPhotoRotation((r) => (r === 0 ? 90 : 0))}
|
||||
wallLandscape={wallLandscape}
|
||||
onToggleWallLandscape={() => setWallLandscape((v) => !v)}
|
||||
useGeotag={useGeotag}
|
||||
openParam={openParamKey}
|
||||
onOpenParamChange={setOpenParamKey}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -97,3 +97,13 @@ export interface CustomWatermark {
|
||||
size?: number; // multiplier on the base 3.2%-of-width text size
|
||||
font?: string | null; // OS font family; null/absent = bundled font
|
||||
}
|
||||
|
||||
// GPS watermark placement (WATERMARK tab drag + pinch): x/y are 0..1 fractions
|
||||
// of the SAME area the custom mark uses (frame card / photo rect / screen), and
|
||||
// the anchor is the top-left corner of the two-line block. size multiplies the
|
||||
// base 3.2%-of-width size. Defaults reproduce the old fixed top-left look.
|
||||
export interface GpsWatermark {
|
||||
x: number;
|
||||
y: number;
|
||||
size: number;
|
||||
}
|
||||
|
||||
+41
-32
@@ -7,7 +7,7 @@ import { Recipe, GPSInfo, FrameId, AspectRatio, ASPECT_RATIO_W_H } from '../type
|
||||
import { getSkiaColorMatrix, applyExposureGain } from './colorUtils';
|
||||
import { TONE_SKSL, getToneUniforms, toneIsActive } from './toneShader';
|
||||
import { CINEMA_SKSL, getCinemaUniforms, cinemaIsActive } from './cinemaShader';
|
||||
import { drawFrameOnCanvas, polaroidLayout, POLAROID_CARD, POLAROID_WIN_W, wallframeLayout, WALLFRAME_W, WALLFRAME_H } from './frameUtils';
|
||||
import { drawFrameOnCanvas, polaroidLayout, POLAROID_CARD, POLAROID_WIN_W, wallframeLayout, WALLFRAME_W, WALLFRAME_H, WALLFRAME_LAND_W, WALLFRAME_LAND_H } from './frameUtils';
|
||||
import { patchJpegDpi } from './jpegDpi';
|
||||
import { rotateSkImage90 } from './skiaImage';
|
||||
|
||||
@@ -50,6 +50,12 @@ export interface ExportOptions {
|
||||
// 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;
|
||||
// WATERMARK tab drag + pinch on the GPS lines: 0..1 fractions of the same
|
||||
// area the custom mark uses (a framed export's canvas IS the frame) plus a
|
||||
// multiplier on the base 3.2%-of-width size. Absent = the old top-left look.
|
||||
gpsWatermark?: { x: number; y: number; size: number } | null;
|
||||
// WALL FRAME hung the other way round (4000x3117, artwork un-rotated).
|
||||
wallframeLandscape?: boolean;
|
||||
// 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
|
||||
@@ -403,8 +409,9 @@ export async function processAndExportPhoto(
|
||||
// processed picture is composited, uncropped, into the window of a bigger
|
||||
// off-white card (real print proportions, see frameUtils) — the export IS
|
||||
// the card, with the photo scaled to the window so it keeps ~WIN_W of the
|
||||
// card width. WALL FRAME: the export IS the ROTATED frame PNG at its native
|
||||
// portrait size (WALLFRAME_W x WALLFRAME_H), with the photo cover-cropped
|
||||
// card width. WALL FRAME: the export IS the frame PNG at its native size —
|
||||
// portrait (rotated 90° CW) or landscape, per the FRAME tab's ORIENTATION
|
||||
// chip — with the photo cover-cropped
|
||||
// (center) into the fixed 2:3 window (the mat opening of a real frame).
|
||||
// Other frames (classic white border, legacy cinematic bars) draw over the
|
||||
// photo-sized canvas as before.
|
||||
@@ -412,7 +419,6 @@ export async function processAndExportPhoto(
|
||||
let stampCanvas = canvas;
|
||||
let stampW = width;
|
||||
let stampH = height;
|
||||
let framedWindow: { x: number; y: number; w: number; h: number } | null = null;
|
||||
if (frameId === 'polaroid') {
|
||||
const photoImg = surface.makeImageSnapshot();
|
||||
const outW = width; // card width matches the photo width
|
||||
@@ -474,15 +480,15 @@ export async function processAndExportPhoto(
|
||||
stampCanvas = cardCanvas;
|
||||
stampW = outW;
|
||||
stampH = outH;
|
||||
framedWindow = win;
|
||||
} else if (frameId === 'wallframe') {
|
||||
// Export at the ROTATED frame's native size (the frame is the product, so
|
||||
// the file is exactly frame-sized — never the photo's own dimensions).
|
||||
const photoImg = surface.makeImageSnapshot();
|
||||
const pw = photoImg.width();
|
||||
const ph = photoImg.height();
|
||||
const outW = WALLFRAME_W; // 3117
|
||||
const outH = WALLFRAME_H; // 4000
|
||||
const wallLand = options?.wallframeLandscape === true;
|
||||
const outW = wallLand ? WALLFRAME_LAND_W : WALLFRAME_W; // 3117 / 4000
|
||||
const outH = wallLand ? WALLFRAME_LAND_H : WALLFRAME_H; // 4000 / 3117
|
||||
const cardSurface = createSurface(outW, outH);
|
||||
if (!cardSurface) {
|
||||
console.error('Failed to create wallframe surface');
|
||||
@@ -512,7 +518,7 @@ export async function processAndExportPhoto(
|
||||
console.error('Failed to decode wallframe.png');
|
||||
return null;
|
||||
}
|
||||
const wl = wallframeLayout(outW, outH, 'fill');
|
||||
const wl = wallframeLayout(outW, outH, 'fill', wallLand);
|
||||
const win = wl.window;
|
||||
// Cover-crop (center): scale the processed photo so it fills the fixed
|
||||
// 3:2 window and cut the overflowing edges equally on both sides.
|
||||
@@ -544,15 +550,19 @@ export async function processAndExportPhoto(
|
||||
// mat covers the rest. The source PNG is landscape — rotate it 90° CW
|
||||
// onto the portrait canvas (same affine as wallframeLayout.drawMatrix).
|
||||
cardCanvas.save();
|
||||
cardCanvas.translate(outW, 0);
|
||||
cardCanvas.rotate(90, 0, 0);
|
||||
cardCanvas.drawImage(wfImage, 0, 0, plainPaint);
|
||||
if (wallLand) {
|
||||
// Artwork is already landscape: outW/outH ARE its own size.
|
||||
cardCanvas.drawImage(wfImage, 0, 0, plainPaint);
|
||||
} else {
|
||||
cardCanvas.translate(outW, 0);
|
||||
cardCanvas.rotate(90, 0, 0);
|
||||
cardCanvas.drawImage(wfImage, 0, 0, plainPaint);
|
||||
}
|
||||
cardCanvas.restore();
|
||||
outputSurface = cardSurface;
|
||||
stampCanvas = cardCanvas;
|
||||
stampW = outW;
|
||||
stampH = outH;
|
||||
framedWindow = win;
|
||||
} else {
|
||||
drawFrameOnCanvas(canvas, width, height, frameId);
|
||||
}
|
||||
@@ -574,41 +584,40 @@ export async function processAndExportPhoto(
|
||||
|
||||
if (typeface && iconTypeface) {
|
||||
const fontSize = Math.round(stampW * 0.032); // 3.2% of output width
|
||||
const font = Skia.Font(typeface, fontSize);
|
||||
const iconFont = Skia.Font(iconTypeface, fontSize);
|
||||
const textPaint = Skia.Paint();
|
||||
textPaint.setColor(Skia.Color('#f59e0b')); // Amber 500
|
||||
const iconPaint = Skia.Paint();
|
||||
iconPaint.setColor(Skia.Color('#ffffff')); // icons white, distinct from text
|
||||
|
||||
// GPS lines keep the historical bottom-left anchor (framed -> inside
|
||||
// the photo window; other frames -> bottom-left of the image).
|
||||
// GPS block: its TOP-LEFT corner sits at the dragged fraction of the
|
||||
// same area the viewfinder maps onto (a framed export's canvas IS the
|
||||
// card/frame), so the preview and the file agree. Defaults = the old
|
||||
// fixed top-left look.
|
||||
if (useGeotag && gpsInfo) {
|
||||
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())}`;
|
||||
const gap = Math.round(fontSize * 1.6); // icon box (~1em) + one clear mono space (~0.6em)
|
||||
|
||||
let baseX: number;
|
||||
let baseY: number;
|
||||
if (framedWindow) {
|
||||
baseX = framedWindow.x + framedWindow.w * 0.05;
|
||||
baseY = framedWindow.y + framedWindow.h - Math.round(fontSize * 1.4);
|
||||
} else {
|
||||
baseX = fontSize * 1.2;
|
||||
baseY = stampH - fontSize * 1.5;
|
||||
}
|
||||
const gpsPos = options?.gpsWatermark ?? null;
|
||||
const clamp01 = (v: number) => Math.max(0, Math.min(1, v));
|
||||
const gpsSize = Math.round(fontSize * (gpsPos?.size ?? 1));
|
||||
const gpsFont = Skia.Font(typeface, gpsSize);
|
||||
const gpsIconFont = Skia.Font(iconTypeface, gpsSize);
|
||||
const gpsGap = Math.round(gpsSize * 1.6); // icon box (~1em) + one clear mono space (~0.6em)
|
||||
const baseX = clamp01(gpsPos?.x ?? 0.05) * stampW;
|
||||
const baseY = clamp01(gpsPos?.y ?? 0.1) * stampH;
|
||||
const row1Y = baseY + Math.round(gpsSize * 1.1);
|
||||
const row2Y = baseY + Math.round(gpsSize * 2.3);
|
||||
|
||||
// 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);
|
||||
if (wmRot) stampCanvas.rotate(wmRot, baseX, baseY);
|
||||
stampCanvas.drawText('📍', baseX, row1Y, iconPaint, gpsIconFont);
|
||||
stampCanvas.drawText(locationName, baseX + gpsGap, row1Y, textPaint, gpsFont);
|
||||
stampCanvas.drawText('📷', baseX, row2Y, iconPaint, gpsIconFont);
|
||||
stampCanvas.drawText(timestampStr, baseX + gpsGap, row2Y, textPaint, gpsFont);
|
||||
stampCanvas.restore();
|
||||
}
|
||||
|
||||
|
||||
+32
-12
@@ -53,6 +53,17 @@ export const WALLFRAME_WIN = {
|
||||
w: 1766 / 3117, // ≈ 0.56657
|
||||
h: 2647 / 4000, // 0.66175
|
||||
} as const;
|
||||
// Landscape hang: the PNG's OWN orientation, so nothing is rotated — the
|
||||
// canvas is the artwork (4000x3117) and the opening is the source window as
|
||||
// fractions of it. Same 3:2 mat opening, hung the other way round.
|
||||
export const WALLFRAME_LAND_W = WALLFRAME_PNG_W; // 4000
|
||||
export const WALLFRAME_LAND_H = WALLFRAME_PNG_H; // 3117
|
||||
export const WALLFRAME_LAND_WIN = {
|
||||
left: 675 / WALLFRAME_PNG_W, // 0.16875
|
||||
top: 675 / WALLFRAME_PNG_H, // ≈ 0.21688
|
||||
w: 2647 / WALLFRAME_PNG_W, // 0.66175
|
||||
h: 1766 / WALLFRAME_PNG_H, // ≈ 0.56657
|
||||
} as const;
|
||||
|
||||
export interface WallframeLayout {
|
||||
frame: { x: number; y: number; w: number; h: number };
|
||||
@@ -63,36 +74,45 @@ export interface WallframeLayout {
|
||||
drawMatrix: number[];
|
||||
}
|
||||
|
||||
// Geometry of the ROTATED wall-frame inside an arbitrary container. The frame
|
||||
// is contain-fitted ('contain', centered, letterboxed) for the live viewfinder,
|
||||
// Geometry of the wall-frame inside an arbitrary container. The frame is
|
||||
// contain-fitted ('contain', centered, letterboxed) for the live viewfinder,
|
||||
// or fills the container exactly ('fill', export canvas already at frame size).
|
||||
// `landscape` hangs the artwork the other way round: same PNG, no rotation.
|
||||
export function wallframeLayout(
|
||||
contW: number,
|
||||
contH: number,
|
||||
mode: 'contain' | 'fill' = 'contain'
|
||||
mode: 'contain' | 'fill' = 'contain',
|
||||
landscape = false
|
||||
): WallframeLayout {
|
||||
const FW = landscape ? WALLFRAME_LAND_W : WALLFRAME_W;
|
||||
const FH = landscape ? WALLFRAME_LAND_H : WALLFRAME_H;
|
||||
const WIN = landscape ? WALLFRAME_LAND_WIN : WALLFRAME_WIN;
|
||||
let frameW: number;
|
||||
let frameH: number;
|
||||
if (mode === 'fill') {
|
||||
frameW = contW;
|
||||
frameH = contH;
|
||||
} else {
|
||||
const s = Math.min(contW / WALLFRAME_W, contH / WALLFRAME_H);
|
||||
frameW = WALLFRAME_W * s;
|
||||
frameH = WALLFRAME_H * s;
|
||||
const s = Math.min(contW / FW, contH / FH);
|
||||
frameW = FW * s;
|
||||
frameH = FH * s;
|
||||
}
|
||||
const fx = (contW - frameW) / 2;
|
||||
const fy = (contH - frameH) / 2;
|
||||
const scale = frameW / WALLFRAME_W; // == frameH / WALLFRAME_H
|
||||
const scale = frameW / FW; // == frameH / FH
|
||||
return {
|
||||
frame: { x: fx, y: fy, w: frameW, h: frameH },
|
||||
window: {
|
||||
x: fx + WALLFRAME_WIN.left * frameW,
|
||||
y: fy + WALLFRAME_WIN.top * frameH,
|
||||
w: WALLFRAME_WIN.w * frameW,
|
||||
h: WALLFRAME_WIN.h * frameH,
|
||||
x: fx + WIN.left * frameW,
|
||||
y: fy + WIN.top * frameH,
|
||||
w: WIN.w * frameW,
|
||||
h: WIN.h * frameH,
|
||||
},
|
||||
drawMatrix: [0, -scale, fx + frameW, scale, 0, fy, 0, 0, 1],
|
||||
// Portrait: the source PNG must be turned 90° CW onto the canvas.
|
||||
// Landscape: it already sits the right way up — scale and centre it.
|
||||
drawMatrix: landscape
|
||||
? [scale, 0, fx, 0, scale, fy, 0, 0, 1]
|
||||
: [0, -scale, fx + frameW, scale, 0, fy, 0, 0, 1],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user