@hyperframes/studio 0.7.72 → 0.7.73
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/{hyperframes-player-Bjf2HPzR.js → hyperframes-player-DGlzhf_s.js} +1 -1
- package/dist/assets/index-B37rWXo4.css +1 -0
- package/dist/assets/{index-Ct_pxETK.js → index-CbVTOJ-E.js} +1 -1
- package/dist/assets/{index-Ch1hbJ3e.js → index-CcA5WVJv.js} +1 -1
- package/dist/assets/index-DXcLI2wu.js +428 -0
- package/dist/index.html +2 -2
- package/dist/index.js +4203 -2281
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/components/editor/PropertyPanelFlat.tsx +1 -0
- package/src/components/editor/colorGradingFrameAnalysis.test.ts +101 -0
- package/src/components/editor/colorGradingFrameAnalysis.ts +203 -0
- package/src/components/editor/propertyPanelColorCurveGraph.tsx +549 -0
- package/src/components/editor/propertyPanelColorCurves.test.tsx +158 -0
- package/src/components/editor/propertyPanelColorCurves.tsx +185 -0
- package/src/components/editor/propertyPanelColorGradingControls.tsx +115 -77
- package/src/components/editor/propertyPanelColorScopes.tsx +258 -0
- package/src/components/editor/propertyPanelColorSecondary.test.tsx +181 -0
- package/src/components/editor/propertyPanelColorSecondary.tsx +448 -0
- package/src/components/editor/propertyPanelColorWheels.test.tsx +119 -0
- package/src/components/editor/propertyPanelColorWheels.tsx +334 -0
- package/src/components/editor/propertyPanelFlatColorGradingAccessory.tsx +109 -0
- package/src/components/editor/propertyPanelFlatColorGradingSection.test.tsx +168 -3
- package/src/components/editor/propertyPanelFlatColorGradingSection.tsx +184 -243
- package/src/components/editor/propertyPanelGradingNumberField.test.tsx +79 -0
- package/src/components/editor/propertyPanelGradingNumberField.tsx +116 -0
- package/src/components/editor/useColorGradingController.test.ts +133 -17
- package/src/components/editor/useColorGradingController.ts +11 -2
- package/src/components/editor/useColorGradingPreviews.ts +54 -11
- package/src/components/editor/useColorGradingScopes.test.tsx +143 -0
- package/src/components/editor/useColorGradingScopes.ts +62 -0
- package/src/components/editor/useInspectorGestureTransaction.ts +30 -1
- package/src/icons/SystemIcons.tsx +4 -0
- package/dist/assets/index-BgZMle9I.css +0 -1
- package/dist/assets/index-DCyWLHnx.js +0 -428
|
@@ -0,0 +1,549 @@
|
|
|
1
|
+
import { useMemo, useRef, type KeyboardEvent as ReactKeyboardEvent } from "react";
|
|
2
|
+
import {
|
|
3
|
+
compileHfColorCurve,
|
|
4
|
+
compileHfHueCurve,
|
|
5
|
+
HF_COLOR_CURVE_MAX_POINTS,
|
|
6
|
+
type HfColorCurvePoint,
|
|
7
|
+
type HfColorGradingCurveKey,
|
|
8
|
+
type HfColorGradingHueCurveKey,
|
|
9
|
+
type HfHueCurvePoint,
|
|
10
|
+
type NormalizedHfColorGradingCurves,
|
|
11
|
+
type NormalizedHfColorGradingHueCurves,
|
|
12
|
+
} from "@hyperframes/core/color-grading";
|
|
13
|
+
import { clampNumber } from "../../utils/studioHelpers";
|
|
14
|
+
|
|
15
|
+
const GRAPH_SIZE = 160;
|
|
16
|
+
const GRAPH_PADDING = 8;
|
|
17
|
+
const SAMPLE_COUNT = 128;
|
|
18
|
+
const INPUT_EPSILON = 0.002;
|
|
19
|
+
|
|
20
|
+
type RgbTab = {
|
|
21
|
+
kind: "rgb";
|
|
22
|
+
key: HfColorGradingCurveKey;
|
|
23
|
+
label: string;
|
|
24
|
+
color: string;
|
|
25
|
+
min: 0;
|
|
26
|
+
max: 1;
|
|
27
|
+
};
|
|
28
|
+
type HueTab = {
|
|
29
|
+
kind: "hue";
|
|
30
|
+
key: HfColorGradingHueCurveKey;
|
|
31
|
+
label: string;
|
|
32
|
+
color: string;
|
|
33
|
+
min: number;
|
|
34
|
+
max: number;
|
|
35
|
+
};
|
|
36
|
+
export type CurveTab = RgbTab | HueTab;
|
|
37
|
+
|
|
38
|
+
export const TABS: readonly CurveTab[] = [
|
|
39
|
+
{ kind: "rgb", key: "master", label: "Master", color: "#e5e7eb", min: 0, max: 1 },
|
|
40
|
+
{ kind: "rgb", key: "red", label: "R", color: "#fb7185", min: 0, max: 1 },
|
|
41
|
+
{ kind: "rgb", key: "green", label: "G", color: "#4ade80", min: 0, max: 1 },
|
|
42
|
+
{ kind: "rgb", key: "blue", label: "B", color: "#60a5fa", min: 0, max: 1 },
|
|
43
|
+
{ kind: "hue", key: "hueVsHue", label: "Hue/Hue", color: "#f0abfc", min: -180, max: 180 },
|
|
44
|
+
{ kind: "hue", key: "hueVsSaturation", label: "Hue/Sat", color: "#facc15", min: -1, max: 1 },
|
|
45
|
+
{ kind: "hue", key: "hueVsLuma", label: "Hue/Luma", color: "#f8fafc", min: -1, max: 1 },
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
export const RGB_IDENTITY: readonly HfColorCurvePoint[] = [
|
|
49
|
+
[0, 0],
|
|
50
|
+
[1, 1],
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
export interface ColorCurveValues {
|
|
54
|
+
curves: NormalizedHfColorGradingCurves;
|
|
55
|
+
hueCurves: NormalizedHfColorGradingHueCurves;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function graphPoint(input: number, output: number, tab: CurveTab) {
|
|
59
|
+
const inner = GRAPH_SIZE - GRAPH_PADDING * 2;
|
|
60
|
+
const xRatio = tab.kind === "rgb" ? input : input / 360;
|
|
61
|
+
const yRatio = (output - tab.min) / (tab.max - tab.min);
|
|
62
|
+
return {
|
|
63
|
+
x: GRAPH_PADDING + xRatio * inner,
|
|
64
|
+
y: GRAPH_SIZE - GRAPH_PADDING - yRatio * inner,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function valueFromPointer(clientX: number, clientY: number, rect: DOMRect, tab: CurveTab) {
|
|
69
|
+
const inner = GRAPH_SIZE - GRAPH_PADDING * 2;
|
|
70
|
+
const graphX = ((clientX - rect.left) / Math.max(rect.width, 1)) * GRAPH_SIZE;
|
|
71
|
+
const graphY = ((clientY - rect.top) / Math.max(rect.height, 1)) * GRAPH_SIZE;
|
|
72
|
+
const xRatio = clampNumber((graphX - GRAPH_PADDING) / inner, 0, 1);
|
|
73
|
+
const yRatio = 1 - clampNumber((graphY - GRAPH_PADDING) / inner, 0, 1);
|
|
74
|
+
return {
|
|
75
|
+
input: tab.kind === "rgb" ? xRatio : Math.min(359.999, xRatio * 360),
|
|
76
|
+
output: tab.min + yRatio * (tab.max - tab.min),
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function curvePath(samples: Float32Array, tab: CurveTab): string {
|
|
81
|
+
return [...samples]
|
|
82
|
+
.map((sample, index) => {
|
|
83
|
+
const input =
|
|
84
|
+
tab.kind === "rgb" ? index / (samples.length - 1) : (index / samples.length) * 360;
|
|
85
|
+
const point = graphPoint(input, sample, tab);
|
|
86
|
+
return `${index === 0 ? "M" : "L"}${point.x.toFixed(2)},${point.y.toFixed(2)}`;
|
|
87
|
+
})
|
|
88
|
+
.join(" ");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function samplesFor(points: readonly (HfColorCurvePoint | HfHueCurvePoint)[], tab: CurveTab) {
|
|
92
|
+
if (tab.kind === "rgb") {
|
|
93
|
+
return compileHfColorCurve(points as readonly HfColorCurvePoint[], SAMPLE_COUNT);
|
|
94
|
+
}
|
|
95
|
+
if (points.length < 3) return new Float32Array(SAMPLE_COUNT);
|
|
96
|
+
return compileHfHueCurve(points as readonly HfHueCurvePoint[], tab.min, tab.max, SAMPLE_COUNT);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function pointsFor(value: ColorCurveValues, tab: CurveTab) {
|
|
100
|
+
return tab.kind === "rgb" ? value.curves[tab.key] : value.hueCurves[tab.key];
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function circularHueDistance(left: number, right: number): number {
|
|
104
|
+
const distance = Math.abs(left - right) % 360;
|
|
105
|
+
return Math.min(distance, 360 - distance);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function withPoints(
|
|
109
|
+
value: ColorCurveValues,
|
|
110
|
+
tab: CurveTab,
|
|
111
|
+
points: readonly (HfColorCurvePoint | HfHueCurvePoint)[],
|
|
112
|
+
): ColorCurveValues {
|
|
113
|
+
return tab.kind === "rgb"
|
|
114
|
+
? {
|
|
115
|
+
...value,
|
|
116
|
+
curves: {
|
|
117
|
+
...value.curves,
|
|
118
|
+
[tab.key]: points as readonly HfColorCurvePoint[],
|
|
119
|
+
},
|
|
120
|
+
}
|
|
121
|
+
: {
|
|
122
|
+
...value,
|
|
123
|
+
hueCurves: {
|
|
124
|
+
...value.hueCurves,
|
|
125
|
+
[tab.key]: points as readonly HfHueCurvePoint[],
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function nearestInputPointIndex(
|
|
131
|
+
points: readonly (HfColorCurvePoint | HfHueCurvePoint)[],
|
|
132
|
+
input: number,
|
|
133
|
+
tab: CurveTab,
|
|
134
|
+
): number {
|
|
135
|
+
const inputSpan = tab.kind === "rgb" ? 1 : 360;
|
|
136
|
+
let closest = -1;
|
|
137
|
+
let distance = 12 / (GRAPH_SIZE - GRAPH_PADDING * 2);
|
|
138
|
+
points.forEach((point, index) => {
|
|
139
|
+
const nextDistance =
|
|
140
|
+
(tab.kind === "hue" ? circularHueDistance(point[0], input) : Math.abs(point[0] - input)) /
|
|
141
|
+
inputSpan;
|
|
142
|
+
if (nextDistance < distance) {
|
|
143
|
+
closest = index;
|
|
144
|
+
distance = nextDistance;
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
return closest;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function nearestGraphPointIndex(
|
|
151
|
+
points: readonly (HfColorCurvePoint | HfHueCurvePoint)[],
|
|
152
|
+
input: number,
|
|
153
|
+
output: number,
|
|
154
|
+
tab: CurveTab,
|
|
155
|
+
): number {
|
|
156
|
+
const target = graphPoint(input, output, tab);
|
|
157
|
+
let closest = -1;
|
|
158
|
+
let distance = 12;
|
|
159
|
+
points.forEach((point, index) => {
|
|
160
|
+
const candidate = graphPoint(point[0], point[1], tab);
|
|
161
|
+
const xDistance =
|
|
162
|
+
tab.kind === "hue"
|
|
163
|
+
? (circularHueDistance(point[0], input) / 360) * (GRAPH_SIZE - GRAPH_PADDING * 2)
|
|
164
|
+
: candidate.x - target.x;
|
|
165
|
+
const nextDistance = Math.hypot(xDistance, candidate.y - target.y);
|
|
166
|
+
if (nextDistance < distance) {
|
|
167
|
+
closest = index;
|
|
168
|
+
distance = nextDistance;
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
return closest;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function insertPoint(
|
|
175
|
+
points: readonly (HfColorCurvePoint | HfHueCurvePoint)[],
|
|
176
|
+
input: number,
|
|
177
|
+
output: number,
|
|
178
|
+
tab: CurveTab,
|
|
179
|
+
) {
|
|
180
|
+
if (points.length >= HF_COLOR_CURVE_MAX_POINTS) return null;
|
|
181
|
+
if (tab.kind === "hue" && points.length < 3) {
|
|
182
|
+
const result: HfHueCurvePoint[] = [
|
|
183
|
+
[((input + 240) % 360) as number, 0],
|
|
184
|
+
[input, output],
|
|
185
|
+
[((input + 120) % 360) as number, 0],
|
|
186
|
+
];
|
|
187
|
+
result.sort((a, b) => a[0] - b[0]);
|
|
188
|
+
return { points: result, selected: result.findIndex((point) => point[0] === input) };
|
|
189
|
+
}
|
|
190
|
+
const next = [...points];
|
|
191
|
+
const point =
|
|
192
|
+
tab.kind === "rgb"
|
|
193
|
+
? ([input, clampNumber(output, 0, 1)] as HfColorCurvePoint)
|
|
194
|
+
: ([input, clampNumber(output, tab.min, tab.max)] as HfHueCurvePoint);
|
|
195
|
+
next.push(point);
|
|
196
|
+
next.sort((a, b) => a[0] - b[0]);
|
|
197
|
+
return { points: next, selected: next.indexOf(point) };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function safeRgbInput(
|
|
201
|
+
points: readonly (HfColorCurvePoint | HfHueCurvePoint)[],
|
|
202
|
+
index: number,
|
|
203
|
+
input: number,
|
|
204
|
+
): number {
|
|
205
|
+
if (index === 0) return 0;
|
|
206
|
+
if (index === points.length - 1) return 1;
|
|
207
|
+
return clampNumber(
|
|
208
|
+
input,
|
|
209
|
+
(points[index - 1]?.[0] ?? 0) + INPUT_EPSILON,
|
|
210
|
+
(points[index + 1]?.[0] ?? 1) - INPUT_EPSILON,
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function hueInputCollides(
|
|
215
|
+
points: readonly (HfColorCurvePoint | HfHueCurvePoint)[],
|
|
216
|
+
index: number,
|
|
217
|
+
input: number,
|
|
218
|
+
): boolean {
|
|
219
|
+
return points.some(
|
|
220
|
+
(point, pointIndex) =>
|
|
221
|
+
pointIndex !== index && circularHueDistance(point[0], input) < INPUT_EPSILON * 360,
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function safeHueInput(
|
|
226
|
+
points: readonly (HfColorCurvePoint | HfHueCurvePoint)[],
|
|
227
|
+
index: number,
|
|
228
|
+
input: number,
|
|
229
|
+
): number {
|
|
230
|
+
const initial = clampNumber(input, 0, 359.999);
|
|
231
|
+
if (!hueInputCollides(points, index, initial)) return initial;
|
|
232
|
+
const spacing = INPUT_EPSILON * 360;
|
|
233
|
+
for (let step = 1; step <= points.length + 1; step += 1) {
|
|
234
|
+
const clockwise = (initial + spacing * step) % 360;
|
|
235
|
+
if (!hueInputCollides(points, index, clockwise)) return clockwise;
|
|
236
|
+
const counterClockwise = (initial - spacing * step + 360) % 360;
|
|
237
|
+
if (!hueInputCollides(points, index, counterClockwise)) return counterClockwise;
|
|
238
|
+
}
|
|
239
|
+
return initial;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export function movePoint(
|
|
243
|
+
points: readonly (HfColorCurvePoint | HfHueCurvePoint)[],
|
|
244
|
+
index: number,
|
|
245
|
+
input: number,
|
|
246
|
+
output: number,
|
|
247
|
+
tab: CurveTab,
|
|
248
|
+
) {
|
|
249
|
+
const next = [...points];
|
|
250
|
+
const safeInput =
|
|
251
|
+
tab.kind === "rgb" ? safeRgbInput(next, index, input) : safeHueInput(next, index, input);
|
|
252
|
+
const moved =
|
|
253
|
+
tab.kind === "rgb"
|
|
254
|
+
? ([safeInput, clampNumber(output, 0, 1)] as HfColorCurvePoint)
|
|
255
|
+
: ([safeInput, clampNumber(output, tab.min, tab.max)] as HfHueCurvePoint);
|
|
256
|
+
next[index] = moved;
|
|
257
|
+
next.sort((a, b) => a[0] - b[0]);
|
|
258
|
+
return { points: next, selected: next.indexOf(moved) };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const ARROW_KEYS = ["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"] as const;
|
|
262
|
+
type ArrowKey = (typeof ARROW_KEYS)[number];
|
|
263
|
+
|
|
264
|
+
function isArrowKey(key: string): key is ArrowKey {
|
|
265
|
+
return ARROW_KEYS.includes(key as ArrowKey);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function curveOutputStep(tab: CurveTab, large: boolean): number {
|
|
269
|
+
if (tab.kind === "rgb") return large ? 0.05 : 0.01;
|
|
270
|
+
if (tab.key === "hueVsHue") return large ? 10 : 1;
|
|
271
|
+
return large ? 0.1 : 0.01;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function keyboardPointPosition(
|
|
275
|
+
point: HfColorCurvePoint | HfHueCurvePoint,
|
|
276
|
+
key: ArrowKey,
|
|
277
|
+
tab: CurveTab,
|
|
278
|
+
large: boolean,
|
|
279
|
+
) {
|
|
280
|
+
const inputStep = tab.kind === "rgb" ? (large ? 0.05 : 0.01) : large ? 10 : 1;
|
|
281
|
+
const outputStep = curveOutputStep(tab, large);
|
|
282
|
+
const inputDelta = key === "ArrowLeft" ? -inputStep : key === "ArrowRight" ? inputStep : 0;
|
|
283
|
+
const outputDelta = key === "ArrowDown" ? -outputStep : key === "ArrowUp" ? outputStep : 0;
|
|
284
|
+
return { input: point[0] + inputDelta, output: point[1] + outputDelta };
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
export function CurveGraph({
|
|
288
|
+
tab,
|
|
289
|
+
points,
|
|
290
|
+
selectedIndex,
|
|
291
|
+
disabled,
|
|
292
|
+
onBegin,
|
|
293
|
+
onPreview,
|
|
294
|
+
onSelect,
|
|
295
|
+
onDelete,
|
|
296
|
+
onSettle,
|
|
297
|
+
onCancel,
|
|
298
|
+
}: {
|
|
299
|
+
tab: CurveTab;
|
|
300
|
+
points: readonly (HfColorCurvePoint | HfHueCurvePoint)[];
|
|
301
|
+
selectedIndex: number | null;
|
|
302
|
+
disabled?: boolean;
|
|
303
|
+
onBegin: () => void;
|
|
304
|
+
onPreview: (
|
|
305
|
+
points: readonly (HfColorCurvePoint | HfHueCurvePoint)[],
|
|
306
|
+
selectedIndex: number,
|
|
307
|
+
) => void;
|
|
308
|
+
onSelect: (index: number | null) => void;
|
|
309
|
+
onDelete: () => void;
|
|
310
|
+
onSettle: () => void;
|
|
311
|
+
onCancel: () => void;
|
|
312
|
+
}) {
|
|
313
|
+
const pointerRef = useRef<{
|
|
314
|
+
pointerId: number;
|
|
315
|
+
index: number;
|
|
316
|
+
points: readonly (HfColorCurvePoint | HfHueCurvePoint)[];
|
|
317
|
+
} | null>(null);
|
|
318
|
+
const samples = useMemo(() => samplesFor(points, tab), [points, tab]);
|
|
319
|
+
const path = useMemo(() => curvePath(samples, tab), [samples, tab]);
|
|
320
|
+
const selectRelativePoint = (offset: number) => {
|
|
321
|
+
if (points.length === 0) return;
|
|
322
|
+
const current = selectedIndex ?? (offset > 0 ? -1 : 0);
|
|
323
|
+
onSelect((current + offset + points.length) % points.length);
|
|
324
|
+
};
|
|
325
|
+
const addKeyboardPoint = () => {
|
|
326
|
+
const input = tab.kind === "rgb" ? 0.5 : 180;
|
|
327
|
+
const existing = nearestInputPointIndex(points, input, tab);
|
|
328
|
+
if (existing >= 0) {
|
|
329
|
+
onSelect(existing);
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
const output =
|
|
333
|
+
tab.kind === "rgb"
|
|
334
|
+
? ((samples[Math.floor((samples.length - 1) / 2)] ?? input) +
|
|
335
|
+
(samples[Math.ceil((samples.length - 1) / 2)] ?? input)) /
|
|
336
|
+
2
|
|
337
|
+
: (samples[Math.round(samples.length / 2)] ?? 0);
|
|
338
|
+
const inserted = insertPoint(points, input, output, tab);
|
|
339
|
+
if (!inserted) return;
|
|
340
|
+
onBegin();
|
|
341
|
+
onPreview(inserted.points, inserted.selected);
|
|
342
|
+
onSettle();
|
|
343
|
+
};
|
|
344
|
+
const moveSelectedByKeyboard = (key: ArrowKey, large: boolean) => {
|
|
345
|
+
if (selectedIndex === null) return false;
|
|
346
|
+
const selected = points[selectedIndex];
|
|
347
|
+
if (!selected) return false;
|
|
348
|
+
const position = keyboardPointPosition(selected, key, tab, large);
|
|
349
|
+
const moved = movePoint(points, selectedIndex, position.input, position.output, tab);
|
|
350
|
+
onBegin();
|
|
351
|
+
onPreview(moved.points, moved.selected);
|
|
352
|
+
return true;
|
|
353
|
+
};
|
|
354
|
+
|
|
355
|
+
const previewFromPointer = (target: SVGSVGElement, clientX: number, clientY: number) => {
|
|
356
|
+
const active = pointerRef.current;
|
|
357
|
+
if (!active) return;
|
|
358
|
+
const nextValue = valueFromPointer(clientX, clientY, target.getBoundingClientRect(), tab);
|
|
359
|
+
const moved = movePoint(active.points, active.index, nextValue.input, nextValue.output, tab);
|
|
360
|
+
pointerRef.current = {
|
|
361
|
+
pointerId: active.pointerId,
|
|
362
|
+
index: moved.selected,
|
|
363
|
+
points: moved.points,
|
|
364
|
+
};
|
|
365
|
+
onPreview(moved.points, moved.selected);
|
|
366
|
+
};
|
|
367
|
+
const handleRemovalKey = (event: ReactKeyboardEvent<SVGSVGElement>) => {
|
|
368
|
+
if (event.key === "Escape") {
|
|
369
|
+
event.preventDefault();
|
|
370
|
+
pointerRef.current = null;
|
|
371
|
+
onCancel();
|
|
372
|
+
return true;
|
|
373
|
+
}
|
|
374
|
+
const deleteKey = event.key === "Delete" || event.key === "Backspace";
|
|
375
|
+
if (deleteKey) {
|
|
376
|
+
if (selectedIndex === null) return;
|
|
377
|
+
event.preventDefault();
|
|
378
|
+
onDelete();
|
|
379
|
+
return true;
|
|
380
|
+
}
|
|
381
|
+
return false;
|
|
382
|
+
};
|
|
383
|
+
const handleAddKey = (event: ReactKeyboardEvent<SVGSVGElement>) => {
|
|
384
|
+
if (event.key === "Enter" || event.key === " ") {
|
|
385
|
+
event.preventDefault();
|
|
386
|
+
addKeyboardPoint();
|
|
387
|
+
return true;
|
|
388
|
+
}
|
|
389
|
+
return false;
|
|
390
|
+
};
|
|
391
|
+
const handleSelectionKey = (event: ReactKeyboardEvent<SVGSVGElement>) => {
|
|
392
|
+
if (event.key === "PageUp" || event.key === "PageDown") {
|
|
393
|
+
event.preventDefault();
|
|
394
|
+
selectRelativePoint(event.key === "PageUp" ? -1 : 1);
|
|
395
|
+
return true;
|
|
396
|
+
}
|
|
397
|
+
if (event.key === "Home" || event.key === "End") {
|
|
398
|
+
if (points.length === 0) return true;
|
|
399
|
+
event.preventDefault();
|
|
400
|
+
onSelect(event.key === "Home" ? 0 : points.length - 1);
|
|
401
|
+
return true;
|
|
402
|
+
}
|
|
403
|
+
return false;
|
|
404
|
+
};
|
|
405
|
+
const handleArrowKey = (event: ReactKeyboardEvent<SVGSVGElement>) => {
|
|
406
|
+
if (!isArrowKey(event.key) || points.length === 0) return false;
|
|
407
|
+
event.preventDefault();
|
|
408
|
+
if (selectedIndex === null) {
|
|
409
|
+
const selectLast = event.key === "ArrowLeft" || event.key === "ArrowDown";
|
|
410
|
+
onSelect(selectLast ? points.length - 1 : 0);
|
|
411
|
+
return true;
|
|
412
|
+
}
|
|
413
|
+
moveSelectedByKeyboard(event.key, event.shiftKey);
|
|
414
|
+
return true;
|
|
415
|
+
};
|
|
416
|
+
const handleKeyDown = (event: ReactKeyboardEvent<SVGSVGElement>) => {
|
|
417
|
+
if (handleRemovalKey(event)) return;
|
|
418
|
+
if (handleAddKey(event)) return;
|
|
419
|
+
if (handleSelectionKey(event)) return;
|
|
420
|
+
handleArrowKey(event);
|
|
421
|
+
};
|
|
422
|
+
|
|
423
|
+
return (
|
|
424
|
+
<svg
|
|
425
|
+
viewBox={`0 0 ${GRAPH_SIZE} ${GRAPH_SIZE}`}
|
|
426
|
+
role="application"
|
|
427
|
+
aria-label={`${tab.label} curve`}
|
|
428
|
+
aria-keyshortcuts="Enter Space ArrowLeft ArrowRight ArrowUp ArrowDown PageUp PageDown Home End Delete"
|
|
429
|
+
tabIndex={disabled ? -1 : 0}
|
|
430
|
+
data-color-curve-graph={tab.key}
|
|
431
|
+
data-color-curve-mid-sample={(samples[Math.floor(samples.length / 2)] ?? 0).toFixed(5)}
|
|
432
|
+
onPointerDown={(event) => {
|
|
433
|
+
if (disabled) return;
|
|
434
|
+
const value = valueFromPointer(
|
|
435
|
+
event.clientX,
|
|
436
|
+
event.clientY,
|
|
437
|
+
event.currentTarget.getBoundingClientRect(),
|
|
438
|
+
tab,
|
|
439
|
+
);
|
|
440
|
+
let index = nearestGraphPointIndex(points, value.input, value.output, tab);
|
|
441
|
+
let nextPoints = points;
|
|
442
|
+
if (index < 0) {
|
|
443
|
+
const inserted = insertPoint(points, value.input, value.output, tab);
|
|
444
|
+
if (!inserted) return;
|
|
445
|
+
nextPoints = inserted.points;
|
|
446
|
+
index = inserted.selected;
|
|
447
|
+
}
|
|
448
|
+
event.currentTarget.setPointerCapture(event.pointerId);
|
|
449
|
+
pointerRef.current = { pointerId: event.pointerId, index, points: nextPoints };
|
|
450
|
+
onBegin();
|
|
451
|
+
onSelect(index);
|
|
452
|
+
const moved = movePoint(nextPoints, index, value.input, value.output, tab);
|
|
453
|
+
pointerRef.current.index = moved.selected;
|
|
454
|
+
pointerRef.current.points = moved.points;
|
|
455
|
+
onPreview(moved.points, moved.selected);
|
|
456
|
+
}}
|
|
457
|
+
onPointerMove={(event) => {
|
|
458
|
+
if (disabled || pointerRef.current?.pointerId !== event.pointerId) return;
|
|
459
|
+
previewFromPointer(event.currentTarget, event.clientX, event.clientY);
|
|
460
|
+
}}
|
|
461
|
+
onPointerUp={(event) => {
|
|
462
|
+
if (pointerRef.current?.pointerId !== event.pointerId) return;
|
|
463
|
+
previewFromPointer(event.currentTarget, event.clientX, event.clientY);
|
|
464
|
+
pointerRef.current = null;
|
|
465
|
+
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
|
|
466
|
+
event.currentTarget.releasePointerCapture(event.pointerId);
|
|
467
|
+
}
|
|
468
|
+
onSettle();
|
|
469
|
+
}}
|
|
470
|
+
onPointerCancel={() => {
|
|
471
|
+
pointerRef.current = null;
|
|
472
|
+
onCancel();
|
|
473
|
+
}}
|
|
474
|
+
onKeyDown={handleKeyDown}
|
|
475
|
+
onKeyUp={(event) => {
|
|
476
|
+
if (selectedIndex !== null && isArrowKey(event.key)) {
|
|
477
|
+
onSettle();
|
|
478
|
+
}
|
|
479
|
+
}}
|
|
480
|
+
className="mx-auto aspect-square w-full max-w-[200px] touch-none rounded border border-panel-border-input bg-black/20 outline-none focus:ring-1 focus:ring-panel-accent"
|
|
481
|
+
>
|
|
482
|
+
<defs>
|
|
483
|
+
<linearGradient id={`hf-hue-axis-${tab.key}`}>
|
|
484
|
+
<stop offset="0%" stopColor="#f33" />
|
|
485
|
+
<stop offset="16.7%" stopColor="#ff3" />
|
|
486
|
+
<stop offset="33.3%" stopColor="#3f3" />
|
|
487
|
+
<stop offset="50%" stopColor="#3ff" />
|
|
488
|
+
<stop offset="66.7%" stopColor="#33f" />
|
|
489
|
+
<stop offset="83.3%" stopColor="#f3f" />
|
|
490
|
+
<stop offset="100%" stopColor="#f33" />
|
|
491
|
+
</linearGradient>
|
|
492
|
+
</defs>
|
|
493
|
+
{[0.25, 0.5, 0.75].map((ratio) => (
|
|
494
|
+
<g key={ratio} stroke="rgba(255,255,255,0.08)" strokeWidth="0.5">
|
|
495
|
+
<line
|
|
496
|
+
x1={GRAPH_PADDING}
|
|
497
|
+
y1={GRAPH_PADDING + ratio * (GRAPH_SIZE - GRAPH_PADDING * 2)}
|
|
498
|
+
x2={GRAPH_SIZE - GRAPH_PADDING}
|
|
499
|
+
y2={GRAPH_PADDING + ratio * (GRAPH_SIZE - GRAPH_PADDING * 2)}
|
|
500
|
+
/>
|
|
501
|
+
<line
|
|
502
|
+
x1={GRAPH_PADDING + ratio * (GRAPH_SIZE - GRAPH_PADDING * 2)}
|
|
503
|
+
y1={GRAPH_PADDING}
|
|
504
|
+
x2={GRAPH_PADDING + ratio * (GRAPH_SIZE - GRAPH_PADDING * 2)}
|
|
505
|
+
y2={GRAPH_SIZE - GRAPH_PADDING}
|
|
506
|
+
/>
|
|
507
|
+
</g>
|
|
508
|
+
))}
|
|
509
|
+
{tab.kind === "hue" && (
|
|
510
|
+
<line
|
|
511
|
+
x1={GRAPH_PADDING}
|
|
512
|
+
y1={GRAPH_SIZE - 3}
|
|
513
|
+
x2={GRAPH_SIZE - GRAPH_PADDING}
|
|
514
|
+
y2={GRAPH_SIZE - 3}
|
|
515
|
+
stroke={`url(#hf-hue-axis-${tab.key})`}
|
|
516
|
+
strokeWidth="3"
|
|
517
|
+
/>
|
|
518
|
+
)}
|
|
519
|
+
<path
|
|
520
|
+
data-color-curve-path="true"
|
|
521
|
+
d={path}
|
|
522
|
+
fill="none"
|
|
523
|
+
stroke={tab.color}
|
|
524
|
+
strokeWidth="1.5"
|
|
525
|
+
/>
|
|
526
|
+
{points.map(([input, output], index) => {
|
|
527
|
+
const point = graphPoint(input, output, tab);
|
|
528
|
+
return (
|
|
529
|
+
<circle
|
|
530
|
+
key={`${input}-${index}`}
|
|
531
|
+
data-color-curve-point={index}
|
|
532
|
+
cx={point.x}
|
|
533
|
+
cy={point.y}
|
|
534
|
+
r={selectedIndex === index ? 3.5 : 2.5}
|
|
535
|
+
fill={selectedIndex === index ? "#fff" : tab.color}
|
|
536
|
+
stroke="#111"
|
|
537
|
+
strokeWidth="1"
|
|
538
|
+
/>
|
|
539
|
+
);
|
|
540
|
+
})}
|
|
541
|
+
</svg>
|
|
542
|
+
);
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
export function formatPointValue(value: number, tab: CurveTab, axis: "input" | "output") {
|
|
546
|
+
if (tab.kind === "hue" && axis === "input") return Number(value.toFixed(1));
|
|
547
|
+
if (tab.kind === "rgb") return Number(value.toFixed(3));
|
|
548
|
+
return Number(value.toFixed(tab.key === "hueVsHue" ? 1 : 3));
|
|
549
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import React, { act } from "react";
|
|
4
|
+
import { createRoot } from "react-dom/client";
|
|
5
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
import type { ColorCurveValues } from "./propertyPanelColorCurves";
|
|
7
|
+
import { ColorCurves } from "./propertyPanelColorCurves";
|
|
8
|
+
|
|
9
|
+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
10
|
+
|
|
11
|
+
const IDENTITY: ColorCurveValues = {
|
|
12
|
+
curves: {
|
|
13
|
+
master: [
|
|
14
|
+
[0, 0],
|
|
15
|
+
[1, 1],
|
|
16
|
+
],
|
|
17
|
+
red: [
|
|
18
|
+
[0, 0],
|
|
19
|
+
[1, 1],
|
|
20
|
+
],
|
|
21
|
+
green: [
|
|
22
|
+
[0, 0],
|
|
23
|
+
[1, 1],
|
|
24
|
+
],
|
|
25
|
+
blue: [
|
|
26
|
+
[0, 0],
|
|
27
|
+
[1, 1],
|
|
28
|
+
],
|
|
29
|
+
},
|
|
30
|
+
hueCurves: { hueVsHue: [], hueVsSaturation: [], hueVsLuma: [] },
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
afterEach(() => {
|
|
34
|
+
document.body.innerHTML = "";
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
function renderCurves(value = IDENTITY) {
|
|
38
|
+
const onPreview = vi.fn();
|
|
39
|
+
const onCommit = vi.fn();
|
|
40
|
+
const host = document.body.appendChild(document.createElement("div"));
|
|
41
|
+
const root = createRoot(host);
|
|
42
|
+
act(() => root.render(<ColorCurves value={value} onPreview={onPreview} onCommit={onCommit} />));
|
|
43
|
+
return { host, root, onPreview, onCommit };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function activate(host: HTMLElement, key: string) {
|
|
47
|
+
act(() => {
|
|
48
|
+
host
|
|
49
|
+
.querySelector<HTMLButtonElement>(`[data-color-curve-tab="${key}"]`)
|
|
50
|
+
?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
51
|
+
});
|
|
52
|
+
const graph = host.querySelector<SVGSVGElement>(`[data-color-curve-graph="${key}"]`);
|
|
53
|
+
if (!graph) throw new Error(`Expected ${key} graph`);
|
|
54
|
+
Object.defineProperty(graph, "getBoundingClientRect", {
|
|
55
|
+
value: () => ({ left: 0, top: 0, width: 160, height: 160, right: 160, bottom: 160 }),
|
|
56
|
+
});
|
|
57
|
+
return graph;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
describe("ColorCurves", () => {
|
|
61
|
+
it("renders the four RGB and three hue-selective curve tabs", () => {
|
|
62
|
+
const { host, root } = renderCurves();
|
|
63
|
+
expect(host.querySelectorAll("[data-color-curve-tab]")).toHaveLength(7);
|
|
64
|
+
expect(host.querySelector('[data-color-curve-graph="master"]')).not.toBeNull();
|
|
65
|
+
act(() => root.unmount());
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("treats points across the red seam as neighbors instead of adding a duplicate", () => {
|
|
69
|
+
const value: ColorCurveValues = {
|
|
70
|
+
...IDENTITY,
|
|
71
|
+
hueCurves: {
|
|
72
|
+
...IDENTITY.hueCurves,
|
|
73
|
+
hueVsSaturation: [
|
|
74
|
+
[120, 0],
|
|
75
|
+
[240, 0],
|
|
76
|
+
[359, 0.2],
|
|
77
|
+
],
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
const { host, root, onCommit } = renderCurves(value);
|
|
81
|
+
const graph = activate(host, "hueVsSaturation");
|
|
82
|
+
|
|
83
|
+
act(() => {
|
|
84
|
+
graph.dispatchEvent(
|
|
85
|
+
new PointerEvent("pointerdown", {
|
|
86
|
+
bubbles: true,
|
|
87
|
+
pointerId: 5,
|
|
88
|
+
clientX: 8,
|
|
89
|
+
clientY: 66,
|
|
90
|
+
}),
|
|
91
|
+
);
|
|
92
|
+
graph.dispatchEvent(
|
|
93
|
+
new PointerEvent("pointerup", {
|
|
94
|
+
bubbles: true,
|
|
95
|
+
pointerId: 5,
|
|
96
|
+
clientX: 8,
|
|
97
|
+
clientY: 66,
|
|
98
|
+
}),
|
|
99
|
+
);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
const points = onCommit.mock.calls[0]?.[0]?.hueCurves.hueVsSaturation;
|
|
103
|
+
expect(points).toHaveLength(3);
|
|
104
|
+
expect(points.some(([hue]: readonly [number, number]) => hue < 1)).toBe(true);
|
|
105
|
+
act(() => root.unmount());
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("previews pointer edits and commits once on release", () => {
|
|
109
|
+
const { host, root, onPreview, onCommit } = renderCurves();
|
|
110
|
+
const graph = activate(host, "master");
|
|
111
|
+
act(() => {
|
|
112
|
+
graph.dispatchEvent(
|
|
113
|
+
new PointerEvent("pointerdown", {
|
|
114
|
+
bubbles: true,
|
|
115
|
+
pointerId: 2,
|
|
116
|
+
clientX: 80,
|
|
117
|
+
clientY: 120,
|
|
118
|
+
}),
|
|
119
|
+
);
|
|
120
|
+
graph.dispatchEvent(
|
|
121
|
+
new PointerEvent("pointerup", {
|
|
122
|
+
bubbles: true,
|
|
123
|
+
pointerId: 2,
|
|
124
|
+
clientX: 80,
|
|
125
|
+
clientY: 120,
|
|
126
|
+
}),
|
|
127
|
+
);
|
|
128
|
+
});
|
|
129
|
+
expect(onPreview.mock.calls[0]?.[0]?.curves.master).toHaveLength(3);
|
|
130
|
+
expect(onPreview.mock.calls.at(-1)?.[0]).toBe(IDENTITY);
|
|
131
|
+
expect(onCommit).toHaveBeenCalledOnce();
|
|
132
|
+
act(() => root.unmount());
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("does not restore a deleted point when an overlapping key gesture settles", () => {
|
|
136
|
+
const { host, root, onCommit } = renderCurves();
|
|
137
|
+
const graph = activate(host, "master");
|
|
138
|
+
act(() => {
|
|
139
|
+
graph.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
|
|
140
|
+
});
|
|
141
|
+
expect(onCommit.mock.calls.at(-1)?.[0]?.curves.master).toHaveLength(3);
|
|
142
|
+
onCommit.mockClear();
|
|
143
|
+
|
|
144
|
+
act(() => {
|
|
145
|
+
graph.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true }));
|
|
146
|
+
});
|
|
147
|
+
act(() => {
|
|
148
|
+
graph.dispatchEvent(new KeyboardEvent("keydown", { key: "Delete", bubbles: true }));
|
|
149
|
+
});
|
|
150
|
+
act(() => {
|
|
151
|
+
graph.dispatchEvent(new KeyboardEvent("keyup", { key: "ArrowDown", bubbles: true }));
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
expect(onCommit).toHaveBeenCalledOnce();
|
|
155
|
+
expect(onCommit.mock.calls[0]?.[0]?.curves.master).toHaveLength(2);
|
|
156
|
+
act(() => root.unmount());
|
|
157
|
+
});
|
|
158
|
+
});
|