mihirmithani
Phase 4
4ce5238
Raw History Blame
15.6 kB
'use client'
import { useState, useCallback, useRef, useEffect } from 'react'
import { useAppStore } from '@/store'
import { PREDEFINED_KERNELS, KERNEL_CATEGORIES, getKernelsByCategory, normalizeKernel } from '@/lib/kernels'
import type { KernelCategory } from '@/lib/kernels'
import { Button, Card, CardContent, Badge, Input } from '@/components/ui'
import { cn } from '@/utils/helpers'
import { Search, SlidersHorizontal, Plus, Copy, Check, RotateCcw, Sparkles } from 'lucide-react'
// Preset matrices for quick start
const CUSTOM_PRESETS = {
identity: {
name: 'Identity',
matrix: [[0, 0, 0], [0, 1, 0], [0, 0, 0]],
size: 3,
},
edgeDetect: {
name: 'Edge Detect',
matrix: [[-1, -1, -1], [-1, 8, -1], [-1, -1, -1]],
size: 3,
},
blur: {
name: 'Box Blur',
matrix: [[1, 1, 1], [1, 1, 1], [1, 1, 1]],
size: 3,
},
sharpen: {
name: 'Sharpen',
matrix: [[0, -1, 0], [-1, 5, -1], [0, -1, 0]],
size: 3,
},
emboss: {
name: 'Emboss',
matrix: [[-2, -1, 0], [-1, 1, 1], [0, 1, 2]],
size: 3,
},
outline: {
name: 'Outline',
matrix: [[-1, -1, -1], [-1, 8, -1], [-1, -1, -1]],
size: 3,
},
}
export function KernelSelector() {
const { selectedKernel, setSelectedKernel, customKernelMatrix, customKernelSize, customKernelNormalize, setCustomKernel, setCustomKernelSize, setCustomKernelNormalize, updateCustomKernelCell } = useAppStore()
const [activeCategory, setActiveCategory] = useState<KernelCategory>('blur')
const [searchQuery, setSearchQuery] = useState('')
const [showCustom, setShowCustom] = useState(false)
const [copiedPreset, setCopiedPreset] = useState<string | null>(null)
// Handle keyboard navigation in matrix inputs
const matrixInputRefs = useRef<HTMLInputElement[][]>([])
useEffect(() => {
matrixInputRefs.current = Array.from({ length: customKernelSize }, () => Array(customKernelSize).fill(null))
}, [customKernelSize])
const handleKeyDown = useCallback(
(e: React.KeyboardEvent, row: number, col: number) => {
const refs = matrixInputRefs.current
let newRow = row
let newCol = col
switch (e.key) {
case 'ArrowUp':
e.preventDefault()
newRow = Math.max(0, row - 1)
break
case 'ArrowDown':
e.preventDefault()
newRow = Math.min(customKernelSize - 1, row + 1)
break
case 'ArrowLeft':
e.preventDefault()
newCol = Math.max(0, col - 1)
break
case 'ArrowRight':
e.preventDefault()
newCol = Math.min(customKernelSize - 1, col + 1)
break
case 'Tab':
e.preventDefault()
if (e.shiftKey) {
newCol = col === 0 ? (row === 0 ? customKernelSize - 1 : customKernelSize - 1) : col - 1
newRow = col === 0 ? Math.max(0, row - 1) : row
} else {
newCol = col === customKernelSize - 1 ? 0 : col + 1
newRow = col === customKernelSize - 1 ? Math.min(customKernelSize - 1, row + 1) : row
}
break
case 'Enter':
e.preventDefault()
newCol = col === customKernelSize - 1 ? 0 : col + 1
newRow = col === customKernelSize - 1 ? Math.min(customKernelSize - 1, row + 1) : row
break
}
if (refs[newRow]?.[newCol]) {
refs[newRow][newCol]?.focus()
}
},
[customKernelSize]
)
const handleKernelSelect = (kernel: typeof PREDEFINED_KERNELS[0]) => {
setSelectedKernel(kernel)
setShowCustom(false)
}
const handleCustomKernelSubmit = () => {
const kernel = {
id: `custom-${Date.now()}`,
name: `Custom ${customKernelSize}×${customKernelSize}`,
category: 'custom' as KernelCategory,
matrix: customKernelMatrix,
size: customKernelSize,
normalize: customKernelNormalize,
description: 'User-defined custom kernel',
}
setSelectedKernel(kernel)
}
const applyPreset = useCallback((presetKey: keyof typeof CUSTOM_PRESETS) => {
const preset = CUSTOM_PRESETS[presetKey]
if (!preset) return
// Resize matrix if needed
if (preset.size !== customKernelSize) {
setCustomKernelSize(preset.size)
// Wait for state update then apply matrix
setTimeout(() => {
const matrix = preset.matrix.map(row => [...row])
setCustomKernel(matrix, preset.size, customKernelNormalize)
}, 0)
} else {
const matrix = preset.matrix.map(row => [...row])
setCustomKernel(matrix, preset.size, customKernelNormalize)
}
setCopiedPreset(presetKey)
setTimeout(() => setCopiedPreset(null), 2000)
}, [customKernelSize, customKernelNormalize, setCustomKernel, setCustomKernelSize])
const copyMatrix = useCallback(() => {
const matrixStr = customKernelMatrix.map(row => row.join(', ')).join('\n')
navigator.clipboard.writeText(matrixStr)
// Show toast or visual feedback
}, [customKernelMatrix])
const pasteMatrix = useCallback(async () => {
try {
const text = await navigator.clipboard.readText()
const rows = text.trim().split('\n').map(line => line.trim().split(/[,\s]+/).map(Number))
if (rows.length > 0 && rows.every(row => row.length === rows[0].length)) {
const size = rows.length
if (size >= 3 && size <= 9 && size % 2 === 1) {
setCustomKernelSize(size)
setTimeout(() => setCustomKernel(rows, size, customKernelNormalize), 0)
}
}
} catch {
// Clipboard access denied or invalid format
}
}, [customKernelNormalize, setCustomKernel, setCustomKernelSize])
const resetMatrix = useCallback(() => {
const matrix = Array(customKernelSize).fill(0).map(() => Array(customKernelSize).fill(0))
const center = Math.floor(customKernelSize / 2)
matrix[center][center] = 1
setCustomKernel(matrix, customKernelSize, customKernelNormalize)
}, [customKernelSize, customKernelNormalize, setCustomKernel])
const validateMatrix = useCallback(() => {
const sum = customKernelMatrix.flat().reduce((a, b) => a + b, 0)
const hasNonZero = customKernelMatrix.flat().some(v => v !== 0)
return { sum, hasNonZero, isNormalized: customKernelNormalize }
}, [customKernelMatrix, customKernelNormalize])
const filteredKernels = PREDEFINED_KERNELS.filter(
(k) =>
k.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
k.category.toLowerCase().includes(searchQuery.toLowerCase()) ||
k.description.toLowerCase().includes(searchQuery.toLowerCase())
)
const kernelsByCategory = KERNEL_CATEGORIES.map((cat) => ({
...cat,
kernels: getKernelsByCategory(cat.value).filter((k) =>
k.name.toLowerCase().includes(searchQuery.toLowerCase())
),
}))
return (
<Card className="w-full max-w-2xl">
<div className="flex items-center justify-between p-4 border-b dark:border-gray-700">
<h2 className="text-lg font-semibold">Kernel Selector</h2>
<Button variant="ghost" size="icon" onClick={() => setShowCustom(!showCustom)}>
<Plus className="h-4 w-4" />
</Button>
</div>
<CardContent className="p-4">
{/* Search */}
<div className="relative mb-4">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
<input
type="text"
placeholder="Search kernels..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg dark:border-gray-600 dark:bg-gray-800 dark:text-white"
/>
</div>
{/* Category Tabs */}
<div className="flex gap-1 mb-4 overflow-x-auto pb-2">
{KERNEL_CATEGORIES.map((cat) => (
<button
key={cat.value}
onClick={() => setActiveCategory(cat.value)}
className={cn(
'px-3 py-1.5 text-sm rounded-full whitespace-nowrap transition-colors',
activeCategory === cat.value
? 'bg-blue-600 text-white'
: 'text-gray-600 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800'
)}
>
{cat.label}
</button>
))}
</div>
{/* Custom Kernel Editor */}
{showCustom && (
<div className="mb-4 p-4 bg-gray-50 dark:bg-gray-800 rounded-lg border dark:border-gray-700">
<div className="flex items-center justify-between mb-3">
<h3 className="font-medium">Custom Kernel Editor</h3>
<div className="flex items-center gap-2">
<Button variant="ghost" size="sm" onClick={copyMatrix} title="Copy matrix">
<Copy className="h-4 w-4" />
</Button>
<Button variant="ghost" size="sm" onClick={pasteMatrix} title="Paste matrix">
<RotateCcw className="h-4 w-4" />
</Button>
<Button variant="ghost" size="sm" onClick={resetMatrix} title="Reset to identity">
<RotateCcw className="h-4 w-4" />
</Button>
</div>
</div>
<div className="flex items-center gap-4 mb-3 flex-wrap">
<Input
type="number"
label="Size"
value={customKernelSize}
onChange={(e) => setCustomKernelSize(Math.max(3, Math.min(9, parseInt(e.target.value) || 3)))}
min={3}
max={9}
step={2}
className="w-24"
/>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={customKernelNormalize}
onChange={(e) => setCustomKernelNormalize(e.target.checked)}
className="rounded border-gray-300"
/>
Normalize
</label>
<div className="flex items-center gap-1 ml-auto text-xs text-gray-500">
{validateMatrix().hasNonZero ? (
<span className="flex items-center gap-1 text-green-600 dark:text-green-400">
<Check className="h-3 w-3" />
Valid
</span>
) : (
<span className="text-red-600 dark:text-red-400">All zeros</span>
)}
{customKernelNormalize && (
<span className="text-blue-600 dark:text-blue-400">Σ={validateMatrix().sum.toFixed(2)}</span>
)}
</div>
</div>
{/* Preset Buttons */}
<div className="mb-3 p-3 bg-gray-100 dark:bg-gray-700 rounded-lg">
<div className="flex items-center gap-2 mb-2">
<Sparkles className="h-4 w-4 text-gray-500" />
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">Quick Presets:</span>
</div>
<div className="flex flex-wrap gap-2">
{Object.entries(CUSTOM_PRESETS).map(([key, preset]) => (
<Button
key={key}
variant={copiedPreset === key ? 'primary' : 'outline'}
size="sm"
onClick={() => applyPreset(key as keyof typeof CUSTOM_PRESETS)}
className="text-xs"
>
{preset.name}
</Button>
))}
</div>
</div>
{/* Matrix Input Grid */}
<div className="grid gap-1 mb-3" style={{ gridTemplateColumns: `repeat(${customKernelSize}, 1fr)` }}>
{Array.from({ length: customKernelSize }, (_, row) =>
Array.from({ length: customKernelSize }, (_, col) => (
<input
key={`${row}-${col}`}
ref={(el) => { if (el && matrixInputRefs.current[row]) matrixInputRefs.current[row][col] = el }}
type="number"
step="0.1"
value={customKernelMatrix[row]?.[col] ?? 0}
onChange={(e) => updateCustomKernelCell(row, col, parseFloat(e.target.value) || 0)}
onKeyDown={(e) => handleKeyDown(e, row, col)}
className="w-full h-8 text-center text-sm border border-gray-300 rounded dark:border-gray-600 dark:bg-gray-700 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
title={`Row ${row + 1}, Col ${col + 1}`}
/>
))
)}
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={copyMatrix} className="flex-1" size="sm">
<Copy className="h-4 w-4 mr-1" /> Copy
</Button>
<Button variant="outline" onClick={pasteMatrix} className="flex-1" size="sm">
<RotateCcw className="h-4 w-4 mr-1" /> Paste
</Button>
<Button onClick={handleCustomKernelSubmit} className="flex-1">
Apply Custom Kernel
</Button>
</div>
</div>
)}
{/* Kernel List */}
<div className="space-y-3 max-h-96 overflow-y-auto">
{kernelsByCategory
.filter((cat) => cat.kernels.length > 0)
.map((cat) => (
<div key={cat.value} className="space-y-2">
<h4 className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">
{cat.label}
</h4>
<div className="grid gap-2" style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(160px, 1fr))' }}>
{cat.kernels.map((kernel) => (
<button
key={kernel.id}
onClick={() => handleKernelSelect(kernel)}
className={cn(
'p-3 text-left rounded-lg border transition-all',
'hover:border-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/20',
selectedKernel?.id === kernel.id
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-gray-200 dark:border-gray-700'
)}
>
<div className="font-medium text-sm">{kernel.name}</div>
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1 line-clamp-2">
{kernel.description}
</div>
<div className="flex items-center gap-1 mt-2">
<Badge variant="outline" className="text-xs">
{kernel.size}×{kernel.size}
</Badge>
{kernel.normalize && (
<Badge variant="secondary" className="text-xs">
Normalized
</Badge>
)}
</div>
</button>
))}
</div>
</div>
))}
{filteredKernels.length === 0 && (
<div className="text-center py-8 text-gray-500">
No kernels found matching &ldquo;{searchQuery}&rdquo;
</div>
)}
</div>
</CardContent>
</Card>
)
}