vercengen/components/ComponentHierarchy/ComponentHierarchy.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`.
 *
 * Hierarchy used for organising both nested and un-nested lists via item/group distinctions.
 * - Functional binding: <span color=00ffff>veHierarchy</span>().
 *
 * ##### Constructor:
 * - `arg0_components_obj`: {@link Object}<{@link ve.Component}|{@link ve.HierarchyDatatype}> - The individual items to append to the current hierarchy.
 * - `arg1_options`: {@link Object}
 *   - `.allow_disabled_reordering=false`: {@link boolean}
 *   - `.disable_default_search=false`: {@link boolean}
 *   - `.disable_searchbar=false`: {@link boolean}
 *   - `.namespace=Class.generateRandomID(ve.Hierarchy)`: {@link string}
 *   - `.onitemchange`: {@link function}(v:{@link Object}, e:{ item_el:{@link HTMLElement}, old_parent_el:{@link HTMLElement}, old_parent_order:{@link Array}<{@link HTMLElement}>, new_parent_el:{@link HTMLElement}, new_parent_order:{@link Array}<{@link HTMLElement}> })
 *   - `.onsearch`: {@link function}(v:{@link string})
 *   - `.searchbar_placeholder`: {@link string}
 *   - `.searchbar_style`: {@link Object} - The Telestyle object to apply to the searchbar.
 *
 * ##### Instance:
 * - `.components_obj`: {@link Object}<{@link ve.Component}|{@link ve.HierarchyDatatype}>
 * - `.nestable`: {@link Nestable}
 * - `.v`: {@link this.components_obj} - Accessor. The current components_obj mounted to the ve.Hierarchy.
 *
 * ##### Methods:
 * - <span color=00ffff>{@link ve.Hierarchy.addItem|addItem}</span>(arg0_parent_el:{@link HTMLElement}, arg1_hierarchy_datatype:{@link ve.HierarchyDatatype})
 * - <span color=00ffff>{@link ve.Hierarchy.getHierarchyArray|getHierarchyArray}</span>(arg0_options:{flatten_object: {@link boolean} }) | {@link Object[]}
 * - <span color=00ffff>{@link ve.Hierarchy.getHierarchyObject|getHierarchyObject}</span>(arg0_options:{flatten_object: {@link boolean} }) | {@link Object}
 * - <span color=00ffff>{@link ve.Hierarchy.removeItem|removeItem}</span>(arg0_hierarchy_datatype:{@link ve.HierarchyDatatype})
 *
 * @augments ve.Component
 * @memberof ve.Component
 * @type {ve.Hierarchy}
 */
ve.Hierarchy = class extends ve.Component {
	static instances = [];
	static reserved_keys = ["element", "id", "name"];
	
	constructor (arg0_components_obj, arg1_options) {
		//Convert from parameters
		let components_obj = (arg0_components_obj) ? arg0_components_obj : {};
		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.setAttribute("component", "ve-hierarchy");
		Object.iterate(options.attributes, (local_key, local_value) =>
			this.element.setAttribute(local_key, local_value.toString()));
		this.element.instance = this;
		this.options = options;
		
		//Append components_obj to this.element
		this.v = components_obj;
		ve.Hierarchy.instances.push(this);
	}
	
	/**
	 * Returns the current {@link this.components_obj}.
	 * - Accessor of {@link ve.Hierarchy}
	 *
	 * @alias v
	 * @memberof ve.Component.ve.Hierarchy
	 * @type {ve.Component[]}
	 */
	get v () {
		//Return statement
		return this.components_obj;
	}
	
	/**
	 * Sets the current {@link this.components_obj} displayed in the hierarchy.
	 * - Accessor of {@link ve.Hierarchy}
	 *
	 * @alias v
	 * @memberof ve.Component.ve.Hierarchy
	 * @param arg0_components_obj {ve.Component[]}
	 */
	set v (arg0_components_obj) {
		this.components_obj = arg0_components_obj;
		
		// Save scroll state before diffing
		this._saveScrollState();
		
		if (this.nestable && typeof this.nestable.destroy === "function") {
			this.nestable.destroy();
			delete this.nestable;
		}
		
		// 1. Manage searchbar
		if (!this.options.disable_searchbar) {
			let searchbar_el = this.element.querySelector('[ve-searchbar="true"]');
			if (!searchbar_el || !this.searchbar_interface) {
				this.searchbar_interface = new ve.RawInterface({
					searchbar_icon: new ve.HTML("<icon>search</icon>", { style: { padding: `var(--cell-padding)` } }),
					searchbar_input: new ve.Datalist({}, {
						attributes: { placeholder: (this.options.searchbar_placeholder) ? this.options.searchbar_placeholder : loc("ve.registry.localisation.Hierarchy_search_for_item") },
						name: " ",
						onuserchange: (v) => { this.updateSearchFilter(v); }
					})
				}, { name: " ", attributes: { "ve-searchbar": "true", "ve-sticky": "true" }, style: this.options.searchbar_style });
				if (searchbar_el) searchbar_el.remove();
				this.searchbar_interface.bind(this.element);
			} else {
				if (this.element.firstChild !== searchbar_el) this.element.insertBefore(searchbar_el, this.element.firstChild);
			}
		} else {
			let searchbar_el = this.element.querySelector('[ve-searchbar="true"]');
			if (searchbar_el) searchbar_el.remove();
			this.searchbar_interface = null;
		}
		
		// 2. Diff non-hierarchy components
		let expected_non_hierarchy_els = [];
		Object.iterate(this.components_obj, (local_key, local_value) => {
			if (!local_value.is_vercengen_hierarchy_datatype) {
				let elem = ve.Component.getElement(local_value);
				if (elem) expected_non_hierarchy_els.push(elem);
			}
		});
		
		let existing_children = Array.from(this.element.children);
		for (let i = 0; i < existing_children.length; i++) {
			let child = existing_children[i];
			if (child.getAttribute("ve-searchbar") === "true" || child.tagName === "OL" || !child.hasAttribute("component")) continue;
			if (!expected_non_hierarchy_els.includes(child)) child.remove();
		}
		
		let reference_node = this.element.querySelector('[ve-searchbar="true"]') ? this.element.querySelector('[ve-searchbar="true"]').nextSibling : this.element.firstChild;
		for (let i = 0; i < expected_non_hierarchy_els.length; i++) {
			let local_el = expected_non_hierarchy_els[i];
			if (local_el.parentElement !== this.element || local_el !== reference_node) this.element.insertBefore(local_el, reference_node);
			reference_node = local_el.nextSibling;
		}
		
		// 3. Diff hierarchy datatype components inside root OL
		let ol_el = this.element.querySelector("ol.ve-hierarchy");
		if (!ol_el) {
			ol_el = document.createElement("ol");
			ol_el.id = (this.options.namespace) ? this.options.namespace : Class.generateRandomID(ve.Hierarchy);
			ol_el.setAttribute("class", "list ve-drag-disabled ve-hierarchy");
			this.element.appendChild(ol_el);
		}
		
		let expected_hierarchy_els = [];
		Object.iterate(this.components_obj, (local_key, local_value) => {
			if (local_value.is_vercengen_hierarchy_datatype) {
				let elem = ve.Component.getElement(local_value);
				if (elem) expected_hierarchy_els.push(elem);
			}
		});
		
		let existing_hierarchy_li_els = Array.from(ol_el.children);
		for (let i = 0; i < existing_hierarchy_li_els.length; i++) {
			if (!expected_hierarchy_els.includes(existing_hierarchy_li_els[i])) existing_hierarchy_li_els[i].remove();
		}
		
		let ol_ref_node = ol_el.firstChild;
		for (let i = 0; i < expected_hierarchy_els.length; i++) {
			let elem = expected_hierarchy_els[i];
			if (elem.parentElement !== ol_el || elem !== ol_ref_node) ol_el.insertBefore(elem, ol_ref_node);
			ol_ref_node = elem.nextSibling;
		}
		
		this.nestable = new Nestable(ol_el, { items: ".group, .item" });
		this.nestable.on("stop", (e) => {
			if (!this.options.allow_disabled_ordering) this._handleDisabledOrdering(e);
			if (this.options.onitemchange) this.options.onitemchange(this.v, { item_el: e.movedNode, old_parent_el: e.originalParentItem, new_parent_el: e.newParentItem });
			this.on_stop_data = e;
			this.fireToBinding();
		});
		
		this.fireFromBinding();
		this._restoreScrollState();
	}
	
	/**
	 * Restores previously saved scroll positions of this hierarchy.
	 * - Private method of: {@link ve.Hierarchy}
	 *
	 * @alias _restoreScrollState
	 * @memberof ve.Component.ve.Hierarchy
	 */
	_restoreScrollState () {
		requestAnimationFrame(() => {
			if (this.element) {
				this.element.scrollTop = this._savedScrollTop || 0;
				let ol_el = this.element.querySelector("ol");
				if (ol_el && this._savedOlScrollTop) {
					ol_el.scrollTop = this._savedOlScrollTop;
				}
			}
			
			// Restore individual sub-lists' positions matching relative parent key
			if (this._savedNestedScrolls?.size && this.element) {
				let allOls = this.element.querySelectorAll("ol");
				for (let i = 0; i < allOls.length; i++) {
					let parentLi = allOls[i].closest("li[component='ve-hierarchy-datatype']");
					let key = parentLi?.instance?.id || parentLi?.id;
					if (key && this._savedNestedScrolls.has(key)) {
						allOls[i].scrollTop = this._savedNestedScrolls.get(key);
					}
				}
			}
		});
	}
	
	/**
	 * Saves top scroll positions of this hierarchy element and children list containers.
	 * - Private method of: {@link ve.Hierarchy}
	 *
	 * @alias _saveScrollState
	 * @memberof ve.Component.ve.Hierarchy
	 */
	_saveScrollState () {
		this._savedScrollTop = this.element ? this.element.scrollTop : 0;
		
		let ol_el = this.element ? this.element.querySelector("ol") : null;
		this._savedOlScrollTop = ol_el ? ol_el.scrollTop : 0;
		
		this._savedNestedScrolls = new Map();
		if (this.element) {
			let scrollables = this.element.querySelectorAll("ol");
			for (let i = 0; i < scrollables.length; i++) {
				if (scrollables[i].scrollTop > 0) {
					let parentLi = scrollables[i].closest("li[component='ve-hierarchy-datatype']");
					let key = parentLi?.instance?.id || parentLi?.id;
					if (key) {
						this._savedNestedScrolls.set(key, scrollables[i].scrollTop);
					}
				}
			}
		}
	}
	
	/**
	 * Prevents placing items before disabled elements.
	 * - Private method of: {@link ve.Hierarchy}
	 *
	 * @alias _handleDisabledOrdering
	 * @memberof ve.Component.ve.Hierarchy
	 * @param {Object} arg0_e
	 */
	_handleDisabledOrdering (arg0_e) {
		//Convert from parameters
		let e = arg0_e;
		
		//Declare local instance variables
		let moved_node = e.movedNode;
		if (!moved_node) return; //Internal guard clause if there is no moved node
		let parent_list = moved_node.parentElement;
		if (!parent_list || parent_list.tagName !== "OL") return; //Internal guard clause if not moved and nested
		let siblings = Array.from(parent_list.children);
		
		let disabled = siblings.filter((local_el) =>
			local_el !== moved_node && (local_el.classList.contains("ve-drag-disabled") || local_el.dataset.nestableDisabled === "disabled"));
		if (!disabled.length) return; //Internal guard clause if there are no disabled elements
		let first_disabled_index = siblings.indexOf(disabled[0]);
		let moved_index = siblings.indexOf(moved_node);
		
		//If moved before disabled, move after last disabled
		if (moved_index < first_disabled_index) {
			let lastDisabled = disabled[disabled.length - 1];
			parent_list.insertBefore(moved_node, lastDisabled.nextSibling);
			
			veToast(`<icon>warning</icon> You cannot move an element before a disabled element.`);
		}
	}
	
	/**
	 * Appends the associated hierarchy datatype to the hierarchy.
	 * - Method of: {@link ve.Hierarchy}
	 *
	 * @alias addItem
	 * @memberof ve.Component.ve.Hierarchy
	 *
	 * @param {HTMLElement} arg0_parent_el
	 * @param {ve.HierarchyDatatype} arg1_hierarchy_datatype
	 */
	addItem (arg0_parent_el, arg1_hierarchy_datatype) {
		//Convert from parameters
		let parent_el = (arg0_parent_el) ? arg0_parent_el : this.element.querySelector("ol");
		let hierarchy_datatype = arg1_hierarchy_datatype;
		
		//Append child
		if (typeof parent_el === "string") parent_el = this.element.querySelector(parent_el);
		if (parent_el)
			parent_el.appendChild(hierarchy_datatype.element);
	}
	
	/**
	 * Returns a flat array representative of the items in the hierarchy, ordered top to bottom.
	 * - Method of: {@link ve.Hierarchy}
	 *
	 * @alias getHierarchyArray
	 * @memberof ve.Component.ve.Hierarchy
	 *
	 * @param {Object} [arg0_options]
	 *  @param {boolean} [arg0_options.flatten_object=false] - Whether the object should be flattened, returning only serialisable JSON keys.
	 *
	 * @returns {Object[]}
	 */
	getHierarchyArray (arg0_options) {
		//Convert from parameters
		let options = arg0_options ? arg0_options : {};
		
		//Declare local instance variables
		let ol_el = this.element.querySelector("ol");
		let reserved_keys = ["id", "element", "instance", "name", "type", "reserved_keys"];
		let result = [];
		
		/**
		 * Recursive helper to traverse the DOM tree top-to-bottom.
		 *
		 * @param {HTMLElement} current_ol - The <ol> element to traverse
		 */
		let traverse = (current_ol) => {
			// Get only direct LI children to maintain correct hierarchy order
			let children = Array.from(current_ol.children).filter((local_el) =>
				local_el.tagName === "LI");
			
			//Iterate over all children
			for (let i = 0; i < children.length; i++) {
				let li_el = children[i];
				let sub_ol = li_el.querySelector("ol");
				
				//Extract metadata (matching getHierarchyObject logic)
				let local_name = (li_el && li_el.instance && li_el.instance.name)
					? li_el.instance.name : "";
				//Determine if this is a group (has children) or a leaf item
				let type = sub_ol ? "group" : "item";
				
				result.push({
					id: li_el.id,
					element: (!options.flatten_object) ? li_el : undefined,
					instance: li_el.instance,
					name: local_name ? local_name : undefined,
					reserved_keys: reserved_keys,
					type: type,
				});
				
				//If this item has a sub-list, recurse into it immediately (Top > Bottom)
				if (sub_ol) traverse(sub_ol);
			}
		};
		
		if (ol_el) traverse(ol_el);
		
		//Return statement
		return result;
	}
	
	/**
	 * Returns an object representative of the items in the hierarchy.
	 * - Method of: {@link ve.Hierarchy}
	 *
	 * @alias getHierarchyObject
	 * @memberof ve.Component.ve.Hierarchy
	 *
	 * @param {Object} [arg0_options]
	 *  @param {boolean} [arg0_options.flatten_object=false] - Whether the object should be flattened, returning only serialisable JSON keys.
	 *
	 * @returns {Object}
	 */
	getHierarchyObject (arg0_options) {
		//Convert from parameters
		let options = (arg0_options) ? arg0_options : {};
		
		//Declare local instance variables
		let ol_el = this.element.querySelector("ol");
		let reserved_keys = ["id", "element", "instance", "name", "type", "reserved_keys"];
		
		//Return statement
		return HTML.listToObject(ol_el, (local_el) => {
			//Declare local instance variables
			let local_name = (local_el && local_el.instance && local_el.instance.name) ?
				local_el.instance.name : "";
			
			//Return statement
			if (local_el.tagName === "OL") {
				let parent_el = local_el.parentElement;
				try {
					local_name = parent_el.instance.name;
				} catch {}
				
				return {
					id: parent_el.id,
					element: (!options.flatten_object) ? parent_el : undefined,
					instance: parent_el.instance,
					name: (local_name) ? local_name : undefined,
					reserved_keys: reserved_keys,
					type: "group"
				};
			} else if (local_el.tagName === "LI") {
				return {
					id: local_el.id,
					element: (!options.flatten_object) ? local_el : undefined,
					instance: local_el.instance,
					name: (local_name) ? local_name : undefined,
					reserved_keys: reserved_keys,
					type: "item"
				};
			}
		});
	}
	
	/**
	 * Removes the associated hierarchy datatype from the hierarchy.
	 * - Method of: {@link ve.Hierarchy}
	 *
	 * @alias removeItem
	 * @memberof ve.Component.ve.Hierarchy
	 *
	 * @param {ve.HierarchyDatatype} arg0_hierarchy_datatype
	 */
	removeItem (arg0_hierarchy_datatype) {
		//Convert from parameters
		let hierarchy_datatype = arg0_hierarchy_datatype;
		
		//Remove item
		hierarchy_datatype.remove();
	}
	
	/**
	 * Updates the current search filter based on the inputted query. Ignores `.options.disabled=true` fields, since they are likely kept at the top as action menus.
	 * - Method of: {@link ve.Hierarchy}
	 *
	 * @alias updateSearchFilter
	 * @memberof ve.Component.ve.Hierarchy
	 *
	 * @param {string} arg0_name
	 */
	updateSearchFilter (arg0_name) {
		//Convert from parameters
		let name = (arg0_name) ? arg0_name : "";
		
		//Default search handler
		if (!this.options.disable_default_search) {
			let all_hierarchy_datatype_els = this.element.querySelectorAll(`[component="ve-hierarchy-datatype"]`);
			
			//If name is nothing, restore visibility to all hidden results
			if (name.length === 0) {
				for (let i = 0; i < all_hierarchy_datatype_els.length; i++)
					all_hierarchy_datatype_els[i].style.display = "block";
			} else {
				let all_filtered_els = [];
				
				for (let i = 0; i < all_hierarchy_datatype_els.length; i++)
					if (all_hierarchy_datatype_els[i].instance.name.toLowerCase().trim().indexOf(name.toLowerCase().trim()) !== -1) {
						all_hierarchy_datatype_els[i].style.display = "block";
						all_filtered_els.push(all_hierarchy_datatype_els[i]);
					} else {
						all_hierarchy_datatype_els[i].style.display = "none";
					}
				
				for (let i = 0; i < all_hierarchy_datatype_els.length; i++)
					for (let x = 0; x < all_filtered_els.length; x++)
						if (all_hierarchy_datatype_els[i].contains(all_filtered_els[x]) || all_hierarchy_datatype_els[i].instance.options.disabled === true)
							all_hierarchy_datatype_els[i].style.display = "block";
			}
		}
		
		//Fire this.options.onsearch if specified
		if (this.options.onsearch) this.options.onsearch(name);
	}
};

//Functional binding

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