feat: add PhpIniCodeEditor component with syntax highlighting and search navigation support

This commit is contained in:
Ümit Tunç
2026-03-30 17:19:31 +03:00
parent d0a48e762f
commit 68a84cd395
+167 -52
View File
@@ -1,8 +1,10 @@
import React from 'react'
import React, { useState, useEffect, useRef, useMemo } from 'react'
import Editor from 'react-simple-code-editor'
import Prism from 'prismjs'
import 'prismjs/components/prism-ini'
import 'prismjs/themes/prism-tomorrow.css'
import { IconButton, Box, Typography, Tooltip } from '@mui/material'
import { KeyboardArrowDown as DownIcon, KeyboardArrowUp as UpIcon } from '@mui/icons-material'
interface PhpIniCodeEditorProps {
value: string
@@ -11,69 +13,178 @@ interface PhpIniCodeEditorProps {
}
const PhpIniCodeEditor: React.FC<PhpIniCodeEditorProps> = ({ value, onChange, searchTerm }) => {
const editorRef = React.useRef<HTMLDivElement>(null)
const editorRef = useRef<HTMLDivElement>(null)
const [matches, setMatches] = useState<number[]>([])
const [currentIndex, setCurrentIndex] = useState(0)
const cleanSearchTerm = useMemo(() => searchTerm?.trim().toLowerCase() || '', [searchTerm])
// Find all matches when searchTerm or value changes
useEffect(() => {
if (cleanSearchTerm.length > 1) {
const indices: number[] = []
let pos = value.toLowerCase().indexOf(cleanSearchTerm)
while (pos !== -1) {
indices.push(pos)
pos = value.toLowerCase().indexOf(cleanSearchTerm, pos + 1)
}
setMatches(indices)
setCurrentIndex(indices.length > 0 ? 0 : -1)
} else {
setMatches([])
setCurrentIndex(-1)
}
}, [cleanSearchTerm, value])
const scrollToMatch = (index: number) => {
if (index >= 0 && index < matches.length && editorRef.current) {
const startPos = matches[index]
const linesBefore = value.substring(0, startPos).split('\n').length
const lineHeight = 21
const container = editorRef.current
container.scrollTop = (linesBefore - 5) * lineHeight
}
}
useEffect(() => {
if (currentIndex !== -1) {
scrollToMatch(currentIndex)
}
}, [currentIndex])
const handleNext = () => {
if (matches.length > 0) {
setCurrentIndex(prev => (prev + 1) % matches.length)
}
}
const handlePrev = () => {
if (matches.length > 0) {
setCurrentIndex(prev => (prev - 1 + matches.length) % matches.length)
}
}
const highlightWithSearch = (code: string) => {
// 1. Get Prism highlighted HTML
let highlighted = Prism.highlight(code, Prism.languages.ini, 'ini')
// 2. If search term exists, wrap it in <mark>
// Use a regex that avoids matching inside existing HTML tags
if (searchTerm && searchTerm.trim().length > 1) {
const escapedSearch = searchTerm.trim().replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
// This regex matches the search term only if it's not inside a tag (negative lookahead for > before <)
if (cleanSearchTerm.length > 1) {
const escapedSearch = cleanSearchTerm.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const regex = new RegExp(`(${escapedSearch})(?![^<]*>)`, 'gi')
highlighted = highlighted.replace(regex, '<mark class="search-match">$1</mark>')
let matchCounter = 0
highlighted = highlighted.replace(regex, (match) => {
const isCurrent = matchCounter === currentIndex
matchCounter++
return `<mark class="search-match ${isCurrent ? 'active-match' : ''}">${match}</mark>`
})
}
return highlighted
}
// Scroll to first match
React.useEffect(() => {
if (searchTerm && searchTerm.trim().length > 1 && editorRef.current) {
const index = value.toLowerCase().indexOf(searchTerm.toLowerCase().trim())
if (index !== -1) {
const container = editorRef.current
const linesBefore = value.substring(0, index).split('\n').length
const lineHeight = 21 // Approx for 14px font
// Scroll the container, not the textarea
container.scrollTop = (linesBefore - 3) * lineHeight
}
// Keyboard support
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && cleanSearchTerm.length > 1) {
e.preventDefault()
if (e.shiftKey) handlePrev()
else handleNext()
}
}, [searchTerm])
}
const totalLines = useMemo(() => value.split('\n').length, [value])
return (
<div
ref={editorRef}
style={{
borderRadius: '4px',
overflow: 'hidden',
border: '1px solid rgba(255,255,255,0.1)',
backgroundColor: '#1a1a1a',
fontFamily: '"Fira code", "Fira Mono", monospace',
fontSize: '14px',
height: '450px',
display: 'flex',
flexDirection: 'column',
scrollBehavior: 'smooth'
}}
>
<Editor
key={searchTerm} // Force re-render/re-highlight when searchTerm changes
value={value}
onValueChange={code => onChange(code)}
highlight={highlightWithSearch}
padding={15}
<Box sx={{ position: 'relative', display: 'flex', flexDirection: 'column', height: '450px', bgcolor: '#1a1a1a' }}>
{/* Search Navigation Bar */}
{cleanSearchTerm.length > 1 && (
<Box sx={{
position: 'absolute',
top: 10,
right: 30, // Leave space for scrollbar/markers
zIndex: 10,
bgcolor: '#333',
borderRadius: '4px',
display: 'flex',
alignItems: 'center',
px: 1,
py: 0.5,
boxShadow: '0 4px 12px rgba(0,0,0,0.5)',
border: '1px solid rgba(255,255,255,0.1)'
}}>
<Typography variant="caption" sx={{ color: '#aaa', mr: 1, minWidth: '40px', textAlign: 'center', userSelect: 'none' }}>
{matches.length > 0 ? `${currentIndex + 1} / ${matches.length}` : '0 / 0'}
</Typography>
<Tooltip title="Önceki (Shift+Enter)">
<IconButton size="small" onClick={handlePrev} sx={{ color: '#fff' }}>
<UpIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Sonraki (Enter)">
<IconButton size="small" onClick={handleNext} sx={{ color: '#fff' }}>
<DownIcon fontSize="small" />
</IconButton>
</Tooltip>
</Box>
)}
{/* Scrollbar Markers (Minimap style) */}
<Box sx={{
position: 'absolute',
top: 0,
right: 0,
width: '12px',
height: '100%',
zIndex: 5,
pointerEvents: 'none',
bgcolor: 'rgba(255,255,255,0.02)'
}}>
{matches.map((pos, i) => {
const line = value.substring(0, pos).split('\n').length
const topPercent = (line / totalLines) * 100
return (
<Box
key={i}
sx={{
position: 'absolute',
top: `${topPercent}%`,
left: 2,
width: '8px',
height: '2px',
bgcolor: i === currentIndex ? '#ff9800' : '#ffcf00',
boxShadow: i === currentIndex ? '0 0 4px #ff9800' : 'none'
}}
/>
)
})}
</Box>
<div
ref={editorRef}
onKeyDown={handleKeyDown}
style={{
flexGrow: 1,
overflow: 'auto',
fontFamily: '"Fira code", "Fira Mono", monospace',
fontSize: 14,
minHeight: '100%',
outline: 'none'
fontSize: '14px',
scrollBehavior: 'smooth'
}}
textareaClassName="ini-editor-textarea"
preClassName="ini-editor-pre"
/>
>
<Editor
key={cleanSearchTerm + currentIndex} // Key for re-highlighting active match
value={value}
onValueChange={code => onChange(code)}
highlight={highlightWithSearch}
padding={15}
style={{
fontFamily: '"Fira code", "Fira Mono", monospace',
fontSize: 14,
minHeight: '100%',
outline: 'none'
}}
textareaClassName="ini-editor-textarea"
preClassName="ini-editor-pre"
/>
</div>
<style>{`
.ini-editor-textarea {
outline: none !important;
@@ -89,14 +200,18 @@ const PhpIniCodeEditor: React.FC<PhpIniCodeEditorProps> = ({ value, onChange, se
.token.punctuation { color: #d4d4d4; }
.token.important { color: #569cd6; }
.search-match {
background: #ffcf00 !important;
background: rgba(255, 207, 0, 0.3) !important;
color: inherit !important;
border-radius: 1px;
}
.active-match {
background: #ff9800 !important;
color: #000 !important;
border-radius: 2px;
box-shadow: 0 0 8px rgba(255, 207, 0, 0.6);
box-shadow: 0 0 8px rgba(255, 152, 0, 0.8);
font-weight: bold;
}
`}</style>
</div>
</Box>
)
}