fix(studio): leave the tone curve with the graph alone

The floating tone-curve card opened with its own row of channel tabs
(RGB / R / G / B) and a RESET button. The panel is meant to be a graph,
so that row is gone: the card is now the head and the curve, nothing
else. The curve edits the composite rgb channel, which is what the row
selected by default anyway.

- ToneCurvePanel.tsx: drop CURVE_CHANNELS, the channel state and the
  curve-tabs block; every read and write goes through 'rgb'.
- app.css: drop .curve-tabs / .curve-tab / .curve-dot / .curve-reset
  and the .curve-line.ghost path that only drew the other channels.

skipped: selecting a channel other than the composite one, and RESET,
add when a channel chooser comes back on purpose.
This commit is contained in:
2026-10-09 18:20:07 +07:00
parent 2d2e662e1a
commit 05c2380a8b
2 changed files with 15 additions and 92 deletions
+1 -38
View File
@@ -556,51 +556,14 @@ button.hint:disabled { cursor: default; }
cursor: pointer;
}
.curve-close:hover { color: var(--fg); }
.curve-tabs { display: flex; gap: 4px; align-items: center; }
.curve-tab {
display: flex;
align-items: center;
gap: 5px;
flex: 1 1 0;
justify-content: center;
padding: 3px 0;
font-family: var(--mono);
font-size: 10px;
letter-spacing: 0.06em;
color: var(--muted);
background: none;
border: 1px solid var(--border);
border-radius: 3px;
cursor: pointer;
}
.curve-tab.on { border-color: var(--accent-line); color: var(--fg); }
/* The dot carries the channel's own colour, so the tab says which line it draws;
it goes amber once that channel has left the diagonal. */
.curve-dot { width: 7px; height: 7px; border-radius: 999px; }
.curve-tab.moved .curve-dot { box-shadow: 0 0 0 2px var(--accent); }
.curve-reset {
flex: 0 0 auto;
padding: 3px 7px;
font-family: var(--mono);
font-size: 10px;
letter-spacing: 0.06em;
color: var(--muted);
background: none;
border: 1px solid var(--border);
border-radius: 3px;
cursor: pointer;
}
.curve-reset:hover { color: var(--fg); border-color: var(--accent-line); }
.curve-graph { display: block; background: rgba(0, 0, 0, 0.35); border: 1px solid var(--border); border-radius: 3px; cursor: crosshair; }
/* The picture's own distribution, the floor the graph is read against. Light on
both themes because the graph itself is always the dark box over a photo. */
.curve-hist { fill: rgba(255, 255, 255, 0.22); }
.curve-grid { stroke: rgba(255, 255, 255, 0.1); stroke-width: 1; }
.curve-diagonal { stroke: rgba(255, 255, 255, 0.28); stroke-width: 1; stroke-dasharray: 3 3; }
/* The line the active tab draws, and — on a channel tab — the composite curve
under it, so the two moves can be told apart. */
/* The line the composite curve draws. */
.curve-line { fill: none; stroke-width: 2; stroke-linecap: round; }
.curve-line.ghost { stroke: rgba(255, 255, 255, 0.35); stroke-width: 1; stroke-dasharray: 4 3; }
.curve-point { fill: var(--bg-elev); stroke-width: 2; cursor: grab; }
.curve-point:active { cursor: grabbing; }
+14 -54
View File
@@ -1,8 +1,7 @@
import { useEffect, useRef, useState } from 'react';
import type { ToneCurve, ToneCurveChannel, ToneCurvePoint } from '../../shared/types';
import type { ToneCurve, ToneCurvePoint } from '../../shared/types';
import { readHistogram, type Histogram } from './Histogram';
import {
CURVE_CHANNELS,
CURVE_CHANNEL_COLOR,
addCurvePoint,
curvePoints,
@@ -12,9 +11,10 @@ import {
sampleCurve,
} from '../../shared/utils/toneCurve';
// LIGHT > TONE CURVE: the graph that hangs on the photo. Four curves — the
// composite one and one per channel — drawn over a grid, with the points the
// user drags. It reads and writes nothing but the `toneCurve` field, so the
// LIGHT > TONE CURVE: the graph that hangs on the photo. One curve — the
// composite one, the `rgb` key — drawn over a grid, with the points the user
// drags. The per-channel tabs are gone: the card is the graph and nothing else.
// It reads and writes nothing but that one key of the `toneCurve` field, so the
// render is the only other thing that has to know what a curve is (see
// toneCurve.ts for the table it hands the renderer).
//
@@ -29,12 +29,7 @@ import {
const SIZE = 224; // the graph's own square, in px
const STEPS = 64; // how finely the line is drawn
const HIT = 11; // px within which the pointer takes hold of a point
const NEEDS = 0.002; // how far off the diagonal counts as a move, as in toneCurve.ts
const LABEL: Record<ToneCurveChannel, string> = { rgb: 'RGB', r: 'R', g: 'G', b: 'B' };
const clamp01 = (v: number) => (v < 0 ? 0 : v > 1 ? 1 : v);
const offDiagonal = (pts: ToneCurvePoint[]) => pts.some((p) => Math.abs(p[1] - p[0]) > NEEDS);
export function ToneCurvePanel({
curve,
@@ -47,7 +42,6 @@ export function ToneCurvePanel({
onChange: (next: ToneCurve) => void;
onClose: () => void;
}) {
const [channel, setChannel] = useState<ToneCurveChannel>('rgb');
const [hist, setHist] = useState<Histogram | null>(null);
const [pos, setPos] = useState<{ x: number; y: number } | null>(null);
const [dragging, setDragging] = useState(false);
@@ -76,15 +70,14 @@ export function ToneCurvePanel({
};
}, [url]);
const pts = curvePoints(curve, channel);
const master = curvePoints(curve, 'rgb');
const pts = curvePoints(curve, 'rgb');
// A channel pulled back to the diagonal stores nothing: the recipe stays as
// A curve pulled back to the diagonal stores nothing: the recipe stays as
// small as the curve is flat, and an old recipe reads the same either way.
const put = (next: ToneCurvePoint[]): ToneCurve => {
const out: ToneCurve = { ...curve };
if (isFlatCurve(next)) delete out[channel];
else out[channel] = next;
if (isFlatCurve(next)) delete out.rgb;
else out.rgb = next;
return out;
};
@@ -231,9 +224,9 @@ export function ToneCurvePanel({
const grid = [0, 1, 2, 3, 4].map((i) => (i * SIZE) / 4);
// What the graph is drawn against: the luminance of the picture on the grey
// channel, and that channel's own distribution when a single channel is being
// bent — the histogram follows the tab, the way Lightroom's does.
const bins = hist ? (channel === 'rgb' ? hist.lum : hist[channel]) : null;
// channel — the shape of the whole frame, since the composite curve is the
// only one the card draws.
const bins = hist ? hist.lum : null;
return (
<div
@@ -256,38 +249,6 @@ export function ToneCurvePanel({
✕
</button>
</div>
<div className="curve-tabs">
{CURVE_CHANNELS.map((ch) => (
<button
key={ch}
type="button"
className={`curve-tab${ch === channel ? ' on' : ''}${offDiagonal(curvePoints(curve, ch)) ? ' moved' : ''}`}
data-key={`curve-tab-${ch}`}
aria-pressed={ch === channel}
onClick={() => {
setChannel(ch);
drag.current = null;
}}
>
<span className="curve-dot" data-color={CURVE_CHANNEL_COLOR[ch]} style={{ background: CURVE_CHANNEL_COLOR[ch] }} />
{LABEL[ch]}
</button>
))}
<button
type="button"
className="curve-reset"
data-key="curve-reset"
onClick={() => {
drag.current = null;
// RESET is the whole graph, not the channel on screen: the button
// sits with the tabs, and "the curve" is what the chip and the
// pixels answer to, so a flat object means every channel is flat.
onChange({});
}}
>
RESET
</button>
</div>
<svg
ref={svgRef}
className="curve-graph"
@@ -333,11 +294,10 @@ export function ToneCurvePanel({
{/* The diagonal is the neutral line: a curve that never leaves it does
nothing, which is what makes "how far has this moved" readable. */}
<line className="curve-diagonal" x1={0} y1={SIZE} x2={SIZE} y2={0} />
{channel !== 'rgb' ? <path className="curve-line ghost" d={line(master)} /> : null}
<path
className="curve-line"
data-key="curve-line"
style={{ stroke: CURVE_CHANNEL_COLOR[channel] }}
style={{ stroke: CURVE_CHANNEL_COLOR.rgb }}
d={line(pts)}
/>
{pts.map((p, i) => (
@@ -348,7 +308,7 @@ export function ToneCurvePanel({
cx={p[0] * SIZE}
cy={(1 - p[1]) * SIZE}
r={i === 0 || i === pts.length - 1 ? 6 : 5}
style={{ stroke: CURVE_CHANNEL_COLOR[channel] }}
style={{ stroke: CURVE_CHANNEL_COLOR.rgb }}
/>
))}
</svg>