refactor(editor): migrate to Milkdown with LaTeX support and clean up legacy code

- Removed old contenteditable-based MarkdownEditor component
- Integrated Milkdown Crepe with LaTeX (KaTeX) rendering support
- Simplified inline suggestion plugin using ProseMirror decorations
- Removed debug logging and unused components (HelloWorld, plan files)
- Increased debounce from 150ms to 500ms for better performance
- Fixed SSE JSON serialization in backend main.py
This commit is contained in:
“ydy0615”
2026-02-12 18:52:16 +08:00
parent 2432e78fe1
commit 16e76e1e90
14 changed files with 487 additions and 2300 deletions
+113 -85
View File
@@ -1,96 +1,124 @@
import { Plugin, PluginKey } from '@milkdown/prose/state';
import { EditorView } from '@milkdown/prose/view';
import { fetchSuggestion } from '../utils/api.js';
import { DEBUG, API_URL } from '../utils/config.js';
import { Plugin, PluginKey, Decoration, DecorationSet } from '@milkdown/prose/state'
import { EditorView } from '@milkdown/prose/view'
import { fetchSuggestion } from '../utils/api.js'
import { API_URL } from '../utils/config.js'
const INLINE_SUGGESTION_KEY = new PluginKey('inline-suggestion');
const DEBOUNCE_MS = 150;
interface InlineSuggestionOptions {
apiUrl?: string;
}
const INLINE_SUGGESTION_KEY = new PluginKey('inline-suggestion')
const DEBOUNCE_MS = 300
interface InlineSuggestionState {
suggestion: string;
visible: boolean;
debounceTimer: ReturnType<typeof setTimeout> | null;
currentSuggestion: string;
suggestionPos: { from: number; to: number };
suggestion: string
suggestionPos: { from: number; to: number }
}
function createInlineSuggestionPlugin(options: InlineSuggestionOptions = {}) {
const apiUrl = options.apiUrl || API_URL;
function createGhostTextDecoration(from: number, to: number, text: string) {
return Decoration.inline(from, to, {
class: 'ghost-text-decoration',
'data-suggestion': text,
}, { side: 1 })
}
return new Plugin<InlineSuggestionState>({
key: INLINE_SUGGESTION_KEY,
state: {
init: () => ({
suggestion: '',
visible: false,
debounceTimer: null,
currentSuggestion: '',
suggestionPos: { from: 0, to: 0 }
}),
apply: (tr, value) => {
if (!tr.docChanged) return value;
const { from, to } = tr.selection;
if (from === value.suggestionPos.from && to === value.suggestionPos.to) {
return value;
}
return { ...value, suggestion: '', visible: false };
},
},
props: {
handleKeyDown: (view: EditorView, event: KeyboardEvent) => {
const state = INLINE_SUGGESTION_KEY.getState(view.state);
if (event.key === 'Tab' && state.visible) {
event.preventDefault();
if (state.suggestion) {
view.dispatch(view.state.tr.insertText(state.suggestion, view.state.selection.from));
return true;
}
}
if (event.key === 'Escape') {
if (state.visible) {
view.dispatch(view.state.tr.setMeta(INLINE_SUGGESTION_KEY, { ...state, suggestion: '', visible: false }));
return true;
}
}
return false;
},
},
appendTransaction: (transactions, oldState, newState) => {
const lastTr = transactions[transactions.length - 1];
if (!lastTr || !lastTr.docChanged) return null;
export function createInlineSuggestionPlugin(options: { apiUrl?: string } = {}) {
const apiUrl = options.apiUrl || API_URL
const currentState = INLINE_SUGGESTION_KEY.getState(newState);
return new Plugin({
key: INLINE_SUGGESTION_KEY,
state: {
init: (): InlineSuggestionState => ({
suggestion: '',
suggestionPos: { from: 0, to: 0 }
}),
apply: (tr, value): InlineSuggestionState => {
if (!tr.docChanged) return value
const { from, to } = tr.selection
if (from === value.suggestionPos.from && to === value.suggestionPos.to) {
return value
}
return { suggestion: '', suggestionPos: { from: 0, to: 0 } }
},
},
props: {
decorations: (state) => {
const pluginState = INLINE_SUGGESTION_KEY.getState(state)
if (!pluginState.suggestion) {
return DecorationSet.empty
}
return DecorationSet.create(state.doc, [
createGhostTextDecoration(
pluginState.suggestionPos.from,
pluginState.suggestionPos.from + pluginState.suggestion.length,
pluginState.suggestion
)
])
},
handleKeyDown: (view: EditorView, event: KeyboardEvent) => {
const state = INLINE_SUGGESTION_KEY.getState(view.state)
if (event.key === 'Tab' && state.suggestion) {
event.preventDefault()
const { state: currentState, dispatch } = view
dispatch(currentState.tr.insertText(state.suggestion, currentState.selection.from))
dispatch(currentState.tr.setMeta(INLINE_SUGGESTION_KEY, {
suggestion: '',
suggestionPos: { from: 0, to: 0 }
}))
return true
}
if (event.key === 'Escape' && state.suggestion) {
event.preventDefault()
view.dispatch(view.state.tr.setMeta(INLINE_SUGGESTION_KEY, {
suggestion: '',
suggestionPos: { from: 0, to: 0 }
}))
return true
}
if (state.suggestion) {
view.dispatch(view.state.tr.setMeta(INLINE_SUGGESTION_KEY, {
suggestion: '',
suggestionPos: { from: 0, to: 0 }
}))
}
return false
},
},
appendTransaction: (transactions, oldState, newState) => {
const lastTr = transactions[transactions.length - 1]
if (!lastTr || !lastTr.docChanged) return null
const currentState = INLINE_SUGGESTION_KEY.getState(newState)
const { from, to } = newState.selection
if (currentState.suggestion && from === currentState.suggestionPos.from) {
return null
}
setTimeout(async () => {
const prefix = newState.doc.textBetween(0, from)
const suffix = newState.doc.textBetween(to, newState.doc.content.size)
try {
const text = await fetchSuggestion(prefix, suffix, apiUrl)
if (text && newState.selection.from === from) {
const newPluginState = {
suggestion: text,
suggestionPos: { from, to: from + text.length }
}
clearTimeout(currentState.debounceTimer);
currentState.debounceTimer = setTimeout(async () => {
const { from, to } = newState.selection;
const prefix = newState.doc.textBetween(0, from);
const suffix = newState.doc.textBetween(to, newState.doc.content.size);
newState.apply(newState.tr.setMeta(INLINE_SUGGESTION_KEY, newPluginState))
}
} catch (e) {
console.error('Inline suggestion error:', e)
}
}, DEBOUNCE_MS)
try {
const text = await fetchSuggestion(prefix, suffix, apiUrl);
if (text && newState.selection.from === from) {
newState.apply(newState.tr.setMeta(INLINE_SUGGESTION_KEY, {
...currentState,
currentSuggestion: text,
suggestionPos: { from, to: from + text.length },
suggestion: text,
visible: true
}));
}
} catch (e) {
if (DEBUG) console.error('Inline suggestion error:', e);
}
}, DEBOUNCE_MS);
return null;
},
});
return null
},
})
}
export { createInlineSuggestionPlugin, INLINE_SUGGESTION_KEY };
export { INLINE_SUGGESTION_KEY }