refactor: v2.0 全量重构 — TypeScript + React + Zustand + IndexedDB
技术栈升级: - JavaScript → TypeScript 5.6(全量类型安全) - 原生 DOM → React 18(函数组件 + Hooks) - 全局变量 → Zustand 5(轻量状态管理) - localStorage → IndexedDB / Dexie.js 4(大容量、异步、索引) - marked.js → unified / remark / rehype(插件化渲染管线) - 无打包 → electron-vite 3(HMR 热更新) - 纯 CSS → CSS Variables + CSS Modules 新增功能: - 标签页状态 IndexedDB 持久化(关闭后可恢复) - 最近打开文件列表 - 大文件虚拟化行号(>2000 行) - 搜索高亮二分查找优化 O(log N) - rehype-sanitize HTML 安全过滤 文件结构: - 7 个源文件 → 51 个模块化文件 - src/main/ 主进程(6 文件) - src/preload/ 预加载(1 文件) - src/renderer/ 渲染进程(42 文件:组件/hooks/stores/lib/db/types/styles) - src/shared/ 共享类型(2 文件) 构建验证: - TypeScript 检查零错误 - electron-vite build 成功 - 产物:main 15kB + preload 2kB + renderer 1.5MB
This commit is contained in:
@@ -0,0 +1,63 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
|
||||
export function DropOverlay() {
|
||||
const [isVisible, setIsVisible] = useState(false)
|
||||
const [dragCounter, setDragCounter] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
const handleDragEnter = (e: DragEvent) => {
|
||||
e.preventDefault()
|
||||
setDragCounter(prev => {
|
||||
const next = prev + 1
|
||||
if (next > 0) setIsVisible(true)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const handleDragLeave = (e: DragEvent) => {
|
||||
e.preventDefault()
|
||||
setDragCounter(prev => {
|
||||
const next = prev - 1
|
||||
if (next <= 0) {
|
||||
setIsVisible(false)
|
||||
return 0
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const handleDragOver = (e: DragEvent) => {
|
||||
e.preventDefault()
|
||||
}
|
||||
|
||||
const handleDrop = () => {
|
||||
setDragCounter(0)
|
||||
setIsVisible(false)
|
||||
}
|
||||
|
||||
document.addEventListener('dragenter', handleDragEnter)
|
||||
document.addEventListener('dragleave', handleDragLeave)
|
||||
document.addEventListener('dragover', handleDragOver)
|
||||
document.addEventListener('drop', handleDrop)
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('dragenter', handleDragEnter)
|
||||
document.removeEventListener('dragleave', handleDragLeave)
|
||||
document.removeEventListener('dragover', handleDragOver)
|
||||
document.removeEventListener('drop', handleDrop)
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (!isVisible) return null
|
||||
|
||||
return (
|
||||
<div id="drop-overlay">
|
||||
<div className="drop-content">
|
||||
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
|
||||
</svg>
|
||||
<p>释放文件以打开</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
import React, { useEffect, useRef, useCallback, useState } from 'react'
|
||||
import { useTabStore } from '../../stores/tabStore'
|
||||
import { useEditorStore } from '../../stores/editorStore'
|
||||
import { useSearchStore } from '../../stores/searchStore'
|
||||
import { findMatches, buildLineStarts, getLineNumber } from '../../lib/searchEngine'
|
||||
import type { SearchMatch } from '../../types/search'
|
||||
|
||||
export function Editor() {
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const lineNumbersRef = useRef<HTMLDivElement>(null)
|
||||
const wrapperRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const activeTabId = useTabStore(s => s.activeTabId)
|
||||
const getActiveTab = useTabStore(s => s.getActiveTab)
|
||||
const updateTabContent = useTabStore(s => s.updateTabContent)
|
||||
const setModified = useTabStore(s => s.setModified)
|
||||
|
||||
const searchStore = useSearchStore()
|
||||
|
||||
const [lineCount, setLineCount] = useState(1)
|
||||
const [cursorPos, setCursorPos] = useState({ line: 1, col: 1 })
|
||||
const [fileSize, setFileSize] = useState('')
|
||||
const updateTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
// 测量行高
|
||||
const lineHeightRef = useRef(20.8)
|
||||
useEffect(() => {
|
||||
const textarea = textareaRef.current
|
||||
if (!textarea) return
|
||||
const style = getComputedStyle(textarea)
|
||||
const measure = document.createElement('div')
|
||||
measure.style.cssText = `position:absolute;visibility:hidden;font-family:${style.fontFamily};font-size:${style.fontSize};line-height:${style.lineHeight};white-space:pre;width:0;`
|
||||
measure.textContent = 'X'
|
||||
document.body.appendChild(measure)
|
||||
const singleHeight = measure.offsetHeight
|
||||
measure.textContent = 'X\nX'
|
||||
const doubleHeight = measure.offsetHeight
|
||||
document.body.removeChild(measure)
|
||||
lineHeightRef.current = doubleHeight - singleHeight || 20.8
|
||||
}, [])
|
||||
|
||||
// 加载标签内容到编辑器
|
||||
useEffect(() => {
|
||||
const tab = getActiveTab()
|
||||
const textarea = textareaRef.current
|
||||
if (!tab || !textarea) return
|
||||
|
||||
textarea.value = tab.content
|
||||
updateLineNumbers(tab.content)
|
||||
updateFileSizeDisplay(tab.content)
|
||||
updateCursorPos()
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
textarea.scrollTop = tab.scrollTop
|
||||
textarea.scrollLeft = tab.scrollLeft
|
||||
textarea.setSelectionRange(tab.selectionStart, tab.selectionEnd)
|
||||
if (lineNumbersRef.current) {
|
||||
lineNumbersRef.current.scrollTop = tab.scrollTop
|
||||
}
|
||||
})
|
||||
}, [activeTabId, getActiveTab])
|
||||
|
||||
// 保存当前标签状态
|
||||
const saveCurrentState = useCallback(() => {
|
||||
const tab = getActiveTab()
|
||||
const textarea = textareaRef.current
|
||||
if (!tab || !textarea) return
|
||||
useTabStore.getState().updateTabScroll(tab.id, {
|
||||
scrollTop: textarea.scrollTop,
|
||||
scrollLeft: textarea.scrollLeft,
|
||||
selectionStart: textarea.selectionStart,
|
||||
selectionEnd: textarea.selectionEnd,
|
||||
previewScrollTop: 0 // will be set by preview
|
||||
})
|
||||
}, [getActiveTab])
|
||||
|
||||
// 更新行号
|
||||
const updateLineNumbers = useCallback((content: string) => {
|
||||
const count = (content.match(/\n/g) || []).length + 1
|
||||
setLineCount(count)
|
||||
}, [])
|
||||
|
||||
// 更新文件大小
|
||||
const updateFileSizeDisplay = useCallback((content: string) => {
|
||||
const bytes = new Blob([content]).size
|
||||
if (bytes < 1024) setFileSize(bytes + ' B')
|
||||
else if (bytes < 1024 * 1024) setFileSize((bytes / 1024).toFixed(1) + ' KB')
|
||||
else setFileSize((bytes / (1024 * 1024)).toFixed(1) + ' MB')
|
||||
}, [])
|
||||
|
||||
// 更新光标位置
|
||||
const updateCursorPos = useCallback(() => {
|
||||
const textarea = textareaRef.current
|
||||
if (!textarea) return
|
||||
const text = textarea.value
|
||||
const pos = textarea.selectionStart
|
||||
const textBefore = text.substring(0, pos)
|
||||
const lines = textBefore.split('\n')
|
||||
setCursorPos({ line: lines.length, col: lines[lines.length - 1].length + 1 })
|
||||
}, [])
|
||||
|
||||
// 输入事件
|
||||
const handleInput = useCallback(() => {
|
||||
const textarea = textareaRef.current
|
||||
const tab = getActiveTab()
|
||||
if (!textarea || !tab) return
|
||||
|
||||
const content = textarea.value
|
||||
updateTabContent(tab.id, content)
|
||||
setModified(tab.id, true)
|
||||
updateLineNumbers(content)
|
||||
updateFileSizeDisplay(content)
|
||||
|
||||
// 防抖更新
|
||||
if (updateTimerRef.current) clearTimeout(updateTimerRef.current)
|
||||
updateTimerRef.current = setTimeout(() => {
|
||||
// 触发预览更新(通过 store)
|
||||
}, 150)
|
||||
}, [getActiveTab, updateTabContent, setModified, updateLineNumbers, updateFileSizeDisplay])
|
||||
|
||||
// 滚动同步
|
||||
const handleScroll = useCallback(() => {
|
||||
const textarea = textareaRef.current
|
||||
if (!textarea) return
|
||||
if (lineNumbersRef.current) {
|
||||
lineNumbersRef.current.scrollTop = textarea.scrollTop
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Tab 键支持
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Tab') {
|
||||
e.preventDefault()
|
||||
const textarea = textareaRef.current
|
||||
if (!textarea) return
|
||||
const start = textarea.selectionStart
|
||||
const end = textarea.selectionEnd
|
||||
if (start !== end) {
|
||||
const lines = textarea.value.substring(start, end).split('\n')
|
||||
const indented = lines.map(line => ' ' + line).join('\n')
|
||||
document.execCommand('insertText', false, indented)
|
||||
} else {
|
||||
document.execCommand('insertText', false, ' ')
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索导航
|
||||
if (e.key === 'Enter' && searchStore.isVisible) {
|
||||
e.preventDefault()
|
||||
if (e.shiftKey) {
|
||||
searchStore.findPrev()
|
||||
} else {
|
||||
searchStore.findNext()
|
||||
}
|
||||
}
|
||||
}, [searchStore])
|
||||
|
||||
// 渲染行号
|
||||
const renderLineNumbers = () => {
|
||||
const lineHeight = lineHeightRef.current
|
||||
const LARGE_THRESHOLD = 2000
|
||||
if (lineCount > LARGE_THRESHOLD) {
|
||||
// 虚拟化
|
||||
const textarea = textareaRef.current
|
||||
if (!textarea) return null
|
||||
const scrollTop = textarea.scrollTop
|
||||
const viewportHeight = textarea.clientHeight
|
||||
const buffer = 20
|
||||
const startLine = Math.max(0, Math.floor(scrollTop / lineHeight) - buffer)
|
||||
const endLine = Math.min(lineCount, Math.ceil((scrollTop + viewportHeight) / lineHeight) + buffer)
|
||||
const topPadding = startLine * lineHeight
|
||||
const bottomPadding = (lineCount - endLine) * lineHeight
|
||||
return (
|
||||
<>
|
||||
<div style={{ height: topPadding }} />
|
||||
{Array.from({ length: endLine - startLine }, (_, i) => (
|
||||
<div key={startLine + i} className="line-num" style={{ height: lineHeight }}>
|
||||
{startLine + i + 1}
|
||||
</div>
|
||||
))}
|
||||
<div style={{ height: bottomPadding }} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
return Array.from({ length: lineCount }, (_, i) => (
|
||||
<div key={i} className="line-num" style={{ height: lineHeight }}>
|
||||
{i + 1}
|
||||
</div>
|
||||
))
|
||||
}
|
||||
|
||||
return (
|
||||
<div id="editor-wrapper" ref={wrapperRef}>
|
||||
<div id="line-numbers" ref={lineNumbersRef}>
|
||||
{renderLineNumbers()}
|
||||
</div>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
id="editor"
|
||||
spellCheck={false}
|
||||
placeholder="在此输入 Markdown 内容,或拖拽 .md 文件到窗口打开..."
|
||||
onInput={handleInput}
|
||||
onScroll={handleScroll}
|
||||
onClick={updateCursorPos}
|
||||
onKeyUp={updateCursorPos}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import React from 'react'
|
||||
|
||||
interface ModifiedBannerProps {
|
||||
onReload: () => void
|
||||
onDismiss: () => void
|
||||
}
|
||||
|
||||
export function ModifiedBanner({ onReload, onDismiss }: ModifiedBannerProps) {
|
||||
return (
|
||||
<div id="modified-banner">
|
||||
<span>文件已被外部程序修改</span>
|
||||
<button className="banner-btn" onClick={onReload}>重新加载</button>
|
||||
<button className="banner-btn" onClick={onDismiss}>忽略</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { useTabStore } from '../../stores/tabStore'
|
||||
import { renderMarkdown } from '../../lib/markdown'
|
||||
|
||||
export function Preview() {
|
||||
const [html, setHtml] = useState('')
|
||||
const previewRef = useRef<HTMLDivElement>(null)
|
||||
const getActiveTab = useTabStore(s => s.getActiveTab)
|
||||
const activeTabId = useTabStore(s => s.activeTabId)
|
||||
|
||||
// 渲染 Markdown
|
||||
useEffect(() => {
|
||||
const tab = getActiveTab()
|
||||
if (!tab) {
|
||||
setHtml('')
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
renderMarkdown(tab.content).then(result => {
|
||||
if (!cancelled) {
|
||||
setHtml(result)
|
||||
}
|
||||
})
|
||||
|
||||
return () => { cancelled = true }
|
||||
}, [activeTabId, getActiveTab])
|
||||
|
||||
// 监听编辑器内容变化
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
const tab = getActiveTab()
|
||||
if (!tab) return
|
||||
renderMarkdown(tab.content).then(result => {
|
||||
setHtml(result)
|
||||
})
|
||||
}, 300)
|
||||
return () => clearInterval(timer)
|
||||
}, [getActiveTab])
|
||||
|
||||
// 拦截链接点击
|
||||
const handleClick = useCallback((e: React.MouseEvent) => {
|
||||
const link = (e.target as HTMLElement).closest('a')
|
||||
if (!link) return
|
||||
e.preventDefault()
|
||||
const href = link.getAttribute('href')
|
||||
if (!href) return
|
||||
if (href.startsWith('#')) {
|
||||
const target = previewRef.current?.querySelector(href)
|
||||
if (target) target.scrollIntoView({ behavior: 'smooth' })
|
||||
return
|
||||
}
|
||||
try {
|
||||
const url = new URL(href)
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') return
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
if (window.electronAPI?.openExternal) {
|
||||
window.electronAPI.openExternal(href)
|
||||
} else {
|
||||
window.open(href, '_blank', 'noopener')
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={previewRef}
|
||||
id="preview"
|
||||
className="markdown-body"
|
||||
onClick={handleClick}
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import React, { useCallback, useRef, useEffect } from 'react'
|
||||
import { useSearchStore } from '../../stores/searchStore'
|
||||
import { useTabStore } from '../../stores/tabStore'
|
||||
import { findMatches } from '../../lib/searchEngine'
|
||||
|
||||
export function SearchBar() {
|
||||
const searchInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const isVisible = useSearchStore(s => s.isVisible)
|
||||
const showReplace = useSearchStore(s => s.showReplace)
|
||||
const searchText = useSearchStore(s => s.searchText)
|
||||
const replaceText = useSearchStore(s => s.replaceText)
|
||||
const matches = useSearchStore(s => s.matches)
|
||||
const currentIndex = useSearchStore(s => s.currentIndex)
|
||||
const options = useSearchStore(s => s.options)
|
||||
const setSearchText = useSearchStore(s => s.setSearchText)
|
||||
const setReplaceText = useSearchStore(s => s.setReplaceText)
|
||||
const setShowReplace = useSearchStore(s => s.setShowReplace)
|
||||
const setMatches = useSearchStore(s => s.setMatches)
|
||||
const findNext = useSearchStore(s => s.findNext)
|
||||
const findPrev = useSearchStore(s => s.findPrev)
|
||||
const toggleCaseSensitive = useSearchStore(s => s.toggleCaseSensitive)
|
||||
const toggleRegex = useSearchStore(s => s.toggleRegex)
|
||||
const close = useSearchStore(s => s.close)
|
||||
|
||||
const getActiveTab = useTabStore(s => s.getActiveTab)
|
||||
|
||||
// 执行搜索
|
||||
const doSearch = useCallback((text: string) => {
|
||||
const tab = getActiveTab()
|
||||
if (!tab || !text) {
|
||||
setMatches([])
|
||||
return
|
||||
}
|
||||
const found = findMatches(tab.content, text, options)
|
||||
setMatches(found)
|
||||
}, [getActiveTab, options, setMatches])
|
||||
|
||||
// 搜索文本变化
|
||||
const handleSearchChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const text = e.target.value
|
||||
setSearchText(text)
|
||||
doSearch(text)
|
||||
}, [setSearchText, doSearch])
|
||||
|
||||
// 替换当前
|
||||
const handleReplaceCurrent = useCallback(() => {
|
||||
const tab = getActiveTab()
|
||||
if (!tab || matches.length === 0 || currentIndex < 0) return
|
||||
const m = matches[currentIndex]
|
||||
const newContent = tab.content.substring(0, m.start) + replaceText + tab.content.substring(m.end)
|
||||
useTabStore.getState().updateTabContent(tab.id, newContent)
|
||||
// 重新搜索
|
||||
doSearch(searchText)
|
||||
}, [getActiveTab, matches, currentIndex, replaceText, searchText, doSearch])
|
||||
|
||||
// 全部替换
|
||||
const handleReplaceAll = useCallback(() => {
|
||||
const tab = getActiveTab()
|
||||
if (!tab || matches.length === 0) return
|
||||
let result = ''
|
||||
let lastEnd = 0
|
||||
for (let i = matches.length - 1; i >= 0; i--) {
|
||||
const m = matches[i]
|
||||
result = tab.content.substring(lastEnd, m.start) + replaceText + result
|
||||
lastEnd = m.end
|
||||
}
|
||||
result = tab.content.substring(0, matches[0].start) + result
|
||||
useTabStore.getState().updateTabContent(tab.id, result)
|
||||
doSearch(searchText)
|
||||
}, [getActiveTab, matches, replaceText, searchText, doSearch])
|
||||
|
||||
// 打开时聚焦
|
||||
useEffect(() => {
|
||||
if (isVisible && searchInputRef.current) {
|
||||
searchInputRef.current.focus()
|
||||
}
|
||||
}, [isVisible])
|
||||
|
||||
if (!isVisible) return null
|
||||
|
||||
return (
|
||||
<div id="search-bar">
|
||||
<div className="search-row">
|
||||
<button
|
||||
id="btn-toggle-replace"
|
||||
className={`search-opt-btn ${showReplace ? 'expanded' : ''}`}
|
||||
onClick={() => setShowReplace(!showReplace)}
|
||||
title="展开替换行"
|
||||
>
|
||||
▾
|
||||
</button>
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
type="text"
|
||||
id="search-input"
|
||||
placeholder="查找..."
|
||||
value={searchText}
|
||||
onChange={handleSearchChange}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
e.shiftKey ? findPrev() : findNext()
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
close()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span id="search-count">
|
||||
{matches.length > 0 ? `${currentIndex + 1}/${matches.length}` : searchText ? '无结果' : ''}
|
||||
</span>
|
||||
<button
|
||||
className={`search-opt-btn ${options.caseSensitive ? 'active' : ''}`}
|
||||
onClick={toggleCaseSensitive}
|
||||
title="区分大小写 (Alt+C)"
|
||||
>
|
||||
Aa
|
||||
</button>
|
||||
<button
|
||||
className={`search-opt-btn ${options.useRegex ? 'active' : ''}`}
|
||||
onClick={toggleRegex}
|
||||
title="正则表达式 (Alt+R)"
|
||||
>
|
||||
.*
|
||||
</button>
|
||||
<button className="search-nav-btn" onClick={findPrev} title="上一个 (Shift+Enter)">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||
<polyline points="18 15 12 9 6 15" />
|
||||
</svg>
|
||||
</button>
|
||||
<button className="search-nav-btn" onClick={findNext} title="下一个 (Enter)">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||
<polyline points="6 9 12 15 18 9" />
|
||||
</svg>
|
||||
</button>
|
||||
<button className="search-nav-btn" onClick={close} title="关闭 (Escape)">✕</button>
|
||||
</div>
|
||||
|
||||
{showReplace && (
|
||||
<div id="replace-row">
|
||||
<input
|
||||
type="text"
|
||||
id="replace-input"
|
||||
placeholder="替换..."
|
||||
value={replaceText}
|
||||
onChange={(e) => setReplaceText(e.target.value)}
|
||||
/>
|
||||
<button className="replace-btn" onClick={handleReplaceCurrent} title="替换 (Ctrl+Shift+G)">替换</button>
|
||||
<button className="replace-btn" onClick={handleReplaceAll} title="全部替换 (Ctrl+Shift+H)">全部</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
import React, { useEffect, useCallback, useState, useRef } from 'react'
|
||||
import { useTabStore } from '../../stores/tabStore'
|
||||
import { useSidebarStore } from '../../stores/sidebarStore'
|
||||
import { getFileName } from '../../lib/fileUtils'
|
||||
import type { FileNode } from '../../types/file'
|
||||
|
||||
export function Sidebar() {
|
||||
const tabs = useTabStore(s => s.tabs)
|
||||
const activeTabId = useTabStore(s => s.activeTabId)
|
||||
const switchToTab = useTabStore(s => s.switchToTab)
|
||||
const createTab = useTabStore(s => s.createTab)
|
||||
|
||||
const rootPath = useSidebarStore(s => s.rootPath)
|
||||
const tree = useSidebarStore(s => s.tree)
|
||||
const expandedDirs = useSidebarStore(s => s.expandedDirs)
|
||||
const toggleDir = useSidebarStore(s => s.toggleDir)
|
||||
const setRootPath = useSidebarStore(s => s.setRootPath)
|
||||
const setTree = useSidebarStore(s => s.setTree)
|
||||
const isVisible = useSidebarStore(s => s.isVisible)
|
||||
|
||||
const [isResizing, setIsResizing] = useState(false)
|
||||
const sidebarRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// 打开文件夹
|
||||
const handleOpenFolder = useCallback(async () => {
|
||||
if (!window.electronAPI) return
|
||||
const dirPath = await window.electronAPI.openFolderDialog()
|
||||
if (dirPath) {
|
||||
setRootPath(dirPath)
|
||||
const result = await window.electronAPI.readDirTree(dirPath)
|
||||
if (result.success && result.tree) {
|
||||
setTree(result.tree)
|
||||
window.electronAPI.watchDir(dirPath)
|
||||
}
|
||||
}
|
||||
}, [setRootPath, setTree])
|
||||
|
||||
// 刷新目录树
|
||||
const refreshTree = useCallback(async () => {
|
||||
if (!rootPath || !window.electronAPI) return
|
||||
const result = await window.electronAPI.readDirTree(rootPath)
|
||||
if (result.success && result.tree) {
|
||||
setTree(result.tree)
|
||||
}
|
||||
}, [rootPath, setTree])
|
||||
|
||||
// 目录变化监听
|
||||
useEffect(() => {
|
||||
if (!window.electronAPI) return
|
||||
window.electronAPI.onDirChanged(() => {
|
||||
refreshTree()
|
||||
})
|
||||
return () => {
|
||||
window.electronAPI?.removeAllListeners('sidebar:dirChanged')
|
||||
}
|
||||
}, [refreshTree])
|
||||
|
||||
// 侧边栏宽度调节
|
||||
useEffect(() => {
|
||||
if (!isResizing) return
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (sidebarRef.current) {
|
||||
const newWidth = Math.max(180, Math.min(500, e.clientX))
|
||||
sidebarRef.current.style.width = newWidth + 'px'
|
||||
}
|
||||
}
|
||||
const handleMouseUp = () => setIsResizing(false)
|
||||
document.addEventListener('mousemove', handleMouseMove)
|
||||
document.addEventListener('mouseup', handleMouseUp)
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove)
|
||||
document.removeEventListener('mouseup', handleMouseUp)
|
||||
}
|
||||
}, [isResizing])
|
||||
|
||||
// 独立文件(不在当前文件夹内的已打开文件)
|
||||
const independentFiles = tabs.filter(t => {
|
||||
if (!t.filePath) return false
|
||||
if (!rootPath) return true
|
||||
return !t.filePath.startsWith(rootPath)
|
||||
})
|
||||
|
||||
if (!isVisible) return null
|
||||
|
||||
return (
|
||||
<div id="sidebar" ref={sidebarRef}>
|
||||
<div id="sidebar-header">
|
||||
<span id="sidebar-title">资源管理器</span>
|
||||
<button className="sidebar-header-btn" onClick={handleOpenFolder} title="打开文件夹">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="sidebar-tree">
|
||||
{/* 独立文件区 */}
|
||||
{independentFiles.length > 0 && (
|
||||
<div className="independent-files-section">
|
||||
<div className="independent-files-header">已打开的文件</div>
|
||||
{independentFiles.map(tab => (
|
||||
<div
|
||||
key={tab.id}
|
||||
className={`tree-item independent-file-item ${tab.id === activeTabId ? 'active' : ''}`}
|
||||
style={{ paddingLeft: '8px' }}
|
||||
onClick={() => switchToTab(tab.id)}
|
||||
>
|
||||
<span className="tree-icon">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
</svg>
|
||||
</span>
|
||||
<span className="tree-name">{getFileName(tab.filePath!)}</span>
|
||||
{tab.isModified && <span className="independent-modified-dot"> •</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 文件夹目录树 */}
|
||||
{rootPath && (
|
||||
<>
|
||||
<div className="sidebar-section-header">文件夹目录树</div>
|
||||
<FileTree
|
||||
nodes={tree}
|
||||
depth={0}
|
||||
expandedDirs={expandedDirs}
|
||||
toggleDir={toggleDir}
|
||||
activeTabId={activeTabId}
|
||||
onFileClick={async (path) => {
|
||||
const existing = tabs.find(t => t.filePath === path)
|
||||
if (existing) {
|
||||
switchToTab(existing.id)
|
||||
} else if (window.electronAPI) {
|
||||
const result = await window.electronAPI.readFile(path)
|
||||
if (result.success && result.content) {
|
||||
createTab(path, result.content)
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 调节手柄 */}
|
||||
<div
|
||||
className="sidebar-resize-handle"
|
||||
onMouseDown={() => setIsResizing(true)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 文件树递归组件
|
||||
function FileTree({ nodes, depth, expandedDirs, toggleDir, activeTabId, onFileClick }: {
|
||||
nodes: FileNode[]
|
||||
depth: number
|
||||
expandedDirs: Set<string>
|
||||
toggleDir: (path: string) => void
|
||||
activeTabId: string | null
|
||||
onFileClick: (path: string) => void
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{nodes.map(node => (
|
||||
<React.Fragment key={node.path}>
|
||||
<div
|
||||
className={`tree-item ${node.type === 'file' && activeTabId ? '' : ''}`}
|
||||
style={{ paddingLeft: (8 + depth * 16) + 'px' }}
|
||||
onClick={() => {
|
||||
if (node.type === 'dir') {
|
||||
toggleDir(node.path)
|
||||
} else {
|
||||
onFileClick(node.path)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{node.type === 'dir' ? (
|
||||
<>
|
||||
<span className={`tree-arrow ${expandedDirs.has(node.path) ? 'expanded' : ''}`}>
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||
<polyline points="9 18 15 12 9 6" />
|
||||
</svg>
|
||||
</span>
|
||||
<span className="tree-icon">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
|
||||
</svg>
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span style={{ width: '16px', flexShrink: 0 }} />
|
||||
<span className="tree-icon">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
</svg>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
<span className="tree-name">{node.name}</span>
|
||||
</div>
|
||||
{node.type === 'dir' && expandedDirs.has(node.path) && node.children && (
|
||||
<FileTree
|
||||
nodes={node.children}
|
||||
depth={depth + 1}
|
||||
expandedDirs={expandedDirs}
|
||||
toggleDir={toggleDir}
|
||||
activeTabId={activeTabId}
|
||||
onFileClick={onFileClick}
|
||||
/>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import React from 'react'
|
||||
import { useTabStore } from '../../stores/tabStore'
|
||||
import { getFileName } from '../../lib/fileUtils'
|
||||
|
||||
export function StatusBar() {
|
||||
const getActiveTab = useTabStore(s => s.getActiveTab)
|
||||
const tab = getActiveTab()
|
||||
|
||||
return (
|
||||
<div id="statusbar">
|
||||
<div className="status-left">
|
||||
<span id="status-text">
|
||||
{tab ? (tab.filePath ? getFileName(tab.filePath) : '未命名') : '就绪'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="status-right">
|
||||
<span id="status-encoding">UTF-8</span>
|
||||
<span className="status-divider">|</span>
|
||||
<span id="status-lang">Markdown</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import React, { useCallback } from 'react'
|
||||
import { useTabStore } from '../../stores/tabStore'
|
||||
import { getFileName } from '../../lib/fileUtils'
|
||||
|
||||
export function TabBar() {
|
||||
const tabs = useTabStore(s => s.tabs)
|
||||
const activeTabId = useTabStore(s => s.activeTabId)
|
||||
const switchToTab = useTabStore(s => s.switchToTab)
|
||||
const closeTab = useTabStore(s => s.closeTab)
|
||||
const createTab = useTabStore(s => s.createTab)
|
||||
|
||||
const handleClose = useCallback((e: React.MouseEvent, tabId: string) => {
|
||||
e.stopPropagation()
|
||||
const tab = tabs.find(t => t.id === tabId)
|
||||
if (tab?.isModified) {
|
||||
const name = tab.filePath ? getFileName(tab.filePath) : '未命名'
|
||||
if (!confirm(`"${name}" 尚未保存,确定要关闭吗?`)) return
|
||||
}
|
||||
closeTab(tabId)
|
||||
}, [tabs, closeTab])
|
||||
|
||||
if (tabs.length === 0) return null
|
||||
|
||||
return (
|
||||
<div id="tab-bar">
|
||||
<div id="tab-list">
|
||||
{tabs.map(tab => (
|
||||
<div
|
||||
key={tab.id}
|
||||
className={`tab-item ${tab.id === activeTabId ? 'active' : ''} ${tab.isModified ? 'modified' : ''}`}
|
||||
onClick={() => switchToTab(tab.id)}
|
||||
>
|
||||
<span className="tab-name">
|
||||
{tab.filePath ? getFileName(tab.filePath) : '未命名'}
|
||||
</span>
|
||||
<button
|
||||
className="tab-close"
|
||||
onClick={(e) => handleClose(e, tab.id)}
|
||||
>
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
className="tab-add-btn"
|
||||
onClick={() => createTab(null, '')}
|
||||
title="新建标签页 (Ctrl+T)"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="12" y1="5" x2="12" y2="19" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import React from 'react'
|
||||
|
||||
interface ToastProps {
|
||||
message: string
|
||||
}
|
||||
|
||||
export function Toast({ message }: ToastProps) {
|
||||
return (
|
||||
<div id="toast-notification" className="show">
|
||||
{message}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import React from 'react'
|
||||
|
||||
interface ToolbarProps {
|
||||
onOpen: () => void
|
||||
onSave: () => void
|
||||
viewMode: 'split' | 'editor' | 'preview'
|
||||
onViewModeChange: (mode: 'split' | 'editor' | 'preview') => void
|
||||
darkMode: boolean
|
||||
onToggleDark: () => void
|
||||
}
|
||||
|
||||
export function Toolbar({ onOpen, onSave, viewMode, onViewModeChange, darkMode, onToggleDark }: ToolbarProps) {
|
||||
return (
|
||||
<div id="toolbar">
|
||||
<div className="toolbar-left">
|
||||
<button className="toolbar-btn" onClick={onOpen} title="打开文件 (Ctrl+O)">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
|
||||
</svg>
|
||||
<span>打开</span>
|
||||
</button>
|
||||
<button className="toolbar-btn" onClick={onSave} title="保存文件 (Ctrl+S)">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z" />
|
||||
<polyline points="17 21 17 13 7 13 7 21" />
|
||||
<polyline points="7 3 7 8 15 8" />
|
||||
</svg>
|
||||
<span>保存</span>
|
||||
</button>
|
||||
<div className="toolbar-divider" />
|
||||
<button className={`toolbar-btn ${viewMode === 'split' ? 'active' : ''}`} onClick={() => onViewModeChange('split')} title="编辑+预览 (Ctrl+1)">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
|
||||
<line x1="12" y1="3" x2="12" y2="21" />
|
||||
</svg>
|
||||
<span>分屏</span>
|
||||
</button>
|
||||
<button className={`toolbar-btn ${viewMode === 'editor' ? 'active' : ''}`} onClick={() => onViewModeChange('editor')} title="纯编辑 (Ctrl+2)">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||
</svg>
|
||||
<span>编辑</span>
|
||||
</button>
|
||||
<button className={`toolbar-btn ${viewMode === 'preview' ? 'active' : ''}`} onClick={() => onViewModeChange('preview')} title="纯预览 (Ctrl+3)">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
<span>预览</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="toolbar-right">
|
||||
<button className="toolbar-btn" onClick={onToggleDark} title="切换暗色主题">
|
||||
{darkMode ? (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="5" />
|
||||
<line x1="12" y1="1" x2="12" y2="3" />
|
||||
<line x1="12" y1="21" x2="12" y2="23" />
|
||||
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64" />
|
||||
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78" />
|
||||
<line x1="1" y1="12" x2="3" y2="12" />
|
||||
<line x1="21" y1="12" x2="23" y2="12" />
|
||||
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36" />
|
||||
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import React from 'react'
|
||||
|
||||
interface WelcomeScreenProps {
|
||||
onOpen: () => void
|
||||
onNew: () => void
|
||||
}
|
||||
|
||||
export function WelcomeScreen({ onOpen, onNew }: WelcomeScreenProps) {
|
||||
return (
|
||||
<div id="welcome-screen">
|
||||
<div className="welcome-content">
|
||||
<div className="welcome-icon">
|
||||
<svg width="80" height="80" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="10" y="5" width="80" height="90" rx="8" fill="#f0f6ff" stroke="#1a73e8" strokeWidth="2" />
|
||||
<text x="50" y="45" textAnchor="middle" fill="#1a73e8" fontFamily="system-ui" fontWeight="bold" fontSize="32">M↓</text>
|
||||
<text x="50" y="70" textAnchor="middle" fill="#5f6368" fontFamily="system-ui" fontSize="12">MarkLite</text>
|
||||
</svg>
|
||||
</div>
|
||||
<h1>欢迎使用 MarkLite</h1>
|
||||
<p>一款轻量级的 Markdown 阅读器</p>
|
||||
<div className="welcome-actions">
|
||||
<button className="welcome-btn primary" onClick={onOpen}>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
|
||||
</svg>
|
||||
打开文件
|
||||
</button>
|
||||
<button className="welcome-btn secondary" onClick={onNew}>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="12" y1="5" x2="12" y2="19" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
新建文件
|
||||
</button>
|
||||
</div>
|
||||
<div className="welcome-tips">
|
||||
<p>💡 提示:可以直接拖拽 .md 文件到窗口打开</p>
|
||||
<p>⌨️ 快捷键:Ctrl+O 打开 | Ctrl+S 保存 | Ctrl+F 搜索 | Ctrl+1/2/3 视图</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user