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
+147 -32
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 Editor from 'react-simple-code-editor'
import Prism from 'prismjs' import Prism from 'prismjs'
import 'prismjs/components/prism-ini' import 'prismjs/components/prism-ini'
import 'prismjs/themes/prism-tomorrow.css' 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 { interface PhpIniCodeEditorProps {
value: string value: string
@@ -11,56 +13,164 @@ interface PhpIniCodeEditorProps {
} }
const PhpIniCodeEditor: React.FC<PhpIniCodeEditorProps> = ({ value, onChange, searchTerm }) => { 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) => { const highlightWithSearch = (code: string) => {
// 1. Get Prism highlighted HTML
let highlighted = Prism.highlight(code, Prism.languages.ini, 'ini') let highlighted = Prism.highlight(code, Prism.languages.ini, 'ini')
// 2. If search term exists, wrap it in <mark> if (cleanSearchTerm.length > 1) {
// Use a regex that avoids matching inside existing HTML tags const escapedSearch = cleanSearchTerm.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
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 <)
const regex = new RegExp(`(${escapedSearch})(?![^<]*>)`, 'gi') 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 return highlighted
} }
// Scroll to first match // Keyboard support
React.useEffect(() => { const handleKeyDown = (e: React.KeyboardEvent) => {
if (searchTerm && searchTerm.trim().length > 1 && editorRef.current) { if (e.key === 'Enter' && cleanSearchTerm.length > 1) {
const index = value.toLowerCase().indexOf(searchTerm.toLowerCase().trim()) e.preventDefault()
if (index !== -1) { if (e.shiftKey) handlePrev()
const container = editorRef.current else handleNext()
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
} }
} }
}, [searchTerm])
const totalLines = useMemo(() => value.split('\n').length, [value])
return ( return (
<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 <div
ref={editorRef} ref={editorRef}
onKeyDown={handleKeyDown}
style={{ style={{
borderRadius: '4px', flexGrow: 1,
overflow: 'hidden', overflow: 'auto',
border: '1px solid rgba(255,255,255,0.1)',
backgroundColor: '#1a1a1a',
fontFamily: '"Fira code", "Fira Mono", monospace', fontFamily: '"Fira code", "Fira Mono", monospace',
fontSize: '14px', fontSize: '14px',
height: '450px',
display: 'flex',
flexDirection: 'column',
scrollBehavior: 'smooth' scrollBehavior: 'smooth'
}} }}
> >
<Editor <Editor
key={searchTerm} // Force re-render/re-highlight when searchTerm changes key={cleanSearchTerm + currentIndex} // Key for re-highlighting active match
value={value} value={value}
onValueChange={code => onChange(code)} onValueChange={code => onChange(code)}
highlight={highlightWithSearch} highlight={highlightWithSearch}
@@ -74,6 +184,7 @@ const PhpIniCodeEditor: React.FC<PhpIniCodeEditorProps> = ({ value, onChange, se
textareaClassName="ini-editor-textarea" textareaClassName="ini-editor-textarea"
preClassName="ini-editor-pre" preClassName="ini-editor-pre"
/> />
</div>
<style>{` <style>{`
.ini-editor-textarea { .ini-editor-textarea {
outline: none !important; outline: none !important;
@@ -89,14 +200,18 @@ const PhpIniCodeEditor: React.FC<PhpIniCodeEditorProps> = ({ value, onChange, se
.token.punctuation { color: #d4d4d4; } .token.punctuation { color: #d4d4d4; }
.token.important { color: #569cd6; } .token.important { color: #569cd6; }
.search-match { .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; color: #000 !important;
border-radius: 2px; box-shadow: 0 0 8px rgba(255, 152, 0, 0.8);
box-shadow: 0 0 8px rgba(255, 207, 0, 0.6);
font-weight: bold; font-weight: bold;
} }
`}</style> `}</style>
</div> </Box>
) )
} }