import { LooseObject, flattenObject, debounce, deepEqual, copyGettersSetters } from './helpers.js'; import { HTMLMcElement, DC } from './MC.js'; import { trigger } from './events.js'; /** Determines if a value is an observable object. @param value - The value to check. @returns `true` if the value is a non-null object, otherwise `false`. */ function isObservableObject(value: any): boolean { return value != null && typeof value === 'object'; } type Callback = (value: any) => void; /** Extracts placeholder names from an HTML element's attributes and text content. Placeholders are strings enclosed in curly braces `{}`. This function scans all attributes and direct child text nodes of the target element for placeholders and returns them as an array of unique names (without braces). @param target - The HTMLElement to scan for placeholders. @returns An array of placeholder names found within the element's attributes and text content. */ function getPlaceholders(target: HTMLElement): string[] { let placeholders: Set = new Set(), regEx = /\{[^\{\}]*\}/g; // scan attributes (target.getAttributeNames ? target.getAttributeNames() : []) .filter((attr) => target.getAttribute(attr)?.match(regEx)) // extract placeholder(s) .forEach((attr) => { let attrPlaceholders: Set = new Set(target.getAttribute(attr)?.match(regEx)); attrPlaceholders.forEach((attrPlaceholder: string) => placeholders.add(attrPlaceholder.replace(/[\{\}]/g, '')) ); }); // scan children text nodes [...target.childNodes] .filter((childNode) => { return ( // is text node and has placeholders childNode.nodeType === 3 && ((childNode as any).nodeValue || '').match(regEx) ); }) .forEach((childNode) => { let textContent: string = childNode?.nodeValue || ''; let contentPlaceholders = textContent.match(regEx) as string[]; contentPlaceholders.forEach((textNodePlaceholder) => placeholders.add(textNodePlaceholder.replace(/[\{\}]/g, '')) ); }); return [...placeholders]; } const inputs = [HTMLInputElement, HTMLSelectElement, HTMLTextAreaElement]; /** A class that manages placeholders within an HTML element and updates them dynamically. This class extends `DC` (Data Controller) and processes the target element to identify placeholder patterns in attributes and text nodes. It then observes changes on its internal data object and updates the corresponding DOM locations when notified of changes. */ class Placeholders extends DC { data: LooseObject = {}; oldDisplay?: string = ''; /** Creates a new `Placeholders` instance for the given HTML element. Initializes the internal data with empty strings for each placeholder found in the target and starts observing the data object. Attaches callbacks to update placeholders as needed. @param target - The HTMLElement to process for placeholders. */ constructor(target: HTMLElement) { super(target); getPlaceholders(target).forEach((property) => (this.data[property] = '')); observe(this); this.#attachCallbacks(); } #attachCallbacks() { let target = this.target, hide: boolean = false; // scan attributes (target.getAttributeNames ? target.getAttributeNames() : []) .filter((attr: string) => target.getAttribute(attr)?.match(/\{[^\{\}]*\}/g)) // extract placeholder(s) .forEach((attr: string) => { let placeholders: Set = new Set(target.getAttribute(attr)?.match(/\{[^\{\}]*\}/g)); // attach callback this.data.subscribe( ((template: string) => (data: LooseObject) => { let result: string = template; placeholders.forEach((placeholder) => { result = result.replace( placeholder, // JSON.stringify(data[placeholder.replace(/[\{\}]/g, '')]) data[placeholder.replace(/[\{\}]/g, '')] ); }); target.setAttribute(attr, result); })(target.getAttribute(attr)) ); }); // scan children text nodes [...target.childNodes] .filter((childNode) => { return ( // is text node and has placeholders childNode.nodeType === 3 && ((childNode as Node).nodeValue || '').match(/\{[^\{\}]*\}/g) ); }) .forEach((childNode) => { hide = true; let placeholders: Set = new Set(childNode.nodeValue.match(/\{[^\{\}]*\}/g)); // attach callback this.data.subscribe( ((template: string) => (data: LooseObject) => { let result: string = template; placeholders.forEach((placeholder) => { result = result.replace( placeholder, // JSON.stringify(data[placeholder.replace(/[\{\}]/g, '')]) data[placeholder.replace(/[\{\}]/g, '')] ); }); if (this.oldDisplay) { target.style = this.oldDisplay; } else { target.removeAttribute('style'); } delete this.oldDisplay; childNode.nodeValue = result; })(childNode.nodeValue) ); // hide for now this.oldDisplay = target.getAttribute('style') || ''; // TBD allow null to indicate there was no old style attribute if ( hide && !inputs.includes((target as any).constructor) && target.tagName !== 'FIELDSET' ) { target.style.display = 'none'; } }); } /** Handles a `data` custom event to update the internal placeholder data. Extracts matching keys from the event's detail data and updates only those properties in the internal `data` object that were previously defined as placeholders. @param ev - The CustomEvent containing new data to merge into the placeholders. */ onData(ev: CustomEvent) { let data: LooseObject = {}; Object.keys(ev.detail.data).forEach((key) => { if (this.data.hasOwnProperty(key)) data[key] = ev.detail.data[key]; }); if (JSON.stringify(data) !== JSON.stringify(this.data)) Object.assign(this.data, data); // will trigger callbacks } } /** Finds all elements within a given scope that contain placeholders. @param target - The HTMLElement, DocumentFragment, or ShadowRoot to search. @returns An array of HTMLElements containing placeholder patterns. */ function getPlaceholderElements( target: HTMLElement | DocumentFragment | ShadowRoot ): HTMLElement[] { let elements: Array = [target] .concat([...(target.querySelectorAll('*') as unknown as HTMLElement[])]) .filter((e) => !!getPlaceholders(e as HTMLElement).length) .filter((e) => (e as HTMLElement)?.tagName !== 'STYLE') .reverse(); // to start with inner elements return elements as HTMLElement[]; } /** Applies placeholder support to an element and its descendants. This function scans the provided target for elements containing placeholders and attaches a `Placeholders` controller instance to each one if not already present. @param element - The HTMLElement, DocumentFragment, or ShadowRoot to process. */ function makePlaceholders(element: HTMLElement | DocumentFragment | ShadowRoot): void { getPlaceholderElements(element).forEach((e: HTMLElement) => { if ((e as HTMLMcElement)?._mc?._placeholders) return; DC.add(e, '_placeholders', new Placeholders(e)); }); } /** Interface representing an observable object that supports subscription, notification, and DOM binding. Observable objects track registered callbacks and invoke them when their internal state changes. They can also be bound to a DOM target to automatically update placeholder values in the view layer. */ export type Observable = { subscribe: (cb: Callback) => void; // Registers a callback function to be invoked when the observable is notified. notify: () => void; // Notifies all registered callbacks with the current observable data. bind: (target: HTMLElement | DocumentFragment | ShadowRoot) => void; // Binds the observable to a DOM target, setting up placeholder updates. callbacks: Array; // Array of registered callback functions. }; type Class = new (...args: any[]) => T; // repository for observable class wrappers const classRepo: LooseObject = {}; /** Creates a new observable wrapper class for the given value's constructor. Returns a subclass of the original constructor that implements the Observable interface, allowing observation and notification of changes. The new class is cached in `classRepo`. @param val - The value whose constructor should be wrapped with observable behavior. @returns A new Observable class that wraps the original constructor. */ function makeObservable(val: any): Class { const Constructor = val.constructor, ConstructorName: string = Constructor.name; let ObservableClass = class extends Constructor { #notify: boolean = true; #callbacks: Array = []; constructor(val: any) { super(val); (this.constructor as LooseObject).callbacks = []; if (isObservableObject(val)) { // you cannot bind native values to the dom Object.getOwnPropertyNames(val).forEach((key) => { this[key] = val[key]; }); } } get callbacks(): Array { return this.#callbacks; } set callbacks(callbacks: Array) { this.#callbacks = callbacks; } /** Registers a callback function to be invoked when the observable object is notified. @param callback - The function to invoke on notification. @returns This instance for chaining. */ subscribe(callback: Function) { this.#callbacks.push(callback); return this; } /** Binds the observable to a DOM target, setting up placeholder updates and triggering initial callbacks. Scans the target and its descendants for placeholders and registers handlers to update them with flattened data on notification. @param target - The HTMLElement, DocumentFragment, or ShadowRoot to bind. @returns This instance for chaining. */ bind(target: HTMLElement | DocumentFragment | ShadowRoot) { const iso = isObservableObject(this.valueOf()); if (iso) { makePlaceholders(target); const placeholders: unknown = [target, ...target.querySelectorAll('[data-mc]')].filter( (e) => !!(e as LooseObject)?._mc?._placeholders ); const func = function callback(data: LooseObject) { // avoid bubbling for speed let flattenedData = flattenObject(data); (placeholders as Node[]).forEach((node) => { // console.log('trigger callback', e); if (!node) return; trigger(node as HTMLMcElement, 'data', flattenedData); }); }; (this as any).#callbacks.push(func); this.notify(); } else { console.warn( 'IGNORED - cannot bind a non-Object to the dom, IS:', typeof this.valueOf(), this ); } return this; } /** Notifies registered callbacks with the current observable data. Clones the internal value (if it's an object and not an array) before passing to callbacks. Respects the internal `#notify` flag that can suppress notifications. @param notify - Optional boolean override for the notification state. @returns This instance for chaining. */ notify(notify?: boolean) { let data = isObservableObject(this.valueOf()) && this.valueOf() instanceof Array === false ? structuredClone(Object.assign({}, this)) : this.valueOf(); if (notify !== undefined) { this.#notify = notify; // return; } if (this.#notify) { (this as any).#callbacks.forEach((f: Function) => { f(data); }); } return this; } }; (classRepo as any)[ConstructorName] = ObservableClass; return ObservableClass; } /** Retrieves or creates an observable wrapper class for the given value. @param val - The value whose constructor should be wrapped with observable behavior. @returns A class that wraps the original constructor and implements observable functionality. */ function getConstructor(val: any): Class { let key: string = val.constructor.name; return classRepo[key] || makeObservable(val); } /** Makes a target object's properties observable by replacing them with wrapped values. Wraps each property of `target` that is not already observable (and meets criteria) in an observable wrapper. Also sets up deep change detection for objects and debounced notifications when the data changes. @param target - The LooseObject whose properties should be made observable. @param propertyName - Optional specific property name to make one property observable instead of all properties. @returns The original target object, now with observable properties. */ export const observe = (target: LooseObject, propertyName?: string): LooseObject => { let keys = !propertyName ? [...Object.getOwnPropertyNames(target)] : [propertyName]; keys.forEach((key) => { if ( target.hasOwnProperty(key) && typeof target[key] !== 'function' && !target?.[key]?.constructor?.prototype?.subscribe ) { if (key[0] === '_' || target?.[key] === undefined) return target; let val: any = target[key]; let o = new (getConstructor(val) as Class)(val); let debouncedNotify: Function = debounce(function debouncedNotify() { o.notify(); }, 0); Object.defineProperty(target, key, { enumerable: true, get() { if (isObservableObject(o.valueOf())) { let check = structuredClone(Object.assign({}, o)); setTimeout(function checkChanges() { let state = structuredClone(Object.assign({}, o)); if (!deepEqual(state, check)) { debouncedNotify(); } }, 0); } return o; }, set(val: any) { if (typeof val === typeof o.valueOf()) { let iso = isObservableObject(o.valueOf()) && o.valueOf() instanceof Array === false; if (iso) { // is observable object if (o?.constructor?.prototype?.subscribe === undefined) { // not observed yet o = new (getConstructor(val) as Class)( structuredClone(Object.assign({}, val)) ); copyGettersSetters(val, o); //o.seal(); } Object.assign(o, val); // mixin set object into observable object o.notify(); // dont test, just assume "changed" status } else { // simple native var let old = o.valueOf(), callbacks = (o as Observable).callbacks; if (val !== old) { o = new (getConstructor(val) as Class)(val); (o as Observable).callbacks = callbacks; debouncedNotify(); } } } else { // set type is not stored type console.warn( 'IGNORED - cannot change observable types, IS:', typeof o.valueOf(), o, '- CANNOT BECOME:', typeof val ); } }, }); target[key] = val; // replace old property with proxy (observable) } }); return target; };