vercengen/features/Window.js

/**
 * Refer to <span color = "yellow">{@link ve.Feature}</span> for methods or fields inherited from the parent, such as automatic destructuring.
 * 
 * Represents a Window Feature that contains a set of components which are wrapped inside an Interface.
 * - Inherited by: {@link ve.ContextMenu}, {@link ve.Modal}, {@link ve.PageMenuWindow}
 *
 * ##### Constructor:
 * - `arg0_components_obj`: {@link Object}<{@link ve.Component}>
 * - `arg1_options`: {@link Object}
 *   - `.anchor="top_left"` - Either 'bottom_left'/'bottom_right'/'top_left'/'top_right'.
 *   - `.height="auto"`: {@link number}
 *   - `.width="12rem"`: {@link number}
 *   - `.x=HTML.mouse_x`: {@link function}|{@link number} - If the type is a function, it must return a number.
 *   - `.y=HTML.mouse_y`: {@link function}|{@link number} - If the type is a function, it must return a number.
 *   -
 *   - `.do_not_wrap=false`: {@link boolean} - Whether to disable wrapping in an always open {@link ve.Interface}.
 *   - `.element`: {@link HTMLElement}
 *   - `.id`: {@link string}
 *   - `.mode="window"`: {@link string} - Either 'static_ui'/'static_window'/'window'.
 *   - `.name=""`: {@link string} - Auto-resolves to 'Window' instead if `.can_rename=true`.#
 *   - `.name_class=""`: {@link string}
 *   - `.theme`: {@link string} - The CSS theme to apply to the Feature.
 *   -
 *   - `.can_close`: {@link boolean}
 *   - `.can_rename`: {@link boolean}
 *   - `.draggable`: {@link boolean}
 *   - `.headless`: {@link boolean}
 *   - `.resizeable`: {@link boolean}
 *   - 
 *   - `.bottom_elements`: {@link Array}<{@link HTMLElement}> - Determines margin from bottom.
 *   - `.left_elements`: {@link Array}<{@link HTMLElement}> - Determines margin from left.
 *   - `.right_elements`: {@link Array}<{@link HTMLElement}> - Determines margin from right.
 *   - `.top_elements`: {@link Array}<{@link HTMLElement}> - Determines margin from top.
 *   - 
 *   - `.onuserchange`: {@link function}(arg0_v:{@link Object}, arg1_e:{@link Event}) - Fires upon user changes to the window. Changes are discrete, and the set of Object keys may vary.
 *     - `arg0_v`: {@link Object}
 *       - `.close`: {@link boolean} - Whether the change is a close event.
 *       - `.name`: {@link string}
 *     - `arg1_e`: {@link Object}
 *       - `.instance`: {@link ve.Window}
 *   
 * ##### Instance:
 * - `.v`: {@link Object}<{@link ve.Component}>
 *
 * ##### Methods:
 * - <span color=00ffff>{@link ve.Window.getZIndex|getZIndex}</span>() | {@link number}
 * - <span color=00ffff>{@link ve.Window.select|select}</span>()
 * - <span color=00ffff>{@link ve.Window.setCoords|setCoords}</span>(arg0_x:{@link number}, arg1_y:{@link number})
 * - <span color=00ffff>{@link ve.Window.setName|setName}</span>(arg0_name:{@link string})
 * - <span color=00ffff>{@link ve.Window.setSize|setSize}</span>(arg0_width:{@link number}|{@link string}, arg1_height:{@link number}|{@link string})
 * - <span color=00ffff>{@link ve.Window.refresh|refresh}</span>(arg0_components_obj:{@link Object}<{@link ve.Component}>)
 * 
 * ##### Static Fields:
 * - `.instances`: {@link Array}<{@link ve.Window}>
 * 
 * ##### Static Methods:
 * - <span color=00ffff>{@link ve.Window.getHighestZIndex}</span>(arg0_options:{ return_object=false: {@link boolean} }) | {@link number}|{@link ve.Window}
 *   
 * @augments ve.Feature
 * @memberof ve.Feature
 * @type {ve.Window}
 */
ve.Window = class extends ve.Feature {
	//Declare local static variables
	static instances = [];
	
	constructor (arg0_components_obj, arg1_options) {
		//Convert from parameters
		let components_obj = arg0_components_obj;
		let options = (arg1_options) ? arg1_options : {};
			super(components_obj, options);
		
		//Initialise options
		let is_coords_well_defined = (typeof options.x === "number" && typeof options.y === "number");
		if (options.mode === undefined)
			options.mode = (is_coords_well_defined) ? "static_ui" : "window";
		
		options.anchor = (options.anchor) ? options.anchor : "top_left";
		options.height = (options.height !== undefined) ? options.height : "auto";
		options.width = (options.width !== undefined) ? options.width : "12rem";
		options.x = (options.x !== undefined) ? options.x : HTML.mouse_x;
		options.y = (options.y !== undefined) ? options.y : HTML.mouse_y;
		
		//Parse options
		let mode_behaviour = {
			static_ui: {
				can_close: false,
				can_rename: false,
				draggable: false,
				resizeable: false,
				headless: true,
				is_static: true
			},
			static_window: {
				can_close: false,
				can_rename: false,
				draggable: false,
				resizeable: false,
				headless: false,
				is_static: true
			},
			window: {
				can_close: true,
				can_rename: true,
				draggable: true,
				resizeable: true,
				headless: false,
				is_static: false
			}
		};
		options = {
			...mode_behaviour[options.mode],
			...options
		};
		
		options.name = (options.name) ? options.name : "";
			if (options.can_rename && options.name === "") options.name = `Window`;
			this.name = options.name;
			
		//Declare local instance variables
		this.options = options;
		
		//Parse to ve.HTML if components_obj is typeof function or string
		//this.components_obj
		if (typeof components_obj === "function" || typeof components_obj === "string") {
			this.components_obj = {
				html: new ve.HTML(components_obj)
			};
		} else if (components_obj.is_vercengen_component) {
			this.components_obj = {
				component: components_obj
			};
		} else {
			this.components_obj = components_obj;
		}
		this.element = document.createElement("div");
		
		//Iterate over all .attributes if extant to set them
		if (typeof options.attributes === "object")
			Object.iterate(options.attributes, (local_key, local_value) => {
				if (local_key === "class") {
					this.element.classList.add(...local_value.toString().split(" "));
				} else {
					this.element.setAttribute(local_key, local_value.toString());
				}
			});
		
		this.id = (this.options.id) ? this.options.id : Class.generateRandomID(ve.Window);
		this.x = options.x;
		this.y = options.y;
		
		//Adjust height/width, position
		{
			setTimeout(() => {
				if (typeof this.x === "function") {
					this.x_position_logic_loop = setInterval(() => {
						this._x = this.x();
						this.setCoords(this._x, this._y);
					}, 100);
				} else {
					this._x = this.x;
				}
				if (typeof this.y === "function") {
					this.y_position_logic_loop = setInterval(() => {
						this._y = this.y();
						this.setCoords(this._x, this._y);
					}, 100);
				} else {
					this._y = this.y;
				}
					
				this.setCoords(this._x, this._y);
				this.setSize(options.width, options.height);
				this.updateOwner();
				
				if (this.options.theme)
					HTML.applyTelestyle(this.element, ve.registry.themes[this.options.theme]);
			});
		}
		
		//Populate Window element
		this.element.instance = this;
		this.element.classList.add("class", `ve`, `window`);
		this.element.id = this.id;
		this.element.innerHTML = `
			${(!options.headless) ? `<div id = "feature-header" class = "feature-header">
				<span id = "window-name"${(this.options.can_rename) ? ` contenteditable = "plaintext-only"` : ""} class = "${(this.options.name_class) ? this.options.name_class : ""}">${this.name}</span>
			</div>` : ""}
			<div id = "feature-body" class = "feature-body"></div>
		`;
		this.element.style.zIndex = ve.Window.instances.length.toString();
		
		let window_name_el = this.element.querySelector(`#window-name`);
		if (window_name_el)
			window_name_el.addEventListener("focusout", (e) => {
				e.instance = this;
				if (this.options.onuserchange)
					this.options.onuserchange({ name: window_name_el.innerHTML }, e);
			});
		
		//Instantiate element handlers
		if (this.options.can_close && !this.options.headless) {
			let close_button = document.createElement("img");
				close_button.id = "close-button";
				close_button.src = `./UF/gfx/close_icon_dark.png`;
			this.element.querySelector(`#feature-header`).appendChild(close_button);
			
			close_button.onclick = (e) => {
				e.instance = this;
				if (this.options.onuserchange)
					this.options.onuserchange({ close: true }, e);
				this.remove();
			};
		}
		if (!this.options.headless)
			HTML.createSection({
				selector: `.ve.window[id="${this.id}"] #feature-header, .ve.window[id="${this.id}"] #feature-body`
			});
		if (this.options.draggable) {
			this.element.classList.add("draggable");
			HTML.elementDragHandler(this.element, {
				instance: this,
				is_resizable: (this.options.resizeable),
				
				bottom_elements: this.options.bottom_elements,
				left_elements: this.options.left_elements,
				right_elements: this.options.right_elements,
				top_elements: (this.options.top_elements) ? 
					this.options.top_elements : [document.querySelector(".ve.navbar")]
			});
		}
		
		//Improved window ergonomics by only listening to headers, allowing for casual body editing
		this.element.addEventListener("mousedown", (e) => {
			let feature_header_el = this.element.querySelector(`#feature-header`);
			
			if (feature_header_el)
				if (this.element.querySelector(`#feature-header`).contains(e.target))
					this.select();
		});
		this.element.addEventListener("dblclick", () => {
			if (this.element.querySelector(`#feature-header`))
				this.select();
		});
		
		//Push Window instance to ve.Window.instances
		this.refresh(this.components_obj);
		ve.window_overlay_el.appendChild(this.element);
		ve.Window.instances.push(this);
		this.select();
	}
	
	/**
	 * Whether an onresize event has been fired.
	 * - Private accessor of: {@link ve.Window}
	 * 
	 * @param {boolean} arg0_value
	 * @private
	 */
	set _onresize (arg0_value) {
		//Convert from parameters
		let value = arg0_value;
		
		//If an onresize pulse is sent, execute an onuserchange event for it
		if (value === true)
			if (this.options.onuserchange)
				this.options.onuserchange({ resize: true }, { instance: this });
	}
	
	/**
	 * Returns the components currently visible in the Window.
	 * - Accessor of: {@link ve.Window}
	 *
	 * @alias v
	 * @memberof ve.Feature.ve.Window
	 * @type {{"<component_key>": ve.Component}}
	 * 
	 * @returns {{"<component_key>": ve.Component}}
	 */
	get v () {
		//Return statement
		return this.components_obj;
	}
	
	/**
	 * Sets the components visible in the current window.
	 * - Accessor of: {@link ve.Window}
	 *
	 * @alias v
	 * @memberof ve.Feature.ve.Window
	 * @type {{"<component_key>": ve.Component}}
	 * 
	 * @param {{"<component_key>": ve.Component}} arg0_components_obj
	 */
	set v (arg0_components_obj) {
		//Convert from parameters
		let components_obj = arg0_components_obj;
		
		//Refresh local instance
		this.refresh(components_obj);
	}
	
	/**
	 * Returns the current z-index of this {@link ve.Window}.
	 * - Method of: {@link ve.Window}
	 *
	 * @alias getZIndex
	 * @memberof ve.Feature.ve.Window
	 *
	 * @returns {number}
	 */
	getZIndex () {
		//Return statement
		return parseInt(getComputedStyle(this.element)["z-index"]);
	}
	
	/**
	 * Refreshes the components display for the current Window.
	 * - Method of: {@link ve.Window}
	 *
	 * @alias refresh
	 * @memberof ve.Feature.ve.Window
	 * 
	 * @param {{"<component_key>": ve.Component}} arg0_components_obj
	 */
	refresh (arg0_components_obj) {
		//Convert from parameters
		this.components_obj = arg0_components_obj;
		
		//Declare local instance variables
		let feature_body_el = this.element.querySelector(`#feature-body`);
		
		//Automatically wrap this.components_obj in a ve.Interface object (.is_folder=false)
		if (!this.options.do_not_wrap) {
			feature_body_el.innerHTML = "";
			this._interface = new ve.Interface(this.components_obj, { //[WIP] - Make generic via .is_container field for ve.Interface
				is_folder: false,
				name: " ",
				
				style: {
					padding: 0,
					width: "100%"
				}
			});
			this._interface.element.setAttribute("class", "ve-disable-nesting");
			feature_body_el.appendChild(this._interface.element);
		} else {
			delete this._interface;
			
			//Append all components in components_obj to this.element.querySelector("#feature-body")
			feature_body_el.innerHTML = "";
			Object.iterate(this.components_obj, (local_key, local_value) => {
				if (local_value.element) {
					local_value.element.id = local_key;
					feature_body_el.appendChild(local_value.element);
				}
				if (ve.registry.automatic_naming && (local_value.name === undefined || local_value.name === ""))
					local_value.name = local_key;
			});
		}
	}
	
	/**
	 * Selects the current {@link ve.Window} instance, raising its z-index above all other Windows.
	 * - Method of: {@link ve.Window}
	 *
	 * @alias select
	 * @memberof ve.Feature.ve.Window
	 */
	select () {
		//Declare local instance variables
		let current_highest_z_index = ve.Window.getHighestZIndex() + 1;
		
		//Swap z-indices
		this.element.style.zIndex = current_highest_z_index.toString();
		ve.Window.normaliseZIndexes();
	}
	
	/**
	 * Sets the present coords of the window to a given X, Y coordinate relative to the present anchor.
	 * - Method of: {@link ve.Window}
	 *
	 * @alias setCoords
	 * @memberof ve.Feature.ve.Window
	 * 
	 * @param {number} arg0_x
	 * @param {number} arg1_y
	 */
	setCoords (arg0_x, arg1_y) {
		//Convert from parameters
		let x = arg0_x;
		let y = arg1_y;
		
		let coords_obj = HTML.getCSSPosition(this.options.anchor, x, y);
		
		//Set element X, Y position; reset position first
		this.element.style.position = "absolute";
		this.element.style.bottom = "";
		this.element.style.left = "";
		this.element.style.right = "";
		this.element.style.top = "";
		
		if (coords_obj.bottom) this.element.style.bottom = coords_obj?.bottom;
		if (coords_obj.left) this.element.style.left = coords_obj?.left;
		if (coords_obj.right) this.element.style.right = coords_obj?.right;
		if (coords_obj.top) this.element.style.top = coords_obj?.top;
	}
	
	/**
	 * Sets the current name of the window.
	 * - Method of: {@link ve.Window}
	 *
	 * @alias setName
	 * @memberof ve.Feature.ve.Window
	 * 
	 * @param {string} arg0_name
	 */
	setName (arg0_name) {
		//Convert from parameters
		let name = (arg0_name) ? arg0_name : "";
		
		//Set name
		this.name = name;
		try {
			this.element.querySelector(`#feature-header #window-name`).innerText = this.name;
		} catch (e) {}
	}
	
	/**
	 * Sets the given size of the current window using either numbers or CSS calculated strings in Telestyle. Any functions must return either a number/string.
	 * - Method of: {@link ve.Window}
	 *
	 * @alias setSize
	 * @memberof ve.Feature.ve.Window
	 * 
	 * @param {function|number|string} arg0_width
	 * @param {function|number|string} arg1_height
	 */
	setSize (arg0_width, arg1_height) {
		//Convert from parameters
		let width = arg0_width;
		let height = arg1_height;
		
		//Apply style
		HTML.applyTelestyle(this.element, {
			...HTML.getCSSSize(width, height)
		});
	}
	
	/**
	 * Returns the highest z-index over the set of all Windows in {@link ve.Window.instances}.
	 * - Static method of: {@link ve.Window}
	 *
	 * @alias #getHighestZIndex
	 * @memberof ve.Feature.ve.Window
	 * 
	 * @param {Object} [arg0_options]
	 *  @param {boolean} [arg0_options.return_object=false] - Whether to return a ve.Window instance.
	 *
	 * @returns {number|ve.Window}
	 */
	static getHighestZIndex (arg0_options) {
		//Convert from parameters
		let options= (arg0_options) ? arg0_options : {};
		
		//Declare local instance variables
		let highest_z_index = [-Infinity, undefined];
		
		//Iterate over all ve.Window.instances
		for (let i = 0; i < ve.Window.instances.length; i++) {
			let local_instance = ve.Window.instances[i];
			
			if (!local_instance.element.classList.contains("ve-context-menu"))
				if (local_instance.getZIndex() > highest_z_index[0])
					highest_z_index = [local_instance.getZIndex(), local_instance];
		}
		
		//Return statement
		return (!options.return_object) ? highest_z_index[0] : highest_z_index[1];
	}
	
	/** 
	 * Normalises all z-indexes over the set of ve.Windows.
	 *
	 * @alias #normaliseZIndexes
	 * @memberof ve.Feature.ve.Window
	 * */
	static normaliseZIndexes () {
		//Declare local instance variables
		let overlay_el = ve.window_overlay_el;
		
		//Get all elements with [data-window-id] and their z-index values
		let all_windows = Array.from(overlay_el.querySelectorAll(`.ve.window`));
		
		// Extract z-index values and sort them numerically
		let z_indexes = all_windows
		.map((window) => ({
			element: window,
			z_index: parseInt(window.style.zIndex || 0, 10),
		}))
		.sort((a, b) => a.z_index - b.z_index);
		
		// Assign normalized z-index values (1, 2, 3, ...)
		z_indexes.forEach((item, index) => {
			item.element.style.zIndex = (index + 1).toString();
			item.element.style.setProperty("--local-z-index", (index + 1).toString());
		});
	}
};

//Functional binding

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