diff --git a/src/components/ui/gradient-editor.test.tsx b/src/components/ui/gradient-editor.test.tsx new file mode 100644 index 000000000..0d54a26ea --- /dev/null +++ b/src/components/ui/gradient-editor.test.tsx @@ -0,0 +1,69 @@ +import "@testing-library/jest-dom"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import type { GradientEditorState } from "./gradient-editor"; +import GradientEditor from "./gradient-editor"; + +const getLastState = (onChange: ReturnType): GradientEditorState => { + const calls = onChange.mock.calls; + expect(calls.length).toBeGreaterThan(0); + return calls[calls.length - 1][0] as GradientEditorState; +}; + +describe("GradientEditor", () => { + it("emits unique point IDs after an interaction", async () => { + const onChange = vi.fn(); + render(); + + const removeButton = screen.getByRole("button", { name: "Remove color" }); + fireEvent.click(removeButton); + await waitFor(() => expect(onChange).toHaveBeenCalled()); + + const state = getLastState(onChange); + const ids = state.points.map((p) => p.id); + expect(new Set(ids).size).toBe(ids.length); + expect(ids).toContain("main"); + expect(ids).toHaveLength(2); + }); + + it("generates a fresh ID after remove→add instead of reusing an existing one", async () => { + const onChange = vi.fn(); + render(); + + fireEvent.click(screen.getByRole("button", { name: "Remove color" })); + await waitFor(() => expect(getLastState(onChange).points).toHaveLength(2)); + expect(getLastState(onChange).points.map((p) => p.id)).toEqual(["main", "o1"]); + + fireEvent.click(screen.getByRole("button", { name: "Add color" })); + await waitFor(() => expect(getLastState(onChange).points).toHaveLength(3)); + const finalIds = getLastState(onChange).points.map((p) => p.id); + expect(new Set(finalIds).size).toBe(finalIds.length); + expect(finalIds).toContain("o3"); + }); + + it("disables add at MAX_COLORS and remove at one color", async () => { + render(); + + const addButton = screen.getByRole("button", { name: "Add color" }); + const removeButton = screen.getByRole("button", { name: "Remove color" }); + + expect(addButton).toBeDisabled(); + expect(removeButton).not.toBeDisabled(); + + fireEvent.click(removeButton); + await waitFor(() => expect(addButton).not.toBeDisabled()); + + fireEvent.click(removeButton); + await waitFor(() => expect(removeButton).toBeDisabled()); + expect(addButton).not.toBeDisabled(); + }); + + it("cycles harmony when eligible", async () => { + const onChange = vi.fn(); + render(); + + fireEvent.click(screen.getByRole("button", { name: "Cycle harmony" })); + await waitFor(() => expect(onChange).toHaveBeenCalled()); + expect(getLastState(onChange).harmonyType).not.toBe("splitComplementary"); + }); +}); diff --git a/src/components/ui/gradient-editor.tsx b/src/components/ui/gradient-editor.tsx new file mode 100644 index 000000000..19bb98603 --- /dev/null +++ b/src/components/ui/gradient-editor.tsx @@ -0,0 +1,585 @@ +import type { PointerEvent as ReactPointerEvent } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +/* ---------- icons ---------- */ +const PlusIcon = () => ( + + + +); + +const MinusIcon = () => ( + + + +); + +const RingsIcon = () => ( + + + + + +); + +const clamp = (v: number, min: number, max: number) => Math.min(max, Math.max(min, v)); + +const MAX_COLORS = 3; +const MAIN_ID = "main"; +const MAIN_MAX_RADIUS = 40; + +/* ---------- color-wheel harmony math ---------- */ +type ColorHarmony = { + type: HarmonyType; + count: number; + angles: number[]; +}; + +type HarmonyType = "complementary" | "analogous" | "splitComplementary" | "triadic" | "square"; + +const colorHarmonies: ColorHarmony[] = [ + { type: "complementary", count: 1, angles: [180] }, + { type: "analogous", count: 1, angles: [35] }, + { type: "splitComplementary", count: 2, angles: [150, 210] }, + { type: "triadic", count: 2, angles: [120, 240] }, + { type: "square", count: 2, angles: [90, 270] }, +]; + +function getEligibleHarmonies(secondaryCount: number): ColorHarmony[] { + return colorHarmonies.filter((h) => h.count === secondaryCount); +} + +function getHarmonyAngles(secondaryCount: number, harmonyType: HarmonyType): number[] { + const eligible = getEligibleHarmonies(secondaryCount); + if (eligible.length === 0) return []; + return (eligible.find((h) => h.type === harmonyType) || eligible[0]).angles; +} + +function computeSecondaryPositions( + mainAngle: number, + mainRadius: number, + secondaries: { id: string }[], + harmonyType: HarmonyType, +): { id: string; x: number; y: number; color: string }[] { + const angles = getHarmonyAngles(secondaries.length, harmonyType); + return secondaries.map((p, i) => { + const angle = mainAngle + (angles[i] ?? 0); + const rad = (angle * Math.PI) / 180; + const x = clamp(50 + mainRadius * Math.cos(rad), 5, 95); + const y = clamp(50 + mainRadius * Math.sin(rad), 5, 95); + return { id: p.id, x, y, color: colorFromPolar(angle, mainRadius) }; + }); +} + +/* ---------- position -> color ---------- */ +function hueToRgb(p: number, q: number, t: number): number { + let tt = t; + if (tt < 0) tt += 1; + if (tt > 1) tt -= 1; + if (tt < 1 / 6) return p + (q - p) * 6 * tt; + if (tt < 1 / 2) return q; + if (tt < 2 / 3) return p + (q - p) * (2 / 3 - tt) * 6; + return p; +} + +function hslToRgb(h: number, s: number, l: number): [number, number, number] { + let r: number; + let g: number; + let b: number; + if (s === 0) { + r = g = b = l; + } else { + const q = l < 0.5 ? l * (1 + s) : l + s - l * s; + const p = 2 * l - q; + r = hueToRgb(p, q, h + 1 / 3); + g = hueToRgb(p, q, h); + b = hueToRgb(p, q, h - 1 / 3); + } + return [Math.round(r * 255), Math.round(g * 255), Math.round(b * 255)]; +} + +function colorFromPolar(angleDeg: number, radiusPct: number): string { + const hue = ((angleDeg % 360) + 360) % 360; + const saturation = clamp(radiusPct / MAIN_MAX_RADIUS, 0, 1); + const [r, g, b] = hslToRgb(hue / 360, saturation, 0.5); + return `rgb(${r}, ${g}, ${b})`; +} + +/* ---------- wavy-slider path ---------- */ +const LINE_PATH = `M 51.373 27.395 L 367.037 27.395`; +const SINE_PATH = `M 51.373 27.395 C 60.14 -8.503 68.906 -8.503 77.671 27.395 C 86.438 63.293 95.205 63.293 103.971 27.395 C 112.738 -8.503 121.504 -8.503 130.271 27.395 C 139.037 63.293 147.803 63.293 156.57 27.395 C 165.335 -8.503 174.101 -8.503 182.868 27.395 C 191.634 63.293 200.4 63.293 209.167 27.395 C 217.933 -8.503 226.7 -8.503 235.467 27.395 C 244.233 63.293 252.999 63.293 261.765 27.395 C 270.531 -8.503 279.297 -8.503 288.064 27.395 C 296.83 63.293 305.596 63.293 314.363 27.395 C 323.13 -8.503 331.896 -8.503 340.662 27.395 M 314.438 27.395 C 323.204 -8.503 331.97 -8.503 340.737 27.395 C 349.503 63.293 358.27 63.293 367.037 27.395`; + +type PathPoint = + | { type: "M"; x: number; y: number } + | { type: "L"; x: number; y: number } + | { type: "C"; x1: number; y1: number; x2: number; y2: number; x: number; y: number }; + +function parseSinePath(pathStr: string): PathPoint[] { + const points: PathPoint[] = []; + const commands = pathStr.match(/[MCL]\s*[\d\s.\-,]+/g); + if (!commands) return points; + + for (const command of commands) { + const type = command.charAt(0); + const coords = command + .slice(1) + .trim() + .split(/[\s,]+/) + .map(Number); + + if (type === "M") { + points.push({ type: "M", x: coords[0], y: coords[1] }); + } else if (type === "C") { + if (coords.length >= 6 && coords.length % 6 === 0) { + for (let i = 0; i < coords.length; i += 6) { + points.push({ + x1: coords[i], + y1: coords[i + 1], + x2: coords[i + 2], + y2: coords[i + 3], + x: coords[i + 4], + y: coords[i + 5], + type: "C", + }); + } + } + } else if (type === "L") { + points.push({ type: "L", x: coords[0], y: coords[1] }); + } + } + return points; +} + +const SINE_POINTS = parseSinePath(SINE_PATH); + +function getInterpolatedWavePath(progress: number): string { + const referenceY = 27.395; + if (SINE_POINTS.length === 0) return progress < 0.5 ? LINE_PATH : SINE_PATH; + if (progress <= 0.001) return LINE_PATH; + if (progress >= 0.999) return SINE_PATH; + + const t = progress; + let d = ""; + for (const p of SINE_POINTS) { + if (p.type === "M") { + d += `M ${p.x} ${referenceY + (p.y - referenceY) * t} `; + } else if (p.type === "C") { + const y1 = referenceY + (p.y1 - referenceY) * t; + const y2 = referenceY + (p.y2 - referenceY) * t; + const y = referenceY + (p.y - referenceY) * t; + d += `C ${p.x1} ${y1} ${p.x2} ${y2} ${p.x} ${y} `; + } else if (p.type === "L") { + d += `L ${p.x} ${p.y} `; + } + } + return d.trim(); +} + +/* ---------- exported state ---------- */ +export type GradientEditorState = { + points: { id: string; x: number; y: number; color: string }[]; + mainX: number; + mainY: number; + mainColor: string; + brightness: number; + angle: number; + harmonyType: HarmonyType; +}; + +type GradientEditorProps = { + onChange?: (state: GradientEditorState) => void; +}; + +export default function GradientEditor({ onChange }: GradientEditorProps) { + const [mainAngle, setMainAngle] = useState(-35); + const [mainRadius, setMainRadius] = useState(20); + const idCounter = useRef(1); + const [orbitPoints, setOrbitPoints] = useState<{ id: string }[]>(() => [ + { id: `o${idCounter.current++}` }, + { id: `o${idCounter.current++}` }, + ]); + const [harmonyType, setHarmonyType] = useState("splitComplementary"); + const [brightness, setBrightness] = useState(55); + const [angle, setAngle] = useState(135); + + const canvasRef = useRef(null); + const trackRef = useRef(null); + const draggingMain = useRef(false); + const mountedRef = useRef(false); + const dragCleanup = useRef<(() => void) | null>(null); + + useEffect(() => { + return () => { + dragCleanup.current?.(); + dragCleanup.current = null; + }; + }, []); + + const totalColors = 1 + orbitPoints.length; + + /* ---------- derived visuals ---------- */ + const mainX = 50 + mainRadius * Math.cos((mainAngle * Math.PI) / 180); + const mainY = 50 + mainRadius * Math.sin((mainAngle * Math.PI) / 180); + const mainColor = colorFromPolar(mainAngle, mainRadius); + + const allPoints = useMemo(() => { + const secondaries = computeSecondaryPositions( + mainAngle, + mainRadius, + orbitPoints, + harmonyType, + ).map((p) => ({ ...p, size: 20 })); + + return [{ id: MAIN_ID, x: mainX, y: mainY, color: mainColor, size: 64 }, ...secondaries]; + }, [mainX, mainY, mainColor, orbitPoints, mainAngle, mainRadius, harmonyType]); + + /* ---------- emit state ---------- */ + const onChangeRef = useRef(onChange); + useEffect(() => { + onChangeRef.current = onChange; + }, [onChange]); + + useEffect(() => { + const cb = onChangeRef.current; + if (!cb) return; + if (!mountedRef.current) { + mountedRef.current = true; + return; + } + + cb({ + points: allPoints.map((p) => ({ id: p.id, x: p.x, y: p.y, color: p.color })), + mainX, + mainY, + mainColor, + brightness, + harmonyType, + angle, + }); + }, [allPoints, mainX, mainY, mainColor, brightness, harmonyType, angle]); + + /* ---------- main point drag ---------- */ + const updateMainFromPointer = useCallback((clientX: number, clientY: number) => { + const el = canvasRef.current; + if (!el) return; + const rect = el.getBoundingClientRect(); + const cx = rect.left + rect.width / 2; + const cy = rect.top + rect.height / 2; + const dx = clientX - cx; + const dy = clientY - cy; + const angle = (Math.atan2(dy, dx) * 180) / Math.PI; + const radius = clamp((Math.hypot(dx, dy) / (rect.width / 2)) * 50, 0, MAIN_MAX_RADIUS); + + setMainAngle(angle); + setMainRadius(radius); + }, []); + + const onMainPointerDown = (e: ReactPointerEvent) => { + e.stopPropagation(); + draggingMain.current = true; + dragCleanup.current?.(); + const onUp = () => { + draggingMain.current = false; + window.removeEventListener("pointermove", onMainPointerMove); + window.removeEventListener("pointerup", onUp); + window.removeEventListener("pointercancel", onUp); + dragCleanup.current = null; + }; + dragCleanup.current = onUp; + window.addEventListener("pointermove", onMainPointerMove); + window.addEventListener("pointerup", onUp); + window.addEventListener("pointercancel", onUp); + }; + + const onMainPointerMove = useCallback( + (e: PointerEvent) => { + if (!draggingMain.current) return; + updateMainFromPointer(e.clientX, e.clientY); + }, + [updateMainFromPointer], + ); + + /* ---------- add / remove / harmony ---------- */ + const addPoint = () => { + if (totalColors >= MAX_COLORS) return; + const id = `o${idCounter.current++}`; + setOrbitPoints((prev) => [...prev, { id }]); + }; + + const removePoint = () => { + if (totalColors <= 1) return; + setOrbitPoints((prev) => prev.slice(0, -1)); + }; + + const eligibleHarmonies = getEligibleHarmonies(orbitPoints.length); + + const cycleHarmony = () => { + if (eligibleHarmonies.length <= 1) return; + const idx = eligibleHarmonies.findIndex((h) => h.type === harmonyType); + const next = eligibleHarmonies[(idx + 1) % eligibleHarmonies.length]; + setHarmonyType(next.type); + }; + + /* ---------- brightness slider ---------- */ + const setBrightnessFromClientX = (clientX: number) => { + const el = trackRef.current; + if (!el) return; + const rect = el.getBoundingClientRect(); + const pct = clamp(((clientX - rect.left) / rect.width) * 100, 0, 100); + setBrightness(pct); + }; + + /* visual clamp so thumb stays on the visible wave path */ + const WAVE_START_PCT = (51.373 / 420) * 100; + const WAVE_END_PCT = (367.037 / 420) * 100; + const clampedBrightness = clamp(brightness, WAVE_START_PCT, WAVE_END_PCT); + + const onSliderPointerDown = (e: ReactPointerEvent) => { + setBrightnessFromClientX(e.clientX); + dragCleanup.current?.(); + const move = (ev: PointerEvent) => setBrightnessFromClientX(ev.clientX); + const onUp = () => { + window.removeEventListener("pointermove", move); + window.removeEventListener("pointerup", onUp); + window.removeEventListener("pointercancel", onUp); + dragCleanup.current = null; + }; + dragCleanup.current = onUp; + window.addEventListener("pointermove", move); + window.addEventListener("pointerup", onUp); + window.addEventListener("pointercancel", onUp); + }; + + /* ---------- angle knob ---------- */ + // Angle knob + const angleKnobRef = useRef(null); + const angleKnobAngle = ((angle % 360) / 360) * 360; + const setAngleFromClientPos = (clientX: number, clientY: number) => { + const el = angleKnobRef.current; + if (!el) return; + const rect = el.getBoundingClientRect(); + const cx = rect.left + rect.width / 2; + const cy = rect.top + rect.height / 2; + let a = (Math.atan2(-(clientY - cy), clientX - cx) * 180) / Math.PI; + if (a < 0) a += 360; + setAngle(Math.round(a)); + }; + const onAngleKnobPointerDown = (e: ReactPointerEvent) => { + e.stopPropagation(); + setAngleFromClientPos(e.clientX, e.clientY); + dragCleanup.current?.(); + const move = (ev: PointerEvent) => setAngleFromClientPos(ev.clientX, ev.clientY); + const onUp = () => { + window.removeEventListener("pointermove", move); + window.removeEventListener("pointerup", onUp); + window.removeEventListener("pointercancel", onUp); + dragCleanup.current = null; + }; + dragCleanup.current = onUp; + window.addEventListener("pointermove", move); + window.addEventListener("pointerup", onUp); + window.addEventListener("pointercancel", onUp); + }; + + // Preview brightness must match the per-channel multiplier in + // applyBrightness so the wheel is WYSIWYG. + const brightnessFilter = `brightness(${brightness / 100})`; + + const wavePath = useMemo(() => getInterpolatedWavePath(brightness / 100), [brightness]); + + /* ---------- keyboard handlers ---------- */ + const onMainKeyDown = useCallback((e: React.KeyboardEvent) => { + const step = e.shiftKey ? 10 : 5; + if (e.key === "ArrowLeft" || e.key === "ArrowDown") { + e.preventDefault(); + setMainAngle((a) => a - step); + } else if (e.key === "ArrowRight" || e.key === "ArrowUp") { + e.preventDefault(); + setMainAngle((a) => a + step); + } + }, []); + + const onSliderKeyDown = useCallback((e: React.KeyboardEvent) => { + const step = e.shiftKey ? 10 : 2; + if (e.key === "ArrowLeft" || e.key === "ArrowDown") { + e.preventDefault(); + setBrightness((b) => clamp(b - step, 0, 100)); + } else if (e.key === "ArrowRight" || e.key === "ArrowUp") { + e.preventDefault(); + setBrightness((b) => clamp(b + step, 0, 100)); + } + }, []); + + const onAngleKeyDown = useCallback((e: React.KeyboardEvent) => { + const step = e.shiftKey ? 15 : 5; + if (e.key === "ArrowLeft" || e.key === "ArrowDown") { + e.preventDefault(); + setAngle((a) => (a - step + 360) % 360); + } else if (e.key === "ArrowRight" || e.key === "ArrowUp") { + e.preventDefault(); + setAngle((a) => (a + step) % 360); + } + }, []); + + return ( +
+ {/* Color Wheel Canvas */} +
+ {/* Center marker */} +
+ + {/* Points container with brightness filter */} +
+ {/* Secondary points */} + {allPoints + .filter((p) => p.id !== MAIN_ID) + .map((p) => ( +
+ ))} + + {/* Main draggable point */} +
+
+ + {/* Controls on canvas */} +
+ + + + + +
+
+ + {/* Bottom controls: Brightness + Angle */} +
+ {/* Brightness wavy slider */} +
+ + + + +
+
+ + {/* Angle knob */} +
+
+
+
+
+
+
+ ); +} diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx index 282a295fd..b80ac021d 100644 --- a/src/components/video-editor/SettingsPanel.tsx +++ b/src/components/video-editor/SettingsPanel.tsx @@ -51,11 +51,13 @@ import { GIF_FRAME_RATES, GIF_SIZE_PRESETS, } from "@/lib/exporter"; +import { buildGradientFromEditor } from "@/lib/gradientBuilder"; import { cn } from "@/lib/utils"; import { resolveImageWallpaperUrl, WALLPAPER_PATHS } from "@/lib/wallpaper"; import { type AspectRatio, isPortraitAspectRatio } from "@/utils/aspectRatioUtils"; import { getTestId } from "@/utils/getTestId"; import ColorPicker from "../ui/color-picker"; +import GradientEditor from "../ui/gradient-editor"; import { AnnotationSettingsPanel } from "./AnnotationSettingsPanel"; import { BlurSettingsPanel } from "./BlurSettingsPanel"; import { BACKGROUND_IMAGE_ACCEPT, isSupportedBackgroundImageType } from "./backgroundImageUpload"; @@ -1825,28 +1827,45 @@ export function SettingsPanel({ /> - -
- {GRADIENTS.map((g, idx) => ( -
{ - setGradient(g); - onWallpaperChange(g); - }} - role="button" - /> - ))} + +
+
+ {t("background.presets")} +
+
+ {GRADIENTS.map((g, idx) => ( +
{ + setGradient(g); + onWallpaperChange(g); + }} + role="button" + /> + ))} +
+
+
+
+ {t("background.custom")} +
+ { + const css = buildGradientFromEditor(state); + setGradient(css); + onWallpaperChange(css); + }} + />
diff --git a/src/i18n/locales/ar/settings.json b/src/i18n/locales/ar/settings.json index a800fb64c..716d37965 100644 --- a/src/i18n/locales/ar/settings.json +++ b/src/i18n/locales/ar/settings.json @@ -66,10 +66,12 @@ "image": "صورة", "color": "لون", "gradient": "تدرج لوني", + "custom": "مخصص", "uploadCustom": "رفع صورة مخصصة", "gradientLabel": "تدرج لوني {{index}}", "colorWheel": "عجلة الألوان", - "colorPalette": "لوحة الألوان" + "colorPalette": "لوحة الألوان", + "presets": "إعدادات مسبقة" }, "crop": { "title": "اقتصاص", diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json index 03c240c39..2349b6caa 100644 --- a/src/i18n/locales/en/settings.json +++ b/src/i18n/locales/en/settings.json @@ -67,10 +67,12 @@ "image": "Image", "color": "Color", "gradient": "Gradient", + "custom": "Custom", "uploadCustom": "Upload Custom", "gradientLabel": "Gradient {{index}}", "colorWheel": "Color Wheel", - "colorPalette": "Color Palette" + "colorPalette": "Color Palette", + "presets": "Presets" }, "crop": { "title": "Crop", diff --git a/src/i18n/locales/es/settings.json b/src/i18n/locales/es/settings.json index 3013b9278..28cc42576 100644 --- a/src/i18n/locales/es/settings.json +++ b/src/i18n/locales/es/settings.json @@ -66,10 +66,12 @@ "image": "Imagen", "color": "Color", "gradient": "Degradado", + "custom": "Personalizado", "uploadCustom": "Subir personalizado", "gradientLabel": "Degradado {{index}}", "colorWheel": "Rueda de colores", - "colorPalette": "Paleta de colores" + "colorPalette": "Paleta de colores", + "presets": "Ajustes preestablecidos" }, "crop": { "title": "Recortar", diff --git a/src/i18n/locales/fr/settings.json b/src/i18n/locales/fr/settings.json index a779def86..e9af6adc4 100644 --- a/src/i18n/locales/fr/settings.json +++ b/src/i18n/locales/fr/settings.json @@ -66,10 +66,12 @@ "image": "Image", "color": "Couleur", "gradient": "Dégradé", + "custom": "Personnalisé", "uploadCustom": "Téléverser une image", "gradientLabel": "Dégradé {{index}}", "colorWheel": "Roue chromatique", - "colorPalette": "Palette de couleurs" + "colorPalette": "Palette de couleurs", + "presets": "Préréglages" }, "crop": { "title": "Recadrage", diff --git a/src/i18n/locales/it/settings.json b/src/i18n/locales/it/settings.json index 48091c4fb..40703bcb9 100644 --- a/src/i18n/locales/it/settings.json +++ b/src/i18n/locales/it/settings.json @@ -66,10 +66,12 @@ "image": "Immagine", "color": "Colore", "gradient": "Sfumatura", + "custom": "Personalizzato", "uploadCustom": "Carica personalizzato", "gradientLabel": "Sfumatura {{index}}", "colorWheel": "Ruota dei colori", - "colorPalette": "Tavolozza dei colori" + "colorPalette": "Tavolozza dei colori", + "presets": "Predefiniti" }, "crop": { "title": "Ritaglia", diff --git a/src/i18n/locales/ja-JP/settings.json b/src/i18n/locales/ja-JP/settings.json index 0bf3b7ef7..790d409e1 100644 --- a/src/i18n/locales/ja-JP/settings.json +++ b/src/i18n/locales/ja-JP/settings.json @@ -66,10 +66,12 @@ "image": "画像", "color": "色", "gradient": "グラデーション", + "custom": "カスタム", "uploadCustom": "カスタム画像を読み込む", "gradientLabel": "グラデーション {{index}}", "colorWheel": "カラーホイール", - "colorPalette": "カラーパレット" + "colorPalette": "カラーパレット", + "presets": "プリセット" }, "crop": { "title": "クロップ", diff --git a/src/i18n/locales/ko-KR/settings.json b/src/i18n/locales/ko-KR/settings.json index 898048164..b1072291e 100644 --- a/src/i18n/locales/ko-KR/settings.json +++ b/src/i18n/locales/ko-KR/settings.json @@ -66,10 +66,12 @@ "image": "이미지", "color": "색상", "gradient": "그라디언트", + "custom": "사용자 지정", "uploadCustom": "직접 업로드", "gradientLabel": "그라디언트 {{index}}", "colorWheel": "색상 휠", - "colorPalette": "색상 팔레트" + "colorPalette": "색상 팔레트", + "presets": "프리셋" }, "crop": { "title": "자르기", diff --git a/src/i18n/locales/pt-BR/settings.json b/src/i18n/locales/pt-BR/settings.json index 3e9d616b2..e137b3fd3 100644 --- a/src/i18n/locales/pt-BR/settings.json +++ b/src/i18n/locales/pt-BR/settings.json @@ -64,10 +64,12 @@ "image": "Imagem", "color": "Cor", "gradient": "Gradiente", + "custom": "Personalizado", "uploadCustom": "Enviar Personalizada", "gradientLabel": "Gradiente {{index}}", "colorWheel": "Roda de Cores", - "colorPalette": "Paleta de Cores" + "colorPalette": "Paleta de Cores", + "presets": "Predefinições" }, "crop": { "title": "Cortar", diff --git a/src/i18n/locales/ru/settings.json b/src/i18n/locales/ru/settings.json index f8f8749c8..a77508f00 100644 --- a/src/i18n/locales/ru/settings.json +++ b/src/i18n/locales/ru/settings.json @@ -66,10 +66,12 @@ "image": "Изображение", "color": "Цвет", "gradient": "Градиент", + "custom": "Свой", "uploadCustom": "Загрузить свой", "gradientLabel": "Градиент {{index}}", "colorWheel": "Цветовой круг", - "colorPalette": "Палитра цветов" + "colorPalette": "Палитра цветов", + "presets": "Пресеты" }, "crop": { "title": "Обрезка", diff --git a/src/i18n/locales/tr/settings.json b/src/i18n/locales/tr/settings.json index 4b68ec2c3..a1d80f481 100644 --- a/src/i18n/locales/tr/settings.json +++ b/src/i18n/locales/tr/settings.json @@ -66,10 +66,12 @@ "image": "Görüntü", "color": "Renk", "gradient": "Gradyan", + "custom": "Özel", "uploadCustom": "Özel Yükle", "gradientLabel": "Gradyan {{index}}", "colorWheel": "Renk çarkı", - "colorPalette": "Renk paleti" + "colorPalette": "Renk paleti", + "presets": "Ön ayarlar" }, "crop": { "title": "Kırpma", diff --git a/src/i18n/locales/vi/settings.json b/src/i18n/locales/vi/settings.json index 4125ce192..8891e67c8 100644 --- a/src/i18n/locales/vi/settings.json +++ b/src/i18n/locales/vi/settings.json @@ -66,10 +66,12 @@ "image": "Hình ảnh", "color": "Màu sắc", "gradient": "Dải màu", + "custom": "Tùy chỉnh", "uploadCustom": "Tải lên tùy chỉnh", "gradientLabel": "Dải màu {{index}}", "colorPalette": "Bảng màu", - "colorWheel": "Vòng màu" + "colorWheel": "Vòng màu", + "presets": "Có sẵn" }, "crop": { "title": "Cắt xén", diff --git a/src/i18n/locales/zh-CN/settings.json b/src/i18n/locales/zh-CN/settings.json index a1a949ab6..a80565931 100644 --- a/src/i18n/locales/zh-CN/settings.json +++ b/src/i18n/locales/zh-CN/settings.json @@ -66,10 +66,12 @@ "image": "图片", "color": "颜色", "gradient": "渐变", + "custom": "自定义", "uploadCustom": "上传自定义", "gradientLabel": "渐变 {{index}}", "colorWheel": "颜色轮", - "colorPalette": "颜色调色板" + "colorPalette": "颜色调色板", + "presets": "预设" }, "crop": { "title": "裁剪", diff --git a/src/i18n/locales/zh-TW/settings.json b/src/i18n/locales/zh-TW/settings.json index 28240463b..f083da6f4 100644 --- a/src/i18n/locales/zh-TW/settings.json +++ b/src/i18n/locales/zh-TW/settings.json @@ -67,10 +67,12 @@ "image": "圖片", "color": "顏色", "gradient": "漸層", + "custom": "自訂", "uploadCustom": "上傳自訂", "gradientLabel": "漸層 {{index}}", "colorWheel": "色輪", - "colorPalette": "調色盤" + "colorPalette": "調色盤", + "presets": "預設" }, "crop": { "title": "裁剪", diff --git a/src/lib/gradientBuilder.test.ts b/src/lib/gradientBuilder.test.ts new file mode 100644 index 000000000..d8f530cbb --- /dev/null +++ b/src/lib/gradientBuilder.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "vitest"; +import type { GradientEditorState } from "@/components/ui/gradient-editor"; +import { buildGradientFromEditor } from "./gradientBuilder"; + +const baseState = (overrides: Partial = {}): GradientEditorState => ({ + points: [{ id: "main", x: 30, y: 40, color: "rgb(255, 0, 0)" }], + mainX: 30, + mainY: 40, + mainColor: "rgb(255, 0, 0)", + brightness: 100, + angle: 135, + harmonyType: "splitComplementary", + ...overrides, +}); + +const threePoints = (brightness: number): GradientEditorState => + baseState({ + brightness, + points: [ + { id: "main", x: 30, y: 40, color: "rgb(255, 0, 0)" }, + { id: "o1", x: 70, y: 20, color: "rgb(0, 255, 0)" }, + { id: "o2", x: 50, y: 80, color: "rgb(0, 0, 255)" }, + ], + }); + +describe("buildGradientFromEditor", () => { + it("emits a 3-stop linear gradient at 135deg", () => { + const css = buildGradientFromEditor(threePoints(100)); + expect(css).toBe( + "linear-gradient(135deg, rgb(255, 0, 0) 0%, rgb(0, 255, 0) 50%, rgb(0, 0, 255) 100%)", + ); + }); + + it("scales every stop's color with the brightness slider", () => { + const full = buildGradientFromEditor(threePoints(100)); + const half = buildGradientFromEditor(threePoints(50)); + expect(half).toBe( + "linear-gradient(135deg, rgb(128, 0, 0) 0%, rgb(0, 128, 0) 50%, rgb(0, 0, 128) 100%)", + ); + expect(full).not.toBe(half); + }); + + it("handles one and two point states", () => { + const one = buildGradientFromEditor(baseState({ brightness: 100 })); + expect(one).toBe("linear-gradient(135deg, rgb(255, 0, 0) 0%, rgb(255, 0, 0) 100%)"); + + const two = buildGradientFromEditor( + baseState({ + brightness: 100, + points: [ + { id: "main", x: 30, y: 40, color: "rgb(10, 20, 30)" }, + { id: "o1", x: 70, y: 20, color: "rgb(40, 50, 60)" }, + ], + }), + ); + expect(two).toBe( + "linear-gradient(135deg, rgb(10, 20, 30) 0%, rgb(40, 50, 60) 50%, rgb(40, 50, 60) 100%)", + ); + }); + + it("falls back to a brightness-driven base color when there are no points", () => { + expect(buildGradientFromEditor(baseState({ points: [], brightness: 0 }))).toBe( + "hsl(0, 0%, 0%)", + ); + expect(buildGradientFromEditor(baseState({ points: [], brightness: 100 }))).toBe( + "hsl(0, 0%, 18%)", + ); + }); +}); diff --git a/src/lib/gradientBuilder.ts b/src/lib/gradientBuilder.ts new file mode 100644 index 000000000..69b44f416 --- /dev/null +++ b/src/lib/gradientBuilder.ts @@ -0,0 +1,41 @@ +import type { GradientEditorState } from "@/components/ui/gradient-editor"; + +/* ---------- editor state -> CSS background ---------- + The editor produces a clean 3-stop LINEAR gradient - the same shape + as a "real" 3-color gradient picker, not clustered blobs. Brightness + is a per-channel multiplier (0 = black, 100 = full color). */ +export function buildGradientFromEditor(state: GradientEditorState): string { + const { points, brightness, angle = 135 } = state; + const colors = points.slice(0, 3).map((p) => applyBrightness(p.color, brightness)); + + if (colors.length === 0) { + return baseForBrightness(brightness); + } + if (colors.length === 1) { + return `linear-gradient(${angle}deg, ${colors[0]} 0%, ${colors[0]} 100%)`; + } + if (colors.length === 2) { + return `linear-gradient(${angle}deg, ${colors[0]} 0%, ${colors[1]} 50%, ${colors[1]} 100%)`; + } + + return `linear-gradient(${angle}deg, ${colors[0]} 0%, ${colors[1]} 50%, ${colors[2]} 100%)`; +} + +function applyBrightness(color: string, brightness: number): string { + const match = color.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/); + if (!match) return color; + const k = brightness / 100; + const r = Math.round(Number(match[1]) * k); + const g = Math.round(Number(match[2]) * k); + const b = Math.round(Number(match[3]) * k); + return `rgb(${r}, ${g}, ${b})`; +} + +function baseForBrightness(brightness: number): string { + const l = Math.round((brightness / 100) * 18); + return `hsl(0, 0%, ${clamp(l, 0, 22)}%)`; +} + +function clamp(v: number, min: number, max: number): number { + return Math.min(max, Math.max(min, v)); +} diff --git a/src/lib/wallpaper.test.ts b/src/lib/wallpaper.test.ts index 02596aaec..7f764e5d6 100644 --- a/src/lib/wallpaper.test.ts +++ b/src/lib/wallpaper.test.ts @@ -121,6 +121,40 @@ describe("classifyWallpaper", () => { path: DEFAULT_WALLPAPER, }); }); + + it("strips a leading url() overlay layer and returns the trailing gradient", () => { + const composite = + 'url("data:image/svg+xml;utf8,") repeat, linear-gradient(135deg, rgb(255,0,0) 0%, rgb(0,0,255) 100%)'; + expect(classifyWallpaper(composite)).toEqual({ + kind: "gradient", + value: "linear-gradient(135deg, rgb(255,0,0) 0%, rgb(0,0,255) 100%)", + }); + }); + + it("handles repeating-gradient variants in composite backgrounds", () => { + const composite = + 'url("data:image/svg+xml;utf8,") repeat, repeating-linear-gradient(45deg, red, blue)'; + expect(classifyWallpaper(composite)).toEqual({ + kind: "gradient", + value: "repeating-linear-gradient(45deg, red, blue)", + }); + }); + + it("tolerates missing or extra whitespace around the comma in composite backgrounds", () => { + const noSpace = + 'url("data:image/svg+xml;utf8,") repeat,linear-gradient(135deg, red, blue)'; + expect(classifyWallpaper(noSpace)).toEqual({ + kind: "gradient", + value: "linear-gradient(135deg, red, blue)", + }); + + const extraSpace = + 'url("data:image/svg+xml;utf8,") repeat, linear-gradient(135deg, red, blue)'; + expect(classifyWallpaper(extraSpace)).toEqual({ + kind: "gradient", + value: "linear-gradient(135deg, red, blue)", + }); + }); }); describe("resolveImageWallpaperUrl", () => { diff --git a/src/lib/wallpaper.ts b/src/lib/wallpaper.ts index 6974a04c9..010f47c2f 100644 --- a/src/lib/wallpaper.ts +++ b/src/lib/wallpaper.ts @@ -23,6 +23,14 @@ export function classifyWallpaper(value: string): WallpaperClassification { if (trimmed === "") { return { kind: "color", value: "#000000" }; } + // Multi-background (e.g. "url(noise), linear-gradient(...)") - peel + // the gradient layer off so the existing parser can still handle it. + // Noise/url overlays are lost on export, but the live preview paints + // the full string via CSS so the overlay shows up there. + if (trimmed.startsWith("url(")) { + const gradient = extractTrailingGradient(trimmed); + if (gradient) return { kind: "gradient", value: gradient }; + } if (trimmed.startsWith("#") || COLOR_FUNC_RE.test(trimmed)) { return { kind: "color", value: trimmed }; } @@ -35,6 +43,11 @@ export function classifyWallpaper(value: string): WallpaperClassification { return { kind: "color", value: trimmed }; } +function extractTrailingGradient(value: string): string | null { + const match = value.match(/,\s*((?:repeating-)?(?:linear|radial|conic)-gradient\(.*\))\s*$/); + return match?.[1] ?? null; +} + const ALLOWED_IMAGE_PREFIX = "/wallpapers/"; export class UnsafeImagePrefixError extends Error {