From 1cc4206b324f5fa8301b218a48be156a231db977 Mon Sep 17 00:00:00 2001 From: thzxx Date: Wed, 27 May 2026 23:04:05 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=9B=BF=E6=8D=A2=20textarea=20?= =?UTF-8?q?=E4=B8=BA=20CodeMirror=206=20=E7=BC=96=E8=BE=91=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增功能: - CodeMirror 6 专业编辑器替代原生 textarea - 语法高亮(Markdown 专用) - 行号(内置,精确同步) - 代码折叠(foldGutter) - 括号匹配 - 多光标选区(rectangularSelection) - 内置搜索高亮(highlightSelectionMatches) - undo/redo 原生支持(history) - Tab 缩进(indentWithTab) - 暗色主题(oneDark) 新增文件: - useCodeMirror.ts: CodeMirror 初始化 Hook - EditorToolbar.tsx: 格式化工具栏(B/I/S/标题/列表/引用/代码/链接/图片) - Editor.tsx: 重写为 CodeMirror 组件 格式化工具栏按钮: - 粗体/斜体/删除线/行内代码 - H1/H2/H3 标题 - 无序列表/有序列表/引用/代码块 - 链接/图片 快捷键: - Ctrl+B 粗体 - Ctrl+I 斜体 --- package.json | 11 +- src/renderer/App.tsx | 2 +- src/renderer/components/Editor/Editor.tsx | 356 ++++-------------- .../components/Editor/EditorToolbar.tsx | 107 ++++++ .../components/Editor/useCodeMirror.ts | 148 ++++++++ src/renderer/styles/global.css | 140 +++++-- 6 files changed, 459 insertions(+), 305 deletions(-) create mode 100644 src/renderer/components/Editor/EditorToolbar.tsx create mode 100644 src/renderer/components/Editor/useCodeMirror.ts diff --git a/package.json b/package.json index 28da3d0..d56a93e 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,13 @@ "author": "MarkLite", "license": "MIT", "dependencies": { + "@codemirror/commands": "^6.10.3", + "@codemirror/lang-markdown": "^6.5.0", + "@codemirror/language": "^6.12.3", + "@codemirror/search": "^6.7.0", + "@codemirror/state": "^6.6.0", + "@codemirror/theme-one-dark": "^6.1.3", + "@codemirror/view": "^6.43.0", "dexie": "^4.0.11", "nanoid": "^5.1.5", "react": "^18.3.1", @@ -54,7 +61,9 @@ "target": [ { "target": "nsis", - "arch": ["x64"] + "arch": [ + "x64" + ] } ], "icon": "assets/icon.ico" diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 888aed9..8d7c12f 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -193,7 +193,7 @@ export default function App() { {viewMode !== 'preview' && (
- +
)} {viewMode !== 'editor' && } diff --git a/src/renderer/components/Editor/Editor.tsx b/src/renderer/components/Editor/Editor.tsx index 61f6210..84eba1b 100644 --- a/src/renderer/components/Editor/Editor.tsx +++ b/src/renderer/components/Editor/Editor.tsx @@ -1,298 +1,108 @@ -import React, { useEffect, useRef, useCallback, useState } from 'react' +import React, { useEffect, useCallback } from 'react' import { useTabStore } from '../../stores/tabStore' import { useSearchStore } from '../../stores/searchStore' -import { findMatches, buildLineStarts, getLineNumber } from '../../lib/searchEngine' +import { useCodeMirror } from './useCodeMirror' +import { EditorToolbar } from './EditorToolbar' -export function Editor() { - const textareaRef = useRef(null) - const lineNumbersRef = useRef(null) - const wrapperRef = useRef(null) - const highlightRef = useRef(null) +interface EditorProps { + darkMode: boolean +} +export function Editor({ darkMode }: EditorProps) { const activeTabId = useTabStore(s => s.activeTabId) const tabs = useTabStore(s => s.tabs) const activeTab = tabs.find(t => t.id === activeTabId) ?? null - const getActiveTab = useTabStore(s => s.getActiveTab) const updateTabContent = useTabStore(s => s.updateTabContent) const setModified = useTabStore(s => s.setModified) + const updateTabScroll = useTabStore(s => s.updateTabScroll) - // 搜索状态 - const searchIsVisible = useSearchStore(s => s.isVisible) - const searchText = useSearchStore(s => s.searchText) - const searchMatches = useSearchStore(s => s.matches) - const searchCurrentIndex = useSearchStore(s => s.currentIndex) - const searchOptions = useSearchStore(s => s.options) + const { + containerRef, + viewRef, + setContent, + getContent, + getScrollTop, + setScrollTop, + getSelection, + setSelection, + scrollTo + } = useCodeMirror({ + content: activeTab?.content ?? '', + onChange: useCallback((value: string) => { + if (!activeTabId) return + updateTabContent(activeTabId, value) + setModified(activeTabId, true) + }, [activeTabId, updateTabContent, setModified]), + darkMode, + onScroll: useCallback((ratio: number) => { + window.dispatchEvent(new CustomEvent('editor-scroll', { detail: { ratio } })) + }, []) + }) - const [lineCount, setLineCount] = useState(1) - const [cursorPos, setCursorPos] = useState({ line: 1, col: 1 }) - const [fileSize, setFileSize] = useState('') - const updateTimerRef = useRef | 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 - }, []) - - // 字符宽度 - const charWidthRef = useRef(8) - useEffect(() => { - const textarea = textareaRef.current - if (!textarea) return - const canvas = document.createElement('canvas') - const ctx = canvas.getContext('2d') - if (!ctx) return - const style = getComputedStyle(textarea) - ctx.font = `${style.fontSize} ${style.fontFamily}` - charWidthRef.current = ctx.measureText('M').width - }, []) - - // 加载标签内容 - useEffect(() => { - const tab = getActiveTab() - const textarea = textareaRef.current - if (!tab || !textarea) return - - textarea.value = tab.content - setLineCount((tab.content.match(/\n/g) || []).length + 1) - const bytes = new Blob([tab.content]).size - setFileSize(bytes < 1024 ? bytes + ' B' : bytes < 1048576 ? (bytes / 1024).toFixed(1) + ' KB' : (bytes / 1048576).toFixed(1) + ' MB') + if (!activeTab) return + setContent(activeTab.content) + // 恢复滚动和选区 requestAnimationFrame(() => { - textarea.scrollTop = tab.scrollTop - textarea.scrollLeft = tab.scrollLeft - textarea.setSelectionRange(tab.selectionStart, tab.selectionEnd) - if (lineNumbersRef.current) lineNumbersRef.current.scrollTop = tab.scrollTop + setScrollTop(activeTab.scrollTop) + setSelection(activeTab.selectionStart, activeTab.selectionEnd) }) - }, [activeTabId, getActiveTab]) + }, [activeTabId]) - // 更新行号 - const updateLineNumbers = useCallback((content: string) => { - setLineCount((content.match(/\n/g) || []).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) - const bytes = new Blob([content]).size - setFileSize(bytes < 1024 ? bytes + ' B' : bytes < 1048576 ? (bytes / 1024).toFixed(1) + ' KB' : (bytes / 1048576).toFixed(1) + ' MB') - }, [getActiveTab, updateTabContent, setModified, updateLineNumbers]) - - // 滚动同步 - const handleScroll = useCallback(() => { - const textarea = textareaRef.current - if (!textarea) return - if (lineNumbersRef.current) { - lineNumbersRef.current.scrollTop = textarea.scrollTop - } - // 更新搜索高亮位置 - if (highlightRef.current) { - highlightRef.current.style.top = -textarea.scrollTop + 'px' - } - const maxScroll = textarea.scrollHeight - textarea.clientHeight - const ratio = maxScroll > 0 ? textarea.scrollTop / maxScroll : 0 - window.dispatchEvent(new CustomEvent('editor-scroll', { detail: { ratio } })) - }, []) - - // 搜索:执行搜索并更新 matches - const activeContent = activeTab?.content - useEffect(() => { - if (!searchIsVisible || !searchText) { - if (highlightRef.current) highlightRef.current.innerHTML = '' - return - } - const textarea = textareaRef.current - if (!textarea) return - - const matches = findMatches(textarea.value, searchText, searchOptions) - useSearchStore.getState().setMatches(matches) - renderHighlights(textarea.value, matches, searchCurrentIndex) - }, [searchIsVisible, searchText, searchOptions, activeContent]) - - // 搜索:currentIndex 变化时滚动到匹配位置 - useEffect(() => { - if (searchCurrentIndex < 0 || searchMatches.length === 0) return - const textarea = textareaRef.current - if (!textarea) return - - const m = searchMatches[searchCurrentIndex] - if (!m) return - - // 选中匹配文本 - textarea.focus() - textarea.setSelectionRange(m.start, m.end) - - // 滚动到匹配位置 - const lineStarts = buildLineStarts(textarea.value) - const line = getLineNumber(lineStarts, m.start) - const lineHeight = lineHeightRef.current - const matchTop = line * lineHeight - const viewTop = textarea.scrollTop - const viewBottom = viewTop + textarea.clientHeight - if (matchTop < viewTop + 40 || matchTop > viewBottom - 40) { - textarea.scrollTop = matchTop - textarea.clientHeight / 2 - } - - // 重新渲染高亮 - renderHighlights(textarea.value, searchMatches, searchCurrentIndex) - }, [searchCurrentIndex, searchMatches]) - - // 渲染搜索高亮 overlay - const renderHighlights = useCallback((content: string, matches: Array<{start: number; end: number}>, currentIndex: number) => { - const wrapper = wrapperRef.current - const textarea = textareaRef.current - if (!wrapper || !textarea) return - - // 创建或复用 overlay - if (!highlightRef.current) { - const overlay = document.createElement('div') - overlay.id = 'search-highlights' - overlay.style.cssText = 'position:absolute;top:0;left:0;right:0;height:' + textarea.scrollHeight + 'px;pointer-events:none;overflow:visible;z-index:1;' - wrapper.style.position = 'relative' - wrapper.appendChild(overlay) - highlightRef.current = overlay - } - - const overlay = highlightRef.current - overlay.style.top = -textarea.scrollTop + 'px' - overlay.style.height = textarea.scrollHeight + 'px' - overlay.innerHTML = '' - - if (matches.length === 0) return - - const style = getComputedStyle(textarea) - const paddingTop = parseFloat(style.paddingTop) - const paddingLeft = parseFloat(style.paddingLeft) - const lineHeight = lineHeightRef.current - const charWidth = charWidthRef.current - const lineStarts = buildLineStarts(content) - - for (let i = 0; i < matches.length; i++) { - const m = matches[i] - const startLine = getLineNumber(lineStarts, m.start) - const startCol = m.start - lineStarts[startLine] - const endLine = getLineNumber(lineStarts, m.end) - const endCol = m.end - lineStarts[endLine] - - if (startLine === endLine) { - // 单行匹配 - const div = document.createElement('div') - div.className = 'search-hl' + (i === currentIndex ? ' current' : '') - div.style.cssText = `position:absolute;top:${startLine * lineHeight + paddingTop}px;left:${startCol * charWidth + paddingLeft}px;height:${lineHeight}px;width:${Math.max((endCol - startCol) * charWidth, 8)}px;` - overlay.appendChild(div) - } else { - // 多行匹配:第一行 - const div1 = document.createElement('div') - div1.className = 'search-hl' + (i === currentIndex ? ' current' : '') - const firstLineWidth = textarea.clientWidth - (startCol * charWidth + paddingLeft) - 20 - div1.style.cssText = `position:absolute;top:${startLine * lineHeight + paddingTop}px;left:${startCol * charWidth + paddingLeft}px;height:${lineHeight}px;width:${firstLineWidth}px;` - overlay.appendChild(div1) - - // 中间行 - for (let l = startLine + 1; l < endLine; l++) { - const divM = document.createElement('div') - divM.className = 'search-hl' + (i === currentIndex ? ' current' : '') - divM.style.cssText = `position:absolute;top:${l * lineHeight + paddingTop}px;left:${paddingLeft}px;height:${lineHeight}px;width:${textarea.clientWidth - paddingLeft * 2}px;` - overlay.appendChild(divM) - } - - // 最后一行 - const div2 = document.createElement('div') - div2.className = 'search-hl' + (i === currentIndex ? ' current' : '') - div2.style.cssText = `position:absolute;top:${endLine * lineHeight + paddingTop}px;left:${paddingLeft}px;height:${lineHeight}px;width:${endCol * charWidth}px;` - overlay.appendChild(div2) - } - } - }, []) - - // 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' && searchIsVisible) { - e.preventDefault() - const state = useSearchStore.getState() - if (e.shiftKey) state.findPrev() - else state.findNext() - } - }, [searchIsVisible]) - - // 更新光标位置 - 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 renderLineNumbers = () => { - const lineHeight = lineHeightRef.current - return Array.from({ length: lineCount }, (_, i) => ( -
- {i + 1} -
- )) - } - - // 清理 overlay + // 保存当前标签状态(切换前) useEffect(() => { return () => { - if (highlightRef.current) { - highlightRef.current.remove() - highlightRef.current = null + if (!activeTabId) return + updateTabScroll(activeTabId, { + scrollTop: getScrollTop(), + selectionStart: getSelection().from, + selectionEnd: getSelection().to + }) + } + }, [activeTabId]) + + // 搜索:Ctrl+F 聚焦搜索框,Enter 导航 + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + const isCtrl = e.ctrlKey || e.metaKey + + // Ctrl+B 粗体 + if (isCtrl && e.key === 'b') { + e.preventDefault() + const view = viewRef.current + if (!view) return + const { from, to } = view.state.selection.main + const selected = view.state.sliceDoc(from, to) || '粗体' + view.dispatch({ + changes: { from, to, insert: `**${selected}**` }, + selection: { anchor: from + 2, head: from + 2 + selected.length } + }) + } + + // Ctrl+I 斜体 + if (isCtrl && e.key === 'i') { + e.preventDefault() + const view = viewRef.current + if (!view) return + const { from, to } = view.state.selection.main + const selected = view.state.sliceDoc(from, to) || '斜体' + view.dispatch({ + changes: { from, to, insert: `*${selected}*` }, + selection: { anchor: from + 1, head: from + 1 + selected.length } + }) } } - }, []) + + document.addEventListener('keydown', handleKeyDown) + return () => document.removeEventListener('keydown', handleKeyDown) + }, [viewRef]) return ( -
-
- {renderLineNumbers()} -
-