Skip to content

Commit

Permalink
feat(hdom): generalize diffElement() (#4)
Browse files Browse the repository at this point in the history
- add HDOMOps interface
- add DEFAULT_OPS implementation
- update diffElement() & diffAttributes() to delegate to ops
- refactor diffElement() to be more legible
- update createDOM(), add support for `__ops` node attrib
  to use custom ops for subtrees
- add getChild(), replaceChild(), setContent()
  • Loading branch information
postspectacular committed Sep 10, 2018
1 parent 5cb4350 commit 525d90d
Show file tree
Hide file tree
Showing 3 changed files with 130 additions and 55 deletions.
15 changes: 15 additions & 0 deletions packages/hdom/src/api.ts
Expand Up @@ -51,4 +51,19 @@ export interface HDOMOpts {
normalize?: boolean;
}

/**
* This interface defines the underlying DOM update operations used by
* `diffElement()`. See `DEFAULT_OPS` (diff.ts) for the default
* implementations.
*/
export interface HDOMOps<T> {
createTree(element: T, tree: any, insert?: number): T | T[];
getChild(element: T, child: number): T;
removeChild(element: T, child: number);
replaceChild(element: T, child: number, newTree: any);
setAttrib(element: T, id: string, value: any, attribs?: any);
removeAttribs(element: T, attribs: string[], prevAttribs: any);
setContent(element: T, value: any);
}

export const DEBUG = false;
150 changes: 97 additions & 53 deletions packages/hdom/src/diff.ts
@@ -1,19 +1,35 @@
import { SEMAPHORE } from "@thi.ng/api/api";
import { IObjectOf, SEMAPHORE } from "@thi.ng/api/api";
import * as isa from "@thi.ng/checks/is-array";
import * as iss from "@thi.ng/checks/is-string";
import { DiffLogEntry } from "@thi.ng/diff/api";
import { diffArray } from "@thi.ng/diff/array";
import { diffObject } from "@thi.ng/diff/object";
import { equiv } from "@thi.ng/equiv";

import { HDOMOps } from "./api";
import {
createDOM,
getChild,
removeAttribs,
removeChild,
setAttrib
replaceChild,
setAttrib,
setContent,
} from "./dom";

const isArray = isa.isArray;
const isString = iss.isString;
const max = Math.max;

const DEFAULT_OPS: HDOMOps<any> = {
createTree: createDOM,
getChild,
replaceChild,
removeChild,
setContent,
removeAttribs,
setAttrib,
};

/**
* Takes a DOM root element and two hiccup trees, `prev` and `curr`.
Expand All @@ -33,139 +49,167 @@ const isString = iss.isString;
* @param root
* @param prev previous tree
* @param curr current tree
* @param ops hdom implementation
*/
export const diffElement = (root: Element, prev: any, curr: any) =>
_diffElement(root, prev, curr, 0);
export const diffElement = <T>(
root: T,
prev: any[],
curr: any[],
ops: HDOMOps<T> = DEFAULT_OPS) =>
_diffElement(ops, root, prev, curr, 0);

const _diffElement = <T>(
ops: HDOMOps<T>,
parent: T,
prev: any[],
curr: any[],
child: number) => {

const _diffElement = (parent: Element, prev: any, curr: any, child: number) => {
if (curr[1].__diff === false) {
releaseDeep(prev);
removeChild(parent, child);
createDOM(parent, curr, child);
ops.replaceChild(parent, child, curr);
return;
}
// TODO use optimized equiv?
const delta = diffArray(prev, curr, equiv, true);
if (delta.distance === 0) {
return;
}
const edits = delta.linear;
const el = parent.children[child];
let i, j, k, eq, e, status, idx, val;
if (edits[0][0] !== 0 || (i = prev[1]).key !== (j = curr[1]).key) {
const el = ops.getChild(parent, child);
let i: number;
let j: number;
let idx: number;
let k: string;
let eq: any[];
let e: DiffLogEntry<any>;
let status: number;
let val: any;
if (edits[0][0] !== 0 || prev[1].key !== curr[1].key) {
// DEBUG && console.log("replace:", prev, curr);
releaseDeep(prev);
removeChild(parent, child);
createDOM(parent, curr, child);
ops.replaceChild(parent, child, curr);
return;
}
if ((i = prev.__release) && i !== curr.__release) {
if ((val = (<any>prev).__release) && val !== (<any>curr).__release) {
releaseDeep(prev);
}
if (edits[1][0] !== 0) {
diffAttributes(el, prev[1], curr[1]);
diffAttributes(ops, el, prev[1], curr[1]);
}
const equivKeys = extractEquivElements(edits);
const n = edits.length;
const noff = prev.length - 1;
const offsets = new Array(noff + 1);
for (i = noff; i >= 2; i--) {
const numEdits = edits.length;
const prevLength = prev.length - 1;
const offsets = new Array(prevLength + 1);
for (i = prevLength; i >= 2; i--) {
offsets[i] = i - 2;
}
for (i = 2; i < n; i++) {
e = edits[i], status = e[0], val = e[2];
for (i = 2; i < numEdits; i++) {
e = edits[i];
status = e[0];
val = e[2];

// DEBUG && console.log(`edit: o:[${offsets.toString()}] i:${idx} s:${status}`, val);

// element removed?
if (status === -1) {
if (isArray(val)) {
k = val[1].key;
if (k !== undefined && equivKeys[k][2] !== undefined) {
eq = equivKeys[k];
k = eq[0];
// DEBUG && console.log(`diff equiv key @ ${k}:`, prev[k], curr[eq[2]]);
_diffElement(el, prev[k], curr[eq[2]], offsets[k]);
_diffElement(ops, el, prev[k], curr[eq[2]], offsets[k]);
} else {
idx = e[1];
// DEBUG && console.log("remove @", offsets[idx], val);
releaseDeep(val);
removeChild(el, offsets[idx]);
for (j = noff; j >= idx; j--) {
offsets[j] = Math.max(offsets[j] - 1, 0);
ops.removeChild(el, offsets[idx]);
for (j = prevLength; j >= idx; j--) {
offsets[j] = max(offsets[j] - 1, 0);
}
}
} else if (isString(val)) {
el.textContent = "";
ops.setContent(el, "");
}

// element added/inserted?
} else if (status === 1) {
if (isString(val)) {
el.textContent = val;
ops.setContent(el, val);
} else if (isArray(val)) {
k = val[1].key;
if (k === undefined || (k && equivKeys[k][0] === undefined)) {
idx = e[1];
// DEBUG && console.log("insert @", offsets[idx], val);
createDOM(el, val, offsets[idx]);
for (j = noff; j >= idx; j--) {
ops.createTree(el, val, offsets[idx]);
for (j = prevLength; j >= idx; j--) {
offsets[j]++;
}
}
}
}
}
if ((i = curr.__init) && i != prev.__init) {
// call __init after all children have been added/updated
if ((val = (<any>curr).__init) && val != (<any>prev).__init) {
// DEBUG && console.log("call __init", curr);
i.apply(curr, [el, ...(curr.__args)]);
}
};

const releaseDeep = (tag: any) => {
if (isArray(tag)) {
if ((<any>tag).__release) {
// DEBUG && console.log("call __release", tag);
(<any>tag).__release.apply(tag, (<any>tag).__args);
delete (<any>tag).__release;
}
for (let i = tag.length; --i >= 2;) {
releaseDeep(tag[i]);
}
val.apply(curr, [el, ...((<any>curr).__args)]);
}
};

const diffAttributes = (el: Element, prev: any, curr: any) => {
const diffAttributes = <T>(ops: HDOMOps<T>, el: T, prev: any, curr: any) => {
let i, e, edits;
const delta = diffObject(prev, curr);
removeAttribs(el, delta.dels, prev);
ops.removeAttribs(el, delta.dels, prev);
let value = SEMAPHORE;
for (edits = delta.edits, i = edits.length; --i >= 0;) {
e = edits[i];
const a = e[0];
if (a.indexOf("on") === 0) {
el.removeEventListener(a.substr(2), prev[a]);
ops.removeAttribs(el, [a], prev);
}
if (a !== "value") {
setAttrib(el, a, e[1], curr);
ops.setAttrib(el, a, e[1], curr);
} else {
value = e[1];
}
}
for (edits = delta.adds, i = edits.length; --i >= 0;) {
e = edits[i];
if (e !== "value") {
setAttrib(el, e, curr[e], curr);
ops.setAttrib(el, e, curr[e], curr);
} else {
value = curr[e];
}
}
if (value !== SEMAPHORE) {
setAttrib(el, "value", value, curr);
ops.setAttrib(el, "value", value, curr);
}
};

const releaseDeep = (tag: any) => {
if (isArray(tag)) {
if ((<any>tag).__release) {
// DEBUG && console.log("call __release", tag);
(<any>tag).__release.apply(tag, (<any>tag).__args);
delete (<any>tag).__release;
}
for (let i = tag.length; --i >= 2;) {
releaseDeep(tag[i]);
}
}
};

const extractEquivElements = (edits: DiffLogEntry<any>[]) => {
let k, v, e, ek;
const equiv = {};
let k: string;
let val: any;
let e: DiffLogEntry<any>;
let ek: any[];
const equiv: IObjectOf<any[]> = {};
for (let i = edits.length; --i >= 0;) {
e = edits[i];
v = e[2];
if (isArray(v) && (k = v[1].key) !== undefined) {
val = e[2];
if (isArray(val) && (k = val[1].key) !== undefined) {
ek = equiv[k];
!ek && (equiv[k] = ek = [, ,]);
ek[e[0] + 1] = e[1];
Expand Down
20 changes: 18 additions & 2 deletions packages/hdom/src/dom.ts
Expand Up @@ -4,6 +4,7 @@ import * as isi from "@thi.ng/checks/is-iterable";
import * as iss from "@thi.ng/checks/is-string";
import { SVG_NS, SVG_TAGS } from "@thi.ng/hiccup/api";
import { css } from "@thi.ng/hiccup/css";
import { HDOMOps } from "./api";

const isArray = isa.isArray;
const isFunction = isf.isFunction;
Expand All @@ -29,7 +30,11 @@ export const createDOM = (parent: Element, tag: any, insert?: number) => {
if (isFunction(t)) {
return createDOM(parent, t.apply(null, tag.slice(1)));
}
const el = createElement(parent, t, tag[1], insert);
const attribs = tag[1];
if (attribs.__ops) {
return (<HDOMOps<any>>attribs.__ops).createTree(parent, tag, insert);
}
const el = createElement(parent, t, attribs, insert);
if ((<any>tag).__init) {
(<any>tag).__init.apply((<any>tag).__this, [el, ...(<any>tag).__args]);
}
Expand Down Expand Up @@ -70,7 +75,7 @@ export const hydrateDOM = (parent: Element, tree: any, i = 0) => {
if (isArray(tree)) {
const el = parent.children[i];
if (isFunction(tree[0])) {
return hydrateDOM(parent, tree[0].apply(null, tree.slice(1)), i);
hydrateDOM(parent, tree[0].apply(null, tree.slice(1)), i);
}
if ((<any>tree).__init) {
(<any>tree).__init.apply((<any>tree).__this, [el, ...(<any>tree).__args]);
Expand Down Expand Up @@ -121,13 +126,24 @@ export const createTextElement = (parent: Element, content: string, insert?: num
return el;
};

export const getChild = (parent: Element, child: number) =>
parent.children[child];

export const replaceChild = (parent: Element, child: number, tree: any) => {
removeChild(parent, child);
createDOM(parent, tree, child);
};

export const cloneWithNewAttribs = (el: Element, attribs: any) => {
const res = <Element>el.cloneNode(true);
setAttribs(res, attribs);
el.parentNode.replaceChild(res, el);
return res;
};

export const setContent = (el: Element, body: any) =>
el.textContent = body;

export const setAttribs = (el: Element, attribs: any) => {
for (let k in attribs) {
setAttrib(el, k, attribs[k], attribs);
Expand Down

0 comments on commit 525d90d

Please sign in to comment.