treeloom 2.0.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,34 @@
1
+ import type { DomItem } from '../domItem/types';
2
+ import type { ChangeList } from './types';
3
+ /**
4
+ * What treeloom last wrote to an element, as the item described it: attribute values by their kabob-case names, the
5
+ * class names, and the styles by their CSS (kabob-case) names. Values are kept exactly as the item had them, not as the
6
+ * browser reports them back ('#333', not 'rgb(51, 51, 51)'; `border`, not its longhands).
7
+ */
8
+ export type AppliedSnapshot = {
9
+ attributes: Record<string, unknown>;
10
+ classes: string[];
11
+ style: Record<string, unknown>;
12
+ };
13
+ /**
14
+ * Remember what was just written to the item's element (call it after the element has been updated to match the item).
15
+ * Does nothing for an item without an element, a text or comment node, or a style that is not an object.
16
+ * @param domItem
17
+ */
18
+ export declare const recordApplied: (domItem: DomItem) => void;
19
+ /**
20
+ * Forget what was written to an element, so its next update compares against the element itself again.
21
+ * @param element
22
+ */
23
+ export declare const forgetApplied: (element: object | null | undefined) => void;
24
+ /**
25
+ * The changes that make the item's element match the item, worked out from what was last written to the element rather
26
+ * than from the element itself: no style or attribute is read back from the DOM, and a value the browser stores in
27
+ * another form ('#333' as 'rgb(51, 51, 51)', `border` as longhands) is not mistaken for a change. Undefined when there is
28
+ * no snapshot to compare with (the element was never written by treeloom, or was captured from existing HTML), so the
29
+ * caller compares against the element instead.
30
+ * @param domItem
31
+ * @param propertyName - The name to set a plain attribute by: the element's own property name when it has one
32
+ * (innerHTML), otherwise the attribute's name (aria-label). Same rule as the element comparison.
33
+ */
34
+ export declare const changesFromApplied: (domItem: DomItem, propertyName: (kabobKey: string) => string) => ChangeList | undefined;
@@ -0,0 +1,134 @@
1
+ "use strict";
2
+
3
+ /* This Source Code Form is subject to the terms of the Mozilla Public
4
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
5
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
6
+ require("core-js/modules/es.array.includes.js");
7
+ var __importDefault = void 0 && (void 0).__importDefault || function (mod) {
8
+ return mod && mod.__esModule ? mod : {
9
+ "default": mod
10
+ };
11
+ };
12
+ Object.defineProperty(exports, "__esModule", {
13
+ value: true
14
+ });
15
+ exports.changesFromApplied = exports.forgetApplied = exports.recordApplied = void 0;
16
+ const si_funciona_1 = __importDefault(require("si-funciona"));
17
+ const createChangeList_1 = __importDefault(require("./createChangeList"));
18
+ /**
19
+ * Properties the person using the page changes directly (typing, ticking, choosing), so the element is their real current
20
+ * state, not what was last written. These are compared with the element itself, as before, so an item still puts them
21
+ * back (a controlled input: the item says '' and what was typed is cleared on the next update).
22
+ */
23
+ const LIVE_PROPERTIES = ['value', 'checked', 'selected', 'indeterminate'];
24
+ // Kept beside the item, not on it, so it never travels with the item (deflate, JSON) and goes away with the element
25
+ const snapshots = new WeakMap();
26
+ /**
27
+ * Whether an item's attributes can be compared through a snapshot: its style, when it has one, is an object of
28
+ * properties. Anything else (a style written as one string) is compared against the element, as before.
29
+ * @param domItem
30
+ */
31
+ const canSnapshot = domItem => typeof domItem.attributes.style === 'undefined' || si_funciona_1.default.isObject(domItem.attributes.style);
32
+ /**
33
+ * The class names in a className string.
34
+ * @param className
35
+ */
36
+ const classNames = className => typeof className === 'string' ? className.split(/\s+/).filter(Boolean) : [];
37
+ /**
38
+ * An item's attributes in the snapshot's form.
39
+ * @param domItem
40
+ */
41
+ const describe = domItem => {
42
+ const snapshot = {
43
+ attributes: {},
44
+ classes: [],
45
+ style: {}
46
+ };
47
+ for (const name in domItem.attributes) {
48
+ const value = domItem.attributes[name];
49
+ if (name === 'className') {
50
+ snapshot.classes = classNames(value);
51
+ continue;
52
+ }
53
+ if (name === 'style') {
54
+ for (const property in value) {
55
+ snapshot.style[si_funciona_1.default.kabobCase(property)] = value[property];
56
+ }
57
+ continue;
58
+ }
59
+ snapshot.attributes[si_funciona_1.default.kabobCase(name)] = value;
60
+ }
61
+ return snapshot;
62
+ };
63
+ /**
64
+ * Remember what was just written to the item's element (call it after the element has been updated to match the item).
65
+ * Does nothing for an item without an element, a text or comment node, or a style that is not an object.
66
+ * @param domItem
67
+ */
68
+ const recordApplied = domItem => {
69
+ if (!domItem.element || domItem.nodeValue !== null || !canSnapshot(domItem)) {
70
+ return;
71
+ }
72
+ snapshots.set(domItem.element, describe(domItem));
73
+ };
74
+ exports.recordApplied = recordApplied;
75
+ /**
76
+ * Forget what was written to an element, so its next update compares against the element itself again.
77
+ * @param element
78
+ */
79
+ const forgetApplied = element => {
80
+ if (element) {
81
+ snapshots.delete(element);
82
+ }
83
+ };
84
+ exports.forgetApplied = forgetApplied;
85
+ /**
86
+ * The changes that make the item's element match the item, worked out from what was last written to the element rather
87
+ * than from the element itself: no style or attribute is read back from the DOM, and a value the browser stores in
88
+ * another form ('#333' as 'rgb(51, 51, 51)', `border` as longhands) is not mistaken for a change. Undefined when there is
89
+ * no snapshot to compare with (the element was never written by treeloom, or was captured from existing HTML), so the
90
+ * caller compares against the element instead.
91
+ * @param domItem
92
+ * @param propertyName - The name to set a plain attribute by: the element's own property name when it has one
93
+ * (innerHTML), otherwise the attribute's name (aria-label). Same rule as the element comparison.
94
+ */
95
+ const changesFromApplied = (domItem, propertyName) => {
96
+ const previous = domItem.element ? snapshots.get(domItem.element) : undefined;
97
+ if (!previous || domItem.nodeValue !== null || !canSnapshot(domItem)) {
98
+ return undefined;
99
+ }
100
+ const next = describe(domItem);
101
+ const changes = (0, createChangeList_1.default)();
102
+ const element = domItem.element;
103
+ for (const key in next.attributes) {
104
+ const live = LIVE_PROPERTIES.includes(key) && key in element;
105
+ const current = live ? element[key] : previous.attributes[key];
106
+ const differs = live && typeof current === 'string' ? String(next.attributes[key] ?? '') !== current : next.attributes[key] !== current;
107
+ if (differs) {
108
+ changes.added[propertyName(key)] = next.attributes[key];
109
+ }
110
+ }
111
+ previous.classes.forEach((name, index) => {
112
+ if (!next.classes.includes(name)) {
113
+ changes.removed[`class.${index}`] = name;
114
+ }
115
+ });
116
+ next.classes.forEach((name, index) => {
117
+ if (!previous.classes.includes(name)) {
118
+ changes.added[`class.${index}`] = name;
119
+ }
120
+ });
121
+ for (const property in previous.style) {
122
+ if (!(property in next.style)) {
123
+ changes.removed[`style.${property}`] = previous.style[property];
124
+ }
125
+ }
126
+ for (const property in next.style) {
127
+ // A changed value is simply set again (setProperty replaces it): no remove first, so one write, not two
128
+ if (next.style[property] !== previous.style[property]) {
129
+ changes.added[`style.${property}`] = next.style[property];
130
+ }
131
+ }
132
+ return changes;
133
+ };
134
+ exports.changesFromApplied = changesFromApplied;
@@ -0,0 +1 @@
1
+ "use strict";require("core-js/modules/es.array.includes.js");var __importDefault=function(e){return e&&e.__esModule?e:{default:e}};Object.defineProperty(exports,"__esModule",{value:!0}),exports.changesFromApplied=exports.forgetApplied=exports.recordApplied=void 0;const si_funciona_1=__importDefault(require("si-funciona")),createChangeList_1=__importDefault(require("./createChangeList")),LIVE_PROPERTIES=["value","checked","selected","indeterminate"],snapshots=new WeakMap,canSnapshot=e=>void 0===e.attributes.style||si_funciona_1.default.isObject(e.attributes.style),classNames=e=>"string"==typeof e?e.split(/\s+/).filter(Boolean):[],describe=e=>{const s={attributes:{},classes:[],style:{}};for(const t in e.attributes){const a=e.attributes[t];if("className"!==t)if("style"!==t)s.attributes[si_funciona_1.default.kabobCase(t)]=a;else for(const e in a)s.style[si_funciona_1.default.kabobCase(e)]=a[e];else s.classes=classNames(a)}return s},recordApplied=e=>{e.element&&null===e.nodeValue&&canSnapshot(e)&&snapshots.set(e.element,describe(e))};exports.recordApplied=recordApplied;const forgetApplied=e=>{e&&snapshots.delete(e)};exports.forgetApplied=forgetApplied;const changesFromApplied=(e,s)=>{const t=e.element?snapshots.get(e.element):void 0;if(!t||null!==e.nodeValue||!canSnapshot(e))return;const a=describe(e),r=(0,createChangeList_1.default)(),n=e.element;for(const e in a.attributes){const i=LIVE_PROPERTIES.includes(e)&&e in n,o=i?n[e]:t.attributes[e];(i&&"string"==typeof o?String(a.attributes[e]??"")!==o:a.attributes[e]!==o)&&(r.added[s(e)]=a.attributes[e])}t.classes.forEach((e,s)=>{a.classes.includes(e)||(r.removed[`class.${s}`]=e)}),a.classes.forEach((e,s)=>{t.classes.includes(e)||(r.added[`class.${s}`]=e)});for(const e in t.style)e in a.style||(r.removed[`style.${e}`]=t.style[e]);for(const e in a.style)a.style[e]!==t.style[e]&&(r.added[`style.${e}`]=a.style[e]);return r};exports.changesFromApplied=changesFromApplied;
@@ -14,6 +14,7 @@ Object.defineProperty(exports, "__esModule", {
14
14
  const si_funciona_1 = __importDefault(require("si-funciona"));
15
15
  const elementHasAttribute_1 = __importDefault(require("./elementHasAttribute"));
16
16
  const createChangeList_1 = __importDefault(require("./createChangeList"));
17
+ const appliedSnapshot_1 = require("./appliedSnapshot");
17
18
  /**
18
19
  * Given some config, provide a function to testing attribute and key existence in that config.
19
20
  * @param element
@@ -86,6 +87,18 @@ const domItemChanges = domItem => {
86
87
  }
87
88
  return changeList;
88
89
  }
90
+ // Compared with what treeloom last wrote to the element when it knows that (see appliedSnapshot): nothing is read
91
+ // back from the DOM. Not when the node is being replaced (nodeName changed): its new element starts fresh.
92
+ if (!changeList.added.nodeName) {
93
+ const fromApplied = (0, appliedSnapshot_1.changesFromApplied)(domItem, kabobKey => {
94
+ const name = attributeName(domItem.attributes, kabobKey);
95
+ const element = domItem.element;
96
+ return element && name in element && name !== 'form' ? name : kabobKey;
97
+ });
98
+ if (fromApplied) {
99
+ return fromApplied;
100
+ }
101
+ }
89
102
  const domItemAttributes = kabobCaseAttributes(domItem.attributes);
90
103
  const diffMap = si_funciona_1.default.mapObject(domItemAttributes, (attr, key) => checkAttribute(domItem.element)(attr, key));
91
104
  return si_funciona_1.default.reduceObject(diffMap, (changed, result, key) => {
@@ -1 +1 @@
1
- "use strict";var __importDefault=function(e){return e&&e.__esModule?e:{default:e}};Object.defineProperty(exports,"__esModule",{value:!0});const si_funciona_1=__importDefault(require("si-funciona")),elementHasAttribute_1=__importDefault(require("./elementHasAttribute")),createChangeList_1=__importDefault(require("./createChangeList")),checkAttribute=e=>(t,a)=>{if("object"==typeof t){const u=t,s=()=>e.style,l=(0,elementHasAttribute_1.default)(e,a,t);return Array.isArray(l)&&1===l.length&&0===l[0].result[0]&&s()[l[0].value]===u[l[0].value]?0:si_funciona_1.default.filterObject(l,e=>0!==e.result[0]||s()[e.value]!==u[e.value])}if("class"===a){const u=(0,elementHasAttribute_1.default)(e,a,t);return Array.isArray(u)&&1===u.length&&0===u[0].result[0]?0:si_funciona_1.default.filterObject(u,e=>0!==e.result[0])}return(0,elementHasAttribute_1.default)(e,a,t)},kabobCaseAttributes=e=>si_funciona_1.default.reduceObject(e,(t,a,u)=>{let s=e[u]??a;si_funciona_1.default.isObject(s)&&(s=kabobCaseAttributes(s));return t["className"===u?"class":si_funciona_1.default.kabobCase(u)]=s,t},{}),attributeName=(e,t)=>Object.keys(e).find(e=>("className"===e?"class":si_funciona_1.default.kabobCase(e))===t)??si_funciona_1.default.camelCase(t),domItemChanges=e=>{const t=(0,createChangeList_1.default)();if(e.element||(t.added.nodeName=e.nodeName.toLowerCase()),e.element&&e.element.nodeName.toLowerCase()!==e.nodeName.toLowerCase()&&(t.removed.nodeName=e.element.nodeName.toLowerCase(),t.added.nodeName=e.nodeName.toLowerCase()),null!==e.nodeValue)return si_funciona_1.default.emptyObject(e.attributes)||(t.added=t.appendChanges("added",si_funciona_1.default.dotNotate(e.attributes))),e.element?(t.removed.nodeValue=e.element.nodeValue,e.element.nodeValue!==e.nodeValue&&(t.added.nodeValue=e.nodeValue),t):(t.added.nodeValue=e.nodeValue,t);const a=kabobCaseAttributes(e.attributes),u=si_funciona_1.default.mapObject(a,(t,a)=>checkAttribute(e.element)(t,a));return si_funciona_1.default.reduceObject(u,(t,u,s)=>{const l=Array.isArray(u);if(!l&&!u)return t;if(!l&&"class"===s)return t.added.className=e.attributes.className,t;if(!l){const a=attributeName(e.attributes,s),u=e.element;return t.added[u&&a in u&&"form"!==a?a:s]=e.attributes[a],t}if("class"!==s&&"style"!==s)return t;if("class"===s)return t.removed=u.reduce((e,t)=>(-1===t.result[0]&&(e[`class.${t.keys[1]}`]=t.value),e),t.removed),t.added=u.reduce((e,t)=>(1===t.result[0]&&(e[`class.${t.keys[0]}`]=t.value),e),t.added),t;const r=()=>e.element.style;return t.removed=u.reduce((t,u)=>e.element?(-1===u.result[0]&&(t[`style.${u.value}`]=r()[u.value]),0===u.result[0]&&r()[u.value]!==a.style[u.value]&&(t[`style.${u.value}`]=r()[u.value]),t):t,t.removed),t.added=u.reduce((e,t)=>(1===t.result[0]&&(e[`style.${t.value}`]=a.style[t.value]),0===t.result[0]&&r()[t.value]!==a.style[t.value]&&(e[`style.${t.value}`]=a.style[t.value]),e),t.added),t},t)};exports.default=domItemChanges;
1
+ "use strict";var __importDefault=function(e){return e&&e.__esModule?e:{default:e}};Object.defineProperty(exports,"__esModule",{value:!0});const si_funciona_1=__importDefault(require("si-funciona")),elementHasAttribute_1=__importDefault(require("./elementHasAttribute")),createChangeList_1=__importDefault(require("./createChangeList")),appliedSnapshot_1=require("./appliedSnapshot"),checkAttribute=e=>(t,a)=>{if("object"==typeof t){const u=t,s=()=>e.style,r=(0,elementHasAttribute_1.default)(e,a,t);return Array.isArray(r)&&1===r.length&&0===r[0].result[0]&&s()[r[0].value]===u[r[0].value]?0:si_funciona_1.default.filterObject(r,e=>0!==e.result[0]||s()[e.value]!==u[e.value])}if("class"===a){const u=(0,elementHasAttribute_1.default)(e,a,t);return Array.isArray(u)&&1===u.length&&0===u[0].result[0]?0:si_funciona_1.default.filterObject(u,e=>0!==e.result[0])}return(0,elementHasAttribute_1.default)(e,a,t)},kabobCaseAttributes=e=>si_funciona_1.default.reduceObject(e,(t,a,u)=>{let s=e[u]??a;si_funciona_1.default.isObject(s)&&(s=kabobCaseAttributes(s));return t["className"===u?"class":si_funciona_1.default.kabobCase(u)]=s,t},{}),attributeName=(e,t)=>Object.keys(e).find(e=>("className"===e?"class":si_funciona_1.default.kabobCase(e))===t)??si_funciona_1.default.camelCase(t),domItemChanges=e=>{const t=(0,createChangeList_1.default)();if(e.element||(t.added.nodeName=e.nodeName.toLowerCase()),e.element&&e.element.nodeName.toLowerCase()!==e.nodeName.toLowerCase()&&(t.removed.nodeName=e.element.nodeName.toLowerCase(),t.added.nodeName=e.nodeName.toLowerCase()),null!==e.nodeValue)return si_funciona_1.default.emptyObject(e.attributes)||(t.added=t.appendChanges("added",si_funciona_1.default.dotNotate(e.attributes))),e.element?(t.removed.nodeValue=e.element.nodeValue,e.element.nodeValue!==e.nodeValue&&(t.added.nodeValue=e.nodeValue),t):(t.added.nodeValue=e.nodeValue,t);if(!t.added.nodeName){const t=(0,appliedSnapshot_1.changesFromApplied)(e,t=>{const a=attributeName(e.attributes,t),u=e.element;return u&&a in u&&"form"!==a?a:t});if(t)return t}const a=kabobCaseAttributes(e.attributes),u=si_funciona_1.default.mapObject(a,(t,a)=>checkAttribute(e.element)(t,a));return si_funciona_1.default.reduceObject(u,(t,u,s)=>{const r=Array.isArray(u);if(!r&&!u)return t;if(!r&&"class"===s)return t.added.className=e.attributes.className,t;if(!r){const a=attributeName(e.attributes,s),u=e.element;return t.added[u&&a in u&&"form"!==a?a:s]=e.attributes[a],t}if("class"!==s&&"style"!==s)return t;if("class"===s)return t.removed=u.reduce((e,t)=>(-1===t.result[0]&&(e[`class.${t.keys[1]}`]=t.value),e),t.removed),t.added=u.reduce((e,t)=>(1===t.result[0]&&(e[`class.${t.keys[0]}`]=t.value),e),t.added),t;const l=()=>e.element.style;return t.removed=u.reduce((t,u)=>e.element?(-1===u.result[0]&&(t[`style.${u.value}`]=l()[u.value]),0===u.result[0]&&l()[u.value]!==a.style[u.value]&&(t[`style.${u.value}`]=l()[u.value]),t):t,t.removed),t.added=u.reduce((e,t)=>(1===t.result[0]&&(e[`style.${t.value}`]=a.style[t.value]),0===t.result[0]&&l()[t.value]!==a.style[t.value]&&(e[`style.${t.value}`]=a.style[t.value]),e),t.added),t},t)};exports.default=domItemChanges;
@@ -1,5 +1,6 @@
1
1
  declare const _default: {
2
2
  generateElement: (domItem: import("../domItem/types").DomItem) => import("../domItem/types").DomItem;
3
+ reconcile: (existing: import("../domItem/types").DomItem, next: import("../domItem/types").DomItem) => import("../domItem/types").DomItem;
3
4
  renderHtml: (item?: import("../domItem/types").DomItem | null, parent?: import("../domItem/types").DomItem, index?: number) => import("../domItem/types").DomItem;
4
5
  updateChildNodes: (domItem: import("../domItem/types").DomItem) => import("../domItem/types").DomItem;
5
6
  updateElement: (domItem: import("../domItem/types").DomItem) => import("../domItem/types").DomItem;
@@ -12,12 +12,14 @@ Object.defineProperty(exports, "__esModule", {
12
12
  value: true
13
13
  });
14
14
  const generateElement_1 = __importDefault(require("./generateElement"));
15
+ const reconcile_1 = __importDefault(require("./reconcile"));
15
16
  const renderHtml_1 = __importDefault(require("./renderHtml"));
16
17
  const updateChildNodes_1 = __importDefault(require("./updateChildNodes"));
17
18
  const updateElement_1 = __importDefault(require("./updateElement"));
18
19
  const updateElements_1 = __importDefault(require("./updateElements"));
19
20
  exports.default = {
20
21
  generateElement: generateElement_1.default,
22
+ reconcile: reconcile_1.default,
21
23
  renderHtml: renderHtml_1.default,
22
24
  updateChildNodes: updateChildNodes_1.default,
23
25
  updateElement: updateElement_1.default,
@@ -1 +1 @@
1
- "use strict";var __importDefault=function(e){return e&&e.__esModule?e:{default:e}};Object.defineProperty(exports,"__esModule",{value:!0});const generateElement_1=__importDefault(require("./generateElement")),renderHtml_1=__importDefault(require("./renderHtml")),updateChildNodes_1=__importDefault(require("./updateChildNodes")),updateElement_1=__importDefault(require("./updateElement")),updateElements_1=__importDefault(require("./updateElements"));exports.default={generateElement:generateElement_1.default,renderHtml:renderHtml_1.default,updateChildNodes:updateChildNodes_1.default,updateElement:updateElement_1.default,updateElements:updateElements_1.default};
1
+ "use strict";var __importDefault=function(e){return e&&e.__esModule?e:{default:e}};Object.defineProperty(exports,"__esModule",{value:!0});const generateElement_1=__importDefault(require("./generateElement")),reconcile_1=__importDefault(require("./reconcile")),renderHtml_1=__importDefault(require("./renderHtml")),updateChildNodes_1=__importDefault(require("./updateChildNodes")),updateElement_1=__importDefault(require("./updateElement")),updateElements_1=__importDefault(require("./updateElements"));exports.default={generateElement:generateElement_1.default,reconcile:reconcile_1.default,renderHtml:renderHtml_1.default,updateChildNodes:updateChildNodes_1.default,updateElement:updateElement_1.default,updateElements:updateElements_1.default};
@@ -0,0 +1,16 @@
1
+ import type { DomItem } from '../domItem/types';
2
+ /**
3
+ * Update a rendered tree to match a new version of it (the next tree, such as one inflated from a server's JSON), by
4
+ * changing only what differs instead of rendering everything again. Children are matched by id, otherwise by
5
+ * position and node name. A matched item keeps its element, its id and its listeners; it takes the next item's
6
+ * attributes and data, and its named listeners are made the next item's. Items the next tree doesn't have are removed,
7
+ * new ones are added, and then only the changed attributes and nodes are written to the page (see updateElement).
8
+ *
9
+ * Listeners added as functions (locally, not carried by the next tree) and focus stay where they are, since the
10
+ * elements do.
11
+ * @param existing - An item already rendered (it has an element). It stays the same kind of node: next must be one too.
12
+ * @param next - The tree it should now match. Its items are taken over: don't render it separately.
13
+ * @returns existing, updated.
14
+ */
15
+ declare const reconcile: (existing: DomItem, next: DomItem) => DomItem;
16
+ export default reconcile;
@@ -0,0 +1,169 @@
1
+ "use strict";
2
+
3
+ require("core-js/modules/es.array.includes.js");
4
+ require("core-js/modules/es.json.stringify.js");
5
+ var __importDefault = void 0 && (void 0).__importDefault || function (mod) {
6
+ return mod && mod.__esModule ? mod : {
7
+ "default": mod
8
+ };
9
+ };
10
+ Object.defineProperty(exports, "__esModule", {
11
+ value: true
12
+ });
13
+ /* This Source Code Form is subject to the terms of the Mozilla Public
14
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
15
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
16
+ const removeEventListener_1 = __importDefault(require("../events/functions/removeEventListener"));
17
+ const itemIdIndex_1 = require("../domItem/tree/itemIdIndex");
18
+ const updateElements_1 = __importDefault(require("./updateElements"));
19
+ /** What makes an item part of the tree rather than its data: kept on the existing item, never taken from the next.
20
+ * The id is taken from the next item, so ids stay the ones the next tree (a server's, say) gives its items. */
21
+ const STRUCTURE = new Set(['element', 'parentItem', 'children', 'eventListeners', 'activeListeners']);
22
+ /**
23
+ * A named listener as plain data, to tell whether two items have the same one. Only named listeners are compared: they
24
+ * are the ones a tree carries as JSON. A listener given as a function was added locally (keyboard navigation, say)
25
+ * and is never in the next tree, so it is left alone.
26
+ * @param listener
27
+ */
28
+ const listenerKey = listener => typeof listener.listenerFunc === 'string' ? JSON.stringify([listener.listenerFunc, listener.listenerArgs ?? null, listener.listenerOptions ?? null]) : null;
29
+ /**
30
+ * Make existing's named listeners the next item's: remove the ones the next item doesn't have (detached from the
31
+ * element), and add the ones it gains (bound when the element is updated). Unchanged ones stay bound as they are.
32
+ * @param existing
33
+ * @param next
34
+ */
35
+ const reconcileListeners = (existing, next) => {
36
+ const nextListeners = next.eventListeners ?? {};
37
+ existing.eventListeners = existing.eventListeners ?? {};
38
+ for (const eventName of Object.keys(existing.eventListeners)) {
39
+ const wanted = (nextListeners[eventName] ?? []).map(listenerKey);
40
+ for (const listener of [...existing.eventListeners[eventName]]) {
41
+ const key = listenerKey(listener);
42
+ if (key !== null && !wanted.includes(key)) {
43
+ (0, removeEventListener_1.default)(eventName, listener.listenerFunc, listener.listenerArgs, listener.listenerOptions, existing);
44
+ }
45
+ }
46
+ }
47
+ for (const eventName of Object.keys(nextListeners)) {
48
+ const have = (existing.eventListeners[eventName] ?? []).map(listenerKey);
49
+ for (const listener of nextListeners[eventName]) {
50
+ const key = listenerKey(listener);
51
+ if (key !== null && !have.includes(key)) {
52
+ existing.eventListeners[eventName] = existing.eventListeners[eventName] ?? [];
53
+ existing.eventListeners[eventName].push({
54
+ ...listener,
55
+ listenerActive: false,
56
+ activeResponder: null
57
+ });
58
+ }
59
+ }
60
+ }
61
+ };
62
+ /**
63
+ * Make existing's own data the next item's (its id included): every property but the structure (element, parent,
64
+ * children, listeners). A property the next item doesn't have is removed, unless it is a function (something added locally).
65
+ * @param existing
66
+ * @param next
67
+ */
68
+ const reconcileData = (existing, next) => {
69
+ const target = existing;
70
+ const source = next;
71
+ for (const key of Object.keys(target)) {
72
+ if (!STRUCTURE.has(key) && !(key in source) && typeof target[key] !== 'function') {
73
+ delete target[key];
74
+ }
75
+ }
76
+ for (const key of Object.keys(source)) {
77
+ if (!STRUCTURE.has(key)) {
78
+ target[key] = source[key];
79
+ }
80
+ }
81
+ };
82
+ /**
83
+ * Pair each next child with the existing child it updates: the one with the same id, otherwise the one at the same
84
+ * position when it is the same kind of node and no next child has it by id (an item without an id in the JSON, a
85
+ * text node say, gets a fresh one when inflated, so its id says nothing about which item it was). Unpaired next
86
+ * children are new.
87
+ * @param existing
88
+ * @param next
89
+ */
90
+ const pairChildren = (existing, next) => {
91
+ const byId = new Map();
92
+ existing.forEach(child => {
93
+ if (typeof child.id === 'number') {
94
+ byId.set(child.id, child);
95
+ }
96
+ });
97
+ // An existing child some next child has by id is that one's, never another's by position
98
+ const claimed = new Set(next.map(child => typeof child.id === 'number' ? byId.get(child.id) : undefined).filter(Boolean));
99
+ const taken = new Set();
100
+ return next.map((nextChild, index) => {
101
+ const sameId = typeof nextChild.id === 'number' ? byId.get(nextChild.id) : undefined;
102
+ const atPosition = existing[index] && !claimed.has(existing[index]) ? existing[index] : undefined;
103
+ const candidate = sameId ?? atPosition;
104
+ if (candidate && !taken.has(candidate) && candidate.nodeName === nextChild.nodeName) {
105
+ taken.add(candidate);
106
+ return candidate;
107
+ }
108
+ return undefined;
109
+ });
110
+ };
111
+ /**
112
+ * Update existing's subtree to match next's, reusing every item (and its element and listeners) that next still has.
113
+ * Items next no longer has are removed; new ones are added. Elements are not touched here; see reconcile.
114
+ * @param existing
115
+ * @param next
116
+ */
117
+ const reconcileItem = (existing, next) => {
118
+ reconcileData(existing, next);
119
+ reconcileListeners(existing, next);
120
+ const previous = existing.children ?? [];
121
+ const nextChildren = next.children ?? [];
122
+ const pairs = pairChildren(previous, nextChildren);
123
+ const kept = new Set(pairs.filter(Boolean));
124
+ previous.filter(child => !kept.has(child)).forEach(child => {
125
+ // Removed (already out of the id index, see reconcile): its element out of the page
126
+ ;
127
+ child.element?.remove?.();
128
+ });
129
+ existing.children = nextChildren.map((nextChild, index) => {
130
+ const child = pairs[index];
131
+ if (child) {
132
+ reconcileItem(child, nextChild);
133
+ return child;
134
+ }
135
+ // New: taken as it is; its element is generated when the subtree is updated below
136
+ nextChild.parentItem = existing;
137
+ return nextChild;
138
+ });
139
+ existing.children.forEach(child => {
140
+ child.parentItem = existing;
141
+ });
142
+ };
143
+ /**
144
+ * Update a rendered tree to match a new version of it (the next tree, such as one inflated from a server's JSON), by
145
+ * changing only what differs instead of rendering everything again. Children are matched by id, otherwise by
146
+ * position and node name. A matched item keeps its element, its id and its listeners; it takes the next item's
147
+ * attributes and data, and its named listeners are made the next item's. Items the next tree doesn't have are removed,
148
+ * new ones are added, and then only the changed attributes and nodes are written to the page (see updateElement).
149
+ *
150
+ * Listeners added as functions (locally, not carried by the next tree) and focus stay where they are, since the
151
+ * elements do.
152
+ * @param existing - An item already rendered (it has an element). It stays the same kind of node: next must be one too.
153
+ * @param next - The tree it should now match. Its items are taken over: don't render it separately.
154
+ * @returns existing, updated.
155
+ */
156
+ const reconcile = (existing, next) => {
157
+ // The item updated in place is where the page's content lives (a <main>, a <body>): changing its kind of node would
158
+ // replace that element with a new one outside the page. Children of another kind are replaced; this one never is.
159
+ if (existing.nodeName.toLowerCase() !== next.nodeName.toLowerCase()) {
160
+ throw new Error(`reconcile can't turn a ${existing.nodeName} into a ${next.nodeName}: give it a tree of the same node, or reconcile its parent.`);
161
+ }
162
+ // Ids can change (they follow the next tree), so the subtree leaves the id index first and is indexed again after
163
+ (0, itemIdIndex_1.unregisterItemIds)(existing);
164
+ reconcileItem(existing, next);
165
+ (0, itemIdIndex_1.registerItemIds)(existing);
166
+ // updateElements patches each item's element (only what changed) and puts its children's elements in order
167
+ return (0, updateElements_1.default)(existing);
168
+ };
169
+ exports.default = reconcile;
@@ -0,0 +1 @@
1
+ "use strict";require("core-js/modules/es.array.includes.js"),require("core-js/modules/es.json.stringify.js");var __importDefault=function(e){return e&&e.__esModule?e:{default:e}};Object.defineProperty(exports,"__esModule",{value:!0});const removeEventListener_1=__importDefault(require("../events/functions/removeEventListener")),itemIdIndex_1=require("../domItem/tree/itemIdIndex"),updateElements_1=__importDefault(require("./updateElements")),STRUCTURE=new Set(["element","parentItem","children","eventListeners","activeListeners"]),listenerKey=e=>"string"==typeof e.listenerFunc?JSON.stringify([e.listenerFunc,e.listenerArgs??null,e.listenerOptions??null]):null,reconcileListeners=(e,t)=>{const n=t.eventListeners??{};e.eventListeners=e.eventListeners??{};for(const t of Object.keys(e.eventListeners)){const r=(n[t]??[]).map(listenerKey);for(const n of[...e.eventListeners[t]]){const s=listenerKey(n);null===s||r.includes(s)||(0,removeEventListener_1.default)(t,n.listenerFunc,n.listenerArgs,n.listenerOptions,e)}}for(const t of Object.keys(n)){const r=(e.eventListeners[t]??[]).map(listenerKey);for(const s of n[t]){const n=listenerKey(s);null===n||r.includes(n)||(e.eventListeners[t]=e.eventListeners[t]??[],e.eventListeners[t].push({...s,listenerActive:!1,activeResponder:null}))}}},reconcileData=(e,t)=>{const n=e,r=t;for(const e of Object.keys(n))STRUCTURE.has(e)||e in r||"function"==typeof n[e]||delete n[e];for(const e of Object.keys(r))STRUCTURE.has(e)||(n[e]=r[e])},pairChildren=(e,t)=>{const n=new Map;e.forEach(e=>{"number"==typeof e.id&&n.set(e.id,e)});const r=new Set(t.map(e=>"number"==typeof e.id?n.get(e.id):void 0).filter(Boolean)),s=new Set;return t.map((t,i)=>{const o="number"==typeof t.id?n.get(t.id):void 0,c=e[i]&&!r.has(e[i])?e[i]:void 0,l=o??c;if(l&&!s.has(l)&&l.nodeName===t.nodeName)return s.add(l),l})},reconcileItem=(e,t)=>{reconcileData(e,t),reconcileListeners(e,t);const n=e.children??[],r=t.children??[],s=pairChildren(n,r),i=new Set(s.filter(Boolean));n.filter(e=>!i.has(e)).forEach(e=>{e.element?.remove?.()}),e.children=r.map((t,n)=>{const r=s[n];return r?(reconcileItem(r,t),r):(t.parentItem=e,t)}),e.children.forEach(t=>{t.parentItem=e})},reconcile=(e,t)=>{if(e.nodeName.toLowerCase()!==t.nodeName.toLowerCase())throw new Error(`reconcile can't turn a ${e.nodeName} into a ${t.nodeName}: give it a tree of the same node, or reconcile its parent.`);return(0,itemIdIndex_1.unregisterItemIds)(e),reconcileItem(e,t),(0,itemIdIndex_1.registerItemIds)(e),(0,updateElements_1.default)(e)};exports.default=reconcile;
@@ -25,31 +25,28 @@ const updateChildNodes = domItem => {
25
25
  return domItem;
26
26
  }
27
27
  const element = domItem.element;
28
- const previousChildNodes = Array.from(element.childNodes);
29
- domItem.children.map((child, index) => {
30
- const existing = previousChildNodes.find(childNode => childNode === child.element);
31
- let childNode = existing ?? child.element;
32
- if (!childNode) {
33
- childNode = (0, updateElement_1.default)(child).element;
34
- }
35
- if (!previousChildNodes[index]) {
36
- element.appendChild(childNode);
37
- return childNode;
38
- }
39
- if (childNode !== previousChildNodes[index]) {
40
- element.replaceChild(childNode, previousChildNodes[index]);
41
- }
28
+ const childNodes = domItem.children.map(child => {
29
+ const childNode = child.element ?? (0, updateElement_1.default)(child).element;
30
+ child.element = childNode;
42
31
  return childNode;
43
- }).forEach((childNode, index) => {
44
- domItem.children[index].element = childNode;
45
32
  });
46
- if (element.childNodes.length > domItem.children.length) {
47
- const maxLength = domItem.children.length;
48
- let nodeToRemove = element.childNodes[maxLength];
49
- while (nodeToRemove) {
50
- element.removeChild(nodeToRemove);
51
- nodeToRemove = element.childNodes[maxLength];
33
+ // Anything in the element that isn't one of the children's nodes goes (as before, when it ended up past the last child)
34
+ const wanted = new Set(childNodes);
35
+ Array.from(element.childNodes).forEach(node => {
36
+ if (!wanted.has(node)) {
37
+ element.removeChild(node);
38
+ }
39
+ });
40
+ // Put the nodes in order from the last one back: a node is moved only when it isn't already right before the one that
41
+ // follows it. So [1, 2, 3, 4] -> [2, 3, 4, 1] moves only 1, not 2, 3 and 4: a node that moves loses focus (and restarts
42
+ // transitions), so as few as possible move. insertBefore, not replaceChild, so nothing in the way is detached.
43
+ let following = null;
44
+ for (let index = childNodes.length - 1; index >= 0; index--) {
45
+ const childNode = childNodes[index];
46
+ if (childNode.parentNode !== element || childNode.nextSibling !== following) {
47
+ element.insertBefore(childNode, following);
52
48
  }
49
+ following = childNode;
53
50
  }
54
51
  return domItem;
55
52
  };
@@ -1 +1 @@
1
- "use strict";var __importDefault=function(e){return e&&e.__esModule?e:{default:e}};Object.defineProperty(exports,"__esModule",{value:!0});const updateElement_1=__importDefault(require("./updateElement")),updateChildNodes=e=>{if(e.element||(e=(0,updateElement_1.default)(e)),!e.children||!e.children.length)return e.children=[],e;const t=e.element,l=Array.from(t.childNodes);if(e.children.map((e,d)=>{const n=l.find(t=>t===e.element);let r=n??e.element;return r||(r=(0,updateElement_1.default)(e).element),l[d]?(r!==l[d]&&t.replaceChild(r,l[d]),r):(t.appendChild(r),r)}).forEach((t,l)=>{e.children[l].element=t}),t.childNodes.length>e.children.length){const l=e.children.length;let d=t.childNodes[l];for(;d;)t.removeChild(d),d=t.childNodes[l]}return e};exports.default=updateChildNodes;
1
+ "use strict";var __importDefault=function(e){return e&&e.__esModule?e:{default:e}};Object.defineProperty(exports,"__esModule",{value:!0});const updateElement_1=__importDefault(require("./updateElement")),updateChildNodes=e=>{if(e.element||(e=(0,updateElement_1.default)(e)),!e.children||!e.children.length)return e.children=[],e;const t=e.element,l=e.children.map(e=>{const t=e.element??(0,updateElement_1.default)(e).element;return e.element=t,t}),n=new Set(l);Array.from(t.childNodes).forEach(e=>{n.has(e)||t.removeChild(e)});let r=null;for(let e=l.length-1;e>=0;e--){const n=l[e];n.parentNode===t&&n.nextSibling===r||t.insertBefore(n,r),r=n}return e};exports.default=updateChildNodes;
@@ -15,6 +15,7 @@ Object.defineProperty(exports, "__esModule", {
15
15
  const bindListeners_1 = __importDefault(require("../events/functions/bindListeners"));
16
16
  const deactivateListeners_1 = __importDefault(require("../events/functions/deactivateListeners"));
17
17
  const domItemChanges_1 = __importDefault(require("../diff/domItemChanges"));
18
+ const appliedSnapshot_1 = require("../diff/appliedSnapshot");
18
19
  const si_funciona_1 = __importDefault(require("si-funciona"));
19
20
  const generateElement_1 = __importDefault(require("./generateElement"));
20
21
  const updateChildNodes_1 = __importDefault(require("./updateChildNodes"));
@@ -105,6 +106,8 @@ const updateElement = domItem => {
105
106
  // error. kabobCase here is a harmless no-op when the key is already kebab, and a real fix when it is not.
106
107
  element.style.setProperty(si_funciona_1.default.kabobCase(si_funciona_1.default.strAfter(dotKey, '.')), value);
107
108
  }
109
+ // The element now matches the item: the next update compares against this rather than reading the element back
110
+ (0, appliedSnapshot_1.recordApplied)(domItem);
108
111
  return (0, bindListeners_1.default)(domItem);
109
112
  };
110
113
  exports.default = updateElement;
@@ -1 +1 @@
1
- "use strict";require("core-js/modules/es.array.includes.js");var __importDefault=function(e){return e&&e.__esModule?e:{default:e}};Object.defineProperty(exports,"__esModule",{value:!0});const bindListeners_1=__importDefault(require("../events/functions/bindListeners")),deactivateListeners_1=__importDefault(require("../events/functions/deactivateListeners")),domItemChanges_1=__importDefault(require("../diff/domItemChanges")),si_funciona_1=__importDefault(require("si-funciona")),generateElement_1=__importDefault(require("./generateElement")),updateChildNodes_1=__importDefault(require("./updateChildNodes")),updateElement=e=>{if(!e.element)return(0,updateChildNodes_1.default)((0,generateElement_1.default)((0,deactivateListeners_1.default)(e)));const t=(0,domItemChanges_1.default)(e),s=e.element,n=s;if(t.removed.nodeName||t.added.nodeName)return s.remove(),(0,updateChildNodes_1.default)((0,generateElement_1.default)((0,deactivateListeners_1.default)(e)));for(const e in t.removed){const i=t.removed[e];if(!e.includes(".")){if(e in s){delete n[e];continue}s.removeAttribute(e);continue}const r=si_funciona_1.default.strBefore(e,".");if("class"!==r&&"style"!==r)throw new Error("Unhandled dotKey: "+e);"class"!==r?s.style.removeProperty(si_funciona_1.default.kabobCase(si_funciona_1.default.strAfter(e,"."))):s.classList.remove(i)}for(const e in t.added){const i=t.added[e];if("className"===e){s.className=i;continue}if("style"===e){for(const e in i)s.style.setProperty(si_funciona_1.default.kabobCase(e),i[e]);continue}if(!e.includes(".")){if(e in s&&"form"!==e){n[e]=i;continue}s.setAttribute(e,i);continue}const r=si_funciona_1.default.strBefore(e,".");if("class"!==r&&"style"!==r)throw new Error("Unhandled dotKey: "+e);("class"!==r||!/\s/.test(i)&&i)&&("class"!==r?s.style.setProperty(si_funciona_1.default.kabobCase(si_funciona_1.default.strAfter(e,".")),i):s.classList.add(i))}return(0,bindListeners_1.default)(e)};exports.default=updateElement;
1
+ "use strict";require("core-js/modules/es.array.includes.js");var __importDefault=function(e){return e&&e.__esModule?e:{default:e}};Object.defineProperty(exports,"__esModule",{value:!0});const bindListeners_1=__importDefault(require("../events/functions/bindListeners")),deactivateListeners_1=__importDefault(require("../events/functions/deactivateListeners")),domItemChanges_1=__importDefault(require("../diff/domItemChanges")),appliedSnapshot_1=require("../diff/appliedSnapshot"),si_funciona_1=__importDefault(require("si-funciona")),generateElement_1=__importDefault(require("./generateElement")),updateChildNodes_1=__importDefault(require("./updateChildNodes")),updateElement=e=>{if(!e.element)return(0,updateChildNodes_1.default)((0,generateElement_1.default)((0,deactivateListeners_1.default)(e)));const t=(0,domItemChanges_1.default)(e),s=e.element,n=s;if(t.removed.nodeName||t.added.nodeName)return s.remove(),(0,updateChildNodes_1.default)((0,generateElement_1.default)((0,deactivateListeners_1.default)(e)));for(const e in t.removed){const i=t.removed[e];if(!e.includes(".")){if(e in s){delete n[e];continue}s.removeAttribute(e);continue}const a=si_funciona_1.default.strBefore(e,".");if("class"!==a&&"style"!==a)throw new Error("Unhandled dotKey: "+e);"class"!==a?s.style.removeProperty(si_funciona_1.default.kabobCase(si_funciona_1.default.strAfter(e,"."))):s.classList.remove(i)}for(const e in t.added){const i=t.added[e];if("className"===e){s.className=i;continue}if("style"===e){for(const e in i)s.style.setProperty(si_funciona_1.default.kabobCase(e),i[e]);continue}if(!e.includes(".")){if(e in s&&"form"!==e){n[e]=i;continue}s.setAttribute(e,i);continue}const a=si_funciona_1.default.strBefore(e,".");if("class"!==a&&"style"!==a)throw new Error("Unhandled dotKey: "+e);("class"!==a||!/\s/.test(i)&&i)&&("class"!==a?s.style.setProperty(si_funciona_1.default.kabobCase(si_funciona_1.default.strAfter(e,".")),i):s.classList.add(i))}return(0,appliedSnapshot_1.recordApplied)(e),(0,bindListeners_1.default)(e)};exports.default=updateElement;
@@ -13,6 +13,7 @@ Object.defineProperty(exports, "__esModule", {
13
13
  });
14
14
  const si_funciona_1 = __importDefault(require("si-funciona"));
15
15
  const elementChanges_1 = __importDefault(require("../diff/elementChanges"));
16
+ const appliedSnapshot_1 = require("../diff/appliedSnapshot");
16
17
  /**
17
18
  * Update a single objects.DomItem element with the provided attributes / style / elementProperties
18
19
  * @param config - The DomItem having config changes to be applied to its
@@ -44,6 +45,9 @@ const updateDomItem = config => {
44
45
  }
45
46
  si_funciona_1.default.dotSet(config, dotKey, changes.added[dotKey]);
46
47
  }
48
+ // The item now holds what the element has, which may not be what treeloom last wrote: compare the next update with
49
+ // the element itself again, or setting the item back to the old value would look like no change at all
50
+ (0, appliedSnapshot_1.forgetApplied)(config.element);
47
51
  return config;
48
52
  };
49
53
  exports.default = updateDomItem;
@@ -1 +1 @@
1
- "use strict";var __importDefault=function(e){return e&&e.__esModule?e:{default:e}};Object.defineProperty(exports,"__esModule",{value:!0});const si_funciona_1=__importDefault(require("si-funciona")),elementChanges_1=__importDefault(require("../diff/elementChanges")),updateDomItem=e=>{if(!e.element)return e;const t=(0,elementChanges_1.default)(e);for(const s in t.removed){const t=si_funciona_1.default.strAfter(s,".");if("className"===si_funciona_1.default.strBefore(t,".")){const s=e.attributes.className.split(" ");s.splice(si_funciona_1.default.strAfterLast(t,"."),1),e.attributes.className=s.join(" ");continue}si_funciona_1.default.dotUnset(e,s)}for(const s in t.added){const a=si_funciona_1.default.strAfter(s,".");if("className"===si_funciona_1.default.strBefore(a,".")){const a=e.attributes.className.split(" ");a.push(t.added[s]),e.attributes.className=a.join(" ");continue}si_funciona_1.default.dotSet(e,s,t.added[s])}return e};exports.default=updateDomItem;
1
+ "use strict";var __importDefault=function(e){return e&&e.__esModule?e:{default:e}};Object.defineProperty(exports,"__esModule",{value:!0});const si_funciona_1=__importDefault(require("si-funciona")),elementChanges_1=__importDefault(require("../diff/elementChanges")),appliedSnapshot_1=require("../diff/appliedSnapshot"),updateDomItem=e=>{if(!e.element)return e;const t=(0,elementChanges_1.default)(e);for(const s in t.removed){const t=si_funciona_1.default.strAfter(s,".");if("className"===si_funciona_1.default.strBefore(t,".")){const s=e.attributes.className.split(" ");s.splice(si_funciona_1.default.strAfterLast(t,"."),1),e.attributes.className=s.join(" ");continue}si_funciona_1.default.dotUnset(e,s)}for(const s in t.added){const a=si_funciona_1.default.strAfter(s,".");if("className"===si_funciona_1.default.strBefore(a,".")){const a=e.attributes.className.split(" ");a.push(t.added[s]),e.attributes.className=a.join(" ");continue}si_funciona_1.default.dotSet(e,s,t.added[s])}return(0,appliedSnapshot_1.forgetApplied)(e.element),e};exports.default=updateDomItem;
package/package.json CHANGED
@@ -13,7 +13,7 @@
13
13
  "serialization",
14
14
  "typescript"
15
15
  ],
16
- "version": "2.0.0",
16
+ "version": "2.1.0",
17
17
  "bugs": {
18
18
  "url": "https://github.com/jheagle/treeloom/issues"
19
19
  },