/** * @module source_editor_instance */ /** * A Source Editor Extension definition * @typedef {Object} SourceEditorExtensionDefinition * @property {Object} definition * @property {Object} setupOptions */ /** * A Source Editor Extension * @typedef {Object} SourceEditorExtension * @property {Object} obj * @property {string} extensionName * @property {Object} api */ import { isEqual } from 'lodash'; import { editor as monacoEditor } from 'monaco-editor'; import { getBlobLanguage } from '~/editor/utils'; import { logError } from '~/lib/logger'; import { sprintf } from '~/locale'; import EditorExtension from './source_editor_extension'; import { EDITOR_EXTENSION_DEFINITION_TYPE_ERROR, EDITOR_EXTENSION_NAMING_CONFLICT_ERROR, EDITOR_EXTENSION_NO_DEFINITION_ERROR, EDITOR_EXTENSION_NOT_REGISTERED_ERROR, EDITOR_EXTENSION_NOT_SPECIFIED_FOR_UNUSE_ERROR, EDITOR_EXTENSION_STORE_IS_MISSING_ERROR, } from './constants'; const utils = { removeExtFromMethod: (method, extensionName, container) => { if (!container) { return; } if (Object.prototype.hasOwnProperty.call(container, method)) { // eslint-disable-next-line no-param-reassign delete container[method]; } }, getStoredExtension: (extensionsStore, extensionName) => { if (!extensionsStore) { logError(EDITOR_EXTENSION_STORE_IS_MISSING_ERROR); return undefined; } return extensionsStore.get(extensionName); }, hasFullApiRegistered: (targetMethods, newMethods) => { return newMethods.find((fn) => !targetMethods.includes(fn)) === undefined; }, }; /** Class representing a Source Editor Instance */ export default class EditorInstance { /** * Create a Source Editor Instance * @param {Object} rootInstance - Monaco instance to build on top of * @param {Map} extensionsStore - The global registry for the extension instances * @returns {Object} - A Proxy returning props/methods from either registered extensions, or Source Editor instance, or underlying Monaco instance */ constructor(rootInstance = {}, extensionsStore = new Map()) { /** The methods provided by extensions. */ this.methods = {}; const seInstance = this; const getHandler = { get(target, prop, receiver) { const methodExtension = Object.prototype.hasOwnProperty.call(seInstance.methods, prop) && seInstance.methods[prop]; if (methodExtension) { const extension = extensionsStore.get(methodExtension); if (typeof extension.api[prop] === 'function') { return extension.api[prop].bind(extension.obj, receiver); } return extension.api[prop]; } return Reflect.get(seInstance[prop] ? seInstance : target, prop, receiver); }, }; const instProxy = new Proxy(rootInstance, getHandler); this.dispatchExtAction = EditorInstance.useUnuse.bind(instProxy, extensionsStore); return instProxy; } /** * A private dispatcher function for both `use` and `unuse` * @param {Map} extensionsStore - The global registry for the extension instances * @param {Function} fn - A function to route to. Either `this.useExtension` or `this.unuseExtension` * @param {SourceEditorExtensionDefinition[]} extensions - The extensions to use/unuse. * @returns {Function} */ static useUnuse(extensionsStore, fn, extensions) { if (Array.isArray(extensions)) { /** * We cut short if the Array is empty and let the destination function to throw * Otherwise, we run the destination function on every entry of the Array */ return extensions.length ? extensions.map(fn.bind(this, extensionsStore)) : fn.call(this, extensionsStore); } return fn.call(this, extensionsStore, extensions); } // // REGISTERING NEW EXTENSION // /** * Run all registrations when using an extension * @param {Map} extensionsStore - The global registry for the extension instances * @param {SourceEditorExtensionDefinition} extension - The extension definition to use. * @returns {EditorExtension|*} */ useExtension(extensionsStore, extension = {}) { const { definition } = extension; if (!definition) { throw new Error(EDITOR_EXTENSION_NO_DEFINITION_ERROR); } if (typeof definition !== 'function') { throw new Error(EDITOR_EXTENSION_DEFINITION_TYPE_ERROR); } // Existing Extension Path const existingExt = utils.getStoredExtension(extensionsStore, definition.extensionName); if (existingExt) { if (isEqual(extension.setupOptions, existingExt.setupOptions)) { if (utils.hasFullApiRegistered(this.extensionsAPI, Object.keys(existingExt.api))) { return existingExt; } } this.unuseExtension(extensionsStore, existingExt); } // New Extension Path const extensionInstance = new EditorExtension(extension); const { setupOptions, obj: extensionObj } = extensionInstance; if (extensionObj.onSetup) { extensionObj.onSetup(this, setupOptions); } if (extensionsStore) { this.registerExtension(extensionInstance, extensionsStore); } this.registerExtensionMethods(extensionInstance); return extensionInstance; } /** * Register extension in the global extensions store * @param {SourceEditorExtension} extension - Instance of Source Editor extension * @param {Map} extensionsStore - The global registry for the extension instances */ registerExtension(extension, extensionsStore) { const { extensionName } = extension; const hasExtensionRegistered = extensionsStore.has(extensionName) && isEqual(extension.setupOptions, extensionsStore.get(extensionName).setupOptions); if (hasExtensionRegistered) { return; } extensionsStore.set(extensionName, extension); const { obj: extensionObj } = extension; if (extensionObj.onUse) { extensionObj.onUse(this); } } /** * Register extension methods in the registry on the instance * @param {SourceEditorExtension} extension - Instance of Source Editor extension */ registerExtensionMethods(extension) { const { api, extensionName } = extension; if (!api) { return; } Object.keys(api).forEach((prop) => { if (this[prop]) { logError(sprintf(EDITOR_EXTENSION_NAMING_CONFLICT_ERROR, { prop })); } else { this.methods[prop] = extensionName; } }, this); } // // UNREGISTERING AN EXTENSION // /** * Unregister extension with the cleanup * @param {Map} extensionsStore - The global registry for the extension instances * @param {SourceEditorExtension} extension - Instance of Source Editor extension to un-use */ unuseExtension(extensionsStore, extension) { if (!extension) { throw new Error(EDITOR_EXTENSION_NOT_SPECIFIED_FOR_UNUSE_ERROR); } const { extensionName } = extension; const existingExt = utils.getStoredExtension(extensionsStore, extensionName); if (!existingExt) { throw new Error(sprintf(EDITOR_EXTENSION_NOT_REGISTERED_ERROR, { extensionName })); } const { obj: extensionObj } = existingExt; if (extensionObj.onBeforeUnuse) { extensionObj.onBeforeUnuse(this); } this.unregisterExtensionMethods(existingExt); if (extensionObj.onUnuse) { extensionObj.onUnuse(this); } } /** * Remove all methods associated with this extension from the registry on the instance * @param {SourceEditorExtension} extension - Instance of Source Editor extension to un-use */ unregisterExtensionMethods(extension) { const { api, extensionName } = extension; if (!api) { return; } Object.keys(api).forEach((method) => { utils.removeExtFromMethod(method, extensionName, this.methods); }); } /** * PUBLIC API OF AN INSTANCE */ /** * Updates model language based on the path * @param {String} path - blob path */ updateModelLanguage(path) { const lang = getBlobLanguage(path); const model = this.getModel(); // return monacoEditor.setModelLanguage(model, lang); monacoEditor.setModelLanguage(model, lang); } /** * Main entry point to apply an extension to the instance * @param {SourceEditorExtensionDefinition[]|SourceEditorExtensionDefinition} extDefs - The extension(s) to use * @returns {EditorExtension|*} */ use(extDefs) { return this.dispatchExtAction(this.useExtension, extDefs); } /** * Main entry point to remove an extension to the instance * @param {SourceEditorExtension[]|SourceEditorExtension} exts - * @returns {*} */ unuse(exts) { return this.dispatchExtAction(this.unuseExtension, exts); } /** * Get the methods returned by extensions. * @returns {Array} */ get extensionsAPI() { return Object.keys(this.methods); } }