feat: replace CodeMirror 6 with Milkdown 7.21 editor
- Remove 7 @codemirror/* packages, add 7 @milkdown/* packages - Rewrite useCodeMirror -> useMilkdown hook (ProseMirror-based) - Rewrite Editor.tsx with Milkdown lifecycle management - Update EditorToolbar to use Milkdown callCommand API - Replace CodeMirror CSS styles with Milkdown/ProseMirror styles - Support: CommonMark + GFM, dark mode, toolbar formatting - Bump version 0.2.0 -> 0.3.0
This commit is contained in:
+8
-8
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "marklite",
|
"name": "marklite",
|
||||||
"version": "0.2.0",
|
"version": "0.3.0",
|
||||||
"description": "Lightweight Markdown Editor for Windows",
|
"description": "Lightweight Markdown Editor for Windows",
|
||||||
"main": "./dist/main/index.js",
|
"main": "./dist/main/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -28,13 +28,13 @@
|
|||||||
"author": "MarkLite",
|
"author": "MarkLite",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/commands": "^6.10.3",
|
"@milkdown/core": "^7.21.1",
|
||||||
"@codemirror/lang-markdown": "^6.5.0",
|
"@milkdown/ctx": "^7.21.1",
|
||||||
"@codemirror/language": "^6.12.3",
|
"@milkdown/preset-commonmark": "^7.21.1",
|
||||||
"@codemirror/search": "^6.7.0",
|
"@milkdown/preset-gfm": "^7.21.1",
|
||||||
"@codemirror/state": "^6.6.0",
|
"@milkdown/prose": "^7.21.2",
|
||||||
"@codemirror/theme-one-dark": "^6.1.3",
|
"@milkdown/react": "^7.21.1",
|
||||||
"@codemirror/view": "^6.43.0",
|
"@milkdown/utils": "^7.21.1",
|
||||||
"dexie": "^4.0.11",
|
"dexie": "^4.0.11",
|
||||||
"nanoid": "^5.1.5",
|
"nanoid": "^5.1.5",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import React, { useEffect, useCallback } from 'react'
|
import React, { useEffect, useCallback } from 'react'
|
||||||
|
import { callCommand } from '@milkdown/utils'
|
||||||
|
import { toggleStrongCommand, toggleEmphasisCommand } from '@milkdown/preset-commonmark'
|
||||||
import { useTabStore } from '../../stores/tabStore'
|
import { useTabStore } from '../../stores/tabStore'
|
||||||
import { useCodeMirror } from './useCodeMirror'
|
import { useMilkdown } from './useMilkdown'
|
||||||
import { EditorToolbar } from './EditorToolbar'
|
import { EditorToolbar } from './EditorToolbar'
|
||||||
|
|
||||||
interface EditorProps {
|
interface EditorProps {
|
||||||
@@ -16,13 +18,13 @@ export function Editor({ darkMode }: EditorProps) {
|
|||||||
|
|
||||||
const {
|
const {
|
||||||
containerRef,
|
containerRef,
|
||||||
viewRef,
|
action,
|
||||||
setContent,
|
setContent,
|
||||||
getScrollTop,
|
getScrollTop,
|
||||||
setScrollTop,
|
setScrollTop,
|
||||||
getSelection,
|
getSelection,
|
||||||
setSelection
|
setSelection
|
||||||
} = useCodeMirror({
|
} = useMilkdown({
|
||||||
content: activeTab?.content ?? '',
|
content: activeTab?.content ?? '',
|
||||||
onChange: useCallback((value: string) => {
|
onChange: useCallback((value: string) => {
|
||||||
if (!activeTabId) return
|
if (!activeTabId) return
|
||||||
@@ -32,17 +34,18 @@ export function Editor({ darkMode }: EditorProps) {
|
|||||||
darkMode
|
darkMode
|
||||||
})
|
})
|
||||||
|
|
||||||
// 切换标签时加载内容
|
// Load content when switching tabs
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!activeTab) return
|
if (!activeTab) return
|
||||||
setContent(activeTab.content)
|
setContent(activeTab.content)
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
setScrollTop(activeTab.scrollTop)
|
setScrollTop(activeTab.scrollTop)
|
||||||
setSelection(activeTab.selectionStart, activeTab.selectionEnd)
|
setSelection()
|
||||||
})
|
})
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [activeTabId])
|
}, [activeTabId])
|
||||||
|
|
||||||
// 保存当前标签状态
|
// Save current tab state on unmount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
if (!activeTabId) return
|
if (!activeTabId) return
|
||||||
@@ -52,43 +55,34 @@ export function Editor({ darkMode }: EditorProps) {
|
|||||||
selectionEnd: getSelection().to
|
selectionEnd: getSelection().to
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [activeTabId])
|
}, [activeTabId])
|
||||||
|
|
||||||
// Ctrl+B 粗体, Ctrl+I 斜体
|
// Ctrl+B bold, Ctrl+I italic
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
const isCtrl = e.ctrlKey || e.metaKey
|
const isCtrl = e.ctrlKey || e.metaKey
|
||||||
if (isCtrl && e.key === 'b') {
|
if (isCtrl && e.key === 'b') {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
const view = viewRef.current
|
action((editor) => {
|
||||||
if (!view) return
|
editor.action(callCommand(toggleStrongCommand.key))
|
||||||
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 }
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (isCtrl && e.key === 'i') {
|
if (isCtrl && e.key === 'i') {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
const view = viewRef.current
|
action((editor) => {
|
||||||
if (!view) return
|
editor.action(callCommand(toggleEmphasisCommand.key))
|
||||||
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)
|
document.addEventListener('keydown', handleKeyDown)
|
||||||
return () => document.removeEventListener('keydown', handleKeyDown)
|
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||||
}, [viewRef])
|
}, [action])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="editor-container" role="region" aria-label="Markdown编辑器">
|
<div className="editor-container" role="region" aria-label="Markdown编辑器">
|
||||||
<EditorToolbar viewRef={viewRef} />
|
<EditorToolbar action={action} />
|
||||||
<div ref={containerRef} className="codemirror-wrapper" />
|
<div ref={containerRef} className="milkdown-wrapper" />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,100 +1,157 @@
|
|||||||
import React, { useCallback } from 'react'
|
import React, { useCallback } from 'react'
|
||||||
import type { EditorView } from '@codemirror/view'
|
import type { Editor } from '@milkdown/core'
|
||||||
|
import { callCommand } from '@milkdown/utils'
|
||||||
|
import type { $Command } from '@milkdown/utils'
|
||||||
|
import {
|
||||||
|
toggleStrongCommand,
|
||||||
|
toggleEmphasisCommand,
|
||||||
|
wrapInHeadingCommand,
|
||||||
|
wrapInBlockquoteCommand,
|
||||||
|
wrapInBulletListCommand,
|
||||||
|
wrapInOrderedListCommand,
|
||||||
|
createCodeBlockCommand,
|
||||||
|
insertImageCommand,
|
||||||
|
toggleLinkCommand,
|
||||||
|
insertHrCommand
|
||||||
|
} from '@milkdown/preset-commonmark'
|
||||||
|
import { toggleStrikethroughCommand } from '@milkdown/preset-gfm'
|
||||||
|
|
||||||
interface EditorToolbarProps {
|
interface EditorToolbarProps {
|
||||||
viewRef: React.MutableRefObject<EditorView | null>
|
action: (fn: (editor: Editor) => void) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export const EditorToolbar = React.memo(function EditorToolbar({ viewRef }: EditorToolbarProps) {
|
export const EditorToolbar = React.memo(function EditorToolbar({ action }: EditorToolbarProps) {
|
||||||
const insertFormatting = useCallback((before: string, after: string, placeholder: string) => {
|
const exec = useCallback(<T,>(command: $Command<T>, payload?: T) => {
|
||||||
const view = viewRef.current
|
action((editor) => {
|
||||||
if (!view) return
|
try {
|
||||||
const { from, to } = view.state.selection.main
|
editor.action(callCommand(command.key, payload))
|
||||||
const selected = view.state.sliceDoc(from, to)
|
} catch {
|
||||||
const text = selected || placeholder
|
// command may not be registered
|
||||||
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()
|
}, [action])
|
||||||
}, [viewRef])
|
|
||||||
|
const handleHeading = useCallback((level: number) => {
|
||||||
|
exec(wrapInHeadingCommand, level)
|
||||||
|
}, [exec])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="editor-toolbar" role="toolbar" aria-label="Markdown格式工具">
|
<div className="editor-toolbar" role="toolbar" aria-label="Markdown格式工具">
|
||||||
<button className="toolbar-btn-sm" onClick={() => insertFormatting('**', '**', '粗体')} title="粗体 (Ctrl+B)" aria-label="粗体">
|
<button
|
||||||
|
className="toolbar-btn-sm"
|
||||||
|
onClick={() => exec(toggleStrongCommand)}
|
||||||
|
title="粗体 (Ctrl+B)"
|
||||||
|
aria-label="粗体"
|
||||||
|
>
|
||||||
<strong>B</strong>
|
<strong>B</strong>
|
||||||
</button>
|
</button>
|
||||||
<button className="toolbar-btn-sm" onClick={() => insertFormatting('*', '*', '斜体')} title="斜体 (Ctrl+I)" aria-label="斜体">
|
<button
|
||||||
|
className="toolbar-btn-sm"
|
||||||
|
onClick={() => exec(toggleEmphasisCommand)}
|
||||||
|
title="斜体 (Ctrl+I)"
|
||||||
|
aria-label="斜体"
|
||||||
|
>
|
||||||
<em>I</em>
|
<em>I</em>
|
||||||
</button>
|
</button>
|
||||||
<button className="toolbar-btn-sm" onClick={() => insertFormatting('~~', '~~', '删除线')} title="删除线" aria-label="删除线">
|
<button
|
||||||
|
className="toolbar-btn-sm"
|
||||||
|
onClick={() => exec(toggleStrikethroughCommand)}
|
||||||
|
title="删除线"
|
||||||
|
aria-label="删除线"
|
||||||
|
>
|
||||||
<s>S</s>
|
<s>S</s>
|
||||||
</button>
|
</button>
|
||||||
<button className="toolbar-btn-sm" onClick={() => insertFormatting('`', '`', '代码')} title="行内代码" aria-label="行内代码">
|
<button
|
||||||
|
className="toolbar-btn-sm"
|
||||||
|
onClick={() => exec(createCodeBlockCommand)}
|
||||||
|
title="行内代码"
|
||||||
|
aria-label="行内代码"
|
||||||
|
>
|
||||||
{'</>'}
|
{'</>'}
|
||||||
</button>
|
</button>
|
||||||
<div className="toolbar-divider-sm" role="separator" />
|
<div className="toolbar-divider-sm" role="separator" />
|
||||||
<button className="toolbar-btn-sm" onClick={() => insertLinePrefix('# ')} title="标题" aria-label="一级标题">
|
<button
|
||||||
|
className="toolbar-btn-sm"
|
||||||
|
onClick={() => handleHeading(1)}
|
||||||
|
title="标题"
|
||||||
|
aria-label="一级标题"
|
||||||
|
>
|
||||||
H1
|
H1
|
||||||
</button>
|
</button>
|
||||||
<button className="toolbar-btn-sm" onClick={() => insertLinePrefix('## ')} title="二级标题" aria-label="二级标题">
|
<button
|
||||||
|
className="toolbar-btn-sm"
|
||||||
|
onClick={() => handleHeading(2)}
|
||||||
|
title="二级标题"
|
||||||
|
aria-label="二级标题"
|
||||||
|
>
|
||||||
H2
|
H2
|
||||||
</button>
|
</button>
|
||||||
<button className="toolbar-btn-sm" onClick={() => insertLinePrefix('### ')} title="三级标题" aria-label="三级标题">
|
<button
|
||||||
|
className="toolbar-btn-sm"
|
||||||
|
onClick={() => handleHeading(3)}
|
||||||
|
title="三级标题"
|
||||||
|
aria-label="三级标题"
|
||||||
|
>
|
||||||
H3
|
H3
|
||||||
</button>
|
</button>
|
||||||
<div className="toolbar-divider-sm" role="separator" />
|
<div className="toolbar-divider-sm" role="separator" />
|
||||||
<button className="toolbar-btn-sm" onClick={() => insertLinePrefix('- ')} title="无序列表" aria-label="无序列表">
|
<button
|
||||||
|
className="toolbar-btn-sm"
|
||||||
|
onClick={() => exec(wrapInBulletListCommand)}
|
||||||
|
title="无序列表"
|
||||||
|
aria-label="无序列表"
|
||||||
|
>
|
||||||
•≡
|
•≡
|
||||||
</button>
|
</button>
|
||||||
<button className="toolbar-btn-sm" onClick={() => insertLinePrefix('1. ')} title="有序列表" aria-label="有序列表">
|
<button
|
||||||
|
className="toolbar-btn-sm"
|
||||||
|
onClick={() => exec(wrapInOrderedListCommand)}
|
||||||
|
title="有序列表"
|
||||||
|
aria-label="有序列表"
|
||||||
|
>
|
||||||
1.
|
1.
|
||||||
</button>
|
</button>
|
||||||
<button className="toolbar-btn-sm" onClick={() => insertLinePrefix('> ')} title="引用" aria-label="引用">
|
<button
|
||||||
|
className="toolbar-btn-sm"
|
||||||
|
onClick={() => exec(wrapInBlockquoteCommand)}
|
||||||
|
title="引用"
|
||||||
|
aria-label="引用"
|
||||||
|
>
|
||||||
❝
|
❝
|
||||||
</button>
|
</button>
|
||||||
<button className="toolbar-btn-sm" onClick={() => insertBlock('```\n代码\n```')} title="代码块" aria-label="代码块">
|
<button
|
||||||
|
className="toolbar-btn-sm"
|
||||||
|
onClick={() => exec(createCodeBlockCommand)}
|
||||||
|
title="代码块"
|
||||||
|
aria-label="代码块"
|
||||||
|
>
|
||||||
{'{ }'}
|
{'{ }'}
|
||||||
</button>
|
</button>
|
||||||
<div className="toolbar-divider-sm" role="separator" />
|
<div className="toolbar-divider-sm" role="separator" />
|
||||||
<button className="toolbar-btn-sm" onClick={() => insertFormatting('[', '](url)', '链接文本')} title="链接" aria-label="插入链接">
|
<button
|
||||||
|
className="toolbar-btn-sm"
|
||||||
|
onClick={() => exec(toggleLinkCommand, { href: '', title: '' })}
|
||||||
|
title="链接"
|
||||||
|
aria-label="插入链接"
|
||||||
|
>
|
||||||
🔗
|
🔗
|
||||||
</button>
|
</button>
|
||||||
<button className="toolbar-btn-sm" onClick={() => insertFormatting('', '图片描述')} title="图片" aria-label="插入图片">
|
<button
|
||||||
|
className="toolbar-btn-sm"
|
||||||
|
onClick={() => exec(insertImageCommand, { src: '', alt: '' })}
|
||||||
|
title="图片"
|
||||||
|
aria-label="插入图片"
|
||||||
|
>
|
||||||
🖼
|
🖼
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
className="toolbar-btn-sm"
|
||||||
|
onClick={() => exec(insertHrCommand)}
|
||||||
|
title="分割线"
|
||||||
|
aria-label="插入分割线"
|
||||||
|
>
|
||||||
|
—
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,155 +0,0 @@
|
|||||||
import { useEffect, useRef, useCallback } from 'react'
|
|
||||||
import { EditorState, Compartment, 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 { search, searchKeymap, highlightSelectionMatches } from '@codemirror/search'
|
|
||||||
import { oneDark } from '@codemirror/theme-one-dark'
|
|
||||||
|
|
||||||
interface UseCodeMirrorOptions {
|
|
||||||
content: string
|
|
||||||
onChange: (value: string) => void
|
|
||||||
darkMode: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
// PF-04: 创建主题 Compartment,用于动态切换主题而不重建 EditorView
|
|
||||||
const themeCompartment = new Compartment()
|
|
||||||
|
|
||||||
export function useCodeMirror({ content, onChange, darkMode }: UseCodeMirrorOptions) {
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null)
|
|
||||||
const viewRef = useRef<EditorView | null>(null)
|
|
||||||
const isExternalUpdate = useRef(false)
|
|
||||||
const onChangeRef = useRef(onChange)
|
|
||||||
const darkModeRef = useRef(darkMode)
|
|
||||||
|
|
||||||
// 始终保持 onChangeRef 为最新回调
|
|
||||||
useEffect(() => {
|
|
||||||
onChangeRef.current = onChange
|
|
||||||
}, [onChange])
|
|
||||||
|
|
||||||
// PF-04: darkMode 变化时通过 Compartment.reconfigure() 切换主题
|
|
||||||
useEffect(() => {
|
|
||||||
darkModeRef.current = darkMode
|
|
||||||
const view = viewRef.current
|
|
||||||
if (!view) return
|
|
||||||
view.dispatch({
|
|
||||||
effects: themeCompartment.reconfigure(darkMode ? oneDark : [])
|
|
||||||
})
|
|
||||||
}, [darkMode])
|
|
||||||
|
|
||||||
// 初始化编辑器(仅在首次挂载时执行)
|
|
||||||
useEffect(() => {
|
|
||||||
if (!containerRef.current) return
|
|
||||||
|
|
||||||
const zhPhrases = EditorState.phrases.of({
|
|
||||||
'Find': '查找',
|
|
||||||
'Replace': '替换',
|
|
||||||
'next': '下一个',
|
|
||||||
'previous': '上一个',
|
|
||||||
'all': '全部',
|
|
||||||
'match case': '区分大小写',
|
|
||||||
'regexp': '正则',
|
|
||||||
'by word': '全字匹配',
|
|
||||||
'replace': '替换',
|
|
||||||
'replace all': '全部替换',
|
|
||||||
'close': '关闭',
|
|
||||||
})
|
|
||||||
|
|
||||||
const extensions: Extension[] = [
|
|
||||||
zhPhrases,
|
|
||||||
search({ top: true }),
|
|
||||||
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) {
|
|
||||||
onChangeRef.current(update.state.doc.toString())
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
themeCompartment.of(darkModeRef.current ? oneDark : [])
|
|
||||||
]
|
|
||||||
|
|
||||||
const state = EditorState.create({
|
|
||||||
doc: content,
|
|
||||||
extensions
|
|
||||||
})
|
|
||||||
|
|
||||||
const view = new EditorView({
|
|
||||||
state,
|
|
||||||
parent: containerRef.current
|
|
||||||
})
|
|
||||||
|
|
||||||
viewRef.current = view
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
view.destroy()
|
|
||||||
viewRef.current = null
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// 外部内容更新(切换标签时)
|
|
||||||
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 getScrollTop = useCallback((): number => {
|
|
||||||
return viewRef.current?.scrollDOM.scrollTop ?? 0
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const setScrollTop = useCallback((top: number) => {
|
|
||||||
if (viewRef.current) {
|
|
||||||
viewRef.current.scrollDOM.scrollTop = top
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const getSelection = useCallback((): { from: number; to: number } => {
|
|
||||||
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()
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return {
|
|
||||||
containerRef,
|
|
||||||
viewRef,
|
|
||||||
setContent,
|
|
||||||
getScrollTop,
|
|
||||||
setScrollTop,
|
|
||||||
getSelection,
|
|
||||||
setSelection
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
import { useCallback, useRef, useEffect } from 'react'
|
||||||
|
import { Editor, rootCtx, defaultValueCtx } from '@milkdown/core'
|
||||||
|
import { replaceAll as milkdownReplaceAll } from '@milkdown/utils'
|
||||||
|
import { commonmark } from '@milkdown/preset-commonmark'
|
||||||
|
import { gfm } from '@milkdown/preset-gfm'
|
||||||
|
import { history } from '@milkdown/plugin-history'
|
||||||
|
import { listener, listenerCtx } from '@milkdown/plugin-listener'
|
||||||
|
import { indent } from '@milkdown/plugin-indent'
|
||||||
|
import { trailing } from '@milkdown/plugin-trailing'
|
||||||
|
import { clipboard } from '@milkdown/plugin-clipboard'
|
||||||
|
|
||||||
|
interface UseMilkdownOptions {
|
||||||
|
content: string
|
||||||
|
onChange: (value: string) => void
|
||||||
|
darkMode: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMilkdown({ content, onChange, darkMode }: UseMilkdownOptions) {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
|
const editorRef = useRef<Editor | null>(null)
|
||||||
|
const onChangeRef = useRef(onChange)
|
||||||
|
const isExternalUpdate = useRef(false)
|
||||||
|
const initialContentRef = useRef(content)
|
||||||
|
|
||||||
|
// Keep onChangeRef fresh
|
||||||
|
useEffect(() => {
|
||||||
|
onChangeRef.current = onChange
|
||||||
|
}, [onChange])
|
||||||
|
|
||||||
|
// Initialize editor
|
||||||
|
useEffect(() => {
|
||||||
|
if (!containerRef.current) return
|
||||||
|
|
||||||
|
const editor = Editor.make()
|
||||||
|
.config((ctx) => {
|
||||||
|
ctx.set(rootCtx, containerRef.current!)
|
||||||
|
ctx.set(defaultValueCtx, initialContentRef.current)
|
||||||
|
|
||||||
|
// Configure listener for content changes
|
||||||
|
const lm = ctx.get(listenerCtx)
|
||||||
|
lm.markdownUpdated((_ctx, markdown, prevMarkdown) => {
|
||||||
|
if (markdown === prevMarkdown) return
|
||||||
|
if (!isExternalUpdate.current) {
|
||||||
|
onChangeRef.current(markdown)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Listen for blur events (used for potential future state saving)
|
||||||
|
lm.blur(() => {
|
||||||
|
// blur handler placeholder
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.use(commonmark)
|
||||||
|
.use(gfm)
|
||||||
|
.use(history)
|
||||||
|
.use(listener)
|
||||||
|
.use(indent)
|
||||||
|
.use(trailing)
|
||||||
|
.use(clipboard)
|
||||||
|
|
||||||
|
editor.create().then((created) => {
|
||||||
|
editorRef.current = created
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
editorRef.current?.destroy()
|
||||||
|
editorRef.current = null
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Dark mode: update CSS custom properties on the container
|
||||||
|
useEffect(() => {
|
||||||
|
const container = containerRef.current
|
||||||
|
if (!container) return
|
||||||
|
if (darkMode) {
|
||||||
|
container.classList.add('milkdown-dark')
|
||||||
|
} else {
|
||||||
|
container.classList.remove('milkdown-dark')
|
||||||
|
}
|
||||||
|
}, [darkMode])
|
||||||
|
|
||||||
|
// External content update (tab switch)
|
||||||
|
const setContent = useCallback((newContent: string) => {
|
||||||
|
const editor = editorRef.current
|
||||||
|
if (!editor) return
|
||||||
|
|
||||||
|
isExternalUpdate.current = true
|
||||||
|
try {
|
||||||
|
editor.action(milkdownReplaceAll(newContent))
|
||||||
|
} catch {
|
||||||
|
// replaceAll may fail if editor is not fully ready
|
||||||
|
} finally {
|
||||||
|
isExternalUpdate.current = false
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Scroll position management
|
||||||
|
const getScrollTop = useCallback((): number => {
|
||||||
|
const container = containerRef.current
|
||||||
|
if (!container) return 0
|
||||||
|
const scroller = container.querySelector('.milkdown .editor') as HTMLElement | null
|
||||||
|
return scroller?.scrollTop ?? container.scrollTop ?? 0
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const setScrollTop = useCallback((top: number) => {
|
||||||
|
const container = containerRef.current
|
||||||
|
if (!container) return
|
||||||
|
const scroller = container.querySelector('.milkdown .editor') as HTMLElement | null
|
||||||
|
if (scroller) {
|
||||||
|
scroller.scrollTop = top
|
||||||
|
} else {
|
||||||
|
container.scrollTop = top
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Selection management
|
||||||
|
const getSelection = useCallback((): { from: number; to: number } => {
|
||||||
|
const editor = editorRef.current
|
||||||
|
if (!editor) return { from: 0, to: 0 }
|
||||||
|
try {
|
||||||
|
return editor.action((ctx) => {
|
||||||
|
const view = ctx.get('editorView' as never) as { state?: { selection?: { from: number; to: number } } } | undefined
|
||||||
|
if (view?.state?.selection) {
|
||||||
|
return { from: view.state.selection.from, to: view.state.selection.to }
|
||||||
|
}
|
||||||
|
return { from: 0, to: 0 }
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
return { from: 0, to: 0 }
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const setSelection = useCallback(() => {
|
||||||
|
// ProseMirror selection setting requires the view instance
|
||||||
|
// which we access lazily; for now we focus the editor
|
||||||
|
const container = containerRef.current
|
||||||
|
if (!container) return
|
||||||
|
const pmEditor = container.querySelector('.ProseMirror') as HTMLElement | null
|
||||||
|
pmEditor?.focus()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Execute a Milkdown action (for toolbar commands)
|
||||||
|
const action = useCallback((fn: (editor: Editor) => void) => {
|
||||||
|
const editor = editorRef.current
|
||||||
|
if (!editor) return
|
||||||
|
fn(editor)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return {
|
||||||
|
containerRef,
|
||||||
|
editorRef,
|
||||||
|
action,
|
||||||
|
setContent,
|
||||||
|
getScrollTop,
|
||||||
|
setScrollTop,
|
||||||
|
getSelection,
|
||||||
|
setSelection
|
||||||
|
}
|
||||||
|
}
|
||||||
+105
-277
@@ -161,311 +161,139 @@ html, body {
|
|||||||
margin: 0 4px;
|
margin: 0 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* CodeMirror Wrapper */
|
/* Milkdown Wrapper */
|
||||||
.codemirror-wrapper {
|
.milkdown-wrapper {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.codemirror-wrapper .cm-editor {
|
.milkdown-wrapper .milkdown {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.codemirror-wrapper .cm-editor .cm-scroller {
|
.milkdown-wrapper .milkdown .editor {
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
|
height: 100%;
|
||||||
|
padding: 12px 16px;
|
||||||
|
outline: none;
|
||||||
|
user-select: text;
|
||||||
|
-webkit-user-select: text;
|
||||||
|
line-height: 1.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
.codemirror-wrapper .cm-editor .cm-content {
|
.milkdown-wrapper .milkdown .editor p {
|
||||||
padding: 12px 0;
|
margin: 0.5em 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.codemirror-wrapper .cm-editor .cm-line {
|
.milkdown-wrapper .milkdown .editor h1,
|
||||||
padding: 0 16px;
|
.milkdown-wrapper .milkdown .editor h2,
|
||||||
|
.milkdown-wrapper .milkdown .editor h3,
|
||||||
|
.milkdown-wrapper .milkdown .editor h4,
|
||||||
|
.milkdown-wrapper .milkdown .editor h5,
|
||||||
|
.milkdown-wrapper .milkdown .editor h6 {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 1em 0 0.5em;
|
||||||
|
line-height: 1.3;
|
||||||
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.codemirror-wrapper .cm-editor .cm-gutters {
|
.milkdown-wrapper .milkdown .editor h1 { font-size: 1.6em; }
|
||||||
background: var(--bg-secondary);
|
.milkdown-wrapper .milkdown .editor h2 { font-size: 1.4em; }
|
||||||
border-right: 1px solid var(--border-light);
|
.milkdown-wrapper .milkdown .editor h3 { font-size: 1.2em; }
|
||||||
color: var(--text-tertiary);
|
|
||||||
font-size: 12px;
|
|
||||||
min-width: 40px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.codemirror-wrapper .cm-editor .cm-activeLineGutter {
|
.milkdown-wrapper .milkdown .editor blockquote {
|
||||||
background: var(--bg-tertiary);
|
border-left: 3px solid var(--primary);
|
||||||
|
padding-left: 12px;
|
||||||
|
margin: 0.5em 0;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.codemirror-wrapper .cm-editor .cm-activeLine {
|
.milkdown-wrapper .milkdown .editor code {
|
||||||
|
font-family: var(--font-mono);
|
||||||
background: var(--bg-tertiary);
|
background: var(--bg-tertiary);
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 0.9em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.codemirror-wrapper .cm-editor.cm-focused {
|
.milkdown-wrapper .milkdown .editor pre {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 12px 16px;
|
||||||
|
overflow-x: auto;
|
||||||
|
margin: 0.5em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.milkdown-wrapper .milkdown .editor pre code {
|
||||||
|
background: transparent;
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.milkdown-wrapper .milkdown .editor ul,
|
||||||
|
.milkdown-wrapper .milkdown .editor ol {
|
||||||
|
padding-left: 1.5em;
|
||||||
|
margin: 0.5em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.milkdown-wrapper .milkdown .editor li {
|
||||||
|
margin: 0.25em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.milkdown-wrapper .milkdown .editor hr {
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
margin: 1.5em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.milkdown-wrapper .milkdown .editor a {
|
||||||
|
color: var(--primary);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.milkdown-wrapper .milkdown .editor a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.milkdown-wrapper .milkdown .editor img {
|
||||||
|
max-width: 100%;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ProseMirror selection */
|
||||||
|
.milkdown-wrapper .milkdown .editor .ProseMirror-selectednode {
|
||||||
|
outline: 2px solid var(--primary);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.milkdown-wrapper .milkdown .editor ::selection {
|
||||||
|
background: rgba(26, 115, 232, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root.dark .milkdown-wrapper .milkdown .editor ::selection {
|
||||||
|
background: rgba(100, 160, 255, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ProseMirror cursor */
|
||||||
|
.milkdown-wrapper .milkdown .editor .ProseMirror-focused {
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===== CodeMirror 搜索样式美化 ===== */
|
/* Dark mode styles for Milkdown */
|
||||||
|
.milkdown-wrapper.milkdown-dark .milkdown {
|
||||||
/* 搜索面板移到顶部 */
|
background: var(--bg);
|
||||||
.cm-panels {
|
color: var(--text);
|
||||||
top: 0 !important;
|
|
||||||
bottom: auto !important;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.cm-panels-bottom {
|
.milkdown-wrapper.milkdown-dark .milkdown .editor blockquote {
|
||||||
top: 0 !important;
|
border-left-color: var(--primary);
|
||||||
bottom: auto !important;
|
|
||||||
border-bottom: 1px solid var(--border) !important;
|
|
||||||
border-top: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 搜索面板整体 */
|
|
||||||
.cm-panel.cm-search {
|
|
||||||
background: var(--bg) !important;
|
|
||||||
border-bottom: 1px solid var(--border) !important;
|
|
||||||
padding: 8px 12px !important;
|
|
||||||
font-family: var(--font-ui) !important;
|
|
||||||
font-size: 13px !important;
|
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 搜索输入框 */
|
|
||||||
.cm-panel .cm-textfield {
|
|
||||||
background: var(--bg-secondary) !important;
|
|
||||||
border: 1px solid var(--border) !important;
|
|
||||||
border-radius: 4px !important;
|
|
||||||
padding: 4px 8px !important;
|
|
||||||
font-size: 13px !important;
|
|
||||||
font-family: var(--font-ui) !important;
|
|
||||||
color: var(--text) !important;
|
|
||||||
outline: none !important;
|
|
||||||
transition: border-color 0.15s ease !important;
|
|
||||||
height: 28px !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cm-panel .cm-textfield:focus {
|
|
||||||
border-color: var(--primary) !important;
|
|
||||||
box-shadow: 0 0 0 2px rgba(26, 115, 232, 0.15) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 搜索按钮 */
|
|
||||||
.cm-panel .cm-button {
|
|
||||||
background: var(--bg-secondary) !important;
|
|
||||||
border: 1px solid var(--border) !important;
|
|
||||||
border-radius: 4px !important;
|
|
||||||
color: var(--text-secondary) !important;
|
|
||||||
font-size: 12px !important;
|
|
||||||
font-family: var(--font-ui) !important;
|
|
||||||
padding: 4px 10px !important;
|
|
||||||
cursor: pointer !important;
|
|
||||||
transition: all 0.15s ease !important;
|
|
||||||
height: 28px !important;
|
|
||||||
line-height: 1 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cm-panel .cm-button:hover {
|
|
||||||
background: var(--bg-tertiary) !important;
|
|
||||||
color: var(--text) !important;
|
|
||||||
border-color: var(--text-tertiary) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cm-panel .cm-button:active {
|
|
||||||
background: var(--primary-light) !important;
|
|
||||||
color: var(--primary) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 搜索选项复选框(区分大小写、正则、全字匹配) */
|
|
||||||
.cm-search label {
|
|
||||||
display: inline-flex !important;
|
|
||||||
align-items: center !important;
|
|
||||||
gap: 5px !important;
|
|
||||||
font-size: 13px !important;
|
|
||||||
font-family: var(--font-ui) !important;
|
|
||||||
color: var(--text-secondary) !important;
|
|
||||||
cursor: pointer !important;
|
|
||||||
user-select: none !important;
|
|
||||||
vertical-align: middle !important;
|
|
||||||
line-height: 1 !important;
|
|
||||||
margin: 0 6px 0 0 !important;
|
|
||||||
padding: 2px 0 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cm-search input[type="checkbox"] {
|
|
||||||
-webkit-appearance: none !important;
|
|
||||||
appearance: none !important;
|
|
||||||
width: 18px !important;
|
|
||||||
height: 18px !important;
|
|
||||||
border: 1.5px solid var(--border) !important;
|
|
||||||
border-radius: 4px !important;
|
|
||||||
background: var(--bg) !important;
|
|
||||||
cursor: pointer !important;
|
|
||||||
position: relative !important;
|
|
||||||
flex-shrink: 0 !important;
|
|
||||||
vertical-align: middle !important;
|
|
||||||
margin: 0 !important;
|
|
||||||
transition: all 0.15s ease !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cm-search input[type="checkbox"]:hover {
|
|
||||||
border-color: var(--primary) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cm-search input[type="checkbox"]:checked {
|
|
||||||
background: var(--primary) !important;
|
|
||||||
border-color: var(--primary) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cm-search input[type="checkbox"]:checked::after {
|
|
||||||
content: '' !important;
|
|
||||||
position: absolute !important;
|
|
||||||
left: 5px !important;
|
|
||||||
top: 2px !important;
|
|
||||||
width: 6px !important;
|
|
||||||
height: 10px !important;
|
|
||||||
border: solid white !important;
|
|
||||||
border-width: 0 2px 2px 0 !important;
|
|
||||||
transform: rotate(45deg) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 搜索结果计数 */
|
|
||||||
.cm-panel .cm-search-info {
|
|
||||||
color: var(--text-tertiary) !important;
|
|
||||||
font-size: 11px !important;
|
|
||||||
font-family: var(--font-ui) !important;
|
|
||||||
padding: 0 8px !important;
|
|
||||||
white-space: nowrap !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 关闭按钮 — 注意:CodeMirror 关闭按钮用的是 name="close",没有 cm-close class */
|
|
||||||
.cm-panel [name="close"] {
|
|
||||||
position: absolute !important;
|
|
||||||
top: 4px !important;
|
|
||||||
right: 4px !important;
|
|
||||||
color: var(--text-tertiary) !important;
|
|
||||||
font-size: 22px !important;
|
|
||||||
line-height: 1 !important;
|
|
||||||
cursor: pointer !important;
|
|
||||||
padding: 4px 6px !important;
|
|
||||||
border-radius: 4px !important;
|
|
||||||
border: none !important;
|
|
||||||
background: transparent !important;
|
|
||||||
transition: all 0.15s ease !important;
|
|
||||||
z-index: 1 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cm-panel [name="close"]:hover {
|
|
||||||
background: var(--bg-tertiary) !important;
|
|
||||||
color: var(--text) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 替换按钮组 */
|
|
||||||
.cm-panel .cm-replace,
|
|
||||||
.cm-panel .cm-replaceAll {
|
|
||||||
background: var(--bg-secondary) !important;
|
|
||||||
border: 1px solid var(--border) !important;
|
|
||||||
border-radius: 4px !important;
|
|
||||||
color: var(--text-secondary) !important;
|
|
||||||
font-size: 12px !important;
|
|
||||||
padding: 4px 8px !important;
|
|
||||||
cursor: pointer !important;
|
|
||||||
transition: all 0.15s ease !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cm-panel .cm-replace:hover,
|
|
||||||
.cm-panel .cm-replaceAll:hover {
|
|
||||||
background: var(--bg-tertiary) !important;
|
|
||||||
color: var(--text) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 搜索高亮匹配 */
|
|
||||||
.cm-searchMatch {
|
|
||||||
background: rgba(255, 220, 0, 0.35) !important;
|
|
||||||
border-radius: 2px !important;
|
|
||||||
outline: 1px solid rgba(255, 220, 0, 0.5) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cm-searchMatch.cm-searchMatch-selected {
|
|
||||||
background: rgba(255, 150, 0, 0.55) !important;
|
|
||||||
outline: 2px solid rgba(255, 150, 0, 0.8) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 暗色主题适配 */
|
|
||||||
:root.dark .cm-searchMatch {
|
|
||||||
background: rgba(255, 220, 0, 0.2) !important;
|
|
||||||
outline-color: rgba(255, 220, 0, 0.3) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root.dark .cm-searchMatch.cm-searchMatch-selected {
|
|
||||||
background: rgba(255, 150, 0, 0.35) !important;
|
|
||||||
outline-color: rgba(255, 150, 0, 0.6) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root.dark .cm-panel {
|
|
||||||
background: var(--bg-secondary) !important;
|
|
||||||
border-color: var(--border) !important;
|
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root.dark .cm-panel .cm-textfield {
|
|
||||||
background: var(--bg-tertiary) !important;
|
|
||||||
border-color: var(--border) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root.dark .cm-panel .cm-button,
|
|
||||||
:root.dark .cm-panel .cm-replace,
|
|
||||||
:root.dark .cm-panel .cm-replaceAll {
|
|
||||||
background: var(--bg-tertiary) !important;
|
|
||||||
border-color: var(--border) !important;
|
|
||||||
color: var(--text-secondary) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root.dark .cm-panel .cm-button:hover,
|
|
||||||
:root.dark .cm-panel .cm-replace:hover,
|
|
||||||
:root.dark .cm-panel .cm-replaceAll:hover {
|
|
||||||
background: var(--border) !important;
|
|
||||||
color: var(--text) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Fold gutter */
|
|
||||||
.cm-foldGutter .cm-gutterElement {
|
|
||||||
cursor: pointer;
|
|
||||||
opacity: 0.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cm-foldGutter .cm-gutterElement:hover {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Selection — 必须匹配oneDark主题的选择器特异性才能覆盖 */
|
|
||||||
.cm-selectionBackground {
|
|
||||||
background: rgba(26, 115, 232, 0.25) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cm-editor.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground,
|
|
||||||
.cm-editor.cm-focused .cm-selectionBackground,
|
|
||||||
.cm-content ::selection {
|
|
||||||
background: rgba(26, 115, 232, 0.3) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root.dark .cm-selectionBackground {
|
|
||||||
background: rgba(100, 160, 255, 0.25) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root.dark .cm-editor.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground,
|
|
||||||
:root.dark .cm-editor.cm-focused .cm-selectionBackground,
|
|
||||||
:root.dark .cm-content ::selection {
|
|
||||||
background: rgba(100, 160, 255, 0.35) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cm-editor .cm-content {
|
|
||||||
user-select: text !important;
|
|
||||||
-webkit-user-select: text !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cm-editor .cm-cursor {
|
|
||||||
border-left-color: var(--text);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Preview Panel */
|
/* Preview Panel */
|
||||||
|
|||||||
Reference in New Issue
Block a user