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}(v:{@link ve.Component}, e:{@link ve.List})
 *   - `.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 () {
    //Reconcile DOM nodes; remove elements no longer in this.value
    let current_elements = this.value.map((local_component) => local_component.element);
    let existing_children = Array.from(this.components_el.children);

    for (let i = 0; i < existing_children.length; i++)
      if (!current_elements.includes(existing_children[i]))
        existing_children[i].remove();

    //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 if not already present
      if (current_component.element.parentElement !== this.components_el)
        current_component.bind(this.components_el);

      //Ensure the element is in the correct order in the DOM tree
      if (this.components_el.children[i] !== current_component.element)
        this.components_el.insertBefore(current_component.element, this.components_el.children[i] || null);

      current_component.setOwner(this, [this]);
      current_component.element.style.display = "inline";
      
      //2. Define context menu interfaces (only if not already initialised)
      if (!current_component._ve_list_init) {
        current_component._ve_list_init = true;

        current_component.element.addEventListener("contextmenu", (e) => {
          e.preventDefault();
          e.stopPropagation(); // Prevents nested lists from opening multiple menus
          
          //Determine index dynamically at runtime
          let get_current_idx = () => this.value.indexOf(current_component);

          let shift_bar_obj = new ve.RawInterface({
            shift_left_button: new ve.Button(() => {
              let local_idx = get_current_idx();
              let shift_positions = this.shift_positions;
              let new_index = Math.max(local_idx - shift_positions, 0);
              
              this.value = Array.moveElement(this.value, local_idx, new_index);
              this.draw();
              this.fireToBinding();
            }, {
              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 local_idx = get_current_idx();
              let shift_positions = this.shift_positions;
              let new_index = Math.min(local_idx + shift_positions, this.value.length - 1);
              
              this.value = Array.moveElement(this.value, local_idx, 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(get_current_idx(), 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(get_current_idx() + 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(() => {
              let local_idx = get_current_idx();
              if (this.options.ondelete !== undefined)
                this.options.ondelete(this.v[local_idx], this);
              
              this.deleteItem(local_idx);
              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
          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_rows) ? "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);
};