vercengen/components/ComponentList.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`.
 *
 * Generic horizontal list input for non-nested lists with reorderable elements, i.e. arrays.
 * - Functional binding: <span color=00ffff>veList</span>().
 *
 * ##### Constructor:
 * - `arg0_value`: {@link Array}<{@link ve.Component}>
 * - `arg1_options`: {@link Object}
 *   - `.do_not_allow_insertion=false`: {@link boolean}
 *   - `.do_not_display_info_button=false`: {@link boolean}
 *   - `.max`: {@link number} - The maximum number of elements in the array.
 *   - `.min=0`: {@link number} - The minimum number of elements in the array.
 *   - `.ondelete`: {@link function}(arg0_component_obj:{@link ve.Component})
 *   - `.options`: {@link Object} - The `.options` field to pass onto elements in the array.
 *   - `.placeholder`: {@link ve.Component} - An instance used as a template for new items. Required if initialising with an empty array.
 *   - `.split_rows=true`: {@link boolean} - Whether to split item rows onto separate lines.
 *
 * ##### Instance:
 * - `.v`: {@link Array}<{@link ve.Component}>
 *
 * @augments ve.Component
 * @memberof ve.Component
 * @type {ve.List}
 */
ve.List = class extends ve.Component { //[WIP] - Refactor at a later date
	static instances = [];
	
	constructor (arg0_value, arg1_options) {
		//Convert from parameters
		let value = (arg0_value !== undefined) ? arg0_value : [];
		let options = (arg1_options) ? arg1_options : {};
		super(options);
		
		//Initialise options
		if (options.split_rows === undefined) options.split_rows = true;
		
		//Declare local instance variables
		this.element = document.createElement("div");
			this.element.setAttribute("component", "ve-horizontal-list");
			if (options.attributes)
				Object.iterate(options.attributes, (local_key, local_value) => {
					this.element.setAttribute(local_key, local_value.toString());
				});
			this.element.instance = this;
			this.element.style.alignItems = "center";
			this.element.style.display = "flex";
		
		//Format html_string
		let html_string = [];
		html_string.push(`<span id = "name"></span> `);
		this.element.innerHTML = html_string.join("");
		
		this.components_el = document.createElement("div");
		this.components_el.id = "component-body";
		this.element.appendChild(this.components_el);
		
		this.options = options;
		this.shift_positions = 1;
		this.value = Array.toArray(value);
		
		//Update logic for insertion
		if (!options.do_not_allow_insertion) {
			this.add_item_button = new ve.Button(() => {
				this.addItem();
				this.fireToBinding();
			}, { name: "<icon>add</icon>", tooltip: loc("ve.registry.localisation.List_add_item") });
			this.add_item_button.bind(this.element);
			
			//Determine the template component (source)
			let source_component = (options.placeholder) ? options.placeholder : this.value[0];
			
			if (source_component) {
				try {
					//1. Determine Class Name
					if (source_component.class_name) {
						this.class_name = source_component.class_name;
					} else {
						// Fallback: Remove 've' prefix from constructor name (e.g., veText -> Text)
						this.class_name = source_component.constructor.name.replace(/^ve/, "");
					}
					
					//2. Determine Placeholder Value
					this.placeholder = source_component.v;
					
				} catch (e) {
					//Disable button if we fail to resolve template
					console.error(`Class name/Placeholder could not be found for template:`, source_component, e);
					this.add_item_button.hide();
				}
			} else {
				//Empty array and no placeholder option provided, we cannot know what to insert, so we hide the add button.
				this.add_item_button.hide();
			}
			
			this.overlay_window = undefined;
		}
		if (!this.options.do_not_display_info_button) {
			this.info_button = new ve.Button(() => {}, {
				name: "<icon>info</icon>", tooltip: `<kbd>RMB</kbd>: ${loc("ve.registry.localisation.List_info_edit_item")}` 
			});
			this.info_button.bind(this.element);
		}
		
		//Set this.v to be the inputted ve.Component[]
		if (options.name) this.name = options.name;
		this.v = value;
	}
	
	/**
	 * Returns the current array value.
	 * - Accessor of: {@link ve.List}
	 *
	 * @alias v
	 * @memberof ve.Component.ve.List
	 * @type {ve.Component[]}
	 */
	get v () {
		//Return statement
		return this.value;
	}
	
	/**
	 * Sets the current array value.
	 * - Accessor of: {@link ve.List}
	 *
	 * @alias v
	 * @memberof ve.Component.ve.List
	 *
	 * @param {ve.Component[]} arg0_value
	 */
	set v (arg0_value) {
		//Convert from parameters
		let value = arg0_value;
		
		//Set value, redraw and fire from binding
		this.value = value;
		this.draw();
		this.fireFromBinding();
	}
	
	/**
	 * Adds an item to the end of the array.
	 * - Method of: {@link ve.List}
	 *
	 * @alias addItem
	 * @memberof ve.Component.ve.List
	 */
	addItem () {
		if (this.options.max && this.value.length >= this.options.max) { //Internal guard clause for this.options.max
			veToast(`<icon>warning</icon> ${loc("ve.registry.localisation.List_error_max_items_reached", String.formatNumber(this.options.max))}`);
			return;
		}
		
		//Push item to end of stack
		//Ensure class_name is valid before attempting creation
		if (this.class_name && global[`ve${this.class_name}`]) {
			this.value.push(global[`ve${this.class_name}`](this.placeholder, this.options.options));
			this.draw();
		} else {
			console.error(`ve.List: Cannot add item. Unknown class: ve${this.class_name}`);
		}
	}
	
	/**
	 * Deletes an item from the array given its index.
	 * - Method of: {@link ve.List}
	 *
	 * @alias deleteItem
	 * @memberof ve.Component.ve.List
	 *
	 * @param {number} arg0_index
	 */
	deleteItem (arg0_index) {
		//Convert from parameters
		let index = (arg0_index >= 0) ? arg0_index : this.value.length - 1;
		if (this.value.length === 0) return; //Internal guard clause if there are already no elements in the array
		
		if (this.options.min && this.value.length - 1 < this.options.min) { //Internal guard clause for this.options.min
			veToast(`<icon>warning</icon>  ${loc("ve.registry.localisation.List_error_min_items_reached", String.formatNumber(this.options.min))}`);
			return;
		}
		
		//Delete item at index
		this.value.splice(index, 1);
		this.draw();
	}
	
	/**
	 * Redraws the present array.
	 * - Method of: {@link ve.List}
	 *
	 * @alias draw
	 * @memberof ve.Component.ve.List
	 */
	draw () {
		//Draw .components_el from this.value
		this.components_el.innerHTML = "";
		
		//Iterate over all components in this.value
		for (let i = 0; i < this.value.length; i++) {
			let current_component = this.value[i];
			
			//1. Bind to DOM
			current_component.bind(this.components_el);
			current_component.setOwner(this, [this]);
			current_component.element.style.display = "inline";
			
			//2. Define context menu interfaces
			let shift_bar_obj = new ve.RawInterface({
				shift_left_button: new ve.Button(() => {
					let shift_positions = this.shift_positions;
					let new_index = Math.max(i - shift_positions, 0);
					
					this.value = Array.moveElement(this.value, i, new_index);
					this.draw();
					this.fireToBinding();
					// No need to update 'i' as redraw resets loop
				}, {
					name: "<icon>chevron_left</icon>",
					tooltip: loc("ve.registry.localisation.List_shift_left")
				}),
				shift_positions: new ve.Number(this.shift_positions, {
					min: 1,
					name: loc("ve.registry.localisation.List_shift"),
					onuserchange: (v) => this.shift_positions = v,
					style: {
						marginLeft: `calc(var(--padding)*0.5)`,
						marginRight: `calc(var(--padding)*0.5)`,
						whiteSpace: "nowrap",
						"input": { textAlign: "center" }
					}
				}),
				shift_right_button: new ve.Button(() => {
					let shift_positions = this.shift_positions;
					let new_index = Math.min(i + shift_positions, this.value.length - 1);
					
					this.value = Array.moveElement(this.value, i, new_index);
					this.draw();
					this.fireToBinding();
				}, {
					name: "<icon>chevron_right</icon>",
					tooltip: loc("ve.registry.localisation.List_shift_right")
				})
			}, { style: { alignItems: "center", display: "flex", justifyContent: "center" } });
			
			let actions_bar_obj = new ve.RawInterface({
				insert_before_button: new ve.Button(() => {
					if (this.options.max && this.value.length >= this.options.max) {
						veToast(`<icon>warning</icon> ${loc("ve.registry.localisation.List_error_max_items_reached", String.formatNumber(this.options.max))}`);
						return;
					}
					this.value.splice(i, 0, global[`ve${this.class_name}`](this.placeholder));
					this.draw();
					this.fireToBinding();
				}, {
					name: "<icon>first_page</icon>",
					limit: () => (!this.options.do_not_allow_insertion),
					tooltip: loc("ve.registry.localisation.List_insert_item_before")
				}),
				insert_after_button: new ve.Button(() => {
					if (this.options.max && this.value.length >= this.options.max) {
						veToast(`<icon>warning</icon> ${loc("ve.registry.localisation.List_error_max_items_reached", String.formatNumber(this.options.max))}`);
						return;
					}
					this.value.splice(i + 1, 0, global[`ve${this.class_name}`](this.placeholder));
					this.draw();
					this.fireToBinding();
				}, {
					name: "<icon>last_page</icon>",
					limit: () => (!this.options.do_not_allow_insertion),
					tooltip: loc("ve.registry.localisation.List_insert_item_after")
				}),
				delete_button: new ve.Button(() => {
					if (this.options.ondelete !== undefined)
						this.options.ondelete(this.v[i]);
					
					this.deleteItem(i); // This 'i' is now strictly tied to the loop iteration
					if (this.overlay_window) this.overlay_window.close();
					this.fireToBinding();
				}, { name: "<icon>delete</icon>", tooltip: loc("ve.registry.localisation.List_delete_item") }),
			}, { style: { alignItems: "center", display: "flex", justifyContent: "center" } });
			
			let overlay_interface = (this.options.split_rows) ? new ve.Interface({
				shift_bar_obj: shift_bar_obj,
				actions_bar_obj: actions_bar_obj
			}, { is_folder: false }) : new ve.Interface({
				actions_bar_obj: new ve.RawInterface({
					...shift_bar_obj.components_obj,
					...actions_bar_obj.components_obj
				}, { style: { display: "flex" } })
			}, { is_folder: false });
			
			//3. Attach Event Listener Directly to the Component Element
			current_component.element.addEventListener("contextmenu", (e) => {
				e.preventDefault();
				e.stopPropagation(); // Prevents nested lists from opening multiple menus
				
				if (this.overlay_window) this.overlay_window.close();
				this.overlay_window = new ve.Window(overlay_interface, {
					can_rename: false,
					name: loc("ve.registry.localisation.List_edit_item"),
					width: (this.options.split_row) ? "12rem" : "14rem",
					y: (HTML.mouse_y < window.innerHeight/2) ?
						HTML.mouse_y + current_component.element.offsetHeight :
						HTML.mouse_y - current_component.element.offsetHeight
				});
			});
		}
	}
};

//Functional binding

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