feat: add PhpIniCodeEditor component with syntax highlighting and search navigation support
This commit is contained in:
@@ -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,69 +13,178 @@ 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 (
|
||||||
<div
|
<Box sx={{ position: 'relative', display: 'flex', flexDirection: 'column', height: '450px', bgcolor: '#1a1a1a' }}>
|
||||||
ref={editorRef}
|
{/* Search Navigation Bar */}
|
||||||
style={{
|
{cleanSearchTerm.length > 1 && (
|
||||||
borderRadius: '4px',
|
<Box sx={{
|
||||||
overflow: 'hidden',
|
position: 'absolute',
|
||||||
border: '1px solid rgba(255,255,255,0.1)',
|
top: 10,
|
||||||
backgroundColor: '#1a1a1a',
|
right: 30, // Leave space for scrollbar/markers
|
||||||
fontFamily: '"Fira code", "Fira Mono", monospace',
|
zIndex: 10,
|
||||||
fontSize: '14px',
|
bgcolor: '#333',
|
||||||
height: '450px',
|
borderRadius: '4px',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
alignItems: 'center',
|
||||||
scrollBehavior: 'smooth'
|
px: 1,
|
||||||
}}
|
py: 0.5,
|
||||||
>
|
boxShadow: '0 4px 12px rgba(0,0,0,0.5)',
|
||||||
<Editor
|
border: '1px solid rgba(255,255,255,0.1)'
|
||||||
key={searchTerm} // Force re-render/re-highlight when searchTerm changes
|
}}>
|
||||||
value={value}
|
<Typography variant="caption" sx={{ color: '#aaa', mr: 1, minWidth: '40px', textAlign: 'center', userSelect: 'none' }}>
|
||||||
onValueChange={code => onChange(code)}
|
{matches.length > 0 ? `${currentIndex + 1} / ${matches.length}` : '0 / 0'}
|
||||||
highlight={highlightWithSearch}
|
</Typography>
|
||||||
padding={15}
|
<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={{
|
style={{
|
||||||
|
flexGrow: 1,
|
||||||
|
overflow: 'auto',
|
||||||
fontFamily: '"Fira code", "Fira Mono", monospace',
|
fontFamily: '"Fira code", "Fira Mono", monospace',
|
||||||
fontSize: 14,
|
fontSize: '14px',
|
||||||
minHeight: '100%',
|
scrollBehavior: 'smooth'
|
||||||
outline: 'none'
|
|
||||||
}}
|
}}
|
||||||
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>{`
|
<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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user