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,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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user