|
/**
|
*
|
* @param {HTMLElement} element
|
* @param {boolean} fadeIn
|
*/
|
window.fade = function (element, fadeIn = true) {
|
if (fadeIn) {
|
element.style.animation = 'fadeIn var(--transition-base)';
|
} else {
|
element.style.animation = 'fadeOut var(--transition-base)';
|
window.debouncer.schedule(
|
`remove-${element.dataset.id??element.id??element.className.replace(' ', '-')}`,
|
() => {
|
element.remove();
|
},
|
500
|
);
|
}
|
}
|
/**
|
* Format a time value as relative time (past or future)
|
* Handles both "X time ago" and "in X time" formats
|
*
|
* @param {string|Date} dateStr Date to format
|
* @returns {string} Formatted time string
|
*/
|
window.formatTimeAgo = function(dateStr, dateFormat = 'default') {
|
const date = dateStr instanceof Date ? dateStr : new Date(dateStr);
|
const now = new Date();
|
const diffMs = date - now;
|
const isPast = diffMs < 0;
|
|
// Work with absolute values for calculations
|
const seconds = Math.floor(Math.abs(diffMs) / 1000);
|
const minutes = Math.floor(seconds / 60);
|
const hours = Math.floor(minutes / 60);
|
const days = Math.floor(hours / 24);
|
|
// Just now (within 1 minute either way)
|
if (minutes === 0) {
|
return 'Just now';
|
}
|
|
// Format the time components
|
let timeStr = '';
|
|
if (seconds < 10) {
|
timeStr = 'a moment';
|
} else if (seconds < 60) {
|
timeStr = 'less than a minute'
|
} else if (minutes < 5) {
|
timeStr = 'a few minutes';
|
} else if (hours < 24) {
|
if (hours === 0) {
|
// Minutes only
|
timeStr = `${minutes} ${minutes === 1 ? 'minute' : 'minutes'}`;
|
} else {
|
// 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 - 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
|
return isPast ? `${timeStr} ago` : `in ${timeStr}`;
|
}
|
|
/**
|
* Capitalize first letter of a string
|
*
|
* @param {string} string String to capitalize
|
* @returns {string} Capitalized string
|
*/
|
window.uppercaseFirst = function(string) {
|
return string.charAt(0).toUpperCase() + string.slice(1);
|
}
|
|
/**
|
* Load HTML templates
|
*/
|
window.templates = new Map();
|
document.addEventListener('DOMContentLoaded', ()=> {
|
// // Default templates
|
// window.templates.set('replyButton', this.createReplyButtonTemplate());
|
// window.templates.set('commentsButton', this.createCommentsButtonTemplate());
|
// window.templates.set('voteButton', this.createVoteButtonsTemplate());
|
|
window.loadTemplates();
|
});
|
|
window.loadTemplates = function() {
|
document.querySelectorAll('template').forEach(template => {
|
const classes = Array.from(template.classList);
|
if (classes.length > 0) {
|
const item = template.content.cloneNode(true).firstElementChild;
|
classes.forEach(key => {
|
if (!window.templates.has(key)) {
|
window.templates.set(key, item);
|
}
|
});
|
}
|
});
|
}
|
|
/**
|
* Helper to load a template from
|
* @param template
|
* @returns {Node|ActiveX.IXMLDOMNode|boolean}
|
*/
|
window.getTemplate = function (template){
|
if (window.templates.size === 0) {
|
window.loadTemplates();
|
}
|
if(window.templates.has(template)){
|
return window.templates.get(template).cloneNode(true);
|
}
|
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
|
* @returns {Node | ActiveX.IXMLDOMNode}
|
*/
|
window.icon = null;
|
window.getIcon = function getIcon(icon, style = ''){
|
if (typeof icon === 'undefined') {
|
return '';
|
}
|
|
if (!window.icon) {
|
window.icon = document.createElement('i');
|
window.icon.className = 'icon';
|
window.icon.ariaHidden = true;
|
}
|
let theIcon = window.icon.cloneNode(true);
|
if (style !== '' && ['regular', 'bold', 'duotone', 'fill', 'light', 'thin' ].includes('style')) {
|
style = `-${style.slice(0, 2)}`;
|
} else {
|
style = '';
|
}
|
theIcon.classList.add(`icon-${icon}${style}`);
|
return theIcon;
|
}
|
|
/**
|
* Format a number with comma separator (e.g., 1,234)
|
* @param {number} num - Number to format
|
* @returns {string} - Formatted number
|
*/
|
window.formatNumber = function(num) {
|
return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
|
}
|
|
/**
|
* Format a price with currency symbol
|
* @param {number} price - Price to format
|
* @param {string} currency - Currency code (default: 'CAD')
|
* @returns {string} - Formatted price
|
*/
|
window.formatPrice = function(price, currency = 'CAD') {
|
return new Intl.NumberFormat('en-CA', {
|
style: 'currency',
|
currency: currency
|
}).format(price);
|
}
|
|
/**
|
* Escape HTML special characters to prevent XSS
|
* @param {string} text - Text to escape
|
* @returns {string} - Escaped text
|
*/
|
window.escapeHtml = function(text) {
|
if (!text) return '';
|
// Convert to string if it's not already a string
|
if (typeof text !== "string" && !(text instanceof String)) {
|
text = String(text);
|
}
|
return text
|
.replace(/&/g, "&")
|
.replace(/</g, "<")
|
.replace(/>/g, ">")
|
.replace(/"/g, """)
|
.replace(/'/g, "'");
|
}
|
|
/**
|
* Should be faster than setting innerHTML = ''
|
* @param node
|
*/
|
window.removeChildren = function(node) {
|
if(node.children.length === 0){
|
return;
|
}
|
while (node.firstChild) {
|
node.removeChild(node.firstChild);
|
}
|
}
|
|
/**
|
* Format a date range (e.g., "Jan 1 - Jan 5, 2023")
|
* @param {string} startDate - Start date ISO string
|
* @param {string} endDate - End date ISO string
|
* @returns {string} - Formatted date range
|
*/
|
window.formatDateRange = function(startDate, endDate) {
|
const start = new Date(startDate);
|
const end = new Date(endDate);
|
|
// If same day, just show one date
|
if (start.toDateString() === end.toDateString()) {
|
return start.toLocaleDateString('en-CA', {
|
year: 'numeric',
|
month: 'short',
|
day: 'numeric'
|
});
|
}
|
|
// If same month and year, show range with month once
|
if (start.getMonth() === end.getMonth() && start.getFullYear() === end.getFullYear()) {
|
return `${start.toLocaleDateString('en-CA', { month: 'short', day: 'numeric' })} - ${end.getDate()}, ${end.getFullYear()}`;
|
}
|
|
// If same year, show full range with year once
|
if (start.getFullYear() === end.getFullYear()) {
|
return `${start.toLocaleDateString('en-CA', { month: 'short', day: 'numeric' })} - ${end.toLocaleDateString('en-CA', { month: 'short', day: 'numeric' })}, ${end.getFullYear()}`;
|
}
|
|
// Different years, show full dates
|
return `${start.toLocaleDateString('en-CA', { month: 'short', day: 'numeric', year: 'numeric' })} - ${end.toLocaleDateString('en-CA', { month: 'short', day: 'numeric', year: 'numeric' })}`;
|
}
|
|
|
/**
|
* Throttle function to limit call frequency
|
* @param {Function} func - Function to throttle
|
* @param {number} limit - Time limit in milliseconds
|
* @returns {Function} - Throttled function
|
*/
|
window.throttle = function(func, limit = 300) {
|
let inThrottle;
|
return function(...args) {
|
if (!inThrottle) {
|
func.apply(this, args);
|
inThrottle = true;
|
setTimeout(() => inThrottle = false, limit);
|
}
|
};
|
}
|
|
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, replace = false) {
|
if (!input) {
|
console.warn('prefixInput called with null/undefined input');
|
return;
|
}
|
let newId = replace ? prefix : `${prefix}${input.name}`;
|
if (input.labels && input.labels.length > 0) {
|
input.labels?.forEach(label => {
|
label.htmlFor = newId;
|
});
|
} else if (input.previousElementSibling?.tagName === 'label') {
|
let label = input.previousElementSibling;
|
if (label) label.htmlFor = newId;
|
} else if (input.nextElementSibling?.tagName === 'label') {
|
let label = input.nextElementSibling;
|
if (label) label.htmlFor = newId;
|
}else {
|
let label = input.closest('[data-field]')?.querySelector(`label[for="${input.id}"]`);
|
if (label) {
|
label.htmlFor = newId;
|
}
|
}
|
|
input.id = newId;
|
}
|
|
/**
|
* Makes first letter uppercase
|
* @param string
|
* @returns {string}
|
*/
|
window.uppercaseFirst = function(string){
|
return string.charAt(0).toUpperCase()
|
+ string.slice(1)
|
}
|
/**
|
* Sanitizes HTML
|
* @param text
|
* @returns {string}
|
*/
|
window.sanitizeHtml = function(text) {
|
const div = document.createElement('div');
|
div.textContent = text;
|
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
|
* @returns {string} Formatted date
|
*/
|
window.formatDate = function(dateString) {
|
if (!dateString) return '';
|
|
const date = new Date(dateString);
|
const now = new Date();
|
const diffDays = Math.floor((now - date) / (1000 * 60 * 60 * 24));
|
|
if (diffDays < 1) {
|
return 'Today';
|
} else if (diffDays < 2) {
|
return 'Yesterday';
|
} else if (diffDays < 7) {
|
return `${diffDays} days ago`;
|
} else {
|
return date.toLocaleDateString();
|
}
|
}
|
|
/**
|
* Pluralizes the content
|
* @param content
|
* @returns {string|string}
|
*/
|
window.getPluralContent = function(content){
|
return (content === 'artwork') ? 'artwork' : content+'s';
|
}
|
|
/**
|
* Shortcut to add notification
|
* @param message
|
* @param type
|
* @param actions
|
*/
|
window.showToast = function(message, type='success', actions={}){
|
window.jvbNotifications.showToast(message, type, actions);
|
}
|
window.dateFormatter = new Intl.DateTimeFormat('en-CA', {
|
year: 'numeric',
|
month: 'long',
|
day: 'numeric',
|
hour: '2-digit',
|
minute: '2-digit',
|
second: '2-digit',
|
timeZoneName: 'short'
|
});
|
|
window.formatDate = function(date) {
|
if (!(date instanceof Date && !isNaN(date))) {
|
date = new Date(date);
|
}
|
return window.dateFormatter.format(date);
|
}
|
|
/**
|
* Outputs the set text as if a typewriter were writing it
|
* @param container
|
* @param text
|
* @param speed
|
* @returns {Promise<unknown>}
|
*/
|
window.typeText = function(container, text, speed = 50) {
|
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 = '';
|
|
container._typeInterval = setInterval(() => {
|
if (index < text.length) {
|
container.textContent += text.charAt(index);
|
index++;
|
} else {
|
clearInterval(container._typeInterval);
|
delete container._typeInterval;
|
resolve();
|
}
|
}, speed);
|
});
|
}
|
|
/**
|
* 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;
|
|
container._eraseInterval = setInterval(() => {
|
if (index > 0) {
|
index--;
|
container.textContent = text.substring(0, index);
|
} else {
|
clearInterval(container._eraseInterval);
|
delete container._eraseInterval;
|
resolve();
|
}
|
}, speed);
|
});
|
}
|
|
/**
|
* Continuously types and erases text in a loop
|
* @param container - The DOM element to display text in
|
* @param text - The text to type and erase
|
* @param typeSpeed - Speed of typing (ms between characters)
|
* @param eraseSpeed - Speed of erasing (ms between character removals)
|
* @param pauseAfterType - Pause after typing completes (ms)
|
* @param pauseAfterErase - Pause after erasing completes (ms)
|
* @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) {
|
await window.typeText(container, text, typeSpeed);
|
if (!isRunning) break;
|
await new Promise(resolve => setTimeout(resolve, pauseAfterType));
|
if (!isRunning) break;
|
await window.eraseText(container, eraseSpeed);
|
if (!isRunning) break;
|
await new Promise(resolve => setTimeout(resolve, pauseAfterErase));
|
}
|
}
|
|
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) {
|
return string.replace(/-([a-z])/g, function (g) { return g[1].toUpperCase(); });
|
}
|
|
window.targetCheck = function (e, selector) {
|
if (Array.isArray(selector)) {
|
selector = selector.join(',');
|
}
|
if (typeof selector !== 'string') {
|
return false;
|
}
|
return (e.target.closest(selector))??false;
|
}
|
|
//Modified rom Stackoverflow: https://stackoverflow.com/a/8596559
|
window.getDifferences = {
|
VALUE_CREATED: "created",
|
VALUE_UPDATED: "updated",
|
VALUE_DELETED: "deleted",
|
VALUE_UNCHANGED: "unchanged",
|
|
map: function(oldData, newData) {
|
if (this.isFunction(oldData) || this.isFunction(newData)) {
|
throw "Invalid argument. Function given, object expected.";
|
}
|
|
if (this.isFile(oldData) || this.isFile(newData)) {
|
const changeType = this.compareFiles(oldData, newData);
|
return changeType === this.VALUE_UNCHANGED ? null : {
|
type: changeType,
|
data: oldData === undefined ? newData : oldData
|
};
|
}
|
|
if (this.isValue(oldData) || this.isValue(newData)) {
|
const changeType = this.compareValues(oldData, newData);
|
if (changeType === this.VALUE_UNCHANGED) return null;
|
|
let resultData;
|
switch (changeType) {
|
case this.VALUE_CREATED:
|
resultData = newData;
|
break;
|
case this.VALUE_DELETED:
|
resultData = this.getEmptyValue(oldData);
|
break;
|
case this.VALUE_UPDATED:
|
default:
|
resultData = newData;
|
}
|
return { type: changeType, data: resultData };
|
}
|
|
let changes = {};
|
let hasChanges = false;
|
|
// Check for modifications and deletions
|
for (let key in oldData) {
|
if (!this.isFunction(oldData[key])) {
|
let newValue = undefined;
|
if (newData && newData[key] !== undefined) {
|
newValue = newData[key];
|
}
|
|
const change = this.map(oldData[key], newValue);
|
if (change !== null) {
|
if (change.hasOwnProperty("type") && change.hasOwnProperty("data")) {
|
changes[key] = change.data;
|
} else {
|
changes[key] = change;
|
}
|
hasChanges = true;
|
}
|
}
|
}
|
|
// Check for additions
|
if (newData) {
|
for (let key in newData) {
|
if (!this.isFunction(newData[key]) && (oldData === undefined || oldData[key] === undefined)) {
|
const change = this.map(undefined, newData[key]);
|
if (change !== null) {
|
if (change.hasOwnProperty("type") && change.hasOwnProperty("data")) {
|
changes[key] = change.data;
|
} else {
|
changes[key] = change;
|
}
|
hasChanges = true;
|
}
|
}
|
}
|
}
|
|
return hasChanges ? changes : null;
|
},
|
|
/**
|
* Get appropriate empty value for a deleted field
|
*/
|
getEmptyValue: function(originalValue) {
|
if (this.isArray(originalValue)) {
|
return [];
|
}
|
if (this.isObject(originalValue)) {
|
return {};
|
}
|
if (typeof originalValue === 'number') {
|
return 0;
|
}
|
if (typeof originalValue === 'boolean') {
|
return false;
|
}
|
// For strings and other types, return empty string
|
return "";
|
},
|
|
compareValues: function(oldValue, newValue) {
|
return oldValue === newValue ||
|
(this.isDate(oldValue) && this.isDate(newValue) && oldValue.getTime() === newValue.getTime())
|
? this.VALUE_UNCHANGED
|
: oldValue === undefined
|
? this.VALUE_CREATED
|
: newValue === undefined
|
? this.VALUE_DELETED
|
: this.VALUE_UPDATED;
|
},
|
|
isFunction: function(value) {
|
return Object.prototype.toString.call(value) === "[object Function]";
|
},
|
|
isArray: function(value) {
|
return Object.prototype.toString.call(value) === "[object Array]";
|
},
|
|
isDate: function(value) {
|
return Object.prototype.toString.call(value) === "[object Date]";
|
},
|
|
isObject: function(value) {
|
return Object.prototype.toString.call(value) === "[object Object]";
|
},
|
|
isFile: function(value) {
|
return value instanceof File;
|
},
|
|
isValue: function(value) {
|
return !this.isObject(value) && !this.isArray(value);
|
},
|
|
compareFiles: function(oldFile, newFile) {
|
if (!this.isFile(oldFile) && this.isFile(newFile)) {
|
return this.VALUE_CREATED;
|
}
|
if (this.isFile(oldFile) && !this.isFile(newFile)) {
|
return this.VALUE_DELETED;
|
}
|
if (this.isFile(oldFile) && this.isFile(newFile)) {
|
return oldFile.name === newFile.name &&
|
oldFile.size === newFile.size &&
|
oldFile.type === newFile.type &&
|
oldFile.lastModified === newFile.lastModified
|
? this.VALUE_UNCHANGED
|
: this.VALUE_UPDATED;
|
}
|
return this.VALUE_UNCHANGED;
|
},
|
|
merge: function(oldData, newData) {
|
if (oldData == null) return newData;
|
if (newData == null) return oldData;
|
if (this.isFunction(oldData) || this.isFunction(newData)) return newData;
|
if (this.isFile(oldData) || this.isFile(newData)) return newData;
|
if (this.isValue(oldData) || this.isValue(newData) || this.isArray(oldData) || this.isArray(newData)) {
|
return newData;
|
}
|
if (this.isObject(oldData) && this.isObject(newData)) {
|
let result = {};
|
for (let key in oldData) {
|
if (!this.isFunction(oldData[key])) {
|
result[key] = oldData[key];
|
}
|
}
|
for (let key in newData) {
|
if (!this.isFunction(newData[key])) {
|
if (oldData[key] !== undefined) {
|
result[key] = this.merge(oldData[key], newData[key]);
|
} else {
|
result[key] = newData[key];
|
}
|
}
|
}
|
return result;
|
}
|
return newData;
|
}
|
};
|
|
window.deepMerge = function(oldData, newData) {
|
return window.getDifferences.merge(oldData, newData);
|
};
|
|
window.isInt = function(n) {
|
return !isNaN(parseInt(n)) && isFinite(n);
|
};
|
|
window.isNumeric = function(n) {
|
return !isNaN(parseFloat(n)) && isFinite(n);
|
};
|
|
/**
|
*
|
* @param {object} selectors
|
* @param {HTMLElement|null} parent
|
* @returns {object}
|
*/
|
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] = (all) ? document.querySelectorAll(selector) : document.querySelector(selector);
|
} else {
|
ui[key] = (all) ? parent.querySelectorAll(selector) : parent.querySelector(selector);
|
}
|
}
|
}
|
return ui;
|
}
|
|
|
class DebouncedActions {
|
constructor() {
|
this.timeouts = new Map();
|
window.addEventListener('beforeunload', () => this.cleanup());
|
}
|
|
schedule(key, callback, delay = 1000) {
|
this.cancel(key);
|
this.timeouts.set(key, setTimeout(() => {
|
callback();
|
this.timeouts.delete(key);
|
}, delay));
|
}
|
|
cancel(key) {
|
if (this.timeouts.has(key)) {
|
clearTimeout(this.timeouts.get(key));
|
this.timeouts.delete(key);
|
}
|
}
|
|
cleanup() {
|
for (let timeout of this.timeouts.values()) {
|
clearTimeout(timeout);
|
}
|
this.timeouts.clear();
|
}
|
}
|
window.debouncer = new DebouncedActions();
|
|
|
// -----------------------------------------------------
|
// Scroll direction + scroll progress
|
// -----------------------------------------------------
|
const body = document.body;
|
const docEl = document.documentElement;
|
const progressBar = document.querySelector('.scroll-progress .bar');
|
|
let lastY = window.scrollY || docEl.scrollTop || 0;
|
let direction = -1;
|
let ticking = false;
|
let maxScroll = 0;
|
|
function updateMaxScroll() {
|
maxScroll = Math.max(0, docEl.scrollHeight - window.innerHeight);
|
}
|
|
function updateScrollProgress(y) {
|
if (!progressBar) return;
|
|
const progress = maxScroll > 0 ? y / maxScroll : 0;
|
const clamped = Math.max(0, Math.min(1, progress));
|
|
progressBar.style.transform = `scaleX(${clamped})`;
|
}
|
|
function onScrollFrame() {
|
const y = window.scrollY || docEl.scrollTop || 0;
|
|
// Direction: 1 = down, -1 = up, keep existing if no movement
|
if (y > lastY) {
|
direction = 1;
|
} else if (y < lastY) {
|
direction = -1;
|
}
|
|
lastY = y;
|
|
// Only add scroll-up when actually below top & moving up
|
document.body.classList.toggle('scroll-up', direction < 0 && y > 0);
|
|
// Update progress bar
|
updateScrollProgress(y);
|
|
ticking = false;
|
}
|
|
// Throttled scroll listener
|
window.addEventListener(
|
'scroll',
|
() => {
|
if (!ticking) {
|
ticking = true;
|
requestAnimationFrame(onScrollFrame);
|
}
|
},
|
{ passive: true }
|
);
|
|
// Debounced resize to recalc scrollable height
|
window.addEventListener('resize', () => {
|
window.debouncer.schedule('recalc-max-scroll', () => {
|
updateMaxScroll();
|
updateScrollProgress(window.scrollY || docEl.scrollTop || 0);
|
}, 20);
|
});
|
|
// Initial setup
|
updateMaxScroll();
|
updateScrollProgress(lastY);
|