v0.3.1: 添加编辑器搜索/替换功能,修复ESLint和lint-staged错误
- 新增 SearchReplace 组件:Ctrl+F 搜索、Ctrl+H 替换、实时高亮、大小写切换、导航、全部替换 - 修复 tabStore.test.ts 未使用变量的 ESLint 错误 - 修复 lint-staged tsc 命令不兼容 Milkdown .d.ts 的问题 - 版本号 0.3.0 → 0.3.1 (package.json + AboutDialog) - tsc 零错误、78/78 测试通过、构建成功
This commit is contained in:
+2
-2
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
<div className="editor-container" role="region" aria-label="Markdown编辑器">
|
||||
<EditorToolbar action={action} />
|
||||
{showSearch && (
|
||||
<SearchReplace
|
||||
editorContainer={getEditorContainer()}
|
||||
onClose={() => setShowSearch(false)}
|
||||
/>
|
||||
)}
|
||||
<div ref={containerRef} className="milkdown-wrapper" />
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -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<HTMLInputElement>(null)
|
||||
const matchElementsRef = useRef<HTMLElement[]>([])
|
||||
const panelRef = useRef<HTMLDivElement>(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<HTMLInputElement>) => {
|
||||
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 (
|
||||
<div
|
||||
ref={panelRef}
|
||||
className="search-replace-panel"
|
||||
role="search"
|
||||
aria-label="搜索和替换"
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<div className="search-row">
|
||||
<div className="search-input-group">
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
type="text"
|
||||
className="search-input"
|
||||
placeholder="搜索..."
|
||||
value={query}
|
||||
onChange={handleQueryChange}
|
||||
aria-label="搜索文本"
|
||||
/>
|
||||
<span className="search-count" aria-live="polite">
|
||||
{matchCount > 0 ? `${currentMatch + 1}/${matchCount}` : query ? '无匹配' : ''}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
className={`search-btn ${caseSensitive ? 'active' : ''}`}
|
||||
onClick={toggleCaseSensitive}
|
||||
title="区分大小写 (点击切换)"
|
||||
aria-label="区分大小写"
|
||||
aria-pressed={caseSensitive}
|
||||
>
|
||||
Aa
|
||||
</button>
|
||||
<button
|
||||
className="search-btn"
|
||||
onClick={() => navigateMatch(-1)}
|
||||
title="上一个 (Shift+Enter)"
|
||||
aria-label="上一个匹配"
|
||||
disabled={matchCount === 0}
|
||||
>
|
||||
↑
|
||||
</button>
|
||||
<button
|
||||
className="search-btn"
|
||||
onClick={() => navigateMatch(1)}
|
||||
title="下一个 (Enter)"
|
||||
aria-label="下一个匹配"
|
||||
disabled={matchCount === 0}
|
||||
>
|
||||
↓
|
||||
</button>
|
||||
<button
|
||||
className="search-btn"
|
||||
onClick={() => setShowReplace(!showReplace)}
|
||||
title="切换替换模式 (Ctrl+H)"
|
||||
aria-label="替换模式"
|
||||
aria-expanded={showReplace}
|
||||
>
|
||||
⇄
|
||||
</button>
|
||||
<button
|
||||
className="search-btn search-close"
|
||||
onClick={() => {
|
||||
if (editorContainer) clearHighlights(editorContainer)
|
||||
onClose()
|
||||
}}
|
||||
title="关闭 (Esc)"
|
||||
aria-label="关闭搜索"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
{showReplace && (
|
||||
<div className="search-row">
|
||||
<div className="search-input-group">
|
||||
<input
|
||||
type="text"
|
||||
className="search-input"
|
||||
placeholder="替换..."
|
||||
value={replacement}
|
||||
onChange={e => setReplacement(e.target.value)}
|
||||
aria-label="替换文本"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
className="search-btn replace-btn"
|
||||
onClick={replaceCurrent}
|
||||
title="替换当前"
|
||||
aria-label="替换当前匹配"
|
||||
disabled={currentMatch < 0}
|
||||
>
|
||||
替换
|
||||
</button>
|
||||
<button
|
||||
className="search-btn replace-btn"
|
||||
onClick={replaceAll}
|
||||
title="全部替换"
|
||||
aria-label="替换全部匹配"
|
||||
disabled={matchCount === 0}
|
||||
>
|
||||
全部
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
SearchReplace.displayName = 'SearchReplace'
|
||||
@@ -0,0 +1 @@
|
||||
export { SearchReplace } from './SearchReplace'
|
||||
@@ -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)
|
||||
|
||||
+241
-37
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user