I reviewed the documentation on the facet you linked and I was able to implement it this way. It does feel better.
I’m including a full working example for anyone with a similar issue. Keep in mind, this doesn’t handle all edge cases. For example, if you have a pair of fences and you delete the very last backtick and then replace it, another set of fences will be inserted.
However, several larger projects like Obsidian that use CodeMirror seem to have this exact same issue. I’m not sure how I would prevent it.
This example uses React and Typescript, but should still be easy to understand.
import {useEffect, useState, useRef} from 'react';
import type {SelectionRange} from '@codemirror/state';
import {EditorState} from '@codemirror/state';
import {EditorView, keymap, highlightActiveLine} from '@codemirror/view';
import {defaultKeymap} from '@codemirror/commands';
import {indentOnInput} from '@codemirror/language';
import {markdown, markdownLanguage} from '@codemirror/lang-markdown';
import type {ViewUpdate} from '@codemirror/view';
import type React from 'react';
interface Props {
doc: string;
onChange?: (state: EditorState) => void;
}
const useCodeMirror = <T extends Element>({
doc,
onChange,
}: Props): [React.MutableRefObject<T | null>, EditorView?] => {
const refContainer = useRef<T>(null);
const [editorView, setEditorView] = useState<EditorView>();
useEffect(() => {
if (!refContainer.current) return;
const startState = EditorState.create({
doc,
extensions: [
keymap.of([...defaultKeymap]),
indentOnInput(),
highlightActiveLine(),
markdown({
base: markdownLanguage,
addKeymap: true,
}),
EditorView.lineWrapping,
EditorView.inputHandler.of(inputHandler),
EditorView.updateListener.of(update => updateListener(update, onChange)),
],
});
const view = new EditorView({
state: startState,
parent: refContainer.current,
});
setEditorView(view);
}, [refContainer]);
return [refContainer, editorView];
};
export default useCodeMirror;
/**
* Runs when changes to the DOM are detected.
*
* @param view The EditorView of the event.
* @param from The selection 'from' index.
* @param to The selection 'to' index.
* @param text The document change.
* @returns Returns true when an action was taken, and false otherwise.
*/
const inputHandler = (view: EditorView, from: number, to: number, text: string): boolean => {
// Return false to allow default action, or true to block it.
let result = false;
result = matchBlocks(view, text);
return result;
};
/**
* If the given text is '`' and conditions are met, insert a matching ending
* codeblock '```' and move the cursor into the middle.
*
* @param view The EditorView associated with the event.
* @param text The inserted text.
* @returns Returns true when an action was taken, and false otherwise.
*/
const matchBlocks = (view: EditorView, text: string): boolean => {
const selection = view.state.selection.main;
if (text != '`' || !isCursor(selection)) return false;
const state = view.state;
const position = selection.from;
if (
/// Previous two characters should be backticks,
// but if the third is also a backtick ignore it to prevent repeat triggers.
state.sliceDoc(position - 2, position) == '``' &&
state.sliceDoc(position - 3, position - 2) != '`'
) {
view.dispatch({
changes: [{from: position, insert: '`\n\n```'}],
selection: {anchor: position + 2},
});
return true;
}
return false;
};
/**
* Determines if the given SelectionRange is a cursor.
*
* @param selection The SelectionRange to check.
* @returns Returns true when `selection.from == selection.to`.
*/
const isCursor = (selection: SelectionRange): boolean => selection.from == selection.to;
/**
* Runs when the associated EditorView is updated.
*
* @param update The ViewUpdate of the event.
* @param onChange A function to run when the event fires.
*/
const updateListener = (
update: ViewUpdate,
onChange: ((state: EditorState) => void) | undefined,
) => {
if (update.changes) {
onChange && onChange(update.state);
}
};
The above code produces this behavior:
Typing “```” will leave you with this text, where ‘n’ represents the cursor position:
```
n
```
Thanks for the help.