feat: 替换 textarea 为 CodeMirror 6 编辑器
新增功能: - 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 斜体
This commit is contained in:
+10
-1
@@ -13,6 +13,13 @@
|
|||||||
"author": "MarkLite",
|
"author": "MarkLite",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"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",
|
"dexie": "^4.0.11",
|
||||||
"nanoid": "^5.1.5",
|
"nanoid": "^5.1.5",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
@@ -54,7 +61,9 @@
|
|||||||
"target": [
|
"target": [
|
||||||
{
|
{
|
||||||
"target": "nsis",
|
"target": "nsis",
|
||||||
"arch": ["x64"]
|
"arch": [
|
||||||
|
"x64"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"icon": "assets/icon.ico"
|
"icon": "assets/icon.ico"
|
||||||
|
|||||||
@@ -193,7 +193,7 @@ export default function App() {
|
|||||||
{viewMode !== 'preview' && (
|
{viewMode !== 'preview' && (
|
||||||
<div id="editor-panel" style={editorStyle}>
|
<div id="editor-panel" style={editorStyle}>
|
||||||
<SearchBar />
|
<SearchBar />
|
||||||
<Editor />
|
<Editor darkMode={darkMode} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{viewMode !== 'editor' && <Resizer onResize={saveSplitRatio} />}
|
{viewMode !== 'editor' && <Resizer onResize={saveSplitRatio} />}
|
||||||
|
|||||||
@@ -1,298 +1,108 @@
|
|||||||
import React, { useEffect, useRef, useCallback, useState } from 'react'
|
import React, { useEffect, useCallback } from 'react'
|
||||||
import { useTabStore } from '../../stores/tabStore'
|
import { useTabStore } from '../../stores/tabStore'
|
||||||
import { useSearchStore } from '../../stores/searchStore'
|
import { useSearchStore } from '../../stores/searchStore'
|
||||||
import { findMatches, buildLineStarts, getLineNumber } from '../../lib/searchEngine'
|
import { useCodeMirror } from './useCodeMirror'
|
||||||
|
import { EditorToolbar } from './EditorToolbar'
|
||||||
|
|
||||||
export function Editor() {
|
interface EditorProps {
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
darkMode: boolean
|
||||||
const lineNumbersRef = useRef<HTMLDivElement>(null)
|
}
|
||||||
const wrapperRef = useRef<HTMLDivElement>(null)
|
|
||||||
const highlightRef = useRef<HTMLDivElement | null>(null)
|
|
||||||
|
|
||||||
|
export function Editor({ darkMode }: EditorProps) {
|
||||||
const activeTabId = useTabStore(s => s.activeTabId)
|
const activeTabId = useTabStore(s => s.activeTabId)
|
||||||
const tabs = useTabStore(s => s.tabs)
|
const tabs = useTabStore(s => s.tabs)
|
||||||
const activeTab = tabs.find(t => t.id === activeTabId) ?? null
|
const activeTab = tabs.find(t => t.id === activeTabId) ?? null
|
||||||
const getActiveTab = useTabStore(s => s.getActiveTab)
|
|
||||||
const updateTabContent = useTabStore(s => s.updateTabContent)
|
const updateTabContent = useTabStore(s => s.updateTabContent)
|
||||||
const setModified = useTabStore(s => s.setModified)
|
const setModified = useTabStore(s => s.setModified)
|
||||||
|
const updateTabScroll = useTabStore(s => s.updateTabScroll)
|
||||||
|
|
||||||
// 搜索状态
|
const {
|
||||||
const searchIsVisible = useSearchStore(s => s.isVisible)
|
containerRef,
|
||||||
const searchText = useSearchStore(s => s.searchText)
|
viewRef,
|
||||||
const searchMatches = useSearchStore(s => s.matches)
|
setContent,
|
||||||
const searchCurrentIndex = useSearchStore(s => s.currentIndex)
|
getContent,
|
||||||
const searchOptions = useSearchStore(s => s.options)
|
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<ReturnType<typeof setTimeout> | null>(null)
|
|
||||||
|
|
||||||
// 行高
|
|
||||||
const lineHeightRef = useRef(20.8)
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const textarea = textareaRef.current
|
if (!activeTab) return
|
||||||
if (!textarea) return
|
setContent(activeTab.content)
|
||||||
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')
|
|
||||||
|
|
||||||
|
// 恢复滚动和选区
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
textarea.scrollTop = tab.scrollTop
|
setScrollTop(activeTab.scrollTop)
|
||||||
textarea.scrollLeft = tab.scrollLeft
|
setSelection(activeTab.selectionStart, activeTab.selectionEnd)
|
||||||
textarea.setSelectionRange(tab.selectionStart, tab.selectionEnd)
|
|
||||||
if (lineNumbersRef.current) lineNumbersRef.current.scrollTop = tab.scrollTop
|
|
||||||
})
|
})
|
||||||
}, [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) => (
|
|
||||||
<div key={i} className="line-num" style={{ height: lineHeight }}>
|
|
||||||
{i + 1}
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
// 清理 overlay
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
if (highlightRef.current) {
|
if (!activeTabId) return
|
||||||
highlightRef.current.remove()
|
updateTabScroll(activeTabId, {
|
||||||
highlightRef.current = null
|
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 (
|
return (
|
||||||
<div id="editor-wrapper" ref={wrapperRef}>
|
<div className="editor-container">
|
||||||
<div id="line-numbers" ref={lineNumbersRef}>
|
<EditorToolbar viewRef={viewRef} />
|
||||||
{renderLineNumbers()}
|
<div ref={containerRef} className="codemirror-wrapper" />
|
||||||
</div>
|
|
||||||
<textarea
|
|
||||||
ref={textareaRef}
|
|
||||||
id="editor"
|
|
||||||
spellCheck={false}
|
|
||||||
placeholder="在此输入 Markdown 内容,或拖拽 .md 文件到窗口打开..."
|
|
||||||
onInput={handleInput}
|
|
||||||
onScroll={handleScroll}
|
|
||||||
onClick={updateCursorPos}
|
|
||||||
onKeyUp={updateCursorPos}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import React, { useCallback } from 'react'
|
||||||
|
import type { EditorView } from '@codemirror/view'
|
||||||
|
|
||||||
|
interface EditorToolbarProps {
|
||||||
|
viewRef: React.MutableRefObject<EditorView | null>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EditorToolbar({ viewRef }: EditorToolbarProps) {
|
||||||
|
const insertFormatting = useCallback((before: string, after: string, placeholder: string) => {
|
||||||
|
const view = viewRef.current
|
||||||
|
if (!view) return
|
||||||
|
|
||||||
|
const { from, to } = view.state.selection.main
|
||||||
|
const selected = view.state.sliceDoc(from, to)
|
||||||
|
const text = selected || placeholder
|
||||||
|
const insert = before + text + after
|
||||||
|
|
||||||
|
view.dispatch({
|
||||||
|
changes: { from, to, insert },
|
||||||
|
selection: { anchor: from + before.length, head: from + before.length + text.length }
|
||||||
|
})
|
||||||
|
view.focus()
|
||||||
|
}, [viewRef])
|
||||||
|
|
||||||
|
const insertLinePrefix = useCallback((prefix: string) => {
|
||||||
|
const view = viewRef.current
|
||||||
|
if (!view) return
|
||||||
|
|
||||||
|
const { from } = view.state.selection.main
|
||||||
|
const line = view.state.doc.lineAt(from)
|
||||||
|
const currentLine = view.state.sliceDoc(line.from, line.to)
|
||||||
|
|
||||||
|
// 如果已经有前缀,移除它
|
||||||
|
if (currentLine.startsWith(prefix)) {
|
||||||
|
view.dispatch({
|
||||||
|
changes: { from: line.from, to: line.from + prefix.length, insert: '' }
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
view.dispatch({
|
||||||
|
changes: { from: line.from, to: line.from, insert: prefix }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
view.focus()
|
||||||
|
}, [viewRef])
|
||||||
|
|
||||||
|
const insertBlock = useCallback((text: string) => {
|
||||||
|
const view = viewRef.current
|
||||||
|
if (!view) return
|
||||||
|
|
||||||
|
const { from } = view.state.selection.main
|
||||||
|
const line = view.state.doc.lineAt(from)
|
||||||
|
const insertPos = line.to + 1
|
||||||
|
|
||||||
|
view.dispatch({
|
||||||
|
changes: { from: insertPos, to: insertPos, insert: '\n' + text + '\n' },
|
||||||
|
selection: { anchor: insertPos + 1, head: insertPos + 1 + text.length }
|
||||||
|
})
|
||||||
|
view.focus()
|
||||||
|
}, [viewRef])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="editor-toolbar">
|
||||||
|
<button className="toolbar-btn-sm" onClick={() => insertFormatting('**', '**', '粗体')} title="粗体 (Ctrl+B)">
|
||||||
|
<strong>B</strong>
|
||||||
|
</button>
|
||||||
|
<button className="toolbar-btn-sm" onClick={() => insertFormatting('*', '*', '斜体')} title="斜体 (Ctrl+I)">
|
||||||
|
<em>I</em>
|
||||||
|
</button>
|
||||||
|
<button className="toolbar-btn-sm" onClick={() => insertFormatting('~~', '~~', '删除线')} title="删除线">
|
||||||
|
<s>S</s>
|
||||||
|
</button>
|
||||||
|
<button className="toolbar-btn-sm" onClick={() => insertFormatting('`', '`', '代码')} title="行内代码">
|
||||||
|
{'</>'}
|
||||||
|
</button>
|
||||||
|
<div className="toolbar-divider-sm" />
|
||||||
|
<button className="toolbar-btn-sm" onClick={() => insertLinePrefix('# ')} title="标题">
|
||||||
|
H1
|
||||||
|
</button>
|
||||||
|
<button className="toolbar-btn-sm" onClick={() => insertLinePrefix('## ')} title="二级标题">
|
||||||
|
H2
|
||||||
|
</button>
|
||||||
|
<button className="toolbar-btn-sm" onClick={() => insertLinePrefix('### ')} title="三级标题">
|
||||||
|
H3
|
||||||
|
</button>
|
||||||
|
<div className="toolbar-divider-sm" />
|
||||||
|
<button className="toolbar-btn-sm" onClick={() => insertLinePrefix('- ')} title="无序列表">
|
||||||
|
•≡
|
||||||
|
</button>
|
||||||
|
<button className="toolbar-btn-sm" onClick={() => insertLinePrefix('1. ')} title="有序列表">
|
||||||
|
1.
|
||||||
|
</button>
|
||||||
|
<button className="toolbar-btn-sm" onClick={() => insertLinePrefix('> ')} title="引用">
|
||||||
|
❝
|
||||||
|
</button>
|
||||||
|
<button className="toolbar-btn-sm" onClick={() => insertBlock('```\n代码\n```')} title="代码块">
|
||||||
|
{'{ }'}
|
||||||
|
</button>
|
||||||
|
<div className="toolbar-divider-sm" />
|
||||||
|
<button className="toolbar-btn-sm" onClick={() => insertFormatting('[', '](url)', '链接文本')} title="链接">
|
||||||
|
🔗
|
||||||
|
</button>
|
||||||
|
<button className="toolbar-btn-sm" onClick={() => insertFormatting('', '图片描述')} title="图片">
|
||||||
|
🖼
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import { useEffect, useRef, useCallback } from 'react'
|
||||||
|
import { EditorState, type Extension } from '@codemirror/state'
|
||||||
|
import { EditorView, keymap, lineNumbers, highlightActiveLine, highlightSpecialChars, drawSelection, rectangularSelection } from '@codemirror/view'
|
||||||
|
import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands'
|
||||||
|
import { markdown, markdownLanguage } from '@codemirror/lang-markdown'
|
||||||
|
import { syntaxHighlighting, defaultHighlightStyle, indentOnInput, bracketMatching, foldGutter } from '@codemirror/language'
|
||||||
|
import { searchKeymap, highlightSelectionMatches } from '@codemirror/search'
|
||||||
|
import { oneDark } from '@codemirror/theme-one-dark'
|
||||||
|
|
||||||
|
interface UseCodeMirrorOptions {
|
||||||
|
content: string
|
||||||
|
onChange: (value: string) => void
|
||||||
|
darkMode: boolean
|
||||||
|
onScroll?: (ratio: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCodeMirror({ content, onChange, darkMode, onScroll }: UseCodeMirrorOptions) {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
|
const viewRef = useRef<EditorView | null>(null)
|
||||||
|
const isExternalUpdate = useRef(false)
|
||||||
|
|
||||||
|
// 初始化编辑器
|
||||||
|
useEffect(() => {
|
||||||
|
if (!containerRef.current) return
|
||||||
|
|
||||||
|
const extensions: Extension[] = [
|
||||||
|
lineNumbers(),
|
||||||
|
highlightActiveLine(),
|
||||||
|
highlightSpecialChars(),
|
||||||
|
drawSelection(),
|
||||||
|
rectangularSelection(),
|
||||||
|
history(),
|
||||||
|
indentOnInput(),
|
||||||
|
bracketMatching(),
|
||||||
|
foldGutter(),
|
||||||
|
highlightSelectionMatches(),
|
||||||
|
keymap.of([
|
||||||
|
...defaultKeymap,
|
||||||
|
...historyKeymap,
|
||||||
|
...searchKeymap,
|
||||||
|
indentWithTab
|
||||||
|
]),
|
||||||
|
markdown({ base: markdownLanguage }),
|
||||||
|
syntaxHighlighting(defaultHighlightStyle),
|
||||||
|
EditorView.lineWrapping,
|
||||||
|
EditorView.updateListener.of(update => {
|
||||||
|
if (update.docChanged && !isExternalUpdate.current) {
|
||||||
|
onChange(update.state.doc.toString())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
]
|
||||||
|
|
||||||
|
if (darkMode) extensions.push(oneDark)
|
||||||
|
|
||||||
|
const state = EditorState.create({
|
||||||
|
doc: content,
|
||||||
|
extensions
|
||||||
|
})
|
||||||
|
|
||||||
|
const view = new EditorView({
|
||||||
|
state,
|
||||||
|
parent: containerRef.current
|
||||||
|
})
|
||||||
|
|
||||||
|
viewRef.current = view
|
||||||
|
|
||||||
|
// 滚动事件监听
|
||||||
|
const handleScroll = () => {
|
||||||
|
if (onScroll) {
|
||||||
|
const dom = view.scrollDOM
|
||||||
|
const maxScroll = dom.scrollHeight - dom.clientHeight
|
||||||
|
const ratio = maxScroll > 0 ? dom.scrollTop / maxScroll : 0
|
||||||
|
onScroll(ratio)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
view.scrollDOM.addEventListener('scroll', handleScroll)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
view.scrollDOM.removeEventListener('scroll', handleScroll)
|
||||||
|
view.destroy()
|
||||||
|
viewRef.current = null
|
||||||
|
}
|
||||||
|
}, [darkMode]) // darkMode 变化时重建
|
||||||
|
|
||||||
|
// 外部内容更新(切换标签时)
|
||||||
|
const setContent = useCallback((newContent: string) => {
|
||||||
|
const view = viewRef.current
|
||||||
|
if (!view) return
|
||||||
|
const current = view.state.doc.toString()
|
||||||
|
if (current !== newContent) {
|
||||||
|
isExternalUpdate.current = true
|
||||||
|
view.dispatch({
|
||||||
|
changes: { from: 0, to: current.length, insert: newContent }
|
||||||
|
})
|
||||||
|
isExternalUpdate.current = false
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// 获取当前内容
|
||||||
|
const getContent = useCallback(() => {
|
||||||
|
return viewRef.current?.state.doc.toString() ?? ''
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// 获取/设置滚动位置
|
||||||
|
const getScrollTop = useCallback(() => {
|
||||||
|
return viewRef.current?.scrollDOM.scrollTop ?? 0
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const setScrollTop = useCallback((top: number) => {
|
||||||
|
if (viewRef.current) {
|
||||||
|
viewRef.current.scrollDOM.scrollTop = top
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// 获取/设置选区
|
||||||
|
const getSelection = useCallback(() => {
|
||||||
|
const view = viewRef.current
|
||||||
|
if (!view) return { from: 0, to: 0 }
|
||||||
|
const ranges = view.state.selection.ranges
|
||||||
|
return { from: ranges[0].from, to: ranges[0].to }
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const setSelection = useCallback((from: number, to: number) => {
|
||||||
|
const view = viewRef.current
|
||||||
|
if (!view) return
|
||||||
|
view.dispatch({ selection: { anchor: from, head: to } })
|
||||||
|
view.focus()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// 滚动到指定位置
|
||||||
|
const scrollTo = useCallback((pos: number) => {
|
||||||
|
const view = viewRef.current
|
||||||
|
if (!view) return
|
||||||
|
view.dispatch({ effects: EditorView.scrollIntoView(pos, { y: 'center' }) })
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return {
|
||||||
|
containerRef,
|
||||||
|
viewRef,
|
||||||
|
setContent,
|
||||||
|
getContent,
|
||||||
|
getScrollTop,
|
||||||
|
setScrollTop,
|
||||||
|
getSelection,
|
||||||
|
setSelection,
|
||||||
|
scrollTo
|
||||||
|
}
|
||||||
|
}
|
||||||
+110
-30
@@ -109,51 +109,131 @@ html, body {
|
|||||||
border-right: 1px solid var(--border);
|
border-right: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
#editor-wrapper {
|
/* Editor Container */
|
||||||
|
.editor-container {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
position: relative;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#line-numbers {
|
/* Editor Toolbar */
|
||||||
width: 50px;
|
.editor-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-bottom: 1px solid var(--border-light);
|
||||||
|
flex-shrink: 0;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-toolbar::-webkit-scrollbar { height: 0; }
|
||||||
|
|
||||||
|
.toolbar-btn-sm {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 28px;
|
||||||
|
height: 26px;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-btn-sm:hover {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-divider-sm {
|
||||||
|
width: 1px;
|
||||||
|
height: 20px;
|
||||||
|
background: var(--border);
|
||||||
|
margin: 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* CodeMirror Wrapper */
|
||||||
|
.codemirror-wrapper {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.codemirror-wrapper .cm-editor {
|
||||||
|
height: 100%;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.codemirror-wrapper .cm-editor .cm-scroller {
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.codemirror-wrapper .cm-editor .cm-content {
|
||||||
|
padding: 12px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.codemirror-wrapper .cm-editor .cm-line {
|
||||||
|
padding: 0 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.codemirror-wrapper .cm-editor .cm-gutters {
|
||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
border-right: 1px solid var(--border-light);
|
border-right: 1px solid var(--border-light);
|
||||||
padding: 12px 0;
|
|
||||||
text-align: right;
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 1.6;
|
|
||||||
color: var(--text-tertiary);
|
color: var(--text-tertiary);
|
||||||
overflow: hidden;
|
font-size: 12px;
|
||||||
user-select: none;
|
min-width: 40px;
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#line-numbers .line-num {
|
.codemirror-wrapper .cm-editor .cm-activeLineGutter {
|
||||||
padding-right: 12px;
|
background: var(--bg-tertiary);
|
||||||
height: 20.8px;
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
#editor {
|
.codemirror-wrapper .cm-editor .cm-activeLine {
|
||||||
flex: 1;
|
background: var(--bg-tertiary);
|
||||||
width: 100%;
|
}
|
||||||
padding: 12px 16px;
|
|
||||||
border: none;
|
.codemirror-wrapper .cm-editor.cm-focused {
|
||||||
outline: none;
|
outline: none;
|
||||||
resize: none;
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 1.6;
|
|
||||||
color: var(--text);
|
|
||||||
background: var(--bg);
|
|
||||||
tab-size: 4;
|
|
||||||
overflow-y: auto;
|
|
||||||
user-select: text;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#editor::placeholder { color: var(--text-tertiary); }
|
/* 搜索高亮 */
|
||||||
|
.cm-searchMatch {
|
||||||
|
background: rgba(255, 220, 0, 0.35);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cm-searchMatch.cm-searchMatch-selected {
|
||||||
|
background: rgba(255, 150, 0, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fold gutter */
|
||||||
|
.cm-foldGutter .cm-gutterElement {
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cm-foldGutter .cm-gutterElement:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Selection */
|
||||||
|
.cm-selectionBackground {
|
||||||
|
background: rgba(26, 115, 232, 0.2) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cm-editor .cm-cursor {
|
||||||
|
border-left-color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
/* Resizer */
|
/* Resizer */
|
||||||
#resizer {
|
#resizer {
|
||||||
|
|||||||
Reference in New Issue
Block a user