@bhsd/codemirror-mediawiki
Version:
Modified CodeMirror mode based on wikimedia/mediawiki-extensions-CodeMirror
467 lines (466 loc) • 16.3 kB
JavaScript
import { EditorView, lineNumbers, keymap, highlightActiveLineGutter, } from '@codemirror/view';
import { Compartment, EditorState, EditorSelection, SelectionRange } from '@codemirror/state';
import { syntaxHighlighting, defaultHighlightStyle, indentOnInput, indentUnit, ensureSyntaxTree, } from '@codemirror/language';
import { defaultKeymap, historyKeymap, history, redo, indentWithTab } from '@codemirror/commands';
import { searchKeymap } from '@codemirror/search';
import { linter, lintGutter, lintKeymap } from '@codemirror/lint';
import { light } from './theme';
export const plain = () => EditorView.contentAttributes.of({ spellcheck: 'true' });
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const languages = { plain };
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const avail = {};
export const linterRegistry = {};
export const menuRegistry = [];
export const destroyListeners = [];
export const themes = { light };
export const optionalFunctions = {
statusBar() {
return [];
},
detectIndent(_, indent) {
return indent;
},
foldHandler() {
return () => { };
},
};
const editExtensions = new Set(['closeBrackets', 'autocompletion', 'signatureHelp']);
const linters = {};
const phrases = {};
/** CodeMirror 6 editor */
export class CodeMirror6 {
#textarea;
#language = new Compartment();
#linter = new Compartment();
#extensions = new Compartment();
#dir = new Compartment();
#indent = new Compartment();
#extraKeys = new Compartment();
#phrases = new Compartment();
#lineWrapping = new Compartment();
#theme = new Compartment();
#view;
#lang;
#visible = false;
#preferred = new Set();
#indentStr = '\t';
#nestedMWLanguage;
/** textarea element */
get textarea() {
return this.#textarea;
}
/** EditorView instance */
get view() {
return this.#view;
}
/** language */
get lang() {
return this.#lang;
}
/** whether the editor view is visible */
get visible() {
return this.#visible && this.textarea.isConnected;
}
/**
* @param textarea textarea element
* @param lang language
* @param config language configuration
* @param init whether to initialize the editor immediately
*/
constructor(textarea, lang = 'plain', config, init = true) {
this.#textarea = textarea;
this.#lang = lang;
if (init) {
this.initialize(config);
}
}
/**
* 获取语言扩展
* @param config 语言设置
*/
#getLanguage(config) {
const lang = (languages[this.#lang] ?? plain)(config);
this.#nestedMWLanguage = lang.nestedMWLanguage;
return lang;
}
/**
* Initialize the editor
* @param config language configuration
*/
initialize(config) {
let timer;
const { textarea, lang } = this, { value, dir: d, accessKey, tabIndex, lang: l, readOnly } = textarea, extensions = [
this.#language.of(this.#getLanguage(config)),
this.#linter.of(linters[lang] ?? []),
this.#extensions.of([]),
this.#dir.of(EditorView.editorAttributes.of({ dir: d })),
this.#extraKeys.of([]),
this.#phrases.of(EditorState.phrases.of(phrases)),
this.#lineWrapping.of(EditorView.lineWrapping),
this.#theme.of(light),
syntaxHighlighting(defaultHighlightStyle),
EditorView.contentAttributes.of({
accesskey: accessKey,
tabindex: String(tabIndex),
}),
EditorView.editorAttributes.of({ lang: l }),
lineNumbers(),
highlightActiveLineGutter(),
keymap.of([
...defaultKeymap,
...searchKeymap,
{
key: 'Mod-Shift-x',
run: () => {
const dir = textarea.dir === 'rtl' ? 'ltr' : 'rtl';
textarea.dir = dir;
this.#effects(this.#dir.reconfigure(EditorView.editorAttributes.of({ dir })));
return true;
},
},
]),
EditorView.theme({
'.cm-panels': {
direction: document.dir,
},
'& .cm-lineNumbers .cm-gutterElement': {
textAlign: 'end',
},
'.cm-textfield, .cm-button, .cm-panel.cm-search label, .cm-panel.cm-gotoLine label': {
fontSize: 'inherit',
},
'.cm-panel [name="close"]': {
color: 'inherit',
},
}),
EditorView.updateListener.of(({ state: { doc }, startState: { doc: startDoc }, docChanged, focusChanged, }) => {
if (docChanged) {
clearTimeout(timer);
timer = setTimeout(() => {
textarea.value = doc.toString();
textarea.dispatchEvent(new Event('input'));
}, 400);
if (!startDoc.toString().trim()) {
this.setIndent(this.#indentStr);
}
}
if (focusChanged) {
textarea.dispatchEvent(new Event(this.#view.hasFocus ? 'focus' : 'blur'));
}
}),
...readOnly
? [
EditorState.readOnly.of(true),
EditorState.transactionFilter.of(tr => tr.docChanged ? [] : tr),
EditorView.theme({
'input[type="color"]': {
pointerEvents: 'none',
},
}),
]
: [
history(),
indentOnInput(),
this.#indent.of(indentUnit.of(optionalFunctions.detectIndent(value, this.#indentStr, lang))),
keymap.of([
...historyKeymap,
indentWithTab,
{ win: 'Ctrl-Shift-z', run: redo, preventDefault: true },
]),
],
];
this.#view = new EditorView({
extensions,
doc: value,
});
const { fontSize, lineHeight, border } = getComputedStyle(textarea);
textarea.before(this.#view.dom);
this.#minHeight();
this.#view.dom.style.border = border;
this.#view.scrollDOM.style.fontSize = fontSize;
this.#view.scrollDOM.style.lineHeight = lineHeight;
this.toggle(true);
this.#view.dom.addEventListener('click', optionalFunctions.foldHandler(this.#view));
this.prefer({});
}
/**
* 修改扩展
* @param effects 扩展变动
*/
#effects(effects) {
this.#view.dispatch({ effects });
}
/**
* 设置编辑器最小高度
* @param linting 是否启用语法检查
*/
#minHeight(linting) {
this.#view.dom.style.minHeight = linting ? 'calc(100px + 2em)' : '2em';
}
/** 获取语法检查扩展 */
#getLintExtension() {
return this.#linter.get(this.#view.state)[0];
}
/**
* Set language
* @param lang language
* @param config language configuration
*/
// eslint-disable-next-line @typescript-eslint/require-await
async setLanguage(lang = 'plain', config) {
this.#lang = lang;
if (this.#view) {
const ext = this.#getLanguage(config);
this.#effects([
this.#language.reconfigure(ext),
this.#linter.reconfigure(linters[lang] ?? []),
]);
this.#minHeight(Boolean(linters[lang]));
this.prefer({});
}
}
/**
* Start syntax checking
* @param lintSource function for syntax checking
*/
lint(lintSource) {
const lintSources = typeof lintSource === 'function' ? [lintSource] : lintSource;
const linterExtension = lintSources
? [
...lintSources.map(source => linter(async ({ state }) => {
const diagnostics = await source(state);
if (state.readOnly) {
for (const diagnostic of diagnostics) {
delete diagnostic.actions;
}
}
return diagnostics;
}, source.delay ? { delay: source.delay } : undefined)),
lintGutter(),
keymap.of(lintKeymap),
optionalFunctions.statusBar(this, lintSources[0].fixer),
]
: [];
if (lintSource) {
linters[this.#lang] = linterExtension;
}
else {
delete linters[this.#lang];
}
if (this.#view) {
this.#effects(this.#linter.reconfigure(linterExtension));
this.#minHeight(Boolean(lintSource));
}
}
/** Update syntax checking immediately */
update() {
if (this.#view) {
const extension = this.#getLintExtension();
if (extension) {
const plugin = this.#view.plugin(extension[1]);
plugin.set = true;
plugin.force();
}
}
}
/**
* Check if the editor enables a specific extension
* @param name extension name
*/
hasPreference(name) {
return this.#preferred.has(name);
}
/**
* Add extensions
* @param names extension names
*/
prefer(names) {
if (Array.isArray(names)) {
this.#preferred = new Set(names.filter(name => Object.prototype.hasOwnProperty.call(avail, name)));
}
else {
for (const [name, enable] of Object.entries(names)) {
if (enable && Object.prototype.hasOwnProperty.call(avail, name)) {
this.#preferred.add(name);
}
else {
this.#preferred.delete(name);
}
}
}
if (this.#view) {
const { readOnly } = this.#view.state;
this.#effects(this.#extensions.reconfigure([...this.#preferred].filter(name => !readOnly || !editExtensions.has(name)).map(name => {
const [extension, configs = {}] = avail[name];
return extension(configs[this.#lang], this);
})));
}
}
/**
* Set text indentation
* @param indent indentation string
*/
setIndent(indent) {
if (this.#view) {
this.#effects(this.#indent.reconfigure(indentUnit.of(optionalFunctions.detectIndent(this.#view.state.doc, indent, this.#lang))));
}
else {
this.#indentStr = indent;
}
}
/**
* Set line wrapping
* @param wrapping whether to enable line wrapping
*/
setLineWrapping(wrapping) {
if (this.#view) {
this.#effects(this.#lineWrapping.reconfigure(wrapping ? EditorView.lineWrapping : []));
}
}
/**
* Get default linter
* @param opt linter options
*/
async getLinter(opt) {
return linterRegistry[this.#lang]?.(opt, this.#view, this.#nestedMWLanguage);
}
/**
* Set content
* @param insert new content
* @param force whether to forcefully replace the content
*/
setContent(insert, force) {
if (this.#view) {
this.#view.dispatch({
changes: { from: 0, to: this.#view.state.doc.length, insert },
filter: !force,
});
}
}
/**
* Switch between textarea and editor view
* @param show whether to show the editor view
*/
toggle(show = !this.#visible) {
if (!this.#view) {
return;
}
else if (show && !this.#visible) {
const { value, selectionStart, selectionEnd, scrollTop, offsetHeight, style: { height } } = this.#textarea, hasFocus = document.activeElement === this.#textarea;
this.setContent(value);
this.#view.dom.style.height = offsetHeight ? `${offsetHeight}px` : height;
this.#view.dom.style.removeProperty('display');
this.#textarea.style.display = 'none';
this.#view.requestMeasure();
this.#view.dispatch({
selection: { anchor: selectionStart, head: selectionEnd },
});
if (hasFocus) {
this.#view.focus();
}
requestAnimationFrame(() => {
this.#view.scrollDOM.scrollTop = scrollTop;
});
}
else if (!show && this.#visible) {
const { state: { selection: { main: { from, to, head } } }, hasFocus } = this.#view, { scrollDOM: { scrollTop } } = this.#view;
this.#view.dom.style.setProperty('display', 'none', 'important');
this.#textarea.style.display = '';
this.#textarea.setSelectionRange(from, to, head === to ? 'forward' : 'backward');
if (hasFocus) {
this.#textarea.focus();
}
requestAnimationFrame(() => {
this.#textarea.scrollTop = scrollTop;
});
}
this.#visible = show;
}
/** Destroy the editor */
destroy() {
if (this.visible) {
this.toggle(false);
}
if (this.#view) {
for (const listener of destroyListeners) {
listener(this.#view);
}
this.#view.destroy();
}
Object.setPrototypeOf(this, null);
}
/**
* Define extra key bindings
* @param keys key bindings
*/
extraKeys(keys) {
if (this.#view) {
this.#effects(this.#extraKeys.reconfigure(keymap.of(keys)));
}
}
/**
* Set translation messages
* @param messages translation messages
*/
localize(messages) {
Object.assign(phrases, messages);
if (this.#view) {
this.#effects(this.#phrases.reconfigure(EditorState.phrases.of(phrases)));
}
}
/**
* Get the syntax node at the specified position
* @param position position
*/
getNodeAt(position) {
return this.#view && ensureSyntaxTree(this.#view.state, position)?.resolveInner(position, 1);
}
/**
* Scroll to the specified position
* @param position position or selection range
*/
scrollTo(position) {
if (this.#view) {
const r = position ?? this.#view.state.selection.main, effects = EditorView.scrollIntoView(typeof r === 'number' || r instanceof SelectionRange
? r
: EditorSelection.range(r.anchor, r.head));
effects.value.isSnapshot = true;
this.#view.dispatch({ effects });
}
}
/**
* Set the editor theme
* @param theme theme name
* @since 3.3.0
*/
setTheme(theme) {
if (theme in themes) {
this.#view?.dispatch({
effects: this.#theme.reconfigure(themes[theme]),
});
}
}
/**
* Replace the current selection with the result of a function
* @param view EditorView instance
* @param func function to produce the replacement text
*/
static replaceSelections(view, func) {
const { state } = view;
view.dispatch(state.changeByRange(({ from, to }) => {
const result = func(state.sliceDoc(from, to), { from, to });
if (typeof result === 'string') {
return {
range: EditorSelection.range(from, from + result.length),
changes: { from, to, insert: result },
};
}
const [insert, start, end = start] = result;
return {
range: EditorSelection.range(start, end),
changes: { from, to, insert },
};
}));
}
}