web: the frame tab straightens the photo by hand

This commit is contained in:
2026-09-18 18:07:14 +07:00
parent 1b71c0196f
commit 10466e122a
2 changed files with 75 additions and 13 deletions
+73 -13
View File
@@ -109,7 +109,7 @@ type GroupKey = 'cx' | 'cxb' | 'dr' | 'crop' | 'rotate' | 'style' | 'temp' | 'wm
const WM_SUBGROUPS: GroupKey[] = ['wmColor', 'wmSize', 'gpsColor', 'gpsName', 'gpsTime'];
const isWmPanel = (g: GroupKey | null) => g === 'wm' || (g !== null && WM_SUBGROUPS.includes(g));
// A slider row opened from a strip returns to it with "<" (COLOR TEMP -> TEMP).
const PARAM_GROUP: Partial<Record<string, GroupKey>> = { temperature: 'temp' };
const PARAM_GROUP: Partial<Record<string, GroupKey>> = { temperature: 'temp', straighten: 'rotate' };
const CHROME_OPTIONS = [
{ v: 'none', d: 'OFF' },
@@ -149,6 +149,7 @@ interface Look {
cropRect: CropRect;
cropApplied: boolean;
rotation: 0 | 90 | 180 | 270;
straighten: number;
markOn: boolean;
markText: string;
markStyle: { color: string; size: number };
@@ -185,6 +186,9 @@ export function Workspace() {
const [cropApplied, setCropApplied] = useState(() => session.cropApplied ?? false);
const [photoDims, setPhotoDims] = useState<{ w: number; h: number } | null>(null);
const [rotation, setRotation] = useState<0 | 90 | 180 | 270>(() => session.rotation ?? 0);
// FRAME's STRAIGHTEN: a fine angle on top of the quarter turn. Same pixels,
// so the photo's own dimensions never change and the crop stays valid.
const [straighten, setStraighten] = useState(() => session.straighten ?? 0);
const [openParam, setOpenParam] = useState<string | null>(null);
// The HSL mixer: which hue band the ruler edits, whether the eyedropper is
// armed, and the last colour it read off the photo.
@@ -213,8 +217,8 @@ export function Workspace() {
// the session. Small JSON, so no debounce is worth the risk of losing the last
// edit; the photo has its own store (engine/session.ts).
useEffect(() => {
saveState({ recipe, simId, frameId, crop, cropRect, cropApplied, rotation, gps, gpsManual, place, useGeotag, gpsStyle, markText, markOn, markStyle });
}, [recipe, simId, frameId, crop, cropRect, cropApplied, rotation, gps, gpsManual, place, useGeotag, gpsStyle, markText, markOn, markStyle]);
saveState({ recipe, simId, frameId, crop, cropRect, cropApplied, rotation, straighten, gps, gpsManual, place, useGeotag, gpsStyle, markText, markOn, markStyle });
}, [recipe, simId, frameId, crop, cropRect, cropApplied, rotation, straighten, gps, gpsManual, place, useGeotag, gpsStyle, markText, markOn, markStyle]);
// Crop has three states: none, framed-but-uncommitted (the frame is draggable
// and the whole photo is shown), and applied (the crop alone is rendered).
@@ -268,7 +272,7 @@ export function Workspace() {
// back returns that filter — and its sim name — instead of guessing PROVIA.
const monoPrev = useRef<{ baseFilter: Recipe['baseFilter']; simId: string | null; name: string } | null>(null);
const lookRef = useRef<Look | null>(null);
lookRef.current = { recipe, simId, frameId, crop, cropRect, cropApplied, rotation, markOn, markText, markStyle, useGeotag, gpsStyle };
lookRef.current = { recipe, simId, frameId, crop, cropRect, cropApplied, rotation, straighten, markOn, markText, markStyle, useGeotag, gpsStyle };
// Twenty looks is enough to walk, but the oldest entry is the floor — the look
// the session loaded with — and is never the one trimmed, so UNDO always
// reaches the start of the session.
@@ -292,6 +296,7 @@ export function Workspace() {
setCropRect(l.cropRect);
setCropApplied(l.cropApplied);
setRotation(l.rotation);
setStraighten(l.straighten);
setMarkOn(l.markOn);
setMarkText(l.markText);
setMarkStyle(l.markStyle);
@@ -310,6 +315,7 @@ export function Workspace() {
cropRect: DEFAULT_CROP_RECT,
cropApplied: false,
rotation: 0,
straighten: 0,
markOn: false,
markText: '',
markStyle: { color: '#ffffff', size: 1 },
@@ -415,6 +421,7 @@ export function Workspace() {
wallframeLandscape: frameId === 'wallframe-landscape',
sharpen: true,
photoRotation: rotation,
photoStraighten: straighten,
cropRect: cropLive ? cropRect : null,
watermark: markOn && markText ? { text: markText, x: 0.5, y: 0.92, ...markStyle } : null,
gpsWatermark: useGeotag && gps ? { x: 0.06, y: 0.7, size: 1.2, ...gpsStyle } : null,
@@ -435,7 +442,7 @@ export function Workspace() {
}, PREVIEW_DEBOUNCE_MS);
return () => clearTimeout(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [engine, previewBytes, recipe, frameId, cropEditing, cropLive, cropRect, rotation, useGeotag, gps, gpsStyle, markOn, markText, markStyle]);
}, [engine, previewBytes, recipe, frameId, cropEditing, cropLive, cropRect, rotation, straighten, useGeotag, gps, gpsStyle, markOn, markText, markStyle]);
// The source copy behind previewBytes, re-made only when previewMax moves:
// applying a crop upgrades it, CANCEL/UNDO brings the 1600px one back.
@@ -572,6 +579,19 @@ export function Workspace() {
[remember, setAdjustment]
);
// FRAME's STRAIGHTEN rides the same one-edit-per-gesture rule as a knob, so
// dragging the ruler is one undo step instead of one per degree.
const setStraightenOnce = useCallback(
(v: number) => {
if (!sliderEditRef.current) {
sliderEditRef.current = true;
remember();
}
setStraighten(Math.max(-180, Math.min(180, Math.round(v))));
},
[remember]
);
// --- crop -----------------------------------------------------------------
// Picking a ratio only opens the frame; the crop is committed by APPLY.
const pickCrop = useCallback(
@@ -838,6 +858,7 @@ export function Workspace() {
dpi: 300,
sharpen: true,
photoRotation: rotation,
photoStraighten: straighten,
cropRect: cropLive ? cropRect : null,
watermark: markOn && markText
? { text: markText, x: 0.5, y: 0.92, ...markStyle }
@@ -873,7 +894,7 @@ export function Workspace() {
} finally {
setExporting(false);
}
}, [engine, source, recipe, frameId, cropEditing, cropLive, cropRect, useGeotag, gps, gpsStyle, rotation, markOn, markText, markStyle, place, user, t, strip, photoSettings]);
}, [engine, source, recipe, frameId, cropEditing, cropLive, cropRect, useGeotag, gps, gpsStyle, rotation, straighten, markOn, markText, markStyle, place, user, t, strip, photoSettings]);
// SAVE PHOTO: the stage as it stands, uploaded to this account's own folder.
// The upload is left clean (no burned strip) so the landing reel can draw its
@@ -923,6 +944,7 @@ export function Workspace() {
dpi: 300,
sharpen: true,
photoRotation: rotation,
photoStraighten: straighten,
cropRect: cropLive ? cropRect : null,
watermark: markOn && markText ? { text: markText, x: 0.5, y: 0.92, ...markStyle } : null,
gpsWatermark: useGeotag && gps ? { x: 0.06, y: 0.7, size: 1.2, ...gpsStyle } : null,
@@ -956,7 +978,7 @@ export function Workspace() {
} finally {
setSavingPhoto(false);
}
}, [user, engine, source, myPhotos, openPhotoId, recipe, frameId, cropEditing, cropLive, cropRect, useGeotag, gps, gpsStyle, rotation, markOn, markText, markStyle, strip, photoSettings, t]);
}, [user, engine, source, myPhotos, openPhotoId, recipe, frameId, cropEditing, cropLive, cropRect, useGeotag, gps, gpsStyle, rotation, straighten, markOn, markText, markStyle, strip, photoSettings, t]);
// Reopen one of this account's saved frames: the bytes come back as a File
// through the same door an upload uses, then the stored look goes on top.
@@ -1078,6 +1100,26 @@ export function Workspace() {
const slider: SliderDef | null = useMemo(() => {
if (!openParam) return null;
// STRAIGHTEN is not a knob either: it is FRAME's own angle, and its ruler
// spans the full circle so a photo can be levelled either way round.
if (openParam === 'straighten') {
return {
key: 'straighten',
label: 'STRAIGHTEN',
value: straighten,
min: -180,
max: 180,
step: 1,
display: `${straighten > 0 ? '+' : ''}${straighten}°`,
edited: straighten !== 0,
onChange: setStraightenOnce,
onReset: () => setStraightenOnce(0),
onBack: () => {
setOpenParam(null);
setOpenGroup('rotate');
},
};
}
// The mixer's three knobs are not in PARAM_DEFS: they read and write one
// band of hslBands rather than a flat field of adjustments.
if (openParam.startsWith('hsl.')) {
@@ -1133,7 +1175,7 @@ export function Workspace() {
},
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [openParam, recipe.adjustments, setAdjustmentOnce, hslBand]);
}, [openParam, recipe.adjustments, setAdjustmentOnce, hslBand, straighten, setStraightenOnce]);
// The eyedropper's report: print what was read, point the ruler at that
// colour's band, and put the tool away — one pick is one colour.
@@ -1184,7 +1226,8 @@ export function Workspace() {
options: CROPS.map((c) => ({ v: c, d: c === 'none' ? 'NONE' : c.toUpperCase() })),
onPick: (v) => pickCrop(v as CropRatio),
},
// ponytail: no STRAIGHTEN/AUTO — the web export carries photoRotation only.
// RESET levels the whole frame; the quarter turns are what this strip picks,
// the fine angle is the STRAIGHTEN chip below it.
rotate: {
label: 'ROTATE', off: '0', value: String(rotation),
options: [
@@ -1197,6 +1240,8 @@ export function Workspace() {
onPick: (v) => {
remember();
setRotation(v === 'reset' ? 0 : (Number(v) as 0 | 90 | 180 | 270));
// RESET levels the whole frame, angle included.
if (v === 'reset') setStraighten(0);
},
},
style: {
@@ -1328,10 +1373,10 @@ export function Workspace() {
return tweaked
|| Object.keys(a.hslBands ?? {}).length > 0
|| (simId ?? 'sim-provia') !== 'sim-provia'
|| frameId !== 'none' || crop !== 'none' || rotation !== 0
|| frameId !== 'none' || crop !== 'none' || rotation !== 0 || straighten !== 0
|| markOn || markText !== '' || markStyle.color !== '#ffffff' || markStyle.size !== 1
|| useGeotag || gpsStyle.color !== '#f59e0b' || !gpsStyle.showName || !gpsStyle.showTime;
}, [recipe, simId, frameId, crop, rotation, markOn, markText, markStyle, useGeotag, gpsStyle]);
}, [recipe, simId, frameId, crop, rotation, straighten, markOn, markText, markStyle, useGeotag, gpsStyle]);
const resetChip = (): ChipDef => ({
key: 'reset-all',
@@ -1364,12 +1409,14 @@ export function Workspace() {
},
})),
groupChip('crop'),
// The chip names whichever of the two is off the straight — and both when
// both are.
{
key: 'grp-rotate',
label: 'ROTATE',
value: `${rotation}°`,
value: straighten !== 0 ? `${rotation}° ${straighten > 0 ? '+' : ''}${straighten}°` : `${rotation}°`,
active: openGroup === 'rotate',
amberValue: openGroup !== 'rotate' && rotation !== 0,
amberValue: openGroup !== 'rotate' && (rotation !== 0 || straighten !== 0),
onClick: () => toggleGroup('rotate'),
},
{
@@ -1531,6 +1578,19 @@ export function Workspace() {
{ key: 'crop-cancel', label: t('crop.cancel'), onClick: cancelCrop },
]
: []),
// STRAIGHTEN is the ROTATE strip's one entry that opens a ruler instead of
// picking a value: the quarter turns are choices, the angle is a knob.
...(g === 'rotate'
? [
{
key: 'straighten',
label: 'STRAIGHTEN',
active: openParam === 'straighten',
amberValue: openParam !== 'straighten' && straighten !== 0,
onClick: () => toggleParam('straighten'),
},
]
: []),
];
// RECIPES = this account's own files (SAVE RECENT) + what this session made
+2
View File
@@ -20,6 +20,8 @@ export interface StudioState {
// false = the frame is on screen but not accepted yet (still draggable).
cropApplied?: boolean;
rotation?: 0 | 90 | 180 | 270;
// FRAME's STRAIGHTEN: degrees off the quarter turn, -180..180.
straighten?: number;
gps?: GPSInfo | null;
gpsManual?: { lat: string; lng: string };
place?: string;