UNPKG

@jinntec/jinn-codemirror

Version:

Source code editor component based on codemirror with language support for XML and Leiden+

289 lines (264 loc) 9.97 kB
import { basicSetup } from "codemirror"; import { EditorView, placeholder, Panel, showPanel } from "@codemirror/view"; import { Command, ViewPlugin, ViewUpdate, keymap, KeyBinding } from "@codemirror/view"; import { syntaxTree } from "@codemirror/language"; import { EditorStateConfig, Extension, EditorSelection } from "@codemirror/state"; import {indentWithTab} from "@codemirror/commands"; import { snippet } from "@codemirror/autocomplete"; import { oneDarkTheme } from "@codemirror/theme-one-dark"; import { materialDark, materialLight } from '@uiw/codemirror-theme-material'; import { solarizedDark, solarizedLight } from "@uiw/codemirror-theme-solarized"; import { Tree } from "@lezer/common"; import { JinnCodemirror } from "./jinn-codemirror"; function theme(name:string): Extension|null { switch(name) { case 'dark': return oneDarkTheme; case 'material-dark': return materialDark; case 'material-light': return materialLight; case 'solarized-dark': return solarizedDark; case 'solarized-light': return solarizedLight; default: return null; } } /** * Supported editor modes */ export enum SourceType { xml = 'xml', html = 'html', leiden_plus = 'leiden_plus', edcs = "edcs", phi = "phi", default = "default", xquery = "xquery", css = "css", tex = "tex", markdown = "markdown", json = "json" }; export abstract class ParametrizedCommand { abstract create(...params:any): Command; } export interface EditorCommands { [index:string]: Command|ParametrizedCommand } /** * Creates a command which wraps the current selection with a prefix and suffix. * * @param start prefix to insert before selection * @param end suffix to insert after selection * @returns command to execute */ export const wrapCommand = (start:string, end:string):Command => (editor) => { editor.dispatch(editor.state.changeByRange(range => { return { changes: [{from: range.from, insert: start}, {from: range.to, insert: end}], range: EditorSelection.range(range.from + start.length, range.to + start.length) }; })); return true; } /** * Creates a command which wraps the current selection with a prefix and suffix. * * @param start prefix to insert before selection * @param end suffix to insert after selection * @returns command to execute */ export const insertCommand = (insert:string):Command => (editor) => { editor.dispatch(editor.state.changeByRange(range => { return { changes: [{from: range.from, insert}], range: EditorSelection.range(range.from, range.from) }; })); return true; } /** * Creates a command which inserts a snippet in place of the current selection. * '${_}' will be replaced by the selection text. * * @param template snippet template string * @returns command to execute */ export const snippetCommand = (template:string):Command => (editor) => { template = template.replace(/\$\|([^|]+)\|/g, '${$1}'); editor.state.selection.ranges.forEach((range) => { const content = editor.state.doc.slice(range.from, range.to); if (content.length > 0) { template = template.replace(/\${(?:\d+:)?_}/, content.toString()); } else { template = template.replace(/\${(\d+:)?_}/, '${$1}'); } const snip = snippet(template); snip(editor, {label: ''}, range.from, range.to); }); return true; } export function initCommand(cmdName: string, cmd: Command|ParametrizedCommand, control: HTMLElement):Command|null { if (cmd.create) { const paramsAttr = (control).dataset.params; if (paramsAttr) { let params; try { params = JSON.parse(paramsAttr); } catch (e) { params = [paramsAttr]; } if (Array.isArray(params) && params.length === cmd.create.length) { return cmd.create.apply(null, params); } else { console.error('<jinn-codemirror> Expected %d arguments for command %s', cmd.create.length, cmdName); return null; } } } return <Command>cmd; } export const defaultCommands:EditorCommands = { snippet: { create: (template:string) => snippetCommand(template) } }; export abstract class EditorConfig { editor: JinnCodemirror; keymap: KeyBinding[]; commands: EditorCommands; threshold: number = 300; _status: HTMLDivElement|null = null; protected namespace: string|null; constructor(editor:JinnCodemirror, toolbar: HTMLElement[] = [], commands: EditorCommands = defaultCommands) { this.editor = editor; this.commands = commands; this.keymap = []; this.namespace = null; if (toolbar) { toolbar.forEach((control) => { const cmdName = <string>(<HTMLElement>control).dataset.command; const cmd = commands[cmdName]; if (cmd) { const shortcut = control.getAttribute('data-key'); if (shortcut && shortcut.length > 0) { const command = initCommand(cmdName, cmd, control); if (command) { const binding:KeyBinding = { key: shortcut, run: command }; this.keymap.push(binding); } } } }); } } async getConfig(): Promise<EditorStateConfig> { const self = this; let runningUpdate:any = null; const updateListener = ViewPlugin.fromClass(class { update(update: ViewUpdate) { if (update.docChanged) { if (runningUpdate) { clearTimeout(runningUpdate); } runningUpdate = setTimeout(() => { const tree = syntaxTree(update.state); const lines = update.state.doc.toJSON(); const content = self.onUpdate(tree, lines.join('\n')); // save content to property `value` on editor parent try { const serialized = self.serialize(); if (serialized != null) { self.editor._value = serialized; self.editor.emitUpdateEvent(content); } } catch (e) { // suppress updates (invalid data) } }, self.threshold); } } }); const createStatusPanel = (view: EditorView): Panel => { this._status = document.createElement("div"); this._status.className = 'status'; this._status.part = 'status'; return { dom: this._status } }; const customExtensions = await this.getExtensions(this.editor); const extensions = [ basicSetup, EditorView.lineWrapping, keymap.of([indentWithTab, ...this.keymap]), placeholder(this.editor.placeholder), ...customExtensions, updateListener, showPanel.of(createStatusPanel) ]; if (this.editor && this.editor.theme) { const extTheme = theme(this.editor.theme); if (extTheme) { extensions.push(extTheme); } else { console.error('<jinn-codemirror> Unknown theme: %s', this.editor.theme); } } return { extensions }; } abstract getExtensions(editor: JinnCodemirror): Promise<Extension[]>; getCommands():EditorCommands { return this.commands; } onUpdate(tree: Tree, content: string) { return content; } /** * Strips default namespace declarations (xmlns="...") from serialized XML string * Only removes namespaces that match this.namespace */ private stripDefaultNamespaces(xmlString: string): string { // If no namespace is set, don't remove anything if (!this.namespace) { return xmlString; } // Remove xmlns="..." attributes only if the namespace URI matches this.namespace // Escape special regex characters in the namespace URI const escapedNamespace = this.namespace.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const regex = new RegExp(`\\s+xmlns\\s*=\\s*["']${escapedNamespace}["']`, 'g'); return xmlString.replace(regex, ''); } setFromValue(value: Element|NodeListOf<ChildNode>|string|null|undefined): string { if (!value) { return ''; } if (value instanceof Node || value instanceof NodeList) { const serializer = new XMLSerializer(); if (value instanceof NodeList) { const buf = []; for (let i = 0; i < (<NodeList>value).length; i++) { buf.push(this.stripDefaultNamespaces(serializer.serializeToString(value[i]))); } return buf.join(''); } return this.stripDefaultNamespaces(serializer.serializeToString(value)); } if (typeof value === 'string') { return value; } return JSON.stringify(value); } abstract serialize(): Element | NodeListOf<ChildNode> | string | null | undefined; set status(msg:string) { if (this._status) { this._status.innerHTML = msg; } } }