UNPKG

cthulhu-rlyeh

Version:

DOM manipulation based on cthulhu node architecture

494 lines (376 loc) 13.9 kB
import { Cthulhu } from 'cthulhu'; import { pascalOrCamelToKebab } from '@keltoi/naming-converting'; var tagBuffer = []; const tagBufferGetAndPush = (prop='') =>{ if (!tagBuffer[prop]) tagBuffer[prop] = pascalOrCamelToKebab(prop); return tagBuffer[prop] }; const cloneByEntry = (obj) => { const copy = {}; const isObjectOrValue = (v) => v instanceof Function ? v : v instanceof Object ? cloneByEntry(v) : v; Object .entries(obj) .forEach(([key, value]) => copy[key] = value instanceof Array ? value.map(isObjectOrValue) : isObjectOrValue(value) ); return copy }; const toMap = (o) => new Map(Object.entries(o)); class Doom extends Cthulhu{ #self; #old; #toRemove=false; #root=false; #rendered = false get root(){return this.#root} set root(value=false){this.#root = value;} get isRendered(){ return this.#rendered } //if the element has been rendered get isVirgin(){ return !this.#old } //if the element has never been built get isDeleted(){ return this.#toRemove } //if the element has been deleted constructor(me){ super(Doom,me,"content","attributes","events","styleProps","props","nsuri","ai"); } static async $(tag='',me){ const doomInstance = await (new Doom(me)).build(tag); doomInstance.root=true; return doomInstance; } static template(me={}){ return Doom.$('template',me) } static async compare({ oldMap=new Map(), newMap=new Map(), set=(key,val)=>{}, remove=(key,val)=>{} }){ newMap.forEach((val,key)=>{ if (oldMap.has(key)){ if (oldMap.get(key)!=val) set(key,val); oldMap.delete(key); } else set(key,val); }); oldMap.forEach((val,key)=>remove(key,val)); } #addAiComment = async (ai='',node = document.createElement())=>{ const comment = document .createComment(`AI:::${ai}`); node.appendChild(comment); } #setAttributes = async (node=document.createElement())=>{ const newMap = toMap(this.attributes); if (this.#old?.has('attributes')){ const oldMap = toMap(this.#old.get('attributes')); await Doom.compare({ oldMap, newMap, set:(key,val)=>node.setAttribute(pascalOrCamelToKebab(key),val), remove:(key)=>{ if (key!='style') node.removeAttribute(pascalOrCamelToKebab(key)); } }); } else newMap.forEach((val,attr)=>node.setAttribute(pascalOrCamelToKebab(attr),val)); } #setEvents= async(node=document.createElement())=>{ const newMap = toMap(this.events); if (this.#old?.has('events')){ const oldMap = toMap(this.#old.get('events')); await Doom.compare({ oldMap, newMap, set:(key,val)=>{ val.forEach(e=>node.addEventListener(key,e)); }, remove:(key,val)=>{ try{ val.forEach(e=>node.removeEventListener(key,e)); } finally { return } } }); } else newMap.forEach((val,eve)=>val.forEach(e=>node.addEventListener(eve,e))); } #setStyle= async (node=document.createElement())=>{ const newMap = toMap(this.styleProps); if (this.#old?.has('styleProps')){ const oldMap = toMap(this.#old.get('styleProps')); await Doom.compare({ oldMap, newMap, set:(key,val)=>node.style[key]=val, remove:(key)=>node.style[key]='' }); } else newMap.forEach((val,sty)=>node.style[sty]=val); } #setContent= async(node=document.createElement())=>{ if (this.#old?.has('content')){ if (this.#old.get('content')!==this.content){ node.innerHTML = this.content; } } else node.innerHTML = this.content; } #inner=(e, update = false)=>{ const self = new Map(Object.entries(this)); let children=[]; let structure = []; let garbage = []; self.forEach((element,prop)=>{ switch(prop){ case 'attributes':structure.push(this.#setAttributes(e));break; case 'events':structure.push(this.#setEvents(e));break; case 'styleProps':structure.push(this.#setStyle(e));break; case 'content':structure.push(this.#setContent(e));break; case 'nsuri':this.nsuri=element ;break; case 'ai': this.#addAiComment(element,e);break; case 'props': break; default:{ const tag = tagBufferGetAndPush(prop); if (element instanceof Array){ children = children .concat( element .map((nest,i)=>{ if (!(nest instanceof Doom)) { nest = new Doom(nest); this[prop][i] = nest; } if (nest.isVirgin) return nest.build(tag) if (update && !nest.isDeleted) return nest.build(null,true) else if (nest.isDeleted) garbage.push(()=> { nest.removeFrom(e); this[prop].splice(i,1); }); return nest }) ); } else if (element instanceof Doom) { if (element.isVirgin) element = element.build( (element.root ?? false) ? null : tag ); else if (update && !element.isDeleted) element = element.build(null,true); else if (element.isDeleted) garbage.push(()=> { element.removeFrom(e); delete this[prop]; }); children.push(element); } else { const newOne = new Doom(element); this[prop] = newOne; children.push(newOne.build(tag)); } } } }); return {children,structure,garbage} } delete(){ this.#rendered = false; this.#toRemove = true; } #raiseElement(name){ if (!name) return this.#self const element = this.nsuri ?document.createElementNS(this.nsuri,name) :document.createElement(name); return name==='template' ? element.content : element } async build(name='div'|null, update =false){ if (this.#toRemove) return this const e = this.#raiseElement(name); const {children, structure, garbage} = this.#inner(e, update); await Promise.all(structure); const childList = await Promise.all(children); childList .filter(child=>!child.isRendered) .forEach(child=>child.renderOn(e)); garbage .forEach(dispose=>dispose()); this.#self = e; const oldBoy = cloneByEntry(this); this.#old = new Map(Object.entries(oldBoy)); return this; } removeFrom(parent=document.createElement()){ if (parent.contains(this.#self)) parent.removeChild(this.#self); } renderOn(parent=document.createElement()){ parent.appendChild(this.#self); this.#rendered = true; } appendChild(child = new Doom()){ child.renderOn(this.#self); } fire(event = new CustomEvent()){ this.#self.dispatchEvent(event); } focus(){ this.#self.focus(); } blur(){ this.#self.blur(); } } const changing = (hook=()=>new Doom())=> hook().build(null, true); class NodeWrapper { #self constructor(node = new Doom()){ this.#self = node; } clean(child=(n=new Doom())=>new Doom()){ child(this.#self).delete(); return this } cleanMany(children=(n=new Doom())=>[new Doom()]){ children(this.#self).forEach(child=>child.delete()); return this } update(change=(n=new Doom())=>{}){ change(this.#self); return this } conciliate=()=>changing(()=>this.#self).then(()=>this) } const use = (node = new Doom())=> new NodeWrapper(node); const routerSlot = (node = new Doom(), child = new Doom()) => use(node.routerSlot) .clean(e=>e.main) .conciliate() .then(u=>u .update(e=>e.main = { child }) .conciliate() ); class Router{ #route static App(router = new Router(),update=(child)=>{}){ window.addEventListener('popstate',(event)=>{ if (event.state) Router .go('browser',router) .then(update); }); } constructor(route={}){ this.#route = route; } matchUrl = () => this.match(window.location.pathname,window.location.search) notFound=()=>Doom.$( 'not-found', { h1:{ content:'Page Not Found' }, p:{ content:'404' } } ) #matching=(link='')=>{ for (const [r,v] of Object.entries(this.#route)){ const paramSearch = /\:[a-zA-Z]+/g; const search = r.replaceAll(paramSearch,'[A-Za-z0-9]+'); const matcher = new RegExp(search); if (matcher.test(link)) return { paramSearch, matcher, route:r, value:v } } return null } match(link='',search=''){ const destination = `${window.location.protocol}//${window.location.host}${link}`; const match = this.#matching(link); if (match){ const linkTokens = link.split('/'); const routeTokens = match.route.split('/'); const params = routeTokens .reduce((p,a,i)=>{ if (match.paramSearch.test(a)) { const param = decodeURI(linkTokens[i]); const numParam = Number.parseFloat(param); p[a.replace(':','')] = Number.isNaN(numParam) ? param : numParam; } return p },{}); const query = (search==='') ?{} :search .replace('?','') .split('&') .reduce((p,a)=>{ const [key,value] = a.split('='); p[key]=value; return p },{}); history.pushState({},'',destination); return match.value({query,params}) } return this.notFound() } static go(url='',router = new Router()){ const [link,search] = url.split('?'); const match = url==='browser' ? router.matchUrl() : router.match(link,search); return (match instanceof Router) ? Router.go(url,match) : match } } class BaseElement extends HTMLElement{ constructor(){ super(); } attributeChangedCallback(name, oldValue, newValue) { if (oldValue === newValue) return this[name] = newValue; } } class CthulhuElement extends BaseElement{ #dom #doom constructor({ doom = async () => new Doom(), css= [Promise.resolve(new CSSStyleSheet)] }){ super(); this.#dom = this.attachShadow({mode:'open'}); const render = () => doom() .then(d=>{ d.renderOn(this.#dom); this.#doom = d; }); if (!!css) Promise .all(css) .then(sheets=> this .#dom .adoptedStyleSheets = sheets ) .then(render); else render(); } get doom(){ return this.#doom } } class TextElement extends BaseElement{ constructor(template = ''){ super(); const shadow = this.attachShadow({mode:'open'}); shadow.innerHTML = template; } } const css = (text)=>new CSSStyleSheet().replace(text); const naming=(type=Doom)=>pascalOrCamelToKebab(type.name); export { BaseElement, CthulhuElement, Doom, Router, TextElement, changing, css, naming, routerSlot, use };