UNPKG

@jinntec/jinn-codemirror

Version:

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

406 lines (353 loc) 13.1 kB
import { EditorView } from "@codemirror/view"; import { EditorState } from "@codemirror/state"; import { XMLConfig } from "./xml"; import { LeidenConfig } from "./leiden+"; import { AncientTextConfig } from "./ancientText"; import { XQueryConfig } from "./xquery"; import { CSSConfig } from "./css"; import { JSONConfig } from "./json"; import { PlainConfig } from "./plain"; import { TeXConfig } from "./tex"; import { EditorConfig, SourceType, initCommand } from "./config"; import { HTMLConfig } from "./html"; import { MarkdownConfig } from "./markdown"; /** * Source code editor component based on [codemirror](https://codemirror.net/). * Features extended support for XML and Leiden+ code. * * @attr {string} mode - editor mode to be used * @attr {string} linter - in XQuery mode: API endpoint to use for linting * @attr {string} code - specifies initial content to be inserted at startup for editing * @slot toolbar - toolbar to be shown * @slot header - optional header to be displayed above the toolbar * @fires update - fired when the content of the editor has changed * @fires valid - fired if the content of the editor is valid (requires a linter to be supported) * @fires invalid - fired if the content of the editor is invalid (requires a linter to be supported) */ export class JinnCodemirror extends HTMLElement { _mode: SourceType = SourceType.xml; _value?: Element | NodeListOf<ChildNode> | string | null; /** * Default element namespace to enforce on the root element in XML mode */ public namespace?: string | null; /** * XQuery mode: the API endpoint to use for linting. */ public linter?: string | null; _placeholder: string = ''; /** * Editor theme to use. Currently `dark`, `material-dark`, `material-light`, `solarized-dark` and * `solarized-light` are supported. */ public theme?: string | null; _editor?: EditorView; _config?: EditorConfig; protected ignoreBlur: boolean; static get observedAttributes() { return ['placeholder', 'mode', 'code']; } constructor() { super(); this.attachShadow({mode: 'open'}); this.ignoreBlur = true; } connectedCallback() { const css = document.createElement('style'); css.innerHTML = this.styles(); this.shadowRoot?.appendChild(css); const headerSlot = document.createElement('slot'); headerSlot.name = 'header'; this.shadowRoot?.appendChild(headerSlot); const toolbarSlot = document.createElement('slot'); toolbarSlot.name = 'toolbar'; this.shadowRoot?.appendChild(toolbarSlot); const wrapper = document.createElement('div'); wrapper.id = 'editor'; this.shadowRoot?.appendChild(wrapper); this.registerToolbar(this.shadowRoot?.querySelector('[name=toolbar]')); this._placeholder = this.getAttribute('placeholder') || ''; this.namespace = this.getAttribute('namespace'); this.linter = this.getAttribute('linter'); this.mode = this.initModes() || this.getAttribute('mode') || 'xml'; if (!this.hasAttribute('mode')) { this.setAttribute('mode', this._mode); } if (this.hasAttribute('code')) { this.value = this.getAttribute('code'); } if (this.hasAttribute('theme')) { this.theme = this.getAttribute('theme'); } this.ignoreBlur = this.hasAttribute('ignore-blur'); if (!this.ignoreBlur) { this.addEventListener('blur', (ev) => { if (!ev.relatedTarget || this.contains(ev.relatedTarget as Node)) { return; } this.dispatchEvent(new CustomEvent('leave', { composed: true, bubbles: true })); }); } } attributeChangedCallback(name: string, oldValue: any, newValue: any) { if (!oldValue || oldValue === newValue) { return; } switch (name) { case 'placeholder': this.placeholder = newValue; break; case 'mode': this.mode = newValue; break; case 'code': this.value = newValue; } } /** * Move keyboard focus to the editor */ focus() { if (this._editor) { this._editor.focus(); } } get placeholder() { return this._placeholder; } /** * A placeholder string to be shown if the user has not yet entered anything. * * @attr {string} placeholder */ set placeholder(label:string) { this._placeholder = label; this.setMode(this.mode); } /** * The mode to use. Currently supported are 'xml', 'xquery', 'css', 'html', 'tex', 'markdown', 'leiden_plus', 'edcs', 'phi' or 'default'. * * @attr {string} mode */ set mode(mode:string) { this.setMode(mode); } setMode(mode:string, update:boolean = true) { const wrapper = this.shadowRoot?.getElementById('editor'); if (!wrapper) { return; } if (this._editor) { this._editor.destroy(); wrapper.innerHTML = ''; } this._mode = SourceType[mode as keyof typeof SourceType]; console.log(`<jinn-codemirror> mode: ${this.mode}`); this.activateToolbar(this.shadowRoot?.querySelector('[name=toolbar]')); this.configure(); const select = this.querySelector('[name=modes]'); if (select && select instanceof HTMLSelectElement) { select.value = this._mode; } this._config?.getConfig().then((stateConfig) => { this._editor = new EditorView({ state: EditorState.create(stateConfig), parent: wrapper }); if (!this._config) { return } if (update) { this.content = this._config.setFromValue(this._value); } }); } protected configure() { const toolbar = this.getToolbarControls(<HTMLSlotElement|null> this.shadowRoot?.querySelector('[name=toolbar]')); switch(this._mode) { case SourceType.edcs: case SourceType.phi: this._config = new AncientTextConfig(this, toolbar, this._mode); break; case SourceType.leiden_plus: this._config = new LeidenConfig(this, toolbar); break; case SourceType.xquery: this._config = new XQueryConfig(this, toolbar, this.linter); break; case SourceType.css: this._config = new CSSConfig(this, toolbar); break; case SourceType.json: this._config = new JSONConfig(this, toolbar); break; case SourceType.tex: this._config = new TeXConfig(this, toolbar); break; case SourceType.html: this._config = new HTMLConfig(this, toolbar); break; case SourceType.xml: this._config = new XMLConfig(this, toolbar, this.namespace); break; case SourceType.markdown: this._config = new MarkdownConfig(this, toolbar); break; default: this._config = new PlainConfig(this, toolbar); break; } } get mode(): string { return this._mode; } set valid(value:boolean) { this.setAttribute('valid', value.toString()); } get valid():boolean { return Boolean(this.hasAttribute('valid')) } /** * Show a status message below the editor. */ set status(msg:string) { this._config.status = msg; } /** * The content edited in the editor as a string. */ set content(text:string) { if (!this._editor) { console.log('no editor'); return; } setTimeout(() => this._editor.dispatch({ changes: {from: 0, to: this._editor.state.doc.length, insert: text} }) ); } get content(): string { return this._editor?.state.doc.toString() || ''; } /** * The value edited in the editor as either an Element or string - depending on the mode set. */ set value(value: Element | NodeListOf<ChildNode> | string | null | undefined) { const updated = this.setValue(value); if (updated && this._editor && this._config) { this.content = this._config?.setFromValue(this._value); } } get value(): Element | NodeListOf<ChildNode> | string | null { return this.getValue(); } protected setValue(value: Element | NodeListOf<ChildNode> | string | null | undefined): boolean { if (!this._config) { return false; } const _val = this._config.setFromValue(value); if (this._value === _val) { return false; } this._value = value; return true; } protected getValue(): Element | NodeListOf<ChildNode> | string | null { if (!this._value) { return null } return this._value; } set code(text: string) { this.value = text; } clear() { this._value = ''; this._editor.dispatch({ changes: {from: 0, to: this._editor.state.doc.length, insert: ''} }); } emitUpdateEvent(content: string | NodeListOf<ChildNode> | Element | null | undefined) { this.dispatchEvent(new CustomEvent('update', { detail: {content}, composed: true, bubbles: true })); } private initModes(): string | null { const select = this.querySelector('[name=modes]'); if (select && select instanceof HTMLSelectElement) { select.addEventListener('change', () => { this.mode = select.value; }); return select.value; } return null; } private registerToolbar(slot:HTMLSlotElement|null|undefined) { slot?.assignedElements().forEach((elem) => { elem.querySelectorAll('slot').forEach(sl => this.registerToolbar(sl)); elem.querySelectorAll('[data-command]').forEach((btn) => { const cmdName = <string>(<HTMLElement>btn).dataset.command; if (btn.hasAttribute('data-key')) { (<HTMLElement>btn).title = `${(<HTMLElement>btn).title} (${btn.getAttribute('data-key')})`; } btn.addEventListener('click', () => { if (!this._config) { return; } const commands = this._config.getCommands(); const command = commands[cmdName]; if (command) { const func = initCommand(cmdName, command, <HTMLElement>btn); if (func) { func(<EditorView>this._editor); if (cmdName !== 'encloseWithCommand') { this._editor?.focus(); } } } }); }); }); } private activateToolbar(slot:HTMLSlotElement|null|undefined) { slot?.assignedElements().forEach((elem) => { elem.querySelectorAll('slot').forEach(sl => this.activateToolbar(sl)); elem.querySelectorAll('[data-command]').forEach((elem) => { const btn = <HTMLElement>elem; if (!btn.dataset.mode || btn.dataset.mode === this._mode) { (<HTMLElement>btn).style.display = 'inline'; } else { (<HTMLElement>btn).style.display = 'none'; } }); }); } protected getToolbarControls(slot:HTMLSlotElement|null|undefined, toolbar:HTMLElement[] = []) { slot?.assignedElements().forEach((elem) => { elem.querySelectorAll('[data-command]').forEach((btn) => { toolbar.push(<HTMLElement>btn); }); elem.querySelectorAll('slot').forEach(sl => this.getToolbarControls(sl, toolbar)); }); return toolbar; } styles() { return ` :host > div { width: 100%; background-color: var(--jinn-codemirror-background-color, #fff); } .cm-cursor { min-height: 1rem; } .status { padding-left: .5rem; } `; } } if (!customElements.get('jinn-codemirror')) { window.customElements.define('jinn-codemirror', JinnCodemirror); }