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 | 401 +++++++++++++++++++++++++++++++++++++++++++++++++++++---
1 files changed, 375 insertions(+), 26 deletions(-)
diff --git a/assets/js/concise/UtilityFunctions.js b/assets/js/concise/UtilityFunctions.js
index 3b9e355..d11cdbc 100644
--- a/assets/js/concise/UtilityFunctions.js
+++ b/assets/js/concise/UtilityFunctions.js
@@ -25,7 +25,7 @@
* @param {string|Date} dateStr Date to format
* @returns {string} Formatted time string
*/
-window.formatTimeAgo = function(dateStr) {
+window.formatTimeAgo = function(dateStr, dateFormat = 'default') {
const date = dateStr instanceof Date ? dateStr : new Date(dateStr);
const now = new Date();
const diffMs = date - now;
@@ -57,14 +57,43 @@
timeStr = `${minutes} ${minutes === 1 ? 'minute' : 'minutes'}`;
} else {
// Hours
- timeStr = `${hours} ${hours === 1 ? 'hour' : 'hours'}`;
+ timeStr = `about ${hours} ${hours === 1 ? 'hour' : 'hours'}`;
}
} else if (days < 7) {
+ if (days === 1) {
+ return isPast ? 'yesterday' : 'tomorrow';
+ }
+ timeStr = `about ${days} days`;
// Days
timeStr = `${days} ${days === 1 ? 'day' : 'days'}`;
} else {
- // More than a week - just show the date
- return date.toLocaleDateString();
+ // More than a week - show the date based on format
+ if (dateFormat === 'default') {
+ return date.toLocaleDateString();
+ }
+
+ // Parse PHP-style format string
+ const formatMap = {
+ 'Y': date.getFullYear(),
+ 'y': String(date.getFullYear()).slice(-2),
+ 'F': date.toLocaleDateString('en-CA', { month: 'long' }),
+ 'M': date.toLocaleDateString('en-CA', { month: 'short' }),
+ 'm': String(date.getMonth() + 1).padStart(2, '0'),
+ 'n': date.getMonth() + 1,
+ 'd': String(date.getDate()).padStart(2, '0'),
+ 'j': date.getDate(),
+ 'D': date.toLocaleDateString('en-CA', { weekday: 'short' }),
+ 'l': date.toLocaleDateString('en-CA', { weekday: 'long' }),
+ 'H': String(date.getHours()).padStart(2, '0'),
+ 'i': String(date.getMinutes()).padStart(2, '0'),
+ 's': String(date.getSeconds()).padStart(2, '0'),
+ 'h': String(date.getHours() % 12 || 12).padStart(2, '0'),
+ 'g': date.getHours() % 12 || 12,
+ 'A': date.getHours() >= 12 ? 'PM' : 'AM',
+ 'a': date.getHours() >= 12 ? 'pm' : 'am',
+ };
+
+ return dateFormat.replace(/[YyFMmnjDlHishgAa]/g, match => formatMap[match]);
}
// Add appropriate prefix/suffix based on past or future
@@ -123,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
@@ -254,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
@@ -274,6 +473,26 @@
return div.innerHTML;
}
+window.generateID = function(prefix = 'jvb') {
+ return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2,9)}`;
+}
+
+window.showProgress = function(elements, current, total, message = '', icon = '') {
+ const show = current < total;
+ if (elements.progress && show) {
+ window.fade(elements.progress, true);
+ }
+ const percent = total > 0 ? (current / total) * 100 : 0;
+ if (elements.fill) elements.fill.style.width = `${percent}%`;
+ if (elements.details) elements.details.textContent = message;
+ if (elements.count) elements.count.textContent = `${current}/${total}`;
+ if (elements.icon) elements.icon.className = (icon === '') ? 'icon' : 'icon icon-'+icon;
+
+ if (elements.progress && current === total) {
+ window.fade(elements.progress, false);
+ }
+}
+
/**
* Format a date string for display
* @param {string} dateString - ISO date string
@@ -340,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);
@@ -358,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);
@@ -391,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) {
@@ -635,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();
@@ -741,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