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