vercengen/components/ComponentScriptManager/ComponentScriptManagerBlockly.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`.
 *
 * Represents a {@link Blockly} sub-component used as a visual editor for {@link ve.ComponentScriptManager}.
 *
 * **Note.** Declaring duplicate {@link ve.ScriptManager} components will reset the main Blockly workspace for each new instance.
 * - Functional binding: <span color=00ffff>veScriptManagerBlockly</span>().
 *
 * ##### Constructor:
 * - `arg0_value`: {@link string} - The code to input into the present Blockly viewer.
 * - `arg1_options`: {@link Object}
 *   - `.script_manager`: {@link ve.ScriptManager}
 *
 * ##### Instance:
 * - `.workspace`: {@link Blockly.Workspace}
 * - `.v`: {@link string}
 *
 * ##### Methods:
 * - <span color=00ffff>{@link ve.ScriptManagerBlockly.disable|disable}</span>()
 * - <span color=00ffff>{@link ve.ScriptManagerBlockly.enable|enable}</span>()
 * - <span color=00ffff>{@link ve.ScriptManagerBlockly.fixBlocklyScaling|fixBlocklyScaling}</span>()
 * - <span color=00ffff>{@link ve.ScriptManagerBlockly.handleCSS|handleCSS}</span>()
 * - <span color=00ffff>{@link ve.ScriptManagerBlockly.hide|hide}</span>()
 * - <span color=00ffff>{@link ve.ScriptManagerBlockly.interceptBlocklyTransforms|interceptBlocklyTransforms}</span>()
 * - <span color=00ffff>{@link ve.ScriptManagerBlockly.setTheme|setTheme}</span>(arg0_theme:{@link string}) - Either 'theme_default'/'theme_light'.
 * - <span color=00ffff>{@link ve.ScriptManagerBlockly.show|show}</span>()
 *
 * @augments ve.Component
 * @memberof ve.Component
 * @type {ve.ScriptManagerBlockly}
 */
ve.ScriptManagerBlockly = 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
		let toolbox = ve.ScriptManager.toolbox;
		
		this.element = document.createElement("div");
			this.element.instance = this;
			this.element.setAttribute("component", "ve-script-manager-blockly");
			HTML.setAttributesObject(this.element, options.attributes);
		this.options = options;
		this.value = value;
		
		//Initialise ScriptManagerBlockly element 
		document.body.appendChild(this.element);
		this.workspace = Blockly.inject(this.element, {
			toolbox: toolbox,
			zoom: {
				controls: true,
				wheel: true,
				startScale: 1.0,
				maxScale: 3,
				minScale: 0.2,
				scaleSpeed: 1.2
			},
			trashcan: true
		});
		window.workspace = this.workspace;
		document.body.removeChild(this.element);
		this.blockly_widget_el = document.querySelector("body > .blocklyWidgetDiv");
		this.blockly_tooltip_el = document.querySelector("body > .blocklyTooltipDiv");
		this.blockly_toolbox_el = document.querySelector("body > .blocklyToolboxDiv");
		
		//Call after Blockly initialization
		this.interceptBlocklyTransforms();
		this.workspace.addChangeListener(() => {
			this._exportToMonaco();
			this.fireToBinding(); 
		});
		
		this.fixBlocklyScaling();
		this.handleCSS();
	}
	
	/**
	 * Returns the code value of the present Component by parsing ES6 JS as an Abstract Syntax Tree.
	 * - Accessor of: {@link ve.ScriptManagerBlockly}
	 *
	 * @alias v
	 * @memberof ve.Component.ve.ScriptManagerBlockly
	 * @type {string}
	 */
	get v () {
		//Return statement
		try {
			return Blockly.Javascript.workspaceToCode(Blockly.mainWorkspace);
		} catch (e) {}
	}
	
	/**
	 * Sets the code value of the present Component if possible.
	 * - Accessor of: {@link ve.ScriptManagerBlockly}
	 *
	 * @alias v
	 * @memberof ve.Component.ve.ScriptManagerBlockly
	 *
	 * @param {string} arg0_value
	 */
	set v (arg0_value) {
		//Convert from parameters
		let value = arg0_value;
		
		if (this._disabled) return; //Internal guard clause if element is disabled
		
		//Instantiate new blocks
		this.to_binding_fire_silently = true;
		js2blocks.parseCode(value);
		setTimeout(() => delete this.to_binding_fire_silently, 100);
		this.fireFromBinding();
	}
	
	_exportToMonaco (arg0_force_export) {
		//Convert from parameters
		let force_export = arg0_force_export;
		
		try {
			//Declare local instance variables
			let blockly_value = Blockly.JavaScript.workspaceToCode(Blockly.mainWorkspace);
			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)
				if (!this.to_binding_fire_silently)
					try {
						//Traverse up to the ScriptManager container, then search down for Monaco
						let manager_el = this.element.closest(`[component="ve-script-manager"]`);
						let monaco_el = (manager_el) ?
							manager_el.querySelector(`[component="ve-script-manager-monaco"]`) :
							undefined;
						
						if (monaco_el && monaco_el.instance) {
							let monaco_obj = monaco_el.instance;
							
							monaco_obj.to_binding_fire_silently = true;
							monaco_obj.v = blockly_value;
							delete monaco_obj.to_binding_fire_silently;
						}
					} catch (e) { console.error(e); }
		} catch (e) {}
	}
	
	/**
	 * Disables the present workspace.
	 * - Method of: {@link ve.ScriptManagerBlockly}
	 *
	 * @alias disable
	 * @memberof ve.Component.ve.ScriptManagerBlockly
	 */
	disable () {
		if (this._disabled) return; //Internal guard clause to ensure file can't be disabled twice
		
		//Declare local instance variables
		this._disabled = true;
		delete window.workspace;
		this.element.classList.add("disabled");
		
		try {
			this.blockly_tooltip_parent_el = this.blockly_tooltip_el.parentElement;
			this.blockly_tooltip_el.parentElement.removeChild(this.blockly_tooltip_el);
			this.blockly_widget_parent_el = this.blockly_widget_el.parentElement;
			this.blockly_widget_el.parentElement.removeChild(this.blockly_widget_el);
		} catch (e) {}
	}
	
	/**
	 * Enables the present workspace.
	 * - Method of: {@link ve.ScriptManagerBlockly}
	 *
	 * @alias enable
	 * @memberof ve.Component.ve.ScriptManagerBlockly
	 */
	enable () {
		if (!this._disabled) return; //Internal guard clause to ensure file can't be enabled twice
		
		//Declare local instance variables
		delete this._disabled;
		window.workspace = this.workspace;
		this.element.classList.remove("disabled");
		
		this.blockly_tooltip_parent_el.appendChild(this.blockly_tooltip_el);
		delete this.blockly_tooltip_parent_el;
		this.blockly_widget_parent_el.appendChild(this.blockly_widget_el);
		delete this.blockly_widget_parent_el;
	}
	
	/**
	 * Internal helper method. Fixes Blockly scaling issues.
	 * - Method of: {@link ve.ScriptManagerBlockly}
	 *
	 * @alias fixBlocklyScaling
	 * @memberof ve.Component.ve.ScriptManagerBlockly
	 */
	fixBlocklyScaling () {
		//Declare local instance variables
		let flyout_bg_el = this.element.querySelector('.blocklyFlyoutBackground');
		let flyout_container_el = this.element.querySelector('.blocklyFlyout');
		
		//Fix flyout scaling - CSS fix
		if (flyout_bg_el && flyout_container_el) {
			//Get the actual flyout container dimensions
			let flyout_rect = flyout_container_el.getBoundingClientRect();
			
			let height = flyout_rect.height;
			let width = flyout_rect.width;
			
			//Set flyout_bg_el's dynamic path
			flyout_bg_el.setAttribute('d', `M 0,0 h ${width} a 8 8 0 0 1 8 8 v ${height - 16} a 8 8 0 0 1 -8 8 h -${width} z`);
		}
	}
	
	/**
	 * Internal helper method. Handles CSS issues so that Blockly can be mounted into a window.
	 * - Method of: {@link ve.ScriptManagerBlockly}
	 *
	 * @alias handleCSS
	 * @memberof ve.Component.ve.ScriptManagerBlockly
	 */
	handleCSS () {
		//Declare local instance variables
		let blockly_raf;
		this.blockly_toolbox_mode = "canvas"; //Either 'body'/'canvas'
		let runToolboxUpdate = () => {
			if (this._hidden) {
				blockly_raf = requestAnimationFrame(runToolboxUpdate);
				return;
			}
			
			if (this._disabled) {
				if (this.blockly_toolbox_el.parentElement)
					this.blockly_toolbox_el.parentElement.removeChild(this.blockly_toolbox_el);
				blockly_raf = requestAnimationFrame(runToolboxUpdate);
				return;
			}
			
			this.svg_el = this.element.querySelector("svg");
				this.svg_el.setAttribute("width", "100%");
				this.svg_el.setAttribute("height", "100%");
			this.svg_rect = this.svg_el.getBoundingClientRect();
			
			let rect = this.element.getBoundingClientRect();
			this.blockly_toolbox_mode =
				this.element.querySelector(".blocklyFlyout:hover") ||
				this.blockly_toolbox_el.querySelector(":hover") ||
				document.querySelector(".blocklyDraggable:hover")
					? "body"
					: "canvas";
			
			if (this.blockly_toolbox_mode === "body") {
				if (!document.querySelector("body > .blocklyToolboxDiv")) {
					document.body.appendChild(this.blockly_toolbox_el);
				}
				this.blockly_toolbox_el.style.height = `${this.toolbox_height}px`;
				this.blockly_toolbox_el.style.left = `${rect.x}px`;
				this.blockly_toolbox_el.style.top = `calc(${rect.y}px + var(--cell-padding))`;
				this.blockly_toolbox_el.style.zIndex = "2";
			} else {
				// canvas mode
				if (!this.element.contains(this.blockly_toolbox_el)) {
					this.element.appendChild(this.blockly_toolbox_el);
				}
				this.blockly_toolbox_el.style.height = `${this.svg_rect.height}px`;
				this.blockly_toolbox_el.style.left = "0px";
				this.blockly_toolbox_el.style.top = `calc(var(--cell-padding))`;
				this.blockly_toolbox_el.style.zIndex = "0";
				this.toolbox_height = HTML.getElementDimensions(this.blockly_toolbox_el).height;
			}
			
			blockly_raf = requestAnimationFrame(runToolboxUpdate);
		};
		
		//Start RAF to handle CSS
		blockly_raf = requestAnimationFrame(runToolboxUpdate);
	}
	
	/**
	 * Hides the present workspace entirely.
	 * - Method of: {@link ve.ScriptManagerBlockly}
	 *
	 * @alias hide
	 * @memberof ve.Component.ve.ScriptManagerBlockly
	 */
	hide () {
		if (this._hidden) return; //Internal guard clause if already hidden
		
		//Declare local instance variables
		this._hidden = true;
		this.element.style.display = "none";
	}
	
	/**
	 * Internal helper method. Fixes Blockly transforms so that Blockly can be mounted into a window.
	 * - Method of: {@link ve.ScriptManagerBlockly}
	 *
	 * @alias interceptBlocklyTransforms
	 * @memberof ve.Component.ve.ScriptManagerBlockly
	 */
	interceptBlocklyTransforms () {
		//Declare local instance variables
		let targets = [
			'.blocklyFlyout .blocklyBlockCanvas',
			'.blocklyFlyout .blocklyBubbleCanvas'
		];
		
		//Iterate over all targets
		targets.forEach((selector) => {
			let local_element = this.element.querySelector(selector);
			
			if (local_element) {
				// Override setAttribute to intercept transform changes
				let originalSetAttribute = local_element.setAttribute;
				local_element.setAttribute = function (name, value) {
					if (name === 'transform' && value.includes('scale(')) {
						// Preserve translate, force scale(1)
						value = value.replace(/scale\([^)]+\)/g, 'scale(1)');
					}
					return originalSetAttribute.call(this, name, value);
				};
			}
		});
	}
	
	/**
	 * Internal helper method. Propagates the main editor theme class down from {@link ve.ScriptManager}. Either 'theme-default'/'theme-light'.
	 * - Method of: {@link ve.ScriptManagerBlockly}
	 *
	 * @alias setTheme
	 * @memberof ve.Component.ve.ScriptManagerBlockly
	 *
	 * @param {string} arg0_theme_class
	 */
	setTheme (arg0_theme_class) {
		//Convert from parameters
		let theme_class = arg0_theme_class;
		
		//Remove previous themes
		if (this._theme)
			this.blockly_toolbox_el.classList.remove(this._theme);
		this._theme = theme_class;
		
		//Add theme to this.blockly_toolbox_el
		this.blockly_toolbox_el.classList.add(this._theme);
	}
	
	/**
	 * Displays the present workspace if hidden.
	 * - Method of: {@link ve.ScriptManagerBlockly}
	 *
	 * @alias show
	 * @memberof ve.Component.ve.ScriptManagerBlockly
	 */
	show () {
		if (!this._hidden) return; //Internal guard clause if already shown
		
		//Declare local instance variables
		delete this._hidden;
		this.element.style.display = "block";
	}
};

//Functional binding

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