Home Reference Source

js/controllers/CodeEditorViewController.js

import ActionControllerDelegate from '~/delegate/ActionControllerDelegate';
import ViewController from '~/controllers/ViewController';
import { CodeMirror as LoadCodeMirror, CodeMirrorMode, CodeMirrorTheme } from '~/helpers/LazyLoad';
import ErrorManager from '~/helpers/ErrorManager';
import Random from '~/modern/Random';

import { fromEvent } from 'rxjs';
import { map, share, startWith } from 'rxjs/operators';

export const CodeEditorModeLoadError = Symbol('CodeEditor.Error.ModeLoad');
export const CodeEditorThemeLoadError = Symbol('CodeEditor.Error.ThemeLoad');
export const CodeEditorLoadError = Symbol('CodeEditor.Error.Load');

/**
 * OO-wrapper for a codeeditor.
 */
export default class CodeEditorViewController extends ViewController {
    /**
     * Creates CodeEditor wrapper for element. Reccomended to use a {@link Random}
     * to create a unique name.
     *
     * @param {HTMLTextArea} element element id. If an HTMLElement w/o parent then appending
     * @param {CodeEditorTheme} theme Theme to use for Ace.
     * @param {Object} [opts={}] config opts
     * @param {?number} opts.lines - amont of lines to show
     * @param {?number} opts.autoresize - If to autoresize the box
     */
    constructor(element, theme = CodeEditorTheme.default, { lines = null, autoresize = null } = {}) {
        super();

        return (async () => {
            const CodeMirror = await LoadCodeMirror();
            const opts = {
                lineNumbers: true,
                gutter: true,
                autoRefresh: true
            };

            if (element.parentNode) {
                this._editor = CodeMirror.fromTextArea(element, opts);
            } else {
                await new Promise((resolve, reject) => {
                    this._editor = CodeMirror((el) => {
                        element.appendChild(el);
                        resolve();
                    }, opts);
                });
            }

            this._editor.getWrapperElement().controller = this;

            this._observeValue = fromEvent(this._editor, 'change')
                .pipe(
                    map(([editor]) => editor.getValue()),
                    startWith(""),
                    share());

            /**
             * @type {CodeEditorTheme}
             */
            this.theme = CodeEditorTheme.default;

            /** @type {ActionControllerDelegate} */
            this.delegate = new ActionControllerDelegate();

            this._editor.on('change', (editor) => {
                this.delegate.didSetStateTo(this, editor.getValue());
            });

            if (lines) this.lines = lines;
            if (autoresize) this.autoresize = autoresize;

            return this;
        })();
    }

    /**
     * Obtains observable of value
     * @return {Observable}
     */
    observeValue() {
        return this._observeValue;
    }

    /**
     * Formats a widget id
     */
    _widgetId(id, value = "") {
        return `@@axtell:${id}:${value}@@`;
    }

    /**
     * Adds a DOM element as a widget
     * @param {Template} template If it implements CodeEditorWidgetTemplate then delegate didSetState to is overriden
     * @param {string} opts.id an 'id' describes the text value of the node
     */
    addWidget(template, { id }) {
        const generatedId = this._widgetId(id, template.state);
        this._editor.replaceSelection(generatedId, "around");

        var from = this._editor.getCursor("from");
        var to = this._editor.getCursor("to");
        const marker = this._editor.markText(from, to, {
            replacedWith: template.unique(),
            clearWhenEmpty: false
        });

        const updateId = (newId, value) => {
            const pos = marker.find();
            if (pos) {
                pos.from.ch += 1;
                pos.to.ch -= 1;
                this._editor.replaceRange(this._widgetId(newId, value), pos.from, pos.to);
            }
        }

        // Support values on ActionControllerDelegate
        if (template.delegate instanceof ActionControllerDelegate) {
            template.delegate.didSetStateTo = (template, state) => {
                this._editor.refresh();
                updateId(id, state);
            };
        }

        return {
            exists: () => {
                return !!marker.find();
            },
            updateId
        };
    }

    /**
     * If should auto resize (i.e. starts at 1)
     * @type {boolean}
     */
    set autoresize(shouldAutoresize) {
        if (shouldAutoresize) {
            this._editor.getWrapperElement().style.height = 'auto';
            this._editor.setOption('viewportMargin', Infinity);
            this._editor.refresh();
        } else {
            this._editor.setOption('viewportMargin', 10);
        }
    }

    /**
     * Sets the height in the # of lines that should be shown
     * @type {nuumber}
     */
    set lines(lineCount) {
        this._editor.setOption('viewportMargin', lineCount);
    }

    /**
     * Returns value of editor
     * @type {string}
     */
    get value() {
        return this._editor.getValue();
    }

    /**
     * Sets the value of the editor
     * @type {string}
     */
    set value(value) {
        return this._editor.setValue(value);
    }

    /**
     * Sets the theme given a type
     * @param {CodeEditorThemeType} type Type of theme
     */
    async setThemeType(type) {
        const themeName = this.theme.nameForType(type);
        try {
            await CodeMirrorTheme(themeName)
        } catch(error) {
            ErrorManager.raise(
                `Failed to load theme ${themeName}`,
                CodeEditorThemeLoadError
            );
        }
        this._editor.setOption('theme', themeName);
    }

    /**
     * Sets the language if possible
     * @param {?Language|string} lang - Language object. Null if default
     */
    async setLanguage(lang) {
        if (lang?.cmName) {
            try {
                await CodeMirrorMode(lang.cmName); // Loads the mode
                this._editor.setOption('mode', lang.cmName);
            } catch(error) {
                ErrorManager.raise(
                    `Failed to load language ${lang.id} of CodeMirror name ${lang.cmName}`,
                    CodeEditorModeLoadError
                );
            }
        } else if (lang && typeof lang === 'object' ? lang.name : typeof lang === 'string') {
            // either { name: something } or 'str'
            this._editor.setOption('mode', lang);
        } else {
            this._editor.setOption('mode');
        }
    }

    /**
     * Checks if should be validated
     * @type {boolean}
     */
    set shouldValidate(should) {
        /* No validation atm */
    }
}

/**
 * Type of theme
 * @typedef {Object} CodeEditorThemeType
 */
export const CodeEditorThemeType = {
    Dark: Symbol('CodeEditor.ThemeType.Dark'),
    Light: Symbol('CodeEditor.ThemeType.Light'),

    /**
     * From a {@link Theme}
     * @param  {Theme} theme Theme file
     * @return {CodeEditorThemeType} respective theme type.
     */
    fromTheme(theme) {
        if (theme.isDark) return this.Dark;
        else return this.Light;
    }
}

export class CodeEditorTheme {
    /**
     * Creates a new Ace theme.
     *
     * @param {Object} opts Theme config
     * @param {string} opts.CodeEditorThemeType.Light LightTheme
     * @param {string} opts.CodeEditorThemeType.Light Dark theme
     */
    constructor(themes) {
        /** @type {Object} */
        this.themes = themes;
    }

    /**
     * Returns name for theme type
     * @param {CodeEditorThemeType} type - type of ace theme
     * @return {string} theme name
     */
    nameForType(type) {
        let theme = this.themes[type];
        if (typeof theme === 'undefined') ErrorManager.raise(`Invalid theme`, BadCodeEditorThemeType);
        return theme;
    }

    /**
     * Default CodeEditorTheme using tomorrow theme.
     * @type {CodeEditorTheme}
     */
    static default = new CodeEditorTheme({
        [CodeEditorThemeType.Light]: "default",
        [CodeEditorThemeType.Dark]: "monokai"
    });
}

export const BadCodeEditorThemeType = Symbol('CodeEditor.ThemeType.Error.BadType');