Skip to content

Commit 3997492

Browse files
authored
Merge pull request #2 from donny-son/claude/custom-pattern-designer-kLa4n
2 parents 2faf959 + cf6de0a commit 3997492

4 files changed

Lines changed: 323 additions & 5 deletions

File tree

src/App.tsx

Lines changed: 149 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import {
55
Aperture,
66
ChevronDown,
77
Download,
8+
Eraser,
9+
Grid3x3,
810
GripVertical,
911
Image as ImageIcon,
1012
Layers3,
@@ -19,7 +21,13 @@ import {
1921
Trash2,
2022
Upload,
2123
} from 'lucide-react';
22-
import { GRADIENT_PRESETS, PRESET_GROUPS } from './lib/gradients';
24+
import {
25+
GRADIENT_PRESETS,
26+
PATTERN_CELL_COUNT,
27+
PATTERN_GRID_SIZE,
28+
PRESET_GROUPS,
29+
buildDefaultPattern,
30+
} from './lib/gradients';
2331
import type { GradientType } from './lib/gradients';
2432
import { WallpaperCanvas } from './components/WallpaperCanvas';
2533

@@ -38,6 +46,7 @@ const GRADIENT_TYPES: Array<{ type: GradientType; label: string; icon: typeof La
3846
{ type: 'radial', label: 'Radial', icon: Aperture },
3947
{ type: 'conic', label: 'Conic', icon: Sparkles },
4048
{ type: 'glow', label: 'Glow', icon: Sunrise },
49+
{ type: 'custom', label: 'Custom', icon: Grid3x3 },
4150
];
4251

4352
// A collapsible control group — a notebook page you fold open or shut.
@@ -82,6 +91,11 @@ export default function App() {
8291
const [overIndex, setOverIndex] = useState<number | null>(null);
8392
const [isProcessing, setIsProcessing] = useState(false);
8493
const [openSections, setOpenSections] = useState<string[]>(['presets', 'palette']);
94+
const [pattern, setPattern] = useState<number[]>(() =>
95+
buildDefaultPattern(GRADIENT_PRESETS[0].colors.length),
96+
);
97+
const [brushIndex, setBrushIndex] = useState(0);
98+
const [isPainting, setIsPainting] = useState(false);
8599
const fileInputRef = useRef<HTMLInputElement>(null);
86100
const canvasRef = useRef<HTMLCanvasElement>(null);
87101
const selectedDevice = DEVICE_PRESETS[device];
@@ -96,6 +110,9 @@ export default function App() {
96110
const applyPalette = (next: string[]) => {
97111
setColors(next);
98112
setWeights(next.map(() => DEFAULT_WEIGHT));
113+
const lastIndex = Math.max(0, next.length - 1);
114+
setPattern((prev) => prev.map((i) => Math.min(i, lastIndex)));
115+
setBrushIndex((current) => Math.min(current, lastIndex));
99116
};
100117

101118
// Reorder a color (and its girth) via drag-and-drop.
@@ -115,8 +132,58 @@ export default function App() {
115132
if (colors.length <= 1) return;
116133
setColors(colors.filter((_, i) => i !== index));
117134
setWeights(weights.filter((_, i) => i !== index));
135+
// Shift painted indices down; cells using the deleted color fall back to 0.
136+
setPattern((prev) =>
137+
prev.map((cellIndex) => {
138+
if (cellIndex === index) return 0;
139+
if (cellIndex > index) return cellIndex - 1;
140+
return cellIndex;
141+
}),
142+
);
143+
setBrushIndex((current) => {
144+
if (current === index) return 0;
145+
if (current > index) return current - 1;
146+
return current;
147+
});
148+
};
149+
150+
// Find the cell beneath the pointer and stamp the active brush color into it.
151+
const paintCellAtPoint = (clientX: number, clientY: number) => {
152+
const target = document.elementFromPoint(clientX, clientY);
153+
const cell = target?.closest<HTMLElement>('[data-pattern-cell]');
154+
if (!cell) return;
155+
const parsed = Number(cell.dataset.patternCell);
156+
if (Number.isNaN(parsed)) return;
157+
setPattern((prev) => {
158+
if (prev[parsed] === brushIndex) return prev;
159+
const next = [...prev];
160+
next[parsed] = brushIndex;
161+
return next;
162+
});
163+
};
164+
165+
const handlePatternPointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {
166+
event.currentTarget.setPointerCapture(event.pointerId);
167+
setIsPainting(true);
168+
paintCellAtPoint(event.clientX, event.clientY);
169+
};
170+
171+
const handlePatternPointerMove = (event: ReactPointerEvent<HTMLDivElement>) => {
172+
if (!isPainting) return;
173+
paintCellAtPoint(event.clientX, event.clientY);
118174
};
119175

176+
const handlePatternPointerUp = () => {
177+
setIsPainting(false);
178+
};
179+
180+
const fillPattern = (colorIndex: number) => {
181+
const safe = Math.max(0, Math.min(colors.length - 1, colorIndex));
182+
setPattern(Array.from({ length: PATTERN_CELL_COUNT }, () => safe));
183+
};
184+
185+
const resetPattern = () => setPattern(buildDefaultPattern(colors.length));
186+
120187
// Pointer-based reorder: works for mouse, pen, and touch. setPointerCapture
121188
// keeps subsequent move/up events on the handle even as the finger drifts
122189
// across other rows; document.elementFromPoint tells us which row sits
@@ -266,6 +333,11 @@ export default function App() {
266333
// Vertical orientation makes the glow read as a
267334
// horizontal "horizon" band.
268335
if (gradientType === 'glow') setAngle(90);
336+
if (gradientType === 'custom') {
337+
setOpenSections((prev) =>
338+
prev.includes('pattern') ? prev : [...prev, 'pattern'],
339+
);
340+
}
269341
}}
270342
className={`segment-cell ${type === gradientType ? 'segment-cell-active' : ''}`}
271343
>
@@ -274,7 +346,7 @@ export default function App() {
274346
</button>
275347
))}
276348
</div>
277-
{type !== 'radial' && (
349+
{type !== 'radial' && type !== 'custom' && (
278350
<div className="field">
279351
<span className="field-label">Angle</span>
280352
<div className="control-cell-range">
@@ -292,6 +364,80 @@ export default function App() {
292364
)}
293365
</Section>
294366

367+
{type === 'custom' && (
368+
<Section
369+
id="pattern"
370+
icon={Grid3x3}
371+
title="Pattern"
372+
open={openSections.includes('pattern')}
373+
onToggle={toggleSection}
374+
>
375+
<div className="field">
376+
<span className="field-label">Brush</span>
377+
<div className="pattern-brush-row">
378+
{colors.map((color, index) => (
379+
<button
380+
key={`brush-${index}`}
381+
type="button"
382+
onClick={() => setBrushIndex(index)}
383+
className={`pattern-brush${brushIndex === index ? ' pattern-brush-active' : ''}`}
384+
style={{ backgroundColor: color }}
385+
aria-label={`Paint with color ${index + 1}`}
386+
aria-pressed={brushIndex === index}
387+
/>
388+
))}
389+
</div>
390+
</div>
391+
392+
<div className="field">
393+
<span className="field-label">Grid</span>
394+
<div
395+
className="pattern-grid"
396+
onPointerDown={handlePatternPointerDown}
397+
onPointerMove={handlePatternPointerMove}
398+
onPointerUp={handlePatternPointerUp}
399+
onPointerCancel={handlePatternPointerUp}
400+
>
401+
{pattern.map((colorIndex, cellIndex) => {
402+
const swatch = colors[colorIndex] ?? colors[0] ?? '#ffffff';
403+
return (
404+
<button
405+
key={cellIndex}
406+
type="button"
407+
data-pattern-cell={cellIndex}
408+
className="pattern-cell"
409+
style={{ backgroundColor: swatch }}
410+
aria-label={`Pattern cell ${cellIndex + 1}, row ${
411+
Math.floor(cellIndex / PATTERN_GRID_SIZE) + 1
412+
}`}
413+
/>
414+
);
415+
})}
416+
</div>
417+
<p className="pattern-hint">
418+
Click or drag — touch and drag works too. Each cell blends into its neighbours.
419+
</p>
420+
</div>
421+
422+
<div className="pattern-actions">
423+
<button
424+
type="button"
425+
onClick={() => fillPattern(brushIndex)}
426+
className="pattern-action"
427+
>
428+
<Palette size={14} /> Fill with brush
429+
</button>
430+
<button
431+
type="button"
432+
onClick={resetPattern}
433+
className="pattern-action"
434+
>
435+
<Eraser size={14} /> Reset bands
436+
</button>
437+
</div>
438+
</Section>
439+
)}
440+
295441
<Section
296442
id="palette"
297443
icon={Palette}
@@ -446,6 +592,7 @@ export default function App() {
446592
grainScale={grainScale}
447593
bandWidth={bandWidth}
448594
weights={weights}
595+
pattern={pattern}
449596
/>
450597

451598
<button type="button" onClick={downloadWallpaper} className="download-btn">

src/components/WallpaperCanvas.tsx

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react';
22
import type { GradientType } from '../lib/gradients';
3-
import { hexToRgb, rgbToCss, samplePalette } from '../lib/gradients';
3+
import { PATTERN_GRID_SIZE, hexToRgb, rgbToCss, samplePalette } from '../lib/gradients';
44

55
// Number of intermediate color stops sampled along the gradient. More stops =
66
// smoother, more "filmic" transitions instead of hard linear blends.
@@ -16,6 +16,7 @@ interface WallpaperCanvasProps {
1616
grainScale: number;
1717
bandWidth: number;
1818
weights: number[];
19+
pattern: number[];
1920
}
2021

2122
const getColorStop = (index: number, count: number) => {
@@ -71,6 +72,45 @@ const applyGrain = (
7172
ctx.restore();
7273
};
7374

75+
// Render a painted 6x6 pattern by drawing each cell into a tiny offscreen
76+
// canvas, then upscaling with the browser's bilinear filter. Cells naturally
77+
// blend into a smooth, multi-directional gradient — and the small canvas
78+
// makes the whole thing cheap regardless of the final 4K size.
79+
const renderCustomPattern = (
80+
ctx: CanvasRenderingContext2D,
81+
width: number,
82+
height: number,
83+
pattern: number[],
84+
palette: string[],
85+
) => {
86+
const fallback = palette[0] ?? '#0f172a';
87+
const source = document.createElement('canvas');
88+
source.width = PATTERN_GRID_SIZE;
89+
source.height = PATTERN_GRID_SIZE;
90+
const sourceCtx = source.getContext('2d');
91+
if (!sourceCtx) return;
92+
93+
for (let y = 0; y < PATTERN_GRID_SIZE; y += 1) {
94+
for (let x = 0; x < PATTERN_GRID_SIZE; x += 1) {
95+
const cellIndex = y * PATTERN_GRID_SIZE + x;
96+
const colorIndex = pattern[cellIndex] ?? 0;
97+
sourceCtx.fillStyle = palette[colorIndex] ?? fallback;
98+
sourceCtx.fillRect(x, y, 1, 1);
99+
}
100+
}
101+
102+
ctx.imageSmoothingEnabled = true;
103+
ctx.imageSmoothingQuality = 'high';
104+
// Inset by half a pixel so the *centers* of the source cells land at the
105+
// corners of the target — otherwise the outermost half-cell stretches flat
106+
// along each edge and the gradient loses its painted shape.
107+
const sx = -width / (PATTERN_GRID_SIZE * 2 - 2);
108+
const sy = -height / (PATTERN_GRID_SIZE * 2 - 2);
109+
const sw = width - sx * 2;
110+
const sh = height - sy * 2;
111+
ctx.drawImage(source, sx, sy, sw, sh);
112+
};
113+
74114
export const WallpaperCanvas = forwardRef<HTMLCanvasElement, WallpaperCanvasProps>(({
75115
colors,
76116
type,
@@ -81,6 +121,7 @@ export const WallpaperCanvas = forwardRef<HTMLCanvasElement, WallpaperCanvasProp
81121
grainScale,
82122
bandWidth,
83123
weights,
124+
pattern,
84125
}, ref) => {
85126
const canvasRef = useRef<HTMLCanvasElement>(null);
86127

@@ -99,6 +140,12 @@ export const WallpaperCanvas = forwardRef<HTMLCanvasElement, WallpaperCanvasProp
99140

100141
const palette = colors.length > 0 ? colors : ['#0f172a'];
101142

143+
if (type === 'custom') {
144+
renderCustomPattern(ctx, width, height, pattern, palette);
145+
applyGrain(ctx, width, height, grainScale);
146+
return;
147+
}
148+
102149
if (palette.length === 1) {
103150
ctx.fillStyle = palette[0];
104151
ctx.fillRect(0, 0, width, height);
@@ -145,7 +192,7 @@ export const WallpaperCanvas = forwardRef<HTMLCanvasElement, WallpaperCanvasProp
145192
ctx.fillStyle = gradient;
146193
ctx.fillRect(0, 0, width, height);
147194
applyGrain(ctx, width, height, grainScale);
148-
}, [colors, type, angle, width, height, grainScale, bandWidth, weights]);
195+
}, [colors, type, angle, width, height, grainScale, bandWidth, weights, pattern]);
149196

150197
return (
151198
<div className={`device-frame ${device === 'phone' ? 'device-frame-phone' : 'device-frame-desktop'}`}>

0 commit comments

Comments
 (0)