@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
text/typescript
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);
}