vercengen/components/ComponentScriptManager/ComponentScriptManagerMonaco.js

/**
 * Refer to <span color = "yellow">{@link ve.Component}</span> for methods or fields inherited from this Component's parent such as `.options.attributes` or `.element`.
 *
 * Manages the Monaco Editor instance for {@link ve.ScriptManager}, the main IDE component for Vercengen.
 * - Functional binding: <span color=00ffff>veScriptManagerMonaco</span>().
 *
 * ##### Constructor:
 * - `arg0_value`: {@link string} - The code to load into the present component.
 * - `arg1_options`: {@link Object}
 *   - `.monaco_options`: {@link Object} - Any monaco options to use upon instantiation.
 *   - `.script_manager`: {@link ve.ScriptManager}
 *   - `.theme`: {@link string} - The initial theme to load.
 *
 * ##### Instance:
 * - `.editor`: {@link Object} - The raw Monaco editor instance.
 * - `.v`: {@link string}
 *
 * ##### Methods:
 * - <span color=00ffff>{@link ve.ScriptManagerMonaco.setOptions|setOptions}</span>(arg0_options:{@link Object})
 * - <span color=00ffff>{@link ve.ScriptManagerMonaco.setTheme|setTheme}</span>(arg0_theme_name:{@link string})
 *
 * @augments ve.Component
 * @memberof ve.Component
 * @type {ve.ScriptManagerMonaco}
 */
ve.ScriptManagerMonaco = class extends ve.Component {
	static excluded_from_demo = true;
	
	constructor (arg0_value, arg1_options) {
		//Convert from parameters
		let value = arg0_value;
		let options = (arg1_options) ? arg1_options : {};
		super(options);
		
		//Initialise options
		options.attributes = (options.attributes) ? options.attributes : {};
		
		//Declare local instance variables
		this.element = document.createElement("div");
			this.element.instance = this;
			this.element.setAttribute("component", "ve-script-manager-monaco");
			HTML.setAttributesObject(this.element, options.attributes);
		
		let computed_style_obj = window.getComputedStyle(document.body);
		let default_font_family = computed_style_obj.getPropertyValue("--monospace-font-family").replace(/"/gm, "");
		this.options = options;
		this._default_options = {
			autoIndent: "advanced",
			automaticLayout: true,
			contextmenu: true,
			detectIndentation: false,
			fixedOverflowWidgets: true,
			fontFamily: default_font_family,
			fontLigatures: true,
			fontSize: 14,
			formatOnPaste: false,
			insertSpaces: true,
			minimap: { enabled: true },
			tabSize: 2,
			wordWrap: "bounded",
			wordWrapColumn: 120
		};
		this._pending_value = (value === null || value === undefined) ? "" : value.toString();
		this._theme = (options.theme) ? options.theme : "nord";
		
		//Load Monaco once DOM populates
		this.monaco_initialisation_loop = setInterval(() => {
			if (!document.body.contains(this.element)) return;
			if (!window.monaco_require) return; // Wait for loader
			
			window.monaco_require(["vs/editor/editor.main"], () => {
				//1. Create Editor
				this.editor = monaco.editor.create(this.element, {
					value: this._pending_value,
					language: "javascript",
					theme: "vs-dark", // Placeholder, we set the real theme immediately after
					
					...this._default_options,
					...this.options.monaco_options
				});
				
				//2. Load and Set the correct theme
				this.setTheme(this._theme);
				
				//3. Fix paste event for Electron
				this.editor.addAction({
					id: "electron-native-paste",
					label: loc("ve.registry.localisation.ScriptManagerMonaco_action_paste"),
					keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyV],
					contextMenuGroupId: "9_cutcopypaste",
					run: (ed) => {
						try {
							const electron = window.node_require ? window.node_require("electron") : null;
							if (electron && electron.clipboard) {
								const text = electron.clipboard.readText();
								const selection = ed.getSelection();
								
								ed.executeEdits("native-paste", [{
									range: selection,
									text: text,
									forceMoveMarkers: true,
								}]);
								ed.pushUndoStop();
							}
						} catch (e) {
							console.error("ve.ScriptManagerMonaco: Paste Error", e);
						}
					},
				});
				
				// 4. Handle value changes and sync to Blockly
				this.editor.onDidChangeModelContent(() => {
					this._exportToBlockly();
					this.fireToBinding();
				});
				
				//Clear pending
				delete this._pending_value;
			});
			
			clearInterval(this.monaco_initialisation_loop);
		}, 100);
	}
	
	/**
	 * Returns the code value for the present Component.
	 * - Accessor of: {@link ve.ScriptManagerMonaco}
	 *
	 * @alias v
	 * @memberof ve.Component.ve.ScriptManagerMonaco
	 * @type {string}
	 */
	get v () {
		if (!this.editor) return (this._pending_value) ? this._pending_value : "";
		return this.editor.getValue();
	}
	
	/**
	 * Sets the code value for the present Component.
	 * - Accessor of: {@link ve.ScriptManagerMonaco}
	 *
	 * @alias v
	 * @memberof ve.Component.ve.ScriptManagerMonaco
	 *
	 * @param {string} arg0_value
	 */
	set v (arg0_value) {
		//Convert from parameters
		let value = (arg0_value === null || arg0_value === undefined) ? "" : String(arg0_value);
		
		this.do_not_fire_to_binding = true;
		if (!this.editor) {
			this._pending_value = value;
		} else {
			//Prevent cursor jumping if value is same
			if (this.editor.getValue() !== value) {
				this.editor.setValue(value);
			}
		}
		delete this.do_not_fire_to_binding;
		
		this.fireFromBinding();
	}
	
	/**
	 * Exports the current value to {@link ve.ScriptManagerBlockly}.
	 * - Method of: {@link ve.ScriptManagerMonaco}
	 * 
	 * @param {boolean} [arg0_force_export=false]
	 * @private
	 */
	_exportToBlockly (arg0_force_export) {
		//Convert from parameters
		let force_export = arg0_force_export;
		
		//Declare local instance variables
		let should_export = false;
			if (this.options.script_manager && !this.options.script_manager._settings.manual_synchronisation)
				should_export = true;
			if (force_export) should_export = true;
		
		//Only export if should_export is true
		if (should_export)
			try {
				if (!this.to_binding_fire_silently) {
					let manager_el = this.element.closest(`[component="ve-script-manager"]`);
					let blockly_el = (manager_el) ?
						manager_el.querySelector(`[component="ve-script-manager-blockly"]`) :
						undefined;
					
					if (blockly_el && blockly_el.instance) {
						let blockly_obj = blockly_el.instance;
						
						blockly_obj.to_binding_fire_silently = true;
						blockly_obj.v = this.editor.getValue();
					}
				}
			} catch (e) { console.warn(e); }
	}
	
	/**
	 * Sets an option for the current code interface and pushes it to the parent {@link ve.ScriptManager}.
	 * - Method of: {@link ve.ScriptManagerMonaco}
	 * 
	 * @param {string} arg0_option_key
	 * @param {any} arg1_value
	 * @private
	 */
	_setOption (arg0_option_key, arg1_value) {
		//Convert from parameters
		let option_key = arg0_option_key;
		let value = arg1_value;
		
		//Declare local instance variables
		let settings_obj = this.options?.script_manager?._settings;
		
		//Try to update the present option
		if (settings_obj) {
			if (!settings_obj.monaco) settings_obj.monaco = {};
			settings_obj.monaco[option_key] = value;
		}
		this.setOptions({ [option_key]: value });
	}
	
	/**
	 * Returns the current options interface with the ability to update the current symbol and export it to `this.options.script_manager._settings`.
	 * - Method of: {@link ve.ScriptManagerMonaco}
	 * 
	 * @param {Object} [arg0_options]
	 *  @param {boolean} [arg0_options.do_not_cache=false]
	 *  @param {string} [arg0_options.name="Code Editor"]
	 * 
	 * @returns {ve.Interface}
	 */
	drawOptionsInterface (arg0_options) {
		//Convert from parameters
		let options = (arg0_options) ? arg0_options : {};
		
		//Declare local instance variables
		let current_options = this.editor.getRawOptions();
		let getMonacoOption = (arg0_key) => current_options[arg0_key];
		let new_interface = new ve.Interface({
			appearance: new ve.Interface({
				font_family: new ve.Text(getMonacoOption("fontFamily"), {
					name: loc("ve.registry.localisation.ScriptManagerMonaco_font_family"),
					onuserchange: (v) => this._setOption("fontFamily", v)
				}),
				font_ligatures: new ve.Toggle(getMonacoOption("fontLigatures"), {
					name: loc("ve.registry.localisation.ScriptManagerMonaco_font_ligatures"),
					onuserchange: (v) => this._setOption("fontLigatures", v)
				}),
				font_size: new ve.Number(getMonacoOption("fontSize"), {
					name: loc("ve.registry.localisation.ScriptManagerMonaco_font_size"),
					onuserchange: (v) => this._setOption("fontSize", v)
				}),
				minimap: new ve.Toggle((getMonacoOption("minimap.enabled")) ? getMonacoOption("minimap.enabled") : true, {
					name: loc("ve.registry.localisation.ScriptManagerMonaco_show_minimap"),
					onuserchange: (v) => {
						this._setOption("minimap.enabled", v);
						this.setOptions({ minimap: { enabled: v } });
					}
				}),
				word_wrap: new ve.Toggle((getMonacoOption("wordWrap") === "bounded"), {
					name: loc("ve.registry.localisation.ScriptManagerMonaco_word_wrap"),
					onuserchange: (v) => this._setOption("wordWrap", (v) ? "bounded" : "off")
				}),
				word_wrap_column: new ve.Number(Math.returnSafeNumber(getMonacoOption("wordWrapColumn"), 120), {
					name: loc("ve.registry.localisation.ScriptManagerMonaco_word_wrap_column"),
					min: 1,
					onuserchange: (v) => this._setOption("wordWrapColumn", v)
				})
			}, { name: loc("ve.registry.localisation.ScriptManagerMonaco_appearance") }),
			indentation: new ve.Interface({
				insert_spaces: new ve.Toggle(getMonacoOption("insertSpaces"), {
					name: loc("ve.registry.localisation.ScriptManagerMonaco_insert_spaces"),
					onuserchange: (v) => this._setOption("insertSpaces", v)
				}),
				tab_size: new ve.Number(getMonacoOption("tabSize"), {
					name: loc("ve.registry.localisation.ScriptManagerMonaco_tab_size"),
					onuserchange: (v) => this._setOption("tabSize", v)
				})
			}, { name: loc("ve.registry.localisation.ScriptManagerMonaco_indentation") }),
			reset_monaco_settings: new ve.Button(() => {
				//Declare local instance variables
				let computed_style_obj = window.getComputedStyle(this.element);
				let settings_obj = this.options?.script_manager?._settings;
				
				//Update the present option if possible
				if (settings_obj)
					settings_obj.monaco = {};
				this._default_options.fontFamily = computed_style_obj.getPropertyValue("--monospace-font-family").replace(/"/gm, "");
				this.refresh();
				setTimeout(() => {
					let new_interface = this.drawOptionsInterface({
						...options,
						do_not_cache: true
					});
					
					//Swap out interface
					this.options_interface.element.innerHTML = "";
					this.options_interface.element.replaceWith(new_interface.element);
					this.options_interface.element = new_interface.element;
					this.options_interface.v = new_interface.components_obj;
				}, 100);
			}, { name: loc("ve.registry.localisation.ScriptManagerMonaco_button_reset") })
		}, {
			name: (options.name) ? options.name : loc("ve.registry.localisation.ScriptManagerMonaco_name_default")
		});
		if (!options.do_not_cache)
			this.options_interface = new_interface;
		
		//Return statement
		return new_interface;
	}
	
	/**
	 * Refreshes the current Monaco display. Used for resetting all cached options.
	 * - Method of: {@link ve.ScriptManagerMonaco}
	 */
	refresh () {
		//Declare local instance variables
		let computed_style_obj = window.getComputedStyle(this.element);
		let current_value = this.editor.getValue();
		let view_state = this.editor.saveViewState();
		
		//Dispose the current editor and refresh it
		this.editor.dispose();
		this.editor = monaco.editor.create(this.element, {
			value: current_value,
			language: "javascript",
			theme: "vs-dark", // Placeholder, we set the real theme immediately after
			
			...this._default_options,
			...this.options.monaco_options
		});
		
		//Apply current theme; restore view state
		if (this._theme)
			this.setTheme(this._theme);
		this.editor.restoreViewState(view_state);
	}
	
	/**
	 * Update behaviour/symbol options for Monaco.
	 * - Method of: {@link ve.ScriptManagerMonaco}
	 * 
	 * @param {Object} arg0_options
	 */
	setOptions (arg0_options) {
		//Convert from parameters
		let options = (arg0_options) ? arg0_options : {};
		
		//Initialise options bindings
		if (options["minimap.enabled"] !== undefined) {
			if (!options.minimap) options.minimap = {};
			options.minimap.enabled = options["minimap.enabled"];
		}
		
		//Set options for Monaco
		this.editor.updateOptions(options);
	}
	
	/**
	 * Dynamically loads and sets a Monaco theme from a remote repository.
	 * - Method of: {@link ve.ScriptManagerMonaco}
	 *
	 * @param {string} arg0_theme_name
	 */
	async setTheme (arg0_theme_name) {
		let theme_name = arg0_theme_name;
		this._theme = theme_name;
		
		//Guard clause: If Monaco isn't loaded yet, the init loop will handle setting this._theme
		if (!window.monaco || !monaco.editor) return;
		
		//If it's a standard internal theme (vs, vs-dark, hc-black), just set it
		if (["vs", "vs-dark", "hc-black"].includes(theme_name)) {
			monaco.editor.setTheme(theme_name);
			return;
		}
		
		try {
			let file_path = `${process.cwd()}/UF/js/vercengen/components/ComponentScriptManager/monaco/themes/${theme_name}.json`;
			
			if (fs.existsSync(file_path)) {
				let theme_data = JSON.parse(fs.readFileSync(file_path, "utf8"));
				
				monaco.editor.defineTheme(theme_name, theme_data);
				monaco.editor.setTheme(theme_name);
			} else {
				console.warn(`ve.ScriptManagerMonaco: Theme file not found: ${theme_name}`);
				//Fallback to vs-dark if custom theme fails
				monaco.editor.setTheme("vs-dark");
			}
		} catch (e) {
			console.error("ve.ScriptManagerMonaco: Theme load failed", theme_name, e);
		}
	}
};

//Functional binding

/**
 * @returns {ve.ScriptManagerMonaco}
 */
veScriptManagerMonaco = function () {
	//Return statement
	return new ve.ScriptManagerMonaco(...arguments);
};