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' ;
2331import type { GradientType } from './lib/gradients' ;
2432import { 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" >
0 commit comments