From 68a84cd395d3d1a32ca4dd6fb5a9ccc848c6604c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=9Cmit=20Tun=C3=A7?= Date: Mon, 30 Mar 2026 17:19:31 +0300 Subject: [PATCH] feat: add PhpIniCodeEditor component with syntax highlighting and search navigation support --- .../src/components/PhpIniCodeEditor.tsx | 219 +++++++++++++----- 1 file changed, 167 insertions(+), 52 deletions(-) diff --git a/src/renderer/src/components/PhpIniCodeEditor.tsx b/src/renderer/src/components/PhpIniCodeEditor.tsx index 8ca776f..4c60a25 100644 --- a/src/renderer/src/components/PhpIniCodeEditor.tsx +++ b/src/renderer/src/components/PhpIniCodeEditor.tsx @@ -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 = ({ value, onChange, searchTerm }) => { - const editorRef = React.useRef(null) + const editorRef = useRef(null) + const [matches, setMatches] = useState([]) + 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 - // 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, '$1') + let matchCounter = 0 + + highlighted = highlighted.replace(regex, (match) => { + const isCurrent = matchCounter === currentIndex + matchCounter++ + return `${match}` + }) } 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 ( -
- onChange(code)} - highlight={highlightWithSearch} - padding={15} + + {/* Search Navigation Bar */} + {cleanSearchTerm.length > 1 && ( + + + {matches.length > 0 ? `${currentIndex + 1} / ${matches.length}` : '0 / 0'} + + + + + + + + + + + + + )} + + {/* Scrollbar Markers (Minimap style) */} + + {matches.map((pos, i) => { + const line = value.substring(0, pos).split('\n').length + const topPercent = (line / totalLines) * 100 + return ( + + ) + })} + + +
+ > + 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" + /> +
-
+ ) }