// Constants for zoom behavior
/** Zoom speed multiplier for mouse wheel interactions - higher values = faster zoom */
export const ZOOM_CONSTANT = 15;
/** Maximum accumulated mouse wheel deltas before allowing default scroll behavior */
export const MOUSE_WHEEL_COUNT = 5;
export const noop = (() => {}) as () => void;
/**
* Easing function for smooth deceleration animations (ease-out quartic)
* Creates a smooth slow-down effect, starting fast and ending slowly
* Formula: -c * ((t/d - 1)^4 - 1) + b
* @param currentTime - Current animation step/time
* @param startValue - Initial value at start of animation
* @param changedValue - Total change in value (target - start)
* @param duration - Total duration in steps/time units
* @returns Interpolated value at current time
* @example
* ```typescript
* // Animate from 0 to 100 over 60 frames
* for (let step = 0; step <= 60; step++) {
* const value = easeOutQuart(step, 0, 100, 60);
* console.log(value); // Smooth deceleration from 0 to 100
* }
* ```
*/
export function easeOutQuart(
currentTime: number,
startValue: number,
changedValue: number,
duration: number,
): number {
currentTime /= duration;
currentTime -= 1;
return -changedValue * (currentTime * currentTime * currentTime * currentTime - 1) + startValue;
}
/**
* Validates image URL to prevent XSS attacks via malicious protocols
* Only allows http:, https:, and blob: protocols
* @param url - URL to validate
* @returns true if URL is safe to use, false otherwise
* @security Prevents javascript:, data:text/html, and other XSS vectors
* @example
* ```typescript
* isValidImageUrl('https://example.com/image.jpg') // true
* isValidImageUrl('javascript:alert(1)') // false
* isValidImageUrl('data:text/html,') // false
* ```
*/
export function isValidImageUrl(url: string | null | undefined): boolean {
if (!url) return false;
try {
const parsed = new URL(url, window.location.href);
const allowedProtocols = ['http:', 'https:', 'blob:'];
return allowedProtocols.includes(parsed.protocol);
} catch {
return false;
}
}
interface CreateElementOptions {
tagName: string;
id?: string;
html?: string;
/** Set to true only for static, trusted markup defined in source code */
trustedHTML?: boolean;
className?: string;
src?: string;
style?: Record; // REFACTOR: Changed from string to object for consistency (Issue C3.8)
child?: Node;
parent: Node;
insertBefore?: Node;
}
/**
* Creates a DOM element with specified properties and inserts it into the DOM
* @param options - Configuration object for element creation
* @param options.tagName - HTML tag name (e.g., 'div', 'img')
* @param options.id - Optional element ID
* @param options.html - Optional inner HTML content (SECURITY: Only use with trusted static content!)
* @param options.className - Optional CSS class names
* @param options.src - Optional src attribute (for img elements, must be valid http/https/blob URL)
* @param options.style - Optional inline CSS styles as object (e.g., { display: 'block', color: 'red' })
* @param options.child - Optional child node to append
* @param options.parent - Parent node to insert element into
* @param options.insertBefore - Optional sibling node to insert before
* @returns The created DOM element
* @throws Error if image src URL has invalid protocol
* @security innerHTML is used without sanitization - only pass trusted static HTML
* @security Image URLs are validated to prevent XSS via javascript: or data: protocols
*/
export function createElement(options: CreateElementOptions) {
const elem = document.createElement(options.tagName);
if (options.id) elem.id = options.id;
// SECURITY: Only allow innerHTML when explicitly marked as trusted static markup
if (options.html) {
if (!options.trustedHTML) {
throw new Error('innerHTML requires trustedHTML=true to avoid XSS risks');
}
elem.innerHTML = options.html;
}
if (options.className) elem.className = options.className;
// SECURITY FIX: Validate image URLs to prevent XSS
if (options.src && elem instanceof HTMLImageElement) {
if (!isValidImageUrl(options.src)) {
throw new Error(`Invalid or unsafe image URL protocol: ${options.src}`);
}
elem.setAttribute('src', options.src);
}
// REFACTOR: Use setStyle() for consistent style handling (Issues C3.8, A1.10)
if (options.style) setStyle(elem, options.style);
if (options.child) elem.appendChild(options.child);
// Insert before
if (options.insertBefore) {
options.parent.insertBefore(elem, options.insertBefore);
// Standard append
} else {
options.parent.appendChild(elem);
}
return elem;
}
// method to add class
export function addClass(el: HTMLElement, className: string): void {
const classNameAry = className.split(' ');
if (classNameAry.length > 1) {
classNameAry.forEach((classItem) => addClass(el, classItem));
} else if (el.classList) {
el.classList.add(className);
} else {
el.className += ` ${className}`;
}
}
// method to remove class
export function removeClass(el: HTMLElement, className: string): void {
const classNameAry = className.split(' ');
if (classNameAry.length > 1) {
classNameAry.forEach((classItem) => removeClass(el, classItem));
} else if (el.classList) {
el.classList.remove(className);
} else {
// P3-2 FIX: Simplified regex - className is always a single class at this point
el.className = el.className.replace(new RegExp(`(^|\\s)${className}(\\s|$)`, 'g'), ' ').trim();
}
}
// function to check if image is loaded
export function imageLoaded(img: HTMLImageElement): boolean {
return img.complete && (typeof img.naturalWidth === 'undefined' || img.naturalWidth !== 0);
}
export function toArray(
list: Node | NodeList | HTMLCollectionOf,
): (HTMLCollectionOf | Element)[] {
if (list instanceof HTMLElement) {
return [list];
} else if (list instanceof NodeList || list instanceof HTMLCollection) {
return Array.prototype.slice.call(list);
} else {
return [];
}
}
type ObjectWithStringKeys = Record;
export function assign(
target: ObjectWithStringKeys,
...rest: ObjectWithStringKeys[]
): ObjectWithStringKeys {
rest.forEach((obj) => {
Object.keys(obj).forEach((key) => {
target[key] = obj[key];
});
});
return target;
}
/**
* Gets the computed style value of a CSS property from an element
* REFACTOR: Extracted from css() for single responsibility (Issue A1.10)
* @param element - Element to get style from
* @param property - CSS property name (kebab-case or camelCase)
* @returns The computed style value, or empty string if not found
* @example
* ```typescript
* const width = getStyle(element, 'width'); // "100px"
* const bgColor = getStyle(element, 'background-color'); // "rgb(255, 0, 0)"
* ```
*/
export function getStyle(element: Element, property: string): string {
const styles = window.getComputedStyle(element);
// Try kebab-case first (e.g., 'border-radius')
const value = styles.getPropertyValue(property);
if (value) return value;
// Fallback: access as property directly (e.g., 'width', 'height')
type StyleKey = keyof CSSStyleDeclaration;
if (property in styles) {
const propValue = styles[property as StyleKey];
return typeof propValue === 'string' ? propValue : String(propValue);
}
return '';
}
/**
* Parses a CSS style property value as a float number
* REFACTOR: Consistent parseFloat helper with fallback (Issue A1.13)
* @param element - Element to read style from
* @param property - CSS property name
* @param defaultValue - Value to return if parsing fails (default: 0)
* @returns Parsed float value or default value
* @example
* ```typescript
* const left = parseStyleFloat(element, 'left'); // 100 (from "100px")
* const top = parseStyleFloat(element, 'top', 50); // 50 if not set
* ```
*/
export function parseStyleFloat(element: HTMLElement, property: string, defaultValue = 0): number {
const value = getStyle(element, property);
const parsed = parseFloat(value);
return isNaN(parsed) ? defaultValue : parsed;
}
/**
* Sets CSS styles on one or more HTML elements
* REFACTOR: Extracted from css() for single responsibility (Issue A1.10)
* @param elements - Single element, NodeList, or HTMLCollection to style
* @param properties - CSS properties as key-value pairs
* @security Sanitizes CSS values to prevent CSS injection attacks
* @example
* ```typescript
* setStyle(element, { width: '100px', display: 'block' });
* setStyle(document.querySelectorAll('.item'), { color: 'red' });
* ```
*/
export function setStyle(
elements: Node | NodeList | HTMLCollectionOf,
properties: Record,
): void {
const elmArray = toArray(elements);
elmArray.forEach((element) => {
if (element instanceof HTMLElement) {
Object.keys(properties).forEach((key: string) => {
const value = properties[key];
const stringValue = String(value);
// SECURITY: Block obvious scriptable CSS payloads
const lower = stringValue.toLowerCase();
if (lower.includes('javascript:') || lower.includes('expression(')) {
throw new Error('Blocked unsafe CSS value');
}
// Basic sanitization to strip angle/quote characters from inline CSS values
const sanitizedValue = stringValue.replace(/[<>'"]/g, '');
// Use direct property assignment for camelCase compatibility
const style = element.style as CSSStyleDeclaration & Record;
style[key] = sanitizedValue;
});
}
});
}
//Node | NodeList | HTMLCollectionOf
/**
* @deprecated Use getStyle() or setStyle() instead for better type safety and clarity
* Legacy function that handles both getting and setting CSS properties
* REFACTOR: Kept for backward compatibility, delegates to getStyle/setStyle (Issue A1.10)
*/
export function css(
elements: Node | NodeList | HTMLCollectionOf,
properties: string | Record,
): string | undefined {
if (typeof properties === 'string') {
// GET operation - delegate to getStyle
const elmArray = toArray(elements);
const element = elmArray[0];
if (element instanceof Element) {
return getStyle(element, properties);
}
return undefined;
}
// SET operation - delegate to setStyle
setStyle(elements, properties);
return undefined;
}
export function removeCss(element: HTMLElement, property: string): void {
element.style.removeProperty(property);
}
/**
* Wraps an element with a new parent element
* Replaces the element in the DOM tree with a wrapper containing the element
* @param element - Element to wrap
* @param options - Wrapper configuration
* @param options.tag - HTML tag for wrapper (default: 'div')
* @param options.className - Optional CSS class for wrapper
* @param options.id - Optional ID for wrapper
* @param options.style - Optional inline styles for wrapper
* @returns The wrapper element
* @throws Error if element has no parent node
*/
export function wrap(
element: HTMLElement,
{
tag = 'div',
className,
id,
style,
}: {
tag?: string;
className?: string;
id?: string;
style?: { display?: string; overflow?: string };
},
): HTMLElement {
const wrapper = document.createElement(tag);
if (className) wrapper.className = className;
if (id) wrapper.id = id;
if (style) {
if (style.display) wrapper.style.display = style.display;
if (style.overflow) wrapper.style.overflow = style.overflow;
}
const parentNode = element.parentNode;
if (!parentNode) {
throw new Error('element does not have a parent node');
}
parentNode.insertBefore(wrapper, element);
parentNode.removeChild(element);
wrapper.appendChild(element);
return wrapper;
}
export function unwrap(element: HTMLElement) {
const parent = element.parentNode as HTMLElement;
if (parent && parent !== document.body) {
parent.parentNode?.insertBefore(element, parent);
parent.parentNode?.removeChild(parent);
}
}
export function remove(elements: NodeListOf | Element | HTMLElement): void {
const elmArray = toArray(elements);
elmArray.forEach((element: Element | HTMLCollectionOf) => {
if (element instanceof Element && element.parentNode) {
element.parentNode.removeChild(element);
}
});
}
export function clamp(num: number, min: number, max: number): number {
return Math.min(Math.max(num, min), max);
}
/**
* Attaches event listener(s) to an element and returns a cleanup function
* Supports attaching multiple event types with a single handler
* @param element - DOM element to attach events to (can be Window, Document, or HTMLElement)
* @param events - Event name or array of event names (e.g., 'click' or ['touchstart', 'mousedown'])
* @param handler - Event handler function
* @returns Cleanup function that removes all attached event listeners
* @example
* ```typescript
* const removeListener = assignEvent(button, ['click', 'touchend'], handleClick);
* // Later: removeListener(); // Removes both event listeners
* ```
*/
export function assignEvent(
element: EventTarget,
events: K | K[],
handler: (event: HTMLElementEventMap[K]) => void,
): () => void;
export function assignEvent(
element: EventTarget,
events: string | string[],
handler: EventListener,
): () => void;
export function assignEvent(
element: EventTarget,
events: string | string[],
handler: EventListener,
): () => void {
const eventList = Array.isArray(events) ? events : [events];
// Use passive: false for wheel events to allow preventDefault
const options: AddEventListenerOptions | undefined = eventList.some((e) => e === 'wheel')
? { passive: false }
: undefined;
eventList.forEach((event) => element.addEventListener(event, handler, options));
return () => {
eventList.forEach((event) => element.removeEventListener(event, handler));
};
}
/**
* Calculates Euclidean distance between two touch points
* Used for pinch-to-zoom gesture detection
* @param touches - TouchList containing at least 2 touch points
* @returns Distance in pixels between first two touch points
* @example
* ```typescript
* element.addEventListener('touchmove', (e) => {
* if (e.touches.length === 2) {
* const distance = getTouchPointsDistance(e.touches);
* console.log('Pinch distance:', distance);
* }
* });
* ```
*/
export function getTouchPointsDistance(touches: TouchList): number {
const touch0 = touches[0];
const touch1 = touches[1];
return Math.sqrt(
Math.pow(touch1.pageX - touch0.pageX, 2) + Math.pow(touch1.pageY - touch0.pageY, 2),
);
}