From 46d681c6b825d21b3f698d793c4e630c687d90ad Mon Sep 17 00:00:00 2001
From: Jake Vanderwerf <get@jakevanderwerf.ca>
Date: Thu, 21 May 2026 21:41:53 +0000
Subject: [PATCH] =Major CustomBlocks.php overhaul, expanding block support and customization from the editor. theme.json should now be updated on new themes to set brand colours, etc. Also note: major change to .col vs .row alignment: simplifying it to .top .bottom vs the confusion of the differences for .col/.row .start and .a-start

---
 assets/js/concise/UtilityFunctions.js |  344 +++++++++++++++++++++++++++++++++++++++++++++++++++++---
 1 files changed, 322 insertions(+), 22 deletions(-)

diff --git a/assets/js/concise/UtilityFunctions.js b/assets/js/concise/UtilityFunctions.js
index fe2f543..d11cdbc 100644
--- a/assets/js/concise/UtilityFunctions.js
+++ b/assets/js/concise/UtilityFunctions.js
@@ -152,6 +152,118 @@
 	return false;
 }
 
+
+
+
+/**
+ * Load and instantiate HTML templates as lightweight components
+ */
+class TemplateRegistry {
+	constructor() {
+		this.templates = new Map();     // name -> <template>
+		this.definitions = new Map();   // name -> component definition
+	}
+
+	/**
+	 * Collect all <template class="name"> elements
+	 */
+	registerAll(root = document) {
+		root.querySelectorAll('template').forEach(tpl => {
+			tpl.classList.forEach(name => {
+				if (!this.templates.has(name)) {
+					this.templates.set(name, tpl);
+				}
+			});
+		});
+	}
+
+	/**
+	 * Define component behavior
+	 */
+	define(name, definition = {}, context = null) {
+		this.definitions.set(name, {
+			refs: definition.refs || null,
+			manyRefs: definition.manyRefs || null,
+			setup: definition.setup || null,
+			context: context
+		});
+	}
+
+	/**
+	 * Create a component instance
+	 */
+	create(name, data = {}) {
+		const tpl = this.templates.get(name);
+		if (!tpl) {
+			console.warn(`[TemplateRegistry] Template "${name}" not found`);
+			return null;
+		}
+
+		const element = tpl.content.cloneNode(true).firstElementChild;
+		if (!element) return null;
+
+		const def = this.definitions.get(name);
+		const refs = def?.refs
+			? this.#collectRefs(element, def.refs)
+			: {};
+		const manyRefs = def?.manyRefs
+			? this.#collectRefs(element, def.manyRefs, false)
+			: {};
+
+		def?.setup?.({
+			el: element,
+			refs,
+			manyRefs,
+			data
+		});
+
+		return element;
+	}
+
+	/**
+	 * Resolve refs declared in component definition
+	 */
+	#collectRefs(root, refMap, single = true) {
+		const refs = {};
+
+		for (const [key, value] of Object.entries(refMap)) {
+			let selector;
+			let required = false;
+
+			if (typeof value === 'string') {
+				selector = value;
+			} else {
+				selector = value.selector;
+				required = !!value.required;
+			}
+
+			const found = (single) ? root.querySelector(selector) : root.querySelectorAll(selector);
+
+			if (required) {
+				if (single && !found) {
+					console.warn(`[TemplateRegistry] Required ref "${key}" not found: ${selector}`);
+				}
+
+				if (!single && found.length === 0) {
+					console.warn(`[TemplateRegistry] Required manyRef "${key}" not found: ${selector}`);
+				}
+			}
+
+			refs[key] = single ? found : Array.from(found);
+		}
+
+		return refs;
+	}
+
+}
+
+
+window.jvbTemplates = new TemplateRegistry();
+
+document.addEventListener('DOMContentLoaded', () => {
+	window.jvbTemplates.registerAll();
+});
+
 /**
  * Gets a clone of an icon element if it exists for efficient DOM manipulation
  * @param icon
@@ -283,6 +395,64 @@
 	};
 }
 
+window.chunkIt = async function(items, renderCallback, placementCallback, size = 10) {
+	const chunks = [];
+	for (let i = 0; i <items.length; i += size) {
+		chunks.push(items.slice(i, i + size));
+	}
+
+	for (const chunk of chunks) {
+		const fragment = document.createDocumentFragment();
+		chunk.forEach(item => {
+			const element = renderCallback(item);
+			if (element) fragment.append(element);
+		});
+
+		placementCallback(fragment);
+		await new Promise(resolve => requestAnimationFrame(resolve));
+	}
+}
+
+window.prefixInput = function(input, prefix, wrapper = null, replace = false, name = false) {
+	if (!input) {
+		console.warn('prefixInput called with null/undefined input');
+		return;
+	}
+	// console.log('Prefixing input: ', input);
+	// console.log('With prefix: ', prefix);
+	// console.log('Wrapper: ', wrapper);
+	const oldId = input.id;
+	const newId = replace ? prefix : `${prefix}${input.name}`;
+	// console.log('Old ID: ', oldId);
+	// console.log('New ID: ', newId);
+	// Search for label within wrapper if provided, otherwise use existing logic
+	let label = null;
+
+	if (wrapper) {
+		// Most reliable: search within wrapper by old ID
+		label = wrapper.querySelector(`label[for="${oldId}"]`);
+	} else if (input.labels && input.labels.length > 0) {
+		// Fallback to input.labels if no wrapper provided
+		label = input.labels[0];
+	} else if (input.previousElementSibling?.tagName === 'LABEL') {
+		label = input.previousElementSibling;
+	} else if (input.nextElementSibling?.tagName === 'LABEL') {
+		label = input.nextElementSibling;
+	} else {
+		// Final fallback: search up the tree
+		label = input.closest('[data-field]')?.querySelector(`label[for="${oldId}"]`);
+	}
+
+	if (label) {
+		label.htmlFor = newId;
+	}
+
+	input.id = newId;
+	if (name) {
+		input.name = newId;
+	}
+}
+
 /**
  * Makes first letter uppercase
  * @param string
@@ -389,17 +559,23 @@
  * @returns {Promise<unknown>}
  */
 window.typeText = function(container, text, speed = 50) {
-	container.classList.add('typeText');
 	return new Promise((resolve) => {
+		// Cancel any existing animation on this container
+		if (container._typeInterval) {
+			clearInterval(container._typeInterval);
+			delete container._typeInterval;
+		}
+
 		let index = 0;
 		container.textContent = '';
 
-		const interval = setInterval(() => {
+		container._typeInterval = setInterval(() => {
 			if (index < text.length) {
 				container.textContent += text.charAt(index);
 				index++;
 			} else {
-				clearInterval(interval);
+				clearInterval(container._typeInterval);
+				delete container._typeInterval;
 				resolve();
 			}
 		}, speed);
@@ -407,22 +583,29 @@
 }
 
 /**
- * Erases text like a keyboard would. TODO: erase a set word from existing text
+ * Erases text like a keyboard would.
  * @param container
  * @param speed
  * @returns {Promise<unknown>}
  */
 window.eraseText = function(container, speed = 10) {
 	return new Promise((resolve) => {
+		// Cancel any existing animation on this container
+		if (container._eraseInterval) {
+			clearInterval(container._eraseInterval);
+			delete container._eraseInterval;
+		}
+
 		let text = container.textContent;
 		let index = text.length;
 
-		const interval = setInterval(() => {
+		container._eraseInterval = setInterval(() => {
 			if (index > 0) {
 				index--;
 				container.textContent = text.substring(0, index);
 			} else {
-				clearInterval(interval);
+				clearInterval(container._eraseInterval);
+				delete container._eraseInterval;
 				resolve();
 			}
 		}, speed);
@@ -440,31 +623,47 @@
  * @returns {Function} - Call this function to stop the loop
  */
 window.typeLoop = function(container, text, typeSpeed = 50, eraseSpeed = 10, pauseAfterType = 1000, pauseAfterErase = 250) {
+	// Generate unique key for this container
+	const containerId = container.id || container.dataset.typeKey || `type-${Date.now()}`;
+	if (!container.dataset.typeKey) {
+		container.dataset.typeKey = containerId;
+	}
+
+	// Stop any existing loop immediately
+	if (container._stopTyping) {
+		container._stopTyping();
+	}
+
 	let isRunning = true;
 
 	async function loop() {
 		while (isRunning) {
-			// Type the text
 			await window.typeText(container, text, typeSpeed);
-
-			// Wait 1 second
+			if (!isRunning) break;
 			await new Promise(resolve => setTimeout(resolve, pauseAfterType));
-
-			// Erase the text
+			if (!isRunning) break;
 			await window.eraseText(container, eraseSpeed);
-
-			// Wait 0.25 seconds before next iteration
+			if (!isRunning) break;
 			await new Promise(resolve => setTimeout(resolve, pauseAfterErase));
 		}
 	}
 
-	// Start the loop
-	loop();
-
-	// Return a function to stop the loop
-	return function stopLoop() {
+	const stopLoop = function() {
 		isRunning = false;
+		if (container._typeInterval) {
+			clearInterval(container._typeInterval);
+			delete container._typeInterval;
+		}
+		if (container._eraseInterval) {
+			clearInterval(container._eraseInterval);
+			delete container._eraseInterval;
+		}
 	};
+
+	container._stopTyping = stopLoop;
+	loop(); // Start immediately
+
+	return stopLoop;
 };
 
 window.toCamelCase = function (string) {
@@ -684,23 +883,26 @@
  * @param {HTMLElement|null} parent
  * @returns {object}
  */
-window.uiFromSelectors = function(selectors, parent = null) {
+window.uiFromSelectors = function(selectors, parent = null, all = false) {
 	let ui = {};
 	for (let [key, selector] of Object.entries(selectors)) {
 		if (typeof selector === 'object') {
 			ui[key] = window.uiFromSelectors(selector, parent);
 		}else {
 			if (!parent) {
-				ui[key] = document.querySelector(selector);
+				ui[key] = (all) ? document.querySelectorAll(selector) : document.querySelector(selector);
 			} else {
-				ui[key] = parent.querySelector(selector);
+				ui[key] = (all) ? parent.querySelectorAll(selector) : parent.querySelector(selector);
 			}
-
 		}
 	}
 	return ui;
 }
 
+window.sleep = async function (ms = 50) {
+	return new Promise(resolve => setTimeout(resolve, ms));
+};
+
 class DebouncedActions {
 	constructor() {
 		this.timeouts = new Map();
@@ -790,15 +992,113 @@
 	{ passive: true }
 );
 
+
+window.previousBGSize = 'Small';
+window.bgSizes = {
+	Small: 500,
+	Med: 768,
+	Large: 1024
+};
+
+window.bgObserver = new IntersectionObserver((entries) => {
+		entries.forEach(entry => {
+			if (entry.isIntersecting) {
+				let newSize = entry.target.dataset[`bg${window.previousBGSize}`];
+				entry.target.style.backgroundImage = `url(${newSize})`;
+				entry.target.dataset.bgImg = window.previousBGSize;
+				window.bgObserver.unobserve(entry.target);
+			}
+		})
+	},
+	{
+		root: null,
+		rootMargin: '0px 0px -100px 0px',
+		threshold: 0
+	});
+
+function updateBG() {
+	let current = window.innerWidth;
+	let newWidth = getBGWidth(current);
+
+	if (newWidth) {
+		window.previousBGSize = newWidth;
+		document.querySelectorAll('[data-bg-img]:not([data-bg-img="'+window.previousBGSize+'"])').forEach(img => {
+			window.bgObserver.observe(img);
+		});
+	}
+
+}
+function getBGWidth(width) {
+	let prev = window.previousBGSize;
+
+	let check = {
+		Small: ['Med','Large'],
+		Med: ['Large'],
+		Large: false
+	};
+
+	if (!check[prev]) {
+		return false;
+	}
+	let next = 'Small';
+
+	check[prev].forEach(w => {
+		if (width => window.bgSizes[w]) {
+			next = w;
+		}
+	});
+	return next;
+}
+
+updateBG();
+
 // Debounced resize to recalc scrollable height
 window.addEventListener('resize', () => {
 	window.debouncer.schedule('recalc-max-scroll', () => {
 		updateMaxScroll();
 		updateScrollProgress(window.scrollY || docEl.scrollTop || 0);
 	}, 20);
+	window.debouncer.schedule('bg-resize', () => {
+		updateBG();
+	});
 });
 
 // Initial setup
 updateMaxScroll();
 updateScrollProgress(lastY);
 
+
+
+window.decodeHTMLEntities = function(text) {
+	if (!window.decodeHelper) {
+		window.decodeHelper = document.createElement('textarea');
+	}
+
+	window.decodeHelper.innerHTML = text;
+	return window.decodeHelper.value;
+}
+
+
+window.focusNextElement = function() {
+	//add all elements we want to include in our selection
+	var focussableElements =
+		'a:not([disabled]), button:not([disabled]), input[type=text]:not([disabled]), [tabindex]:not([disabled]):not([tabindex="-1"])';
+	if (document.activeElement && document.activeElement.form) {
+		var focussable = Array.prototype.filter.call(
+			document.activeElement.form.querySelectorAll(focussableElements),
+			function (element) {
+				//check for visibility while always include the current activeElement
+				return (
+					element.offsetWidth > 0 ||
+					element.offsetHeight > 0 ||
+					element === document.activeElement
+				);
+			}
+		);
+		var index = focussable.indexOf(document.activeElement);
+		if (index > -1) {
+			var nextElement = focussable[index + 1] || focussable[0];
+			nextElement.focus();
+		}
+	}
+}

--
Gitblit v1.10.0