@twobirds/microcomponents
Version:
Micro Components Organization Class
422 lines (368 loc) • 11.2 kB
text/typescript
;
import {
LooseObject,
debounce,
deepEqual,
flattenObject,
copyGettersSetters,
} from './helpers.js';
import { DC, HTMLMcElement, McEvent } from './MC.js';
function isObservableObject(value: any): boolean {
return value != null && typeof value === 'object';
}
type Callback = (value: any) => void;
function getPlaceholders(target: HTMLElement): string[] {
let placeholders: Set<string> = new Set(),
regEx = /\{[^\{\}]*\}/g;
// scan attributes
target
.getAttributeNames()
.filter((attr) => target.getAttribute(attr)?.match(regEx)) // extract placeholder(s)
.forEach((attr) => {
let phs: Set<string> = new Set(target.getAttribute(attr)?.match(regEx));
phs.forEach((placeholder) =>
placeholders.add(placeholder.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) => {
((childNode as any).nodeValue || '')
.match(regEx)
.forEach((placeholder: string) => {
placeholders.add(placeholder.replace(/[\{\}]/g, ''));
});
});
// extract {} and return result
return [...placeholders].map((placeholder) =>
placeholder.replace(/[\{\}]/g, '')
);
}
const inputs = [HTMLInputElement, HTMLSelectElement, HTMLTextAreaElement];
class Placeholders extends DC {
data: LooseObject = {};
oldDisplay?: string = '';
constructor(target: HTMLElement) {
super(target);
let that = this;
getPlaceholders(target).forEach((property) => (that.data[property] = ''));
observe(that);
that.#attachCallbacks();
delete that._mc; // cleanup, not needed
}
#attachCallbacks() {
let that = this,
target = that.target,
hide: boolean = false;
//console.log('attachCallbacks', that.target);
// scan attributes
target
.getAttributeNames()
.filter((attr: string) =>
target.getAttribute(attr)?.match(/\{[^\{\}]*\}/g)
) // extract placeholder(s)
.forEach((attr: string) => {
let phs: Set<string> = new Set(
target.getAttribute(attr)?.match(/\{[^\{\}]*\}/g)
);
// attach callback
that.data.observe(
((template: string) => (data: LooseObject) => {
let result: string = template;
phs.forEach((placeholder) => {
result = result.replace(
placeholder,
data[placeholder.replace(/[\{\}]/g, '')]
);
});
target.setAttribute(attr, result);
//console.log('cb setAttribute()', data, template, 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 any).nodeValue || '').match(/\{[^\{\}]*\}/g)
);
})
.forEach((childNode) => {
hide = true;
let phs: Set<string> = new Set(
childNode.nodeValue.match(/\{[^\{\}]*\}/g)
);
// attach callback
that.data.observe(
((template: string) => (data: LooseObject) => {
let result: string = template;
//console.log('text node "', template, '" callbach data:', data);
phs.forEach((placeholder) => {
result = result.replace(
placeholder,
data[placeholder.replace(/[\{\}]/g, '')]
);
});
if (
Object.keys(data).some((key) => {
return !!data[key];
})
) {
if (!!that.oldDisplay) {
target.style = that.oldDisplay;
} else {
target.removeAttribute('style');
}
delete that.oldDisplay;
}
childNode.nodeValue = result;
})(childNode.nodeValue)
);
// hide for now
that.oldDisplay = target.getAttribute('style)') || '';
if (
hide &&
!inputs.includes((target as any).constructor) &&
target.tagName !== 'FIELDSET'
) {
target.style.display = 'none';
}
});
}
onData(ev: McEvent) {
let that = this,
data: LooseObject = {};
Object.keys(ev.data).forEach((key) => {
if (that.data.hasOwnProperty(key)) data[key] = ev.data[key];
});
//console.log('onData()', ev, data, that.data);
if (JSON.stringify(data) !== JSON.stringify(that.data))
Object.assign(that.data, data); // will trigger callbacks
}
}
function getPlaceholderElements(
target: HTMLElement | DocumentFragment | ShadowRoot
): HTMLElement[] {
let elements: Array<HTMLElement> = (
target instanceof HTMLElement ? [target] : []
)
.concat([...(target.querySelectorAll('*') as unknown as HTMLElement[])])
.filter((e) => !!getPlaceholders(e as HTMLElement).length)
.filter((e) => e.tagName !== 'STYLE');
//console.log('getPlaceholderElements', elements);
return elements as HTMLElement[];
}
function makePlaceholders(
element: HTMLElement | DocumentFragment | ShadowRoot
): void {
//console.log('makePlaceholders', element);
getPlaceholderElements(element).forEach((e: HTMLElement) => {
if ((e as HTMLMcElement)?._mc?._placeholders) return;
DC.add(e, '_placeholders', new Placeholders(e));
});
}
export type Observable = {
observe: (cb: Callback) => void;
notify: () => void;
bind: (target: HTMLElement | DocumentFragment | ShadowRoot ) => void;
callbacks: Array<Function>;
};
type Class<T> = new (...args: any[]) => T;
// repository for observable class wrappers
const classRepo: LooseObject = {};
// creates a new observable class wrapper
function makeObservable(val: any): Class<typeof val>{
const Constructor = val.constructor,
ConstructorName: string = Constructor.name;
let ObservableClass = class extends Constructor {
#notify: boolean = true;
#callbacks: Array<Function> = [];
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<Function> {
return this.#callbacks
}
set callbacks( callbacks: Array<Function> ){
this.#callbacks = callbacks
}
observe(f: Function) {
//console.log('observe');
this.#callbacks.push(f);
return this;
}
bind(target: HTMLElement | DocumentFragment | ShadowRoot) {
const that = this,
iso = isObservableObject(that.valueOf());
//console.log('bind', iso, target);
if (iso) {
makePlaceholders(target);
const placeholders: unknown = [
...target.querySelectorAll('[_mc]'),
].filter((e) => !!(e as LooseObject)?._mc?._placeholders);
//console.log('placeholders:', target, placeholders);
const func = function callback(data: LooseObject) {
// avoid bubbling for speed
let d = flattenObject(data);
//console.log('inside callback', target, d);
(placeholders as DC[]).forEach((e) => {
// console.log('trigger callback', e);
if (!e) return;
(e as any)._mc.trigger('data', d);
});
};
(that as any).#callbacks.push(func);
that.notify();
} else {
console.warn(
'IGNORED - cannot bind a non-Object to the dom, IS:',
typeof that.valueOf(),
that
);
}
return this;
}
notify(notify?: boolean) {
let that = this,
data =
isObservableObject(that.valueOf()) &&
that.valueOf() instanceof Array === false
? structuredClone(Object.assign({}, that))
: that.valueOf();
if (notify !== undefined) {
that.#notify = notify;
// return;
}
//if (that.#notify) console.log('notify', this, data);
(that as any).#callbacks.forEach((f: Function) => {
// console.log(
// (this.constructor as any).callbacks,
// 'notify: exec callback',
// f,
// data
// );
f(data);
});
return that;
}
};
(classRepo as any)[ConstructorName] = ObservableClass;
// console.log( 'classRepo:', classRepo );
return ObservableClass;
}
function getConstructor( val: any ): Class<typeof val> {
let key: string = val.constructor.name;
return classRepo[key] || makeObservable( val );
}
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?.observe
) {
// console.log( 'co propname', key, typeof target[key], target[key] );
if (key[0] === '_' || target?.[key] === undefined) return target;
let val: any = target[key];
let o = new ( getConstructor(val) as Class<typeof val> )( val );
Object.defineProperty(target, key, {
enumerable: true,
get() {
if (isObservableObject(o.valueOf())) {
let check = structuredClone(Object.assign({}, o));
setTimeout(
debounce(function checkChanges() {
let state = structuredClone(Object.assign({}, o));
//console.log( 'check changes', state, check );
if (!deepEqual(state, check)) {
//console.log( '-> not equal!' );
o.notify();
}
}, 0),
0
);
}
return o;
},
set(val: any) {
//console.log( 'SET:', isObservableObject( val.valueOf() ), 'set:', val, 'to', isObservableObject( o.valueOf() ), o, );
if (typeof val === typeof o.valueOf()) {
let iso =
isObservableObject(o.valueOf()) &&
o.valueOf() instanceof Array === false;
//console.log('target iso:', iso);
if (iso) {
let old = structuredClone(Object.assign({}, o));
//val = structuredClone(Object.assign({}, val));
//let callbacks = (o.constructor as LooseObject).callbacks;
if (o?.constructor?.prototype?.observe === undefined ) {
o = new ( getConstructor(val) as Class<typeof val> )( structuredClone(Object.assign({}, val)) );
copyGettersSetters(val, o);
o.seal();
}
Object.assign(o, val);
// console.log('after set o=', o, 'check', old);
// (o.constructor as LooseObject).callbacks = callbacks;
setTimeout(
// check for changes
debounce(function checkChanges() {
let state = structuredClone(Object.assign({}, o));
//console.log( 'check changes', state, check );
if (!deepEqual(state, old)) {
//console.log( '-> not equal!' );
o.notify();
}
}, 0),
0
);
} else {
// simple native var
let old = o.valueOf(),
callbacks = (o as Observable).callbacks;
// console.log( 'sO', o, o.valueOf(), 'val', val );
if (val !== old) {
// console.log( '-> sO new val', val );
o = new ( getConstructor(val) as Class<typeof val> )( val );
(o as Observable).callbacks = callbacks;
o.notify();
}
}
} else {
console.warn(
'IGNORED - cannot change observable types, IS:',
typeof o.valueOf(),
o,
'- CANNOT BECOME:',
typeof val
);
}
},
});
// replace old property with proxy (observable)
target[key] = val;
};
});
return target;
};