1 /* RetroV - A retro-style "vanilla JS" VDOM template library.
     2  * MIT License - Copyright 2023-2024 David Gauer (ratfactor.com)
     3  */
     4 (function RetroV(){
     5     var create_callbacks = [];
     6 
     7     function user_render(dom_container, v){
     8         // Normalize v to a list. We'll flatten if needed.
     9         var vlist = [v];
    10 
    11         // Convert the user array to internal object tree representation.
    12         var old_vlist = []; // default is empty list
    13         var new_vlist = make_children(vlist);
    14 
    15         if(dom_container.rv_old_vlist){
    16             // We've been here before, load old vnode tree
    17             old_vlist = dom_container.rv_old_vlist;
    18         }
    19         else
    20         {
    21             // First time here, clear this DOM element
    22             dom_container.replaceChildren();
    23         }
    24 
    25         // Save the new one as next run's "old"
    26         dom_container.rv_old_vlist = new_vlist;
    27 
    28         // Call internal render function with old/new.
    29         render_children(dom_container, old_vlist, new_vlist);
    30 
    31         // If any 'oncreate' functions were found, call
    32         // them now:
    33         create_callbacks.forEach(function(cc){
    34             cc.fn(cc.el);
    35         });
    36         create_callbacks = [];
    37     }
    38 
    39     function render(dom_container, old_v, new_v, dom_index){
    40         //   old | new | Action
    41         //  -----+-----+-----------------------------------------
    42         //    A  |     | Remove old
    43         //    *  |false| Boolean 'false' means "no change"
    44         //       |  A  | Append to container_dom
    45         //    B  |  A  | Replace old with new
    46         //    A  |  A  | update() old with new props and children
    47 
    48         var child = dom_container.childNodes[dom_index];
    49 
    50         if(typeof new_v === 'undefined'){
    51             // No new node here, we must be a sibling that has been removed.
    52             // Note that an undefined *placeholder* node is "!" at this point.
    53             dom_container.removeChild(child);
    54             return;
    55         }
    56 
    57         if(new_v.t === false){
    58             if(typeof old_v === 'undefined'){
    59                 // This false didn't exist before
    60                 dom_container.append(placeholder('false'));
    61             }
    62             // Otherwise, completely ignore node
    63             return;
    64         }
    65 
    66         if(typeof old_v === 'undefined'){
    67             // No old node here, append new to container
    68             dom_container.appendChild(create(new_v));
    69             return;
    70         }
    71 
    72         if(old_v.t !== new_v.t){
    73             // Different types, replace old with new
    74             child.replaceWith(create(new_v));
    75             return;
    76         }
    77 
    78         // null or undefined placeholder updated with same, nothing to do
    79         if(!new_v.t || new_v.t === '!'){
    80             return;
    81         }
    82 
    83         // They must be the same type, update props and children
    84         update(child, old_v, new_v);
    85     }
    86 
    87     function create(v){
    88         if(v.t === '"'){
    89             return document.createTextNode(v.text);
    90         } 
    91 
    92         if(v.t === null){
    93             return placeholder('null');
    94         }
    95 
    96         if(v.t === false){
    97             return placeholder('false');
    98         }
    99 
   100         if(v.t === '!'){
   101             return placeholder('undefined');
   102         }
   103 
   104         if(v.t === '<'){
   105             // Verbatim HTML as a string to render
   106             return create_html(v.html);
   107         }
   108 
   109         // Else we're creating a normal element
   110         try{
   111             var el = document.createElement(v.t);
   112         } catch(e) {
   113             if(e instanceof DOMException){
   114                 console.error("RetroV: Bad element name: ", v.t);
   115             }
   116             throw e;
   117         }
   118 
   119         // Set new element props
   120         Object.keys(v.p).forEach(function(k){
   121             if(k === 'style'){
   122                 // Special handling for style property
   123                 set_or_update_style(el, {}, v);
   124                 return;
   125             }
   126             if(k === 'for'){
   127                 // Special handling of label 'for'
   128                 el['htmlFor'] = v.p['for'];
   129                 return;
   130             }
   131             if(k === 'rvid'){
   132                 // Special handling of "RV id" property
   133                 RV.id[v.p[k]] = el;
   134             }
   135             if(k === 'oncreate' && typeof v.p[k] === 'function'){
   136                 // Special pseudo-event: we're creating this.
   137                 // Pass reference to new element.
   138                 create_callbacks.push({el:el,fn:v.p[k]});
   139                 return;
   140             }
   141             el[k] = v.p[k];
   142         });
   143 
   144         // Append any children to new element
   145         v.c.forEach(function(child){
   146             el.append(create(child));
   147         });
   148 
   149         return el;
   150     }
   151 
   152     function create_html(html){
   153         // Make temporary container
   154         var d = document.createElement('div');
   155         d.innerHTML = html;
   156 
   157         // Note that this can only return ONE element created from
   158         // the HTML string. Sure, we could return an array, but then
   159         // our DOM child count would no longer line up with the
   160         // virtual tree and the program would get sad and explode.
   161         return d.childNodes[0];
   162     }
   163 
   164     function placeholder(t){
   165         return document.createComment('RV:' + t + '-placeholder');
   166     }
   167 
   168     function set_or_update_style(dom_elem, old_v, new_v){
   169         var old_style = (old_v.p && old_v.p.style ? old_v.p.style : {});
   170         Object.keys(new_v.p.style).forEach(function(sk){
   171             if(new_v.p.style[sk] != old_style[sk]){
   172                 dom_elem.style[sk] = new_v.p.style[sk];
   173             }
   174         });
   175     }
   176 
   177     function update(dom_elem, old_v, new_v){
   178         // Text nodes just have data
   179         if(new_v.t === '"'){
   180             dom_elem.data = new_v.text;
   181             return;
   182         }
   183 
   184         // Update raw HTML only if it has changed
   185         if(new_v.t === '<' && new_v.html !== old_v.html){
   186             dom_elem.replaceWith(create_html(new_v.html));
   187             return;
   188         }
   189 
   190         // Update element props
   191         Object.keys(new_v.p).forEach(function(k){
   192             if(k === 'style'){
   193                 // Special handling for style property
   194                 set_or_update_style(dom_elem, old_v, new_v);
   195                 return;
   196             }
   197             if(k === 'value' || k === 'checked'){
   198                 // Special - we *always* update form element values
   199                 dom_elem[k] = new_v.p[k];
   200                 return;
   201             }
   202             if(new_v.p[k] !== old_v.p[k]){
   203                 dom_elem[k] = new_v.p[k];
   204             }
   205         });
   206 
   207         // Now recurse into element children
   208         render_children(dom_elem, old_v.c, new_v.c);
   209     }
   210 
   211     function render_children(dom_elem, old_c, new_c){
   212         if(old_c.length > new_c.length){
   213             // If we'll be removing, we need to go in *reverse*!
   214             for(var i=old_c.length-1; i>=0; i--){
   215                 render(dom_elem, old_c[i], new_c[i], i);
   216             }
   217         }
   218         else{
   219             for(var i=0; i<new_c.length; i++){
   220                 render(dom_elem, old_c[i], new_c[i], i);
   221             }
   222         }
   223     }
   224 
   225     function make_obj(v){
   226         // Turn everything into a {t:<type>,...} object so we can
   227         // easily compare t between objects later.
   228         // Note: make_obj() and make_children() are co-recursive.
   229         // Types (t):
   230         //   '<'   = verbatim HTML
   231         //   false = special "leave me alone" node
   232         //   '!'   = undefined
   233         //   (any string) = HTML tag name
   234         if(typeof v === 'string' || typeof v === 'number'){
   235             return {t:'"', text:v};
   236         }
   237         if(Array.isArray(v) && typeof v[0] === 'string' && v[0][0] === '<'){
   238             return {t:'<', html:v};
   239         }
   240         if(v === null){
   241             return {t:null};
   242         }
   243         if(v === false){
   244             return {t:false};
   245         }
   246         if(v === undefined){
   247             return {t:'!'};
   248         }
   249         if(Array.isArray(v) && typeof v[0] === 'string'){
   250             // This is a regular element vnode.
   251             var child_start = 1;
   252             var props = {};
   253             if(v[1] && typeof v[1] === 'object' && !Array.isArray(v[1])){
   254                 props = v[1];
   255                 if(typeof props['class'] !== 'undefined'){
   256                     props['className'] = props['class'];
   257                     delete props['class'];
   258                 }
   259                 if(typeof props['for'] !== 'undefined'){
   260                     props['htmlFor'] = props['for'];
   261                     delete props['for'];
   262                 }
   263                 child_start = 2;
   264             }
   265 
   266             // Decode classes (e.g. "div.message.bold"):
   267             var tag = v[0];
   268             var myclasses = [];
   269             var m = tag.split('.');
   270             var mytag = m.shift();
   271             if(mytag.length === 0) {
   272               mytag = 'div';
   273             }
   274             m.forEach(function (v) {
   275                 myclasses.push(v);
   276             });
   277             if(myclasses.length > 0){
   278                 // append to className (if that was defined as prop already)
   279                 props.className = props.className ?
   280                     props.className + ' ' + myclasses.join(' ')
   281                     : myclasses.join(' ');
   282             }
   283 
   284             var children = make_children(v.slice(child_start));
   285 
   286             return {t:mytag, p:props, c:children};
   287         }
   288 
   289         console.error("RetroV: make_obj() cannot handle this:",v);
   290     }
   291 
   292     function make_children(vlist){
   293         var objs = [];
   294         vlist.forEach(function(v){
   295             if(!Array.isArray(v) || typeof v[0] === 'string'){
   296                 objs.push(make_obj(v));
   297             }
   298             else{
   299                 // This is a list in our list! Flatten by making
   300                 // these children recursively and adding them.
   301                 make_children(v).forEach(function(flat_v){
   302                     objs.push(flat_v);
   303                 });
   304             }
   305         });
   306         return objs;
   307     }
   308 
   309     // Export interface object
   310     window.RV = { render: user_render, id: [] };
   311 })();