web: the frame tab straightens the photo by hand
This commit is contained in:
+73
-13
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user