UNPKG

@bhsd/codemirror-mediawiki

Version:

Modified CodeMirror mode based on wikimedia/mediawiki-extensions-CodeMirror

467 lines (466 loc) 16.3 kB
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 }, }; })); } }