@morphika/andami 0.1.2 → 0.1.5
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/app/(site)/[slug]/page.tsx +2 -2
- package/app/(site)/layout.tsx +1 -0
- package/app/(site)/page.tsx +2 -2
- package/app/(site)/preview/page.tsx +4 -4
- package/app/(site)/work/[slug]/page.tsx +2 -2
- package/app/admin/layout.tsx +2 -2
- package/app/admin/login/page.tsx +5 -5
- package/app/admin/navigation/page.tsx +255 -157
- package/app/api/admin/assets/relink/confirm/route.ts +1 -1
- package/app/api/admin/pages/[slug]/route.ts +1 -1
- package/app/api/admin/settings/route.ts +40 -15
- package/app/api/admin/setup/complete/route.ts +1 -1
- package/app/api/admin/setup/route.ts +6 -3
- package/components/admin/index.ts +7 -0
- package/components/admin/nav-builder/NavGeneralSettings.tsx +11 -15
- package/components/admin/nav-builder/NavItemSettings.tsx +29 -5
- package/components/admin/nav-builder/NavLivePreview.tsx +4 -1
- package/components/admin/nav-builder/NavMobileLivePreview.tsx +226 -0
- package/components/admin/nav-builder/NavMobileSettings.tsx +223 -0
- package/components/admin/nav-builder/index.ts +2 -0
- package/components/blocks/BlockRenderer.tsx +65 -13
- package/components/blocks/ButtonBlockRenderer.tsx +29 -6
- package/components/blocks/CoverBlockRenderer.tsx +36 -14
- package/components/blocks/ImageBlockRenderer.tsx +5 -3
- package/components/blocks/ImageGridBlockRenderer.tsx +13 -6
- package/components/blocks/PageRenderer.tsx +4 -2
- package/components/blocks/ProjectGridBlockRenderer.tsx +18 -3
- package/components/blocks/SectionRenderer.tsx +9 -8
- package/components/blocks/SectionV2Renderer.tsx +8 -8
- package/components/blocks/SpacerBlockRenderer.tsx +4 -2
- package/components/blocks/TextBlockRenderer.tsx +9 -4
- package/components/builder/BuilderCanvas.tsx +10 -4
- package/components/builder/ColorPicker.tsx +51 -243
- package/components/builder/ColorSwatchPicker.tsx +214 -274
- package/components/builder/DndWrapper.tsx +5 -2
- package/components/builder/SectionV2Canvas.tsx +15 -4
- package/components/builder/asset-browser/useAssetBrowser.ts +9 -1
- package/components/builder/color-picker/AlphaSlider.tsx +141 -0
- package/components/builder/color-picker/AngleControl.tsx +138 -0
- package/components/builder/color-picker/ColorInputs.tsx +105 -0
- package/components/builder/color-picker/EyedropperButton.tsx +74 -0
- package/components/builder/color-picker/GradientBar.tsx +222 -0
- package/components/builder/color-picker/GradientPreview.tsx +53 -0
- package/components/builder/color-picker/HueSlider.tsx +124 -0
- package/components/builder/color-picker/MeshCanvas.tsx +172 -0
- package/components/builder/color-picker/MeshPointEditor.tsx +133 -0
- package/components/builder/color-picker/MeshPointList.tsx +200 -0
- package/components/builder/color-picker/PositionControl.tsx +158 -0
- package/components/builder/color-picker/SaturationCanvas.tsx +142 -0
- package/components/builder/color-picker/StopEditor.tsx +178 -0
- package/components/builder/color-picker/SwatchBar.tsx +93 -0
- package/components/builder/color-picker/UnifiedColorPicker.tsx +713 -0
- package/components/builder/color-picker/index.ts +62 -0
- package/components/builder/color-picker/types.ts +115 -0
- package/components/builder/color-picker/utils.ts +138 -0
- package/components/builder/editors/CoverBlockEditor.tsx +86 -32
- package/components/builder/editors/ProjectGridEditor.tsx +51 -4
- package/components/builder/hooks/useColumnDrag.ts +25 -27
- package/components/builder/settings-panel/BlockLayoutTab.tsx +29 -7
- package/components/builder/settings-panel/LayoutTab.tsx +382 -310
- package/components/builder/settings-panel/PageSettings.tsx +6 -4
- package/components/builder/settings-panel/ParallaxSlideSettings.tsx +2 -2
- package/components/builder/settings-panel/SectionV2LayoutTab.tsx +392 -312
- package/components/builder/settings-panel/SectionV2Settings.tsx +65 -35
- package/components/ui/Navbar.tsx +95 -25
- package/components/ui/PortfolioTracker.tsx +3 -3
- package/lib/assets.ts +1 -1
- package/lib/auth.ts +1 -1
- package/lib/builder/gradient-presets.ts +128 -0
- package/lib/builder/layout-styles.ts +16 -10
- package/lib/builder/serializer.ts +1 -0
- package/lib/builder/store-blocks.ts +48 -61
- package/lib/builder/store-helpers.ts +31 -14
- package/lib/builder/store.ts +59 -41
- package/lib/builder/types.ts +14 -0
- package/lib/color-utils.ts +200 -0
- package/lib/config/index.ts +14 -43
- package/lib/revalidate.ts +2 -2
- package/lib/sanity/queries.ts +4 -3
- package/lib/sanity/types.ts +76 -1
- package/lib/setup/detect.ts +1 -1
- package/package.json +8 -12
- package/sanity/schemas/siteSettings.ts +34 -0
- package/styles/base.css +7 -51
- package/app/globals.css +0 -7
|
@@ -0,0 +1,713 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* UnifiedColorPicker — Modal-based color picker with gradient support.
|
|
5
|
+
*
|
|
6
|
+
* Phase 1: Solid color picker with modal UI.
|
|
7
|
+
* Phase 3: Added gradient tabs [Solid | Linear | Radial | Mesh].
|
|
8
|
+
*
|
|
9
|
+
* Renders via createPortal to document.body.
|
|
10
|
+
* Tabs only visible when `allowGradients={true}`.
|
|
11
|
+
* Modal widens to ~580px when Mesh tab is active.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { useState, useCallback, useEffect, useRef, useMemo } from "react";
|
|
15
|
+
import { createPortal } from "react-dom";
|
|
16
|
+
import SaturationCanvas from "./SaturationCanvas";
|
|
17
|
+
import HueSlider from "./HueSlider";
|
|
18
|
+
import AlphaSlider from "./AlphaSlider";
|
|
19
|
+
import ColorInputs from "./ColorInputs";
|
|
20
|
+
import EyedropperButton from "./EyedropperButton";
|
|
21
|
+
import SwatchBar from "./SwatchBar";
|
|
22
|
+
import GradientBar from "./GradientBar";
|
|
23
|
+
import GradientPreview from "./GradientPreview";
|
|
24
|
+
import AngleControl from "./AngleControl";
|
|
25
|
+
import PositionControl from "./PositionControl";
|
|
26
|
+
import StopEditor from "./StopEditor";
|
|
27
|
+
import MeshCanvas from "./MeshCanvas";
|
|
28
|
+
import MeshPointList from "./MeshPointList";
|
|
29
|
+
import MeshPointEditor from "./MeshPointEditor";
|
|
30
|
+
import type { UnifiedColorPickerProps } from "./types";
|
|
31
|
+
import type {
|
|
32
|
+
ColorField,
|
|
33
|
+
GradientStop,
|
|
34
|
+
LinearGradient,
|
|
35
|
+
RadialGradient,
|
|
36
|
+
MeshGradient,
|
|
37
|
+
MeshPoint,
|
|
38
|
+
} from "./types";
|
|
39
|
+
import { hexToHSV, hsvToHex, isValidHex, hexToRgba } from "./utils";
|
|
40
|
+
import {
|
|
41
|
+
isGradient,
|
|
42
|
+
resolveColorHex,
|
|
43
|
+
colorToCSS,
|
|
44
|
+
} from "../../../lib/color-utils";
|
|
45
|
+
import { getPresetsForCategory } from "../../../lib/builder/gradient-presets";
|
|
46
|
+
import type { GradientPresetInfo } from "../../../lib/builder/gradient-presets";
|
|
47
|
+
import type { ColorSwatch } from "../../../lib/sanity/types";
|
|
48
|
+
|
|
49
|
+
type GradientTab = "solid" | "linear" | "radial" | "mesh";
|
|
50
|
+
|
|
51
|
+
// ─── Default gradient values ───
|
|
52
|
+
|
|
53
|
+
function defaultLinear(): LinearGradient {
|
|
54
|
+
return {
|
|
55
|
+
type: "linear",
|
|
56
|
+
stops: [
|
|
57
|
+
{ color: "#000000", alpha: 1, position: 0 },
|
|
58
|
+
{ color: "#ffffff", alpha: 1, position: 100 },
|
|
59
|
+
],
|
|
60
|
+
angle: 135,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function defaultRadial(): RadialGradient {
|
|
65
|
+
return {
|
|
66
|
+
type: "radial",
|
|
67
|
+
stops: [
|
|
68
|
+
{ color: "#000000", alpha: 1, position: 0 },
|
|
69
|
+
{ color: "#ffffff", alpha: 1, position: 100 },
|
|
70
|
+
],
|
|
71
|
+
position: { x: 50, y: 50 },
|
|
72
|
+
shape: "ellipse",
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function defaultMesh(): MeshGradient {
|
|
77
|
+
return {
|
|
78
|
+
type: "mesh",
|
|
79
|
+
points: [
|
|
80
|
+
{ color: "#f1ddd5", x: 20, y: 30 },
|
|
81
|
+
{ color: "#6f737b", x: 80, y: 40 },
|
|
82
|
+
{ color: "#cfcbc1", x: 50, y: 80 },
|
|
83
|
+
],
|
|
84
|
+
background: "#181818",
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Generate the linear-gradient CSS for the GradientBar display */
|
|
89
|
+
function stopsToBarCSS(stops: GradientStop[]): string {
|
|
90
|
+
const sorted = [...stops].sort((a, b) => a.position - b.position);
|
|
91
|
+
const parts = sorted
|
|
92
|
+
.map((s) => `${hexToRgba(s.color, s.alpha)} ${s.position}%`)
|
|
93
|
+
.join(", ");
|
|
94
|
+
return `linear-gradient(to right, ${parts})`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export default function UnifiedColorPicker({
|
|
98
|
+
value,
|
|
99
|
+
onChange,
|
|
100
|
+
onClose,
|
|
101
|
+
swatches = [],
|
|
102
|
+
confirmLabel = "Apply Color",
|
|
103
|
+
alpha: initialAlpha = 1,
|
|
104
|
+
onAlphaChange,
|
|
105
|
+
allowGradients = false,
|
|
106
|
+
onPreview,
|
|
107
|
+
}: UnifiedColorPickerProps) {
|
|
108
|
+
// ─── Determine initial tab and state from value ───
|
|
109
|
+
const initTab: GradientTab =
|
|
110
|
+
typeof value === "string"
|
|
111
|
+
? "solid"
|
|
112
|
+
: value.type === "linear"
|
|
113
|
+
? "linear"
|
|
114
|
+
: value.type === "radial"
|
|
115
|
+
? "radial"
|
|
116
|
+
: "mesh";
|
|
117
|
+
|
|
118
|
+
const initHex =
|
|
119
|
+
typeof value === "string"
|
|
120
|
+
? isValidHex(value)
|
|
121
|
+
? value
|
|
122
|
+
: "#ffffff"
|
|
123
|
+
: resolveColorHex(value);
|
|
124
|
+
|
|
125
|
+
const initHsv = hexToHSV(initHex);
|
|
126
|
+
|
|
127
|
+
// ─── Solid state ───
|
|
128
|
+
const [hue, setHue] = useState(initHsv.h);
|
|
129
|
+
const [sat, setSat] = useState(initHsv.s);
|
|
130
|
+
const [val, setVal] = useState(initHsv.v);
|
|
131
|
+
const [hex, setHex] = useState(initHex);
|
|
132
|
+
const [alpha, setAlpha] = useState(initialAlpha);
|
|
133
|
+
|
|
134
|
+
// ─── Tab state ───
|
|
135
|
+
const [activeTab, setActiveTab] = useState<GradientTab>(
|
|
136
|
+
allowGradients ? initTab : "solid"
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
// ─── Gradient states ───
|
|
140
|
+
const [linearState, setLinearState] = useState<LinearGradient>(() =>
|
|
141
|
+
typeof value !== "string" && value.type === "linear"
|
|
142
|
+
? value
|
|
143
|
+
: defaultLinear()
|
|
144
|
+
);
|
|
145
|
+
const [radialState, setRadialState] = useState<RadialGradient>(() =>
|
|
146
|
+
typeof value !== "string" && value.type === "radial"
|
|
147
|
+
? value
|
|
148
|
+
: defaultRadial()
|
|
149
|
+
);
|
|
150
|
+
const [meshState, setMeshState] = useState<MeshGradient>(() =>
|
|
151
|
+
typeof value !== "string" && value.type === "mesh"
|
|
152
|
+
? value
|
|
153
|
+
: defaultMesh()
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
const [selectedStopIndex, setSelectedStopIndex] = useState(0);
|
|
157
|
+
const [selectedMeshIndex, setSelectedMeshIndex] = useState(0);
|
|
158
|
+
|
|
159
|
+
const modalRef = useRef<HTMLDivElement>(null);
|
|
160
|
+
|
|
161
|
+
// ─── rAF-throttled preview dispatch (Phase 4) ───
|
|
162
|
+
const rafRef = useRef<number | null>(null);
|
|
163
|
+
const emitPreview = useCallback(
|
|
164
|
+
(previewValue: ColorField) => {
|
|
165
|
+
if (!onPreview) return;
|
|
166
|
+
if (rafRef.current !== null) cancelAnimationFrame(rafRef.current);
|
|
167
|
+
rafRef.current = requestAnimationFrame(() => {
|
|
168
|
+
rafRef.current = null;
|
|
169
|
+
onPreview(previewValue);
|
|
170
|
+
});
|
|
171
|
+
},
|
|
172
|
+
[onPreview]
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
// Cleanup rAF on unmount
|
|
176
|
+
useEffect(() => {
|
|
177
|
+
return () => {
|
|
178
|
+
if (rafRef.current !== null) cancelAnimationFrame(rafRef.current);
|
|
179
|
+
};
|
|
180
|
+
}, []);
|
|
181
|
+
|
|
182
|
+
// ─── Gradient presets per tab (Phase 4) ───
|
|
183
|
+
const linearPresets = useMemo(() => getPresetsForCategory("linear"), []);
|
|
184
|
+
const meshPresets = useMemo(() => getPresetsForCategory("mesh"), []);
|
|
185
|
+
|
|
186
|
+
// ─── Solid color handlers ───
|
|
187
|
+
// PERF-002 fix: Use refs for hue/sat/val so callbacks are stable and
|
|
188
|
+
// don't create new event listeners in child components on every change.
|
|
189
|
+
const hueRef = useRef(hue);
|
|
190
|
+
const satRef = useRef(sat);
|
|
191
|
+
const valRef = useRef(val);
|
|
192
|
+
hueRef.current = hue;
|
|
193
|
+
satRef.current = sat;
|
|
194
|
+
valRef.current = val;
|
|
195
|
+
|
|
196
|
+
const updateFromHSV = useCallback(
|
|
197
|
+
(h: number, s: number, v: number) => {
|
|
198
|
+
setHex(hsvToHex(h, s, v));
|
|
199
|
+
},
|
|
200
|
+
[]
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
const handleSatValChange = useCallback(
|
|
204
|
+
(s: number, v: number) => {
|
|
205
|
+
setSat(s);
|
|
206
|
+
setVal(v);
|
|
207
|
+
const newHex = hsvToHex(hueRef.current, s, v);
|
|
208
|
+
setHex(newHex);
|
|
209
|
+
emitPreview(newHex);
|
|
210
|
+
},
|
|
211
|
+
[emitPreview]
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
const handleHueChange = useCallback(
|
|
215
|
+
(h: number) => {
|
|
216
|
+
setHue(h);
|
|
217
|
+
const newHex = hsvToHex(h, satRef.current, valRef.current);
|
|
218
|
+
setHex(newHex);
|
|
219
|
+
emitPreview(newHex);
|
|
220
|
+
},
|
|
221
|
+
[emitPreview]
|
|
222
|
+
);
|
|
223
|
+
|
|
224
|
+
const handleAlphaChange = useCallback(
|
|
225
|
+
(a: number) => {
|
|
226
|
+
setAlpha(a);
|
|
227
|
+
onAlphaChange?.(a);
|
|
228
|
+
},
|
|
229
|
+
[onAlphaChange]
|
|
230
|
+
);
|
|
231
|
+
|
|
232
|
+
const handleHexChange = useCallback((newHex: string) => {
|
|
233
|
+
if (isValidHex(newHex)) {
|
|
234
|
+
setHex(newHex);
|
|
235
|
+
const hsv = hexToHSV(newHex);
|
|
236
|
+
setHue(hsv.h);
|
|
237
|
+
setSat(hsv.s);
|
|
238
|
+
setVal(hsv.v);
|
|
239
|
+
}
|
|
240
|
+
}, []);
|
|
241
|
+
|
|
242
|
+
const handleEyedropper = useCallback((picked: string) => {
|
|
243
|
+
if (isValidHex(picked)) {
|
|
244
|
+
setHex(picked);
|
|
245
|
+
const hsv = hexToHSV(picked);
|
|
246
|
+
setHue(hsv.h);
|
|
247
|
+
setSat(hsv.s);
|
|
248
|
+
setVal(hsv.v);
|
|
249
|
+
}
|
|
250
|
+
}, []);
|
|
251
|
+
|
|
252
|
+
const handleSwatchSelect = useCallback((swatchHex: string) => {
|
|
253
|
+
if (isValidHex(swatchHex)) {
|
|
254
|
+
setHex(swatchHex);
|
|
255
|
+
const hsv = hexToHSV(swatchHex);
|
|
256
|
+
setHue(hsv.h);
|
|
257
|
+
setSat(hsv.s);
|
|
258
|
+
setVal(hsv.v);
|
|
259
|
+
}
|
|
260
|
+
}, []);
|
|
261
|
+
|
|
262
|
+
// ─── Linear gradient handlers ───
|
|
263
|
+
|
|
264
|
+
const handleLinearStopsChange = useCallback((stops: GradientStop[]) => {
|
|
265
|
+
setLinearState((prev) => {
|
|
266
|
+
const next = { ...prev, stops };
|
|
267
|
+
emitPreview(next);
|
|
268
|
+
return next;
|
|
269
|
+
});
|
|
270
|
+
}, [emitPreview]);
|
|
271
|
+
|
|
272
|
+
const handleLinearAngleChange = useCallback((angle: number) => {
|
|
273
|
+
setLinearState((prev) => {
|
|
274
|
+
const next = { ...prev, angle };
|
|
275
|
+
emitPreview(next);
|
|
276
|
+
return next;
|
|
277
|
+
});
|
|
278
|
+
}, [emitPreview]);
|
|
279
|
+
|
|
280
|
+
const handleLinearStopEdit = useCallback(
|
|
281
|
+
(stop: GradientStop) => {
|
|
282
|
+
setLinearState((prev) => {
|
|
283
|
+
const next = {
|
|
284
|
+
...prev,
|
|
285
|
+
stops: prev.stops.map((s, i) => (i === selectedStopIndex ? stop : s)),
|
|
286
|
+
};
|
|
287
|
+
emitPreview(next);
|
|
288
|
+
return next;
|
|
289
|
+
});
|
|
290
|
+
},
|
|
291
|
+
[selectedStopIndex, emitPreview]
|
|
292
|
+
);
|
|
293
|
+
|
|
294
|
+
// ─── Radial gradient handlers ───
|
|
295
|
+
|
|
296
|
+
const handleRadialStopsChange = useCallback((stops: GradientStop[]) => {
|
|
297
|
+
setRadialState((prev) => {
|
|
298
|
+
const next = { ...prev, stops };
|
|
299
|
+
emitPreview(next);
|
|
300
|
+
return next;
|
|
301
|
+
});
|
|
302
|
+
}, [emitPreview]);
|
|
303
|
+
|
|
304
|
+
const handleRadialPositionChange = useCallback((x: number, y: number) => {
|
|
305
|
+
setRadialState((prev) => {
|
|
306
|
+
const next = { ...prev, position: { x, y } };
|
|
307
|
+
emitPreview(next);
|
|
308
|
+
return next;
|
|
309
|
+
});
|
|
310
|
+
}, [emitPreview]);
|
|
311
|
+
|
|
312
|
+
const handleRadialShapeChange = useCallback(
|
|
313
|
+
(shape: "circle" | "ellipse") => {
|
|
314
|
+
setRadialState((prev) => {
|
|
315
|
+
const next = { ...prev, shape };
|
|
316
|
+
emitPreview(next);
|
|
317
|
+
return next;
|
|
318
|
+
});
|
|
319
|
+
},
|
|
320
|
+
[emitPreview]
|
|
321
|
+
);
|
|
322
|
+
|
|
323
|
+
const handleRadialStopEdit = useCallback(
|
|
324
|
+
(stop: GradientStop) => {
|
|
325
|
+
setRadialState((prev) => {
|
|
326
|
+
const next = {
|
|
327
|
+
...prev,
|
|
328
|
+
stops: prev.stops.map((s, i) => (i === selectedStopIndex ? stop : s)),
|
|
329
|
+
};
|
|
330
|
+
emitPreview(next);
|
|
331
|
+
return next;
|
|
332
|
+
});
|
|
333
|
+
},
|
|
334
|
+
[selectedStopIndex, emitPreview]
|
|
335
|
+
);
|
|
336
|
+
|
|
337
|
+
// ─── Mesh gradient handlers ───
|
|
338
|
+
|
|
339
|
+
const handleMeshPointsChange = useCallback((points: MeshPoint[]) => {
|
|
340
|
+
setMeshState((prev) => {
|
|
341
|
+
const next = { ...prev, points };
|
|
342
|
+
emitPreview(next);
|
|
343
|
+
return next;
|
|
344
|
+
});
|
|
345
|
+
}, [emitPreview]);
|
|
346
|
+
|
|
347
|
+
const handleMeshBackgroundChange = useCallback((bg: string) => {
|
|
348
|
+
setMeshState((prev) => {
|
|
349
|
+
const next = { ...prev, background: bg };
|
|
350
|
+
emitPreview(next);
|
|
351
|
+
return next;
|
|
352
|
+
});
|
|
353
|
+
}, [emitPreview]);
|
|
354
|
+
|
|
355
|
+
/** Update a single mesh point (from MeshPointEditor color change) */
|
|
356
|
+
const handleMeshPointEdit = useCallback(
|
|
357
|
+
(updatedPoint: MeshPoint) => {
|
|
358
|
+
setMeshState((prev) => {
|
|
359
|
+
const next = {
|
|
360
|
+
...prev,
|
|
361
|
+
points: prev.points.map((p, i) => (i === selectedMeshIndex ? updatedPoint : p)),
|
|
362
|
+
};
|
|
363
|
+
emitPreview(next);
|
|
364
|
+
return next;
|
|
365
|
+
});
|
|
366
|
+
},
|
|
367
|
+
[selectedMeshIndex, emitPreview]
|
|
368
|
+
);
|
|
369
|
+
|
|
370
|
+
// ─── Preset selection (Phase 4) ───
|
|
371
|
+
|
|
372
|
+
const handlePresetSelect = useCallback(
|
|
373
|
+
(preset: GradientPresetInfo) => {
|
|
374
|
+
const tpl = structuredClone(preset.template);
|
|
375
|
+
switch (tpl.type) {
|
|
376
|
+
case "linear":
|
|
377
|
+
setLinearState(tpl);
|
|
378
|
+
setActiveTab("linear");
|
|
379
|
+
break;
|
|
380
|
+
case "radial":
|
|
381
|
+
setRadialState(tpl as RadialGradient);
|
|
382
|
+
setActiveTab("radial");
|
|
383
|
+
break;
|
|
384
|
+
case "mesh":
|
|
385
|
+
setMeshState(tpl as MeshGradient);
|
|
386
|
+
setActiveTab("mesh");
|
|
387
|
+
break;
|
|
388
|
+
}
|
|
389
|
+
setSelectedStopIndex(0);
|
|
390
|
+
setSelectedMeshIndex(0);
|
|
391
|
+
emitPreview(tpl);
|
|
392
|
+
},
|
|
393
|
+
[emitPreview]
|
|
394
|
+
);
|
|
395
|
+
|
|
396
|
+
// ─── Confirm ───
|
|
397
|
+
|
|
398
|
+
const handleConfirm = useCallback(() => {
|
|
399
|
+
let result: ColorField;
|
|
400
|
+
switch (activeTab) {
|
|
401
|
+
case "solid":
|
|
402
|
+
result = hex;
|
|
403
|
+
break;
|
|
404
|
+
case "linear":
|
|
405
|
+
result = linearState;
|
|
406
|
+
break;
|
|
407
|
+
case "radial":
|
|
408
|
+
result = radialState;
|
|
409
|
+
break;
|
|
410
|
+
case "mesh":
|
|
411
|
+
result = meshState;
|
|
412
|
+
break;
|
|
413
|
+
}
|
|
414
|
+
onChange(result);
|
|
415
|
+
onClose();
|
|
416
|
+
}, [activeTab, hex, linearState, radialState, meshState, onChange, onClose]);
|
|
417
|
+
|
|
418
|
+
// ─── Close on Escape ───
|
|
419
|
+
useEffect(() => {
|
|
420
|
+
const handleKeyDown = (e: KeyboardEvent) => {
|
|
421
|
+
if (e.key === "Escape") onClose();
|
|
422
|
+
};
|
|
423
|
+
window.addEventListener("keydown", handleKeyDown);
|
|
424
|
+
return () => window.removeEventListener("keydown", handleKeyDown);
|
|
425
|
+
}, [onClose]);
|
|
426
|
+
|
|
427
|
+
// Close on backdrop click
|
|
428
|
+
const handleBackdropClick = useCallback(
|
|
429
|
+
(e: React.MouseEvent) => {
|
|
430
|
+
if (e.target === e.currentTarget) onClose();
|
|
431
|
+
},
|
|
432
|
+
[onClose]
|
|
433
|
+
);
|
|
434
|
+
|
|
435
|
+
// Trap focus
|
|
436
|
+
useEffect(() => {
|
|
437
|
+
const prev = document.activeElement as HTMLElement | null;
|
|
438
|
+
modalRef.current?.focus();
|
|
439
|
+
return () => {
|
|
440
|
+
prev?.focus?.();
|
|
441
|
+
};
|
|
442
|
+
}, []);
|
|
443
|
+
|
|
444
|
+
if (typeof document === "undefined") return null;
|
|
445
|
+
|
|
446
|
+
const isMesh = activeTab === "mesh";
|
|
447
|
+
const showTabs = allowGradients;
|
|
448
|
+
|
|
449
|
+
return createPortal(
|
|
450
|
+
<div
|
|
451
|
+
className="fixed inset-0 z-[10000] flex items-center justify-center"
|
|
452
|
+
style={{
|
|
453
|
+
background: "rgba(0, 0, 0, 0.4)",
|
|
454
|
+
backdropFilter: "blur(4px)",
|
|
455
|
+
}}
|
|
456
|
+
onClick={handleBackdropClick}
|
|
457
|
+
>
|
|
458
|
+
<div
|
|
459
|
+
ref={modalRef}
|
|
460
|
+
tabIndex={-1}
|
|
461
|
+
role="dialog"
|
|
462
|
+
aria-label="Color picker"
|
|
463
|
+
className={`bg-white rounded-2xl p-6 shadow-2xl border border-neutral-200 outline-none transition-[width] duration-200 ${
|
|
464
|
+
isMesh ? "w-[580px]" : "w-[420px]"
|
|
465
|
+
}`}
|
|
466
|
+
style={{
|
|
467
|
+
boxShadow:
|
|
468
|
+
"0 25px 80px rgba(0,0,0,0.15), 0 8px 24px rgba(0,0,0,0.1)",
|
|
469
|
+
}}
|
|
470
|
+
>
|
|
471
|
+
{/* ─── Tabs ─── */}
|
|
472
|
+
{showTabs && (
|
|
473
|
+
<div className="flex gap-1 mb-5 bg-neutral-100 rounded-xl p-1">
|
|
474
|
+
{(["solid", "linear", "radial", "mesh"] as GradientTab[]).map(
|
|
475
|
+
(tab) => (
|
|
476
|
+
<button
|
|
477
|
+
key={tab}
|
|
478
|
+
type="button"
|
|
479
|
+
onClick={() => setActiveTab(tab)}
|
|
480
|
+
className={`flex-1 py-2 px-3 text-[13px] font-medium rounded-lg cursor-pointer transition-all capitalize ${
|
|
481
|
+
activeTab === tab
|
|
482
|
+
? "bg-white text-neutral-900 shadow-sm"
|
|
483
|
+
: "bg-transparent text-neutral-400 hover:text-neutral-600"
|
|
484
|
+
}`}
|
|
485
|
+
>
|
|
486
|
+
{tab}
|
|
487
|
+
</button>
|
|
488
|
+
)
|
|
489
|
+
)}
|
|
490
|
+
</div>
|
|
491
|
+
)}
|
|
492
|
+
|
|
493
|
+
{/* ─── Tab: Solid ─── */}
|
|
494
|
+
{activeTab === "solid" && (
|
|
495
|
+
<>
|
|
496
|
+
<div className="mb-4">
|
|
497
|
+
<SaturationCanvas
|
|
498
|
+
hue={hue}
|
|
499
|
+
saturation={sat}
|
|
500
|
+
value={val}
|
|
501
|
+
onChange={handleSatValChange}
|
|
502
|
+
/>
|
|
503
|
+
</div>
|
|
504
|
+
|
|
505
|
+
<div className="flex items-center gap-3 mb-3">
|
|
506
|
+
<span className="text-xs text-neutral-400 w-3.5 text-center shrink-0">
|
|
507
|
+
<svg width="12" height="12" viewBox="0 0 12 12">
|
|
508
|
+
<circle cx="6" cy="6" r="5" fill="none" stroke="currentColor" strokeWidth="1.5" />
|
|
509
|
+
<circle cx="6" cy="6" r="2" fill="currentColor" />
|
|
510
|
+
</svg>
|
|
511
|
+
</span>
|
|
512
|
+
<HueSlider hue={hue} onChange={handleHueChange} />
|
|
513
|
+
</div>
|
|
514
|
+
|
|
515
|
+
<div className="flex items-center gap-3 mb-4">
|
|
516
|
+
<span className="text-xs text-neutral-400 w-3.5 text-center shrink-0">
|
|
517
|
+
<svg width="12" height="12" viewBox="0 0 12 12">
|
|
518
|
+
<rect x="0" y="0" width="6" height="6" fill="currentColor" opacity="0.6" />
|
|
519
|
+
<rect x="6" y="6" width="6" height="6" fill="currentColor" opacity="0.6" />
|
|
520
|
+
<rect x="6" y="0" width="6" height="6" fill="currentColor" opacity="0.2" />
|
|
521
|
+
<rect x="0" y="6" width="6" height="6" fill="currentColor" opacity="0.2" />
|
|
522
|
+
</svg>
|
|
523
|
+
</span>
|
|
524
|
+
<AlphaSlider color={hex} alpha={alpha} onChange={handleAlphaChange} />
|
|
525
|
+
</div>
|
|
526
|
+
|
|
527
|
+
<div className="flex items-center gap-2 mb-4">
|
|
528
|
+
<div className="flex-1">
|
|
529
|
+
<ColorInputs hex={hex} onHexChange={handleHexChange} alpha={alpha} />
|
|
530
|
+
</div>
|
|
531
|
+
<EyedropperButton onColorPicked={handleEyedropper} />
|
|
532
|
+
</div>
|
|
533
|
+
|
|
534
|
+
<div className="mb-4">
|
|
535
|
+
<SwatchBar
|
|
536
|
+
value={hex}
|
|
537
|
+
onSelect={handleSwatchSelect}
|
|
538
|
+
swatches={swatches}
|
|
539
|
+
currentColor={hex}
|
|
540
|
+
/>
|
|
541
|
+
</div>
|
|
542
|
+
</>
|
|
543
|
+
)}
|
|
544
|
+
|
|
545
|
+
{/* ─── Tab: Linear Gradient ─── */}
|
|
546
|
+
{activeTab === "linear" && (
|
|
547
|
+
<>
|
|
548
|
+
<GradientPreview value={linearState} height={160} />
|
|
549
|
+
|
|
550
|
+
{/* Presets row (Phase 4) */}
|
|
551
|
+
{linearPresets.length > 0 && (
|
|
552
|
+
<div className="flex gap-1.5 mb-3">
|
|
553
|
+
{linearPresets.map((preset) => (
|
|
554
|
+
<button
|
|
555
|
+
key={preset.id}
|
|
556
|
+
type="button"
|
|
557
|
+
title={preset.label}
|
|
558
|
+
onClick={() => handlePresetSelect(preset)}
|
|
559
|
+
className="w-8 h-8 rounded-lg border border-neutral-200 hover:border-neutral-400 transition-colors cursor-pointer shrink-0"
|
|
560
|
+
style={{ backgroundImage: colorToCSS(preset.template) }}
|
|
561
|
+
/>
|
|
562
|
+
))}
|
|
563
|
+
</div>
|
|
564
|
+
)}
|
|
565
|
+
|
|
566
|
+
<GradientBar
|
|
567
|
+
stops={linearState.stops}
|
|
568
|
+
onChange={handleLinearStopsChange}
|
|
569
|
+
selectedIndex={selectedStopIndex}
|
|
570
|
+
onSelect={setSelectedStopIndex}
|
|
571
|
+
gradientCSS={stopsToBarCSS(linearState.stops)}
|
|
572
|
+
/>
|
|
573
|
+
|
|
574
|
+
<AngleControl
|
|
575
|
+
angle={linearState.angle}
|
|
576
|
+
onChange={handleLinearAngleChange}
|
|
577
|
+
/>
|
|
578
|
+
|
|
579
|
+
{linearState.stops[selectedStopIndex] && (
|
|
580
|
+
<StopEditor
|
|
581
|
+
stop={linearState.stops[selectedStopIndex]}
|
|
582
|
+
onChange={handleLinearStopEdit}
|
|
583
|
+
/>
|
|
584
|
+
)}
|
|
585
|
+
</>
|
|
586
|
+
)}
|
|
587
|
+
|
|
588
|
+
{/* ─── Tab: Radial Gradient ─── */}
|
|
589
|
+
{activeTab === "radial" && (
|
|
590
|
+
<>
|
|
591
|
+
<GradientPreview value={radialState} height={160} />
|
|
592
|
+
|
|
593
|
+
<GradientBar
|
|
594
|
+
stops={radialState.stops}
|
|
595
|
+
onChange={handleRadialStopsChange}
|
|
596
|
+
selectedIndex={selectedStopIndex}
|
|
597
|
+
onSelect={setSelectedStopIndex}
|
|
598
|
+
gradientCSS={stopsToBarCSS(radialState.stops)}
|
|
599
|
+
/>
|
|
600
|
+
|
|
601
|
+
<div className="flex items-center gap-3 mb-4">
|
|
602
|
+
<PositionControl
|
|
603
|
+
x={radialState.position.x}
|
|
604
|
+
y={radialState.position.y}
|
|
605
|
+
onChange={handleRadialPositionChange}
|
|
606
|
+
/>
|
|
607
|
+
|
|
608
|
+
{/* Shape toggle */}
|
|
609
|
+
<div className="flex flex-col gap-1.5">
|
|
610
|
+
<div className="text-[10px] text-neutral-400 uppercase tracking-wider">
|
|
611
|
+
Shape
|
|
612
|
+
</div>
|
|
613
|
+
<div className="flex gap-1 bg-neutral-100 rounded-lg p-0.5">
|
|
614
|
+
{(["ellipse", "circle"] as const).map((shape) => (
|
|
615
|
+
<button
|
|
616
|
+
key={shape}
|
|
617
|
+
type="button"
|
|
618
|
+
onClick={() => handleRadialShapeChange(shape)}
|
|
619
|
+
className={`px-2.5 py-1 text-[11px] rounded-md cursor-pointer transition-all capitalize ${
|
|
620
|
+
radialState.shape === shape
|
|
621
|
+
? "bg-white text-neutral-900 shadow-sm"
|
|
622
|
+
: "bg-transparent text-neutral-400 hover:text-neutral-600"
|
|
623
|
+
}`}
|
|
624
|
+
>
|
|
625
|
+
{shape}
|
|
626
|
+
</button>
|
|
627
|
+
))}
|
|
628
|
+
</div>
|
|
629
|
+
</div>
|
|
630
|
+
</div>
|
|
631
|
+
|
|
632
|
+
{radialState.stops[selectedStopIndex] && (
|
|
633
|
+
<StopEditor
|
|
634
|
+
stop={radialState.stops[selectedStopIndex]}
|
|
635
|
+
onChange={handleRadialStopEdit}
|
|
636
|
+
/>
|
|
637
|
+
)}
|
|
638
|
+
</>
|
|
639
|
+
)}
|
|
640
|
+
|
|
641
|
+
{/* ─── Tab: Mesh Gradient ─── */}
|
|
642
|
+
{activeTab === "mesh" && (
|
|
643
|
+
<>
|
|
644
|
+
<div className="grid grid-cols-[1fr_160px] gap-4">
|
|
645
|
+
<div>
|
|
646
|
+
<MeshCanvas
|
|
647
|
+
points={meshState.points}
|
|
648
|
+
background={meshState.background}
|
|
649
|
+
onChange={handleMeshPointsChange}
|
|
650
|
+
selectedIndex={selectedMeshIndex}
|
|
651
|
+
onSelect={setSelectedMeshIndex}
|
|
652
|
+
/>
|
|
653
|
+
</div>
|
|
654
|
+
<MeshPointList
|
|
655
|
+
points={meshState.points}
|
|
656
|
+
onChange={handleMeshPointsChange}
|
|
657
|
+
selectedIndex={selectedMeshIndex}
|
|
658
|
+
onSelect={setSelectedMeshIndex}
|
|
659
|
+
background={meshState.background}
|
|
660
|
+
onBackgroundChange={handleMeshBackgroundChange}
|
|
661
|
+
/>
|
|
662
|
+
</div>
|
|
663
|
+
|
|
664
|
+
{/* Mesh point color editor */}
|
|
665
|
+
{meshState.points[selectedMeshIndex] && (
|
|
666
|
+
<div className="mt-3">
|
|
667
|
+
<MeshPointEditor
|
|
668
|
+
point={meshState.points[selectedMeshIndex]}
|
|
669
|
+
onChange={handleMeshPointEdit}
|
|
670
|
+
/>
|
|
671
|
+
</div>
|
|
672
|
+
)}
|
|
673
|
+
|
|
674
|
+
{/* Mesh presets row (Phase 4) */}
|
|
675
|
+
{meshPresets.length > 0 && (
|
|
676
|
+
<div className="flex gap-1.5 mt-3">
|
|
677
|
+
{meshPresets.map((preset) => (
|
|
678
|
+
<button
|
|
679
|
+
key={preset.id}
|
|
680
|
+
type="button"
|
|
681
|
+
title={preset.label}
|
|
682
|
+
onClick={() => handlePresetSelect(preset)}
|
|
683
|
+
className="w-8 h-8 rounded-lg border border-neutral-200 hover:border-neutral-400 transition-colors cursor-pointer shrink-0"
|
|
684
|
+
style={{ backgroundImage: colorToCSS(preset.template) }}
|
|
685
|
+
/>
|
|
686
|
+
))}
|
|
687
|
+
</div>
|
|
688
|
+
)}
|
|
689
|
+
</>
|
|
690
|
+
)}
|
|
691
|
+
|
|
692
|
+
{/* ─── Action buttons ─── */}
|
|
693
|
+
<div className="flex gap-2.5 mt-4">
|
|
694
|
+
<button
|
|
695
|
+
type="button"
|
|
696
|
+
onClick={onClose}
|
|
697
|
+
className="flex-1 py-2.5 rounded-xl border border-neutral-200 bg-transparent text-neutral-500 text-sm cursor-pointer hover:border-neutral-300 hover:text-neutral-700 transition-colors"
|
|
698
|
+
>
|
|
699
|
+
Cancel
|
|
700
|
+
</button>
|
|
701
|
+
<button
|
|
702
|
+
type="button"
|
|
703
|
+
onClick={handleConfirm}
|
|
704
|
+
className="flex-1 py-2.5 rounded-xl border-none bg-neutral-900 text-white text-sm font-medium cursor-pointer hover:bg-neutral-800 transition-colors"
|
|
705
|
+
>
|
|
706
|
+
{confirmLabel}
|
|
707
|
+
</button>
|
|
708
|
+
</div>
|
|
709
|
+
</div>
|
|
710
|
+
</div>,
|
|
711
|
+
document.body
|
|
712
|
+
);
|
|
713
|
+
}
|