diff --git a/package.json b/package.json
index 9422da1..a4f0ae0 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "marklite",
- "version": "0.3.0",
+ "version": "0.3.1",
"description": "Lightweight Markdown Editor for Windows",
"main": "./dist/main/index.js",
"scripts": {
@@ -19,7 +19,7 @@
"lint-staged": {
"*.{ts,tsx}": [
"eslint --fix",
- "tsc --noEmit --pretty"
+ "bash -c 'tsc --noEmit --pretty --skipLibCheck'"
],
"*.{json,css,md}": [
"prettier --write"
diff --git a/src/renderer/components/AboutDialog/AboutDialog.tsx b/src/renderer/components/AboutDialog/AboutDialog.tsx
index 9e99fd4..22ebdf0 100644
--- a/src/renderer/components/AboutDialog/AboutDialog.tsx
+++ b/src/renderer/components/AboutDialog/AboutDialog.tsx
@@ -1,7 +1,7 @@
import React from 'react'
import { AppIcon, Gitee } from '../Icons'
-const APP_VERSION = 'v0.2.0'
+const APP_VERSION = 'v0.3.1'
interface AboutDialogProps {
onClose: () => void
diff --git a/src/renderer/components/Editor/Editor.tsx b/src/renderer/components/Editor/Editor.tsx
index de448fe..bd59e12 100644
--- a/src/renderer/components/Editor/Editor.tsx
+++ b/src/renderer/components/Editor/Editor.tsx
@@ -1,9 +1,10 @@
-import React, { useEffect, useCallback } from 'react'
+import React, { useEffect, useCallback, useState } from 'react'
import { callCommand } from '@milkdown/utils'
import { toggleStrongCommand, toggleEmphasisCommand } from '@milkdown/preset-commonmark'
import { useTabStore } from '../../stores/tabStore'
import { useMilkdown } from './useMilkdown'
import { EditorToolbar } from './EditorToolbar'
+import { SearchReplace } from '../SearchReplace'
interface EditorProps {
darkMode: boolean
@@ -15,6 +16,7 @@ export function Editor({ darkMode }: EditorProps) {
const updateTabContent = useTabStore(s => s.updateTabContent)
const setModified = useTabStore(s => s.setModified)
const updateTabScroll = useTabStore(s => s.updateTabScroll)
+ const [showSearch, setShowSearch] = useState(false)
const {
containerRef,
@@ -58,7 +60,7 @@ export function Editor({ darkMode }: EditorProps) {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeTabId])
- // Ctrl+B bold, Ctrl+I italic
+ // Ctrl+B bold, Ctrl+I italic, Ctrl+F search, Ctrl+H replace
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
const isCtrl = e.ctrlKey || e.metaKey
@@ -74,14 +76,29 @@ export function Editor({ darkMode }: EditorProps) {
editor.action(callCommand(toggleEmphasisCommand.key))
})
}
+ if (isCtrl && (e.key === 'f' || e.key === 'h')) {
+ e.preventDefault()
+ setShowSearch(true)
+ }
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [action])
+ // 获取编辑器容器 DOM(用于搜索高亮)
+ const getEditorContainer = useCallback((): HTMLDivElement | null => {
+ return containerRef.current
+ }, [containerRef])
+
return (
+ {showSearch && (
+
setShowSearch(false)}
+ />
+ )}
)
diff --git a/src/renderer/components/SearchReplace/SearchReplace.tsx b/src/renderer/components/SearchReplace/SearchReplace.tsx
new file mode 100644
index 0000000..4b93d76
--- /dev/null
+++ b/src/renderer/components/SearchReplace/SearchReplace.tsx
@@ -0,0 +1,375 @@
+import React, { useState, useCallback, useRef, useEffect, memo } from 'react'
+
+interface SearchReplaceProps {
+ /** 获取 Milkdown 编辑器容器 DOM(用于遍历文本节点) */
+ editorContainer: HTMLDivElement | null
+ /** 关闭面板 */
+ onClose: () => void
+}
+
+const HIGHLIGHT_CLASS = 'search-match-highlight'
+const ACTIVE_HIGHLIGHT_CLASS = 'search-match-active'
+
+/**
+ * 清除所有搜索高亮
+ */
+function clearHighlights(root: HTMLElement) {
+ const highlighted = root.querySelectorAll(`.${HIGHLIGHT_CLASS}, .${ACTIVE_HIGHLIGHT_CLASS}`)
+ highlighted.forEach(el => {
+ const parent = el.parentNode
+ if (!parent) return
+ while (el.firstChild) parent.insertBefore(el.firstChild, el)
+ parent.removeChild(el)
+ })
+ // 合并相邻文本节点
+ root.normalize()
+}
+
+/**
+ * 高亮所有匹配项
+ * 返回高亮后的元素列表(用于导航)
+ */
+function highlightMatches(root: HTMLElement, query: string, caseSensitive: boolean): HTMLElement[] {
+ clearHighlights(root)
+ if (!query) return []
+
+ const matchElements: HTMLElement[] = []
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null)
+ const normalizedQuery = caseSensitive ? query : query.toLowerCase()
+
+ const textNodes: Text[] = []
+ let node: Node | null
+ while ((node = walker.nextNode())) {
+ textNodes.push(node as Text)
+ }
+
+ // 从后往前处理,避免偏移量变化
+ for (let i = textNodes.length - 1; i >= 0; i--) {
+ const textNode = textNodes[i]
+ const text = textNode.textContent || ''
+ const searchText = caseSensitive ? text : text.toLowerCase()
+
+ // 收集该文本节点中的所有匹配位置
+ const positions: number[] = []
+ let offset = 0
+ let idx: number
+ while ((idx = searchText.indexOf(normalizedQuery, offset)) !== -1) {
+ positions.push(idx)
+ offset = idx + 1
+ }
+
+ if (positions.length === 0) continue
+
+ // 从后往前拆分文本节点并插入高亮
+ const parent = textNode.parentNode
+ if (!parent) continue
+
+ for (let j = positions.length - 1; j >= 0; j--) {
+ const pos = positions[j]
+ const afterText = text.substring(pos + query.length)
+ const matchText = text.substring(pos, pos + query.length)
+ const beforeText = text.substring(0, pos)
+
+ // 移除原始文本节点
+ if (j === positions.length - 1) {
+ parent.removeChild(textNode)
+ }
+
+ // 插入后续文本
+ if (afterText) {
+ parent.insertBefore(document.createTextNode(afterText), parent.children[0] || null)
+ }
+
+ // 插入高亮标记
+ const mark = document.createElement('mark')
+ mark.className = HIGHLIGHT_CLASS
+ mark.textContent = matchText
+ mark.setAttribute('data-match-index', String(matchElements.length))
+ parent.insertBefore(mark, parent.children[0] || null)
+ matchElements.unshift(mark)
+
+ // 插入前面文本(仅第一个匹配时)
+ if (j === 0 && beforeText) {
+ parent.insertBefore(document.createTextNode(beforeText), parent.children[0] || null)
+ }
+ }
+ }
+
+ return matchElements
+}
+
+/**
+ * SearchReplace — 编辑器内搜索/替换面板
+ *
+ * 功能:
+ * - Ctrl+F 打开搜索,Ctrl+H 打开替换
+ * - 大小写敏感切换
+ * - Enter 下一个,Shift+Enter 上一个
+ * - 替换当前匹配 / 全部替换
+ * - ESC 关闭面板
+ */
+export const SearchReplace = memo(function SearchReplace({
+ editorContainer,
+ onClose
+}: SearchReplaceProps) {
+ const [query, setQuery] = useState('')
+ const [replacement, setReplacement] = useState('')
+ const [showReplace, setShowReplace] = useState(false)
+ const [caseSensitive, setCaseSensitive] = useState(false)
+ const [matchCount, setMatchCount] = useState(0)
+ const [currentMatch, setCurrentMatch] = useState(-1)
+
+ const searchInputRef = useRef(null)
+ const matchElementsRef = useRef([])
+ const panelRef = useRef(null)
+
+ // 执行搜索
+ const doSearch = useCallback((searchQuery: string, cs: boolean) => {
+ if (!editorContainer) return
+
+ if (!searchQuery.trim()) {
+ clearHighlights(editorContainer)
+ matchElementsRef.current = []
+ setMatchCount(0)
+ setCurrentMatch(-1)
+ return
+ }
+
+ const elements = highlightMatches(editorContainer, searchQuery, cs)
+ matchElementsRef.current = elements
+ setMatchCount(elements.length)
+
+ if (elements.length > 0) {
+ setCurrentMatch(0)
+ elements[0].classList.add(ACTIVE_HIGHLIGHT_CLASS)
+ elements[0].scrollIntoView({ behavior: 'smooth', block: 'center' })
+ } else {
+ setCurrentMatch(-1)
+ }
+ }, [editorContainer])
+
+ // 搜索框输入变化
+ const handleQueryChange = useCallback((e: React.ChangeEvent) => {
+ const value = e.target.value
+ setQuery(value)
+ doSearch(value, caseSensitive)
+ }, [doSearch, caseSensitive])
+
+ // 大小写切换
+ const toggleCaseSensitive = useCallback(() => {
+ const newCS = !caseSensitive
+ setCaseSensitive(newCS)
+ doSearch(query, newCS)
+ }, [caseSensitive, query, doSearch])
+
+ // 导航到下一个/上一个匹配
+ const navigateMatch = useCallback((direction: 1 | -1) => {
+ const elements = matchElementsRef.current
+ if (elements.length === 0) return
+
+ // 移除当前高亮
+ if (currentMatch >= 0 && currentMatch < elements.length) {
+ elements[currentMatch].classList.remove(ACTIVE_HIGHLIGHT_CLASS)
+ }
+
+ let next = currentMatch + direction
+ if (next >= elements.length) next = 0
+ if (next < 0) next = elements.length - 1
+
+ setCurrentMatch(next)
+ elements[next].classList.add(ACTIVE_HIGHLIGHT_CLASS)
+ elements[next].scrollIntoView({ behavior: 'smooth', block: 'center' })
+ }, [currentMatch])
+
+ // 替换当前匹配
+ const replaceCurrent = useCallback(() => {
+ if (currentMatch < 0 || !editorContainer) return
+
+ const elements = matchElementsRef.current
+ const el = elements[currentMatch]
+ if (!el || !el.parentNode) return
+
+ // 替换文本
+ const textNode = document.createTextNode(replacement)
+ el.parentNode.replaceChild(textNode, el)
+ editorContainer.normalize()
+
+ // 重新搜索
+ doSearch(query, caseSensitive)
+ }, [currentMatch, replacement, editorContainer, query, caseSensitive, doSearch])
+
+ // 全部替换
+ const replaceAll = useCallback(() => {
+ if (!editorContainer || matchCount === 0) return
+
+ const elements = [...matchElementsRef.current]
+ elements.forEach(el => {
+ if (el.parentNode) {
+ el.parentNode.replaceChild(document.createTextNode(replacement), el)
+ }
+ })
+ editorContainer.normalize()
+
+ doSearch(query, caseSensitive)
+ }, [editorContainer, matchCount, replacement, query, caseSensitive, doSearch])
+
+ // 键盘事件
+ const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
+ if (e.key === 'Escape') {
+ e.preventDefault()
+ if (editorContainer) clearHighlights(editorContainer)
+ onClose()
+ return
+ }
+ if (e.key === 'Enter') {
+ e.preventDefault()
+ if (e.shiftKey) {
+ navigateMatch(-1)
+ } else {
+ navigateMatch(1)
+ }
+ }
+ }, [onClose, navigateMatch, editorContainer])
+
+ // 全局 Ctrl+F / Ctrl+H 拦截
+ useEffect(() => {
+ const handleGlobalKey = (e: KeyboardEvent) => {
+ const isCtrl = e.ctrlKey || e.metaKey
+ if (isCtrl && e.key === 'f') {
+ e.preventDefault()
+ setShowReplace(false)
+ searchInputRef.current?.focus()
+ searchInputRef.current?.select()
+ }
+ if (isCtrl && e.key === 'h') {
+ e.preventDefault()
+ setShowReplace(true)
+ searchInputRef.current?.focus()
+ searchInputRef.current?.select()
+ }
+ }
+ document.addEventListener('keydown', handleGlobalKey)
+ return () => document.removeEventListener('keydown', handleGlobalKey)
+ }, [])
+
+ // 打开时聚焦搜索框
+ useEffect(() => {
+ searchInputRef.current?.focus()
+ searchInputRef.current?.select()
+ }, [])
+
+ // 清理高亮
+ useEffect(() => {
+ return () => {
+ if (editorContainer) clearHighlights(editorContainer)
+ }
+ }, [editorContainer])
+
+ return (
+
+
+
+
+
+ {matchCount > 0 ? `${currentMatch + 1}/${matchCount}` : query ? '无匹配' : ''}
+
+
+
+
+
+
+
+
+ {showReplace && (
+
+
+ setReplacement(e.target.value)}
+ aria-label="替换文本"
+ />
+
+
+
+
+ )}
+
+ )
+})
+
+SearchReplace.displayName = 'SearchReplace'
diff --git a/src/renderer/components/SearchReplace/index.ts b/src/renderer/components/SearchReplace/index.ts
new file mode 100644
index 0000000..ca2683a
--- /dev/null
+++ b/src/renderer/components/SearchReplace/index.ts
@@ -0,0 +1 @@
+export { SearchReplace } from './SearchReplace'
diff --git a/src/renderer/stores/__tests__/tabStore.test.ts b/src/renderer/stores/__tests__/tabStore.test.ts
index d83e673..46448d4 100644
--- a/src/renderer/stores/__tests__/tabStore.test.ts
+++ b/src/renderer/stores/__tests__/tabStore.test.ts
@@ -52,14 +52,13 @@ describe('tabStore', () => {
})
it('should switch to existing tab if same filePath is opened', () => {
- const { createTab } = useTabStore.getState()
- createTab('/test.md', '# First')
+ useTabStore.getState().createTab('/test.md', '# First')
// Create another tab to make it active
- createTab('/other.md', '# Other')
+ useTabStore.getState().createTab('/other.md', '# Other')
// Now try to open the first file again
- const existing = createTab('/test.md', '# Updated')
+ const existing = useTabStore.getState().createTab('/test.md', '# Updated')
const state = useTabStore.getState()
expect(state.tabs).toHaveLength(2)
diff --git a/src/renderer/styles/global.css b/src/renderer/styles/global.css
index 1300775..a4a3b12 100644
--- a/src/renderer/styles/global.css
+++ b/src/renderer/styles/global.css
@@ -4,7 +4,8 @@
box-sizing: border-box;
}
-html, body {
+html,
+body {
height: 100%;
font-family: var(--font-ui);
font-size: 14px;
@@ -33,7 +34,8 @@ html, body {
z-index: 10;
}
-.toolbar-left, .toolbar-right {
+.toolbar-left,
+.toolbar-right {
display: flex;
align-items: center;
gap: 2px;
@@ -65,7 +67,9 @@ html, body {
color: var(--primary);
}
-.toolbar-btn svg { flex-shrink: 0; }
+.toolbar-btn svg {
+ flex-shrink: 0;
+}
.toolbar-divider {
width: 1px;
@@ -129,7 +133,9 @@ html, body {
overflow-x: auto;
}
-.editor-toolbar::-webkit-scrollbar { height: 0; }
+.editor-toolbar::-webkit-scrollbar {
+ height: 0;
+}
.toolbar-btn-sm {
display: flex;
@@ -203,9 +209,15 @@ html, body {
color: var(--text);
}
-.milkdown-wrapper .milkdown .editor h1 { font-size: 1.6em; }
-.milkdown-wrapper .milkdown .editor h2 { font-size: 1.4em; }
-.milkdown-wrapper .milkdown .editor h3 { font-size: 1.2em; }
+.milkdown-wrapper .milkdown .editor h1 {
+ font-size: 1.6em;
+}
+.milkdown-wrapper .milkdown .editor h2 {
+ font-size: 1.4em;
+}
+.milkdown-wrapper .milkdown .editor h3 {
+ font-size: 1.2em;
+}
.milkdown-wrapper .milkdown .editor blockquote {
border-left: 3px solid var(--primary);
@@ -420,7 +432,9 @@ html, body {
.tab-item:hover .tab-close,
.tab-item.active .tab-close,
-.tab-item.modified .tab-close { opacity: 1; }
+.tab-item.modified .tab-close {
+ opacity: 1;
+}
.tab-close:hover {
background: var(--border);
@@ -541,13 +555,16 @@ html, body {
flex-shrink: 0;
}
-.status-left, .status-right {
+.status-left,
+.status-right {
display: flex;
align-items: center;
gap: 8px;
}
-.status-divider { color: var(--border); }
+.status-divider {
+ color: var(--border);
+}
/* Drop Overlay */
#drop-overlay {
@@ -603,7 +620,9 @@ html, body {
animation: fadeIn 0.3s ease;
}
-.welcome-icon { margin-bottom: 24px; }
+.welcome-icon {
+ margin-bottom: 24px;
+}
.welcome-content h1 {
font-size: 28px;
@@ -655,7 +674,9 @@ html, body {
border: 1px solid var(--border);
}
-.welcome-btn.secondary:hover { background: var(--bg-tertiary); }
+.welcome-btn.secondary:hover {
+ background: var(--bg-tertiary);
+}
/* 最近打开文件 */
.welcome-recent {
@@ -698,7 +719,9 @@ html, body {
border-bottom: 1px solid var(--border-light);
}
-.recent-item:last-child { border-bottom: none; }
+.recent-item:last-child {
+ border-bottom: none;
+}
.recent-item:hover {
background: var(--bg-tertiary);
@@ -737,7 +760,9 @@ html, body {
margin-bottom: 6px;
}
-.welcome-tips p:last-child { margin-bottom: 0; }
+.welcome-tips p:last-child {
+ margin-bottom: 0;
+}
/* Sidebar */
#sidebar {
@@ -754,7 +779,9 @@ html, body {
position: relative;
}
-#sidebar.collapsed { display: none; }
+#sidebar.collapsed {
+ display: none;
+}
#sidebar-header {
display: flex;
@@ -811,7 +838,9 @@ html, body {
border-radius: 0;
}
-.tree-item:hover { background: var(--sidebar-hover); }
+.tree-item:hover {
+ background: var(--sidebar-hover);
+}
.tree-item.active {
background: var(--sidebar-active);
@@ -819,7 +848,9 @@ html, body {
font-weight: 500;
}
-.tree-indent { flex-shrink: 0; }
+.tree-indent {
+ flex-shrink: 0;
+}
.tree-arrow {
display: flex;
@@ -832,7 +863,9 @@ html, body {
transition: transform 0.15s ease;
}
-.tree-arrow.expanded { transform: rotate(90deg); }
+.tree-arrow.expanded {
+ transform: rotate(90deg);
+}
.tree-icon {
display: flex;
@@ -844,7 +877,10 @@ html, body {
margin-right: 4px;
}
-.tree-icon svg { width: 14px; height: 14px; }
+.tree-icon svg {
+ width: 14px;
+ height: 14px;
+}
.tree-name {
overflow: hidden;
@@ -858,7 +894,8 @@ html, body {
padding-bottom: 4px;
}
-.sidebar-section-header, .independent-files-header {
+.sidebar-section-header,
+.independent-files-header {
font-size: 11px;
font-weight: 600;
color: var(--text-tertiary);
@@ -889,8 +926,12 @@ html, body {
}
/* View Modes */
-#app.mode-preview #editor-panel { display: none; }
-#app.mode-editor #preview-panel { display: none; }
+#app.mode-preview #editor-panel {
+ display: none;
+}
+#app.mode-editor #preview-panel {
+ display: none;
+}
/* Toast */
#toast-notification {
@@ -907,7 +948,9 @@ html, body {
box-shadow: var(--shadow-lg);
opacity: 0;
pointer-events: none;
- transition: opacity 0.3s ease, transform 0.3s ease;
+ transition:
+ opacity 0.3s ease,
+ transform 0.3s ease;
z-index: 9999;
max-width: 480px;
text-align: center;
@@ -920,15 +963,31 @@ html, body {
}
/* Scrollbar */
-::-webkit-scrollbar { width: 8px; height: 8px; }
-::-webkit-scrollbar-track { background: transparent; }
-::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
-::-webkit-scrollbar-thumb:hover { background: var(--text-tertiary); }
+::-webkit-scrollbar {
+ width: 8px;
+ height: 8px;
+}
+::-webkit-scrollbar-track {
+ background: transparent;
+}
+::-webkit-scrollbar-thumb {
+ background: var(--border);
+ border-radius: 4px;
+}
+::-webkit-scrollbar-thumb:hover {
+ background: var(--text-tertiary);
+}
/* Animations */
@keyframes fadeIn {
- from { opacity: 0; transform: translateY(8px); }
- to { opacity: 1; transform: translateY(0); }
+ from {
+ opacity: 0;
+ transform: translateY(8px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
}
/* About Dialog */
@@ -1094,9 +1153,15 @@ html, body {
margin: 0;
}
-.confirm-header.confirm-danger h3 { color: #d93025; }
-.confirm-header.confirm-warning h3 { color: #e37400; }
-:root.dark .confirm-header.confirm-warning h3 { color: #fdd663; }
+.confirm-header.confirm-danger h3 {
+ color: #d93025;
+}
+.confirm-header.confirm-warning h3 {
+ color: #e37400;
+}
+:root.dark .confirm-header.confirm-warning h3 {
+ color: #fdd663;
+}
.confirm-body {
padding: 12px 24px 20px;
@@ -1212,8 +1277,12 @@ html, body {
}
@keyframes spin {
- from { transform: rotate(0deg); }
- to { transform: rotate(360deg); }
+ from {
+ transform: rotate(0deg);
+ }
+ to {
+ transform: rotate(360deg);
+ }
}
/* Status bar loading indicator */
@@ -1365,9 +1434,9 @@ html, body {
/* Focus visible 为所有交互元素提供清晰的焦点指示 */
button:focus-visible,
-[role="treeitem"]:focus-visible,
-[role="tab"]:focus-visible,
-[role="menuitem"]:focus-visible {
+[role='treeitem']:focus-visible,
+[role='tab']:focus-visible,
+[role='menuitem']:focus-visible {
outline: 2px solid var(--primary);
outline-offset: 2px;
}
@@ -1396,3 +1465,138 @@ button:focus-visible,
white-space: nowrap;
border: 0;
}
+
+/* ===== Search & Replace Panel ===== */
+
+.search-replace-panel {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ padding: 8px 12px;
+ background: var(--bg-secondary);
+ border-bottom: 1px solid var(--border);
+ flex-shrink: 0;
+ animation: slideDown 0.15s ease-out;
+}
+
+@keyframes slideDown {
+ from {
+ transform: translateY(-100%);
+ opacity: 0;
+ }
+ to {
+ transform: translateY(0);
+ opacity: 1;
+ }
+}
+
+.search-row {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+}
+
+.search-input-group {
+ flex: 1;
+ display: flex;
+ align-items: center;
+ position: relative;
+ min-width: 0;
+}
+
+.search-input {
+ flex: 1;
+ height: 28px;
+ padding: 0 8px;
+ border: 1px solid var(--border);
+ border-radius: 4px;
+ background: var(--bg);
+ color: var(--text);
+ font-size: 12px;
+ font-family: var(--font-ui);
+ outline: none;
+ transition: border-color 0.15s;
+}
+
+.search-input:focus {
+ border-color: var(--primary);
+}
+
+.search-input::placeholder {
+ color: var(--text-tertiary);
+}
+
+.search-count {
+ position: absolute;
+ right: 8px;
+ font-size: 11px;
+ color: var(--text-tertiary);
+ pointer-events: none;
+ white-space: nowrap;
+}
+
+.search-btn {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ min-width: 28px;
+ height: 28px;
+ padding: 0 6px;
+ border: none;
+ background: transparent;
+ color: var(--text-secondary);
+ font-size: 12px;
+ font-family: var(--font-ui);
+ border-radius: 4px;
+ cursor: pointer;
+ transition: all 0.15s ease;
+ white-space: nowrap;
+}
+
+.search-btn:hover {
+ background: var(--bg-tertiary);
+ color: var(--text);
+}
+
+.search-btn:disabled {
+ opacity: 0.4;
+ cursor: not-allowed;
+}
+
+.search-btn.active {
+ background: var(--primary-light);
+ color: var(--primary);
+}
+
+.search-close:hover {
+ background: #e74c3c20;
+ color: #e74c3c;
+}
+
+.replace-btn {
+ font-size: 11px;
+ padding: 0 8px;
+}
+
+/* Search match highlights */
+mark.search-match-highlight {
+ background: rgba(255, 213, 0, 0.4);
+ color: inherit;
+ border-radius: 2px;
+ padding: 0;
+}
+
+mark.search-match-active {
+ background: rgba(255, 150, 0, 0.6);
+ outline: 1px solid rgba(255, 150, 0, 0.8);
+ border-radius: 2px;
+}
+
+:root.dark mark.search-match-highlight {
+ background: rgba(255, 213, 0, 0.25);
+}
+
+:root.dark mark.search-match-active {
+ background: rgba(255, 180, 0, 0.4);
+ outline-color: rgba(255, 180, 0, 0.6);
+}