@webtides/element-js 0.7.4 → 1.0.0-alpha.10
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.
- package/README.md +2 -2
- package/docs/README.md +174 -0
- package/docs/concepts/classes.md +167 -0
- package/docs/concepts/hierarchy.md +61 -0
- package/docs/concepts/lifecycle.md +45 -0
- package/docs/concepts/reactivity.md +14 -0
- package/docs/features/computed-properties.md +28 -0
- package/docs/features/dependency-injection-context-protocol.md +132 -0
- package/docs/features/events.md +155 -0
- package/docs/features/global-styles.md +131 -0
- package/docs/features/methods.md +34 -0
- package/docs/features/properties.md +56 -0
- package/docs/features/refs.md +47 -0
- package/docs/features/renderless.md +70 -0
- package/docs/features/store.md +143 -0
- package/docs/features/styles.md +169 -0
- package/docs/features/templates.md +308 -0
- package/docs/features/watch.md +54 -0
- package/docs/getting-started/upgrade-to-v1.md +243 -0
- package/docs/guide/guide.md +112 -0
- package/index.js +9 -2
- package/package.json +19 -27
- package/src/BaseElement.js +176 -86
- package/src/StyledElement.js +66 -55
- package/src/TemplateElement.js +34 -8
- package/src/dom-parts/AttributePart.js +167 -0
- package/src/dom-parts/ChildNodePart.js +329 -0
- package/src/dom-parts/NodePart.js +38 -0
- package/src/dom-parts/Part.js +12 -0
- package/src/dom-parts/TemplatePart.js +134 -0
- package/src/dom-parts/TemplateResult.js +342 -0
- package/src/dom-parts/directives.js +111 -0
- package/src/dom-parts/html.js +12 -0
- package/src/dom-parts/render.js +10 -0
- package/src/util/AttributeParser.js +62 -29
- package/src/util/DOMHelper.js +253 -5
- package/src/util/Directive.js +42 -0
- package/src/util/GlobalStylesStore.js +66 -0
- package/src/util/SerializeStateHelper.js +103 -0
- package/src/util/Store.js +92 -9
- package/src/util/defineElement.js +2 -2
- package/src/util/toString.js +2 -2
- package/CHANGELOG.md +0 -154
- package/dist/index.js +0 -1673
- package/dist/index.js.map +0 -7
- package/src/renderer/vanilla/TemplateElement.js +0 -50
- package/src/renderer/vanilla/index.js +0 -18
- package/src/renderer/vanilla/util/directives.js +0 -78
- package/src/renderer/vanilla/util/html.js +0 -65
- package/src/renderer/vanilla/util/render.js +0 -149
- package/src/util/i18n.js +0 -20
package/src/util/DOMHelper.js
CHANGED
|
@@ -1,3 +1,35 @@
|
|
|
1
|
+
/* An Element node like <p> or <div> */
|
|
2
|
+
export const ELEMENT_NODE = 1;
|
|
3
|
+
/* An Attribute of an Element */
|
|
4
|
+
export const ATTRIBUTE_NODE = 2;
|
|
5
|
+
/* The actual Text inside an Element or Attr */
|
|
6
|
+
export const TEXT_NODE = 3;
|
|
7
|
+
/* A Comment node, such as <!-- … --> */
|
|
8
|
+
export const COMMENT_NODE = 8;
|
|
9
|
+
/* A Document Fragment, a document without a parent */
|
|
10
|
+
export const DOCUMENT_FRAGMENT_NODE = 11;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Gets a list of (childNode) indexes for the given node
|
|
14
|
+
* @param {Node} node
|
|
15
|
+
* @return {number[]}
|
|
16
|
+
*/
|
|
17
|
+
export const getNodePath = (node) => {
|
|
18
|
+
const path = [];
|
|
19
|
+
let parentNode = node.parentNode;
|
|
20
|
+
while (parentNode) {
|
|
21
|
+
path.push([...parentNode.childNodes].indexOf(node));
|
|
22
|
+
node = parentNode;
|
|
23
|
+
parentNode = node.parentNode;
|
|
24
|
+
}
|
|
25
|
+
return path;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Get a list of all parent nodes up the DOM tree
|
|
30
|
+
* @param {Node} domNode
|
|
31
|
+
* @returns {Node[]}
|
|
32
|
+
*/
|
|
1
33
|
export function getAllParentNodes(domNode) {
|
|
2
34
|
let node = domNode.parentElement;
|
|
3
35
|
let parents = [];
|
|
@@ -9,7 +41,12 @@ export function getAllParentNodes(domNode) {
|
|
|
9
41
|
return parents;
|
|
10
42
|
}
|
|
11
43
|
|
|
12
|
-
|
|
44
|
+
/**
|
|
45
|
+
* Gets the parent/root element of a shadow root or the global document body if not in shadow root.
|
|
46
|
+
* https://stackoverflow.com/questions/27453617/how-can-i-tell-if-an-element-is-in-a-shadow-dom
|
|
47
|
+
* @param {Node} element
|
|
48
|
+
* @returns {ShadowRoot | HTMLElement}
|
|
49
|
+
*/
|
|
13
50
|
export function getShadowParentOrBody(element) {
|
|
14
51
|
if (element instanceof ShadowRoot) {
|
|
15
52
|
return element;
|
|
@@ -25,6 +62,11 @@ export function getShadowParentOrBody(element) {
|
|
|
25
62
|
|
|
26
63
|
// TODO: add function for getClosestParentOfNodeType('custom-element')
|
|
27
64
|
|
|
65
|
+
/**
|
|
66
|
+
* Get the closest/first parent node that is a custom element
|
|
67
|
+
* @param {Node} domNode
|
|
68
|
+
* @returns {Node}
|
|
69
|
+
*/
|
|
28
70
|
export function getClosestParentCustomElementNode(domNode) {
|
|
29
71
|
const customElementParents = getAllParentNodes(domNode).filter((node) => {
|
|
30
72
|
return node.localName && node.localName.indexOf('-') !== -1;
|
|
@@ -33,6 +75,12 @@ export function getClosestParentCustomElementNode(domNode) {
|
|
|
33
75
|
return customElementParents.pop();
|
|
34
76
|
}
|
|
35
77
|
|
|
78
|
+
/**
|
|
79
|
+
* Get all
|
|
80
|
+
* @param {Node} domNode
|
|
81
|
+
* @param {{prototype: HTMLElement, new()}} instanceOfFilter
|
|
82
|
+
* @return {Node[]}
|
|
83
|
+
*/
|
|
36
84
|
export function getAllElementChildren(domNode, instanceOfFilter = HTMLElement) {
|
|
37
85
|
const nodeIterator = document.createNodeIterator(domNode, NodeFilter.SHOW_ELEMENT, (node) =>
|
|
38
86
|
node.nodeName.includes('-') && node instanceof instanceOfFilter
|
|
@@ -47,14 +95,214 @@ export function getAllElementChildren(domNode, instanceOfFilter = HTMLElement) {
|
|
|
47
95
|
return customElements;
|
|
48
96
|
}
|
|
49
97
|
|
|
98
|
+
/**
|
|
99
|
+
* Checks whether two nodes are of the same node type
|
|
100
|
+
* @param {Node} nodeA
|
|
101
|
+
* @param {Node} nodeB
|
|
102
|
+
* @returns {boolean}
|
|
103
|
+
*/
|
|
50
104
|
export function isOfSameNodeType(nodeA, nodeB) {
|
|
51
105
|
if (!nodeA && !nodeA.localName) return false;
|
|
52
106
|
if (!nodeB && !nodeB.localName) return false;
|
|
53
107
|
return nodeA.localName === nodeB.localName;
|
|
54
108
|
}
|
|
55
109
|
|
|
56
|
-
|
|
57
|
-
|
|
110
|
+
/**
|
|
111
|
+
* Checks if adopting style sheets is supported in browser
|
|
112
|
+
* @returns {boolean}
|
|
113
|
+
*/
|
|
114
|
+
export const supportsAdoptingStyleSheets = () => {
|
|
115
|
+
return 'adoptedStyleSheets' in Document.prototype && 'replace' in CSSStyleSheet.prototype;
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Better implementation of hasChildNodes check because the native check will also count for empty whitespace as child nodes... :(
|
|
120
|
+
* @param {Node} node
|
|
121
|
+
* @returns {boolean}
|
|
122
|
+
*/
|
|
123
|
+
export const hasChildNodes = (node) => {
|
|
124
|
+
const childNodesLength = node.childNodes.length;
|
|
125
|
+
|
|
126
|
+
if (childNodesLength === 0) return false;
|
|
127
|
+
|
|
128
|
+
if (childNodesLength === 1 && node.firstChild.nodeType === 3 && node.firstChild.textContent.trim() === '')
|
|
129
|
+
return false;
|
|
130
|
+
|
|
131
|
+
// children will not contain text nodes...
|
|
132
|
+
if (childNodesLength > 0) return true;
|
|
133
|
+
|
|
134
|
+
// TODO: check if more than 1 child nodes, but all childnodes are whitespace text ndoes?!
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Parses the given string as HTML template and returns a real DOM element
|
|
139
|
+
* @param {string} template
|
|
140
|
+
* @returns {HTMLElement}
|
|
141
|
+
*/
|
|
142
|
+
export const convertStringToHTML = (template) => {
|
|
143
|
+
const parser = new DOMParser();
|
|
144
|
+
const document = parser.parseFromString(template.toString(), 'text/html');
|
|
145
|
+
return document.body;
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Parses the given string and returns a DocumentFragment which contains the DOM subtree
|
|
150
|
+
* representing the <template> element's template contents.
|
|
151
|
+
* @param {string} string
|
|
152
|
+
* @return {DocumentFragment}
|
|
153
|
+
*/
|
|
154
|
+
export const convertStringToTemplate = (string) => {
|
|
155
|
+
const template = globalThis.document?.createElement('template');
|
|
156
|
+
// TODO: ensure that SSRed and CSRed templates have the same amount of whitespace text nodes
|
|
157
|
+
template.innerHTML = string.trimStart().trimEnd();
|
|
158
|
+
return template.content;
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Diff the attributes for a live DOM element against a template DOM element
|
|
163
|
+
* @param {Element} templateElement - The new template
|
|
164
|
+
* @param {Element} domElement - The existing DOM node
|
|
165
|
+
*/
|
|
166
|
+
export const diffAttributes = function (templateElement, domElement) {
|
|
167
|
+
// remove or update previous attributes
|
|
168
|
+
for (/** @type {Attr} */ const attribute of Array.from(domElement.attributes)) {
|
|
169
|
+
if (templateElement.hasAttribute(attribute.name)) {
|
|
170
|
+
const oldValue = attribute.value;
|
|
171
|
+
const newValue = templateElement.getAttribute(attribute.name) || '';
|
|
172
|
+
if (oldValue !== newValue) {
|
|
173
|
+
domElement.setAttribute(attribute.name, newValue);
|
|
174
|
+
}
|
|
175
|
+
} else {
|
|
176
|
+
domElement.removeAttribute(attribute.name);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// set newly added attributes
|
|
181
|
+
for (/** @type {Attr} */ const attribute of Array.from(templateElement.attributes)) {
|
|
182
|
+
if (!domElement.hasAttribute(attribute.name)) {
|
|
183
|
+
domElement.setAttribute(attribute.name, attribute.value);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Diff a live DOM node against a template DOM node
|
|
190
|
+
* @param {Node} templateNode
|
|
191
|
+
* @param {Node} domNode
|
|
192
|
+
* @param {(Node) => Boolean} shouldDiffChildNodes
|
|
193
|
+
*/
|
|
194
|
+
export const diffNodes = function (templateNode, domNode, shouldDiffChildNodes = (templateChildNode) => true) {
|
|
195
|
+
// If the DOM node should be empty, remove all child nodes
|
|
196
|
+
if (hasChildNodes(domNode) && !hasChildNodes(templateNode)) {
|
|
197
|
+
domNode.replaceChildren();
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// If the DOM node is empty, but should have children, add child nodes from the template node
|
|
202
|
+
if (!hasChildNodes(domNode) && hasChildNodes(templateNode)) {
|
|
203
|
+
domNode.replaceChildren(...templateNode.childNodes);
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const templateChildNodes = [...templateNode.childNodes];
|
|
208
|
+
const domChildNodes = [...domNode.childNodes];
|
|
209
|
+
|
|
210
|
+
// Diff each node in the child node lists
|
|
211
|
+
let length = Math.max(templateChildNodes.length, domChildNodes.length);
|
|
212
|
+
for (let index = 0; index < length; index++) {
|
|
213
|
+
const templateChildNode = templateChildNodes[index];
|
|
214
|
+
const domChildNode = domChildNodes[index];
|
|
215
|
+
|
|
216
|
+
// If the DOM node doesn't exist, append/copy the template node
|
|
217
|
+
if (!domChildNode) {
|
|
218
|
+
domNode.appendChild(templateChildNode);
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// If the template node doesn't exist, remove the node in the DOM
|
|
223
|
+
if (!templateChildNode) {
|
|
224
|
+
domNode.removeChild(domChildNode);
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// If DOM node is equal to the template node, don't do anything
|
|
229
|
+
if (domChildNode.isEqualNode(templateChildNode)) {
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// the following two checks don't have to be correct, but it could bring as back to the fast path
|
|
234
|
+
// in the worst case we still have to compare and swap all the elements
|
|
235
|
+
// but in the best case we find the one element that should actually be inserted or removed
|
|
236
|
+
// and from there on, every node that comes after should be equal again
|
|
237
|
+
|
|
238
|
+
// fast path for removing DOM nodes - we delete the node now instead of at the end
|
|
239
|
+
if (domNode.childNodes.length > templateNode.childNodes.length) {
|
|
240
|
+
domChildNodes.splice(index, 1);
|
|
241
|
+
domNode.removeChild(domChildNode);
|
|
242
|
+
// because domChildNodes will get shorter by splicing it, everything moves up by one
|
|
243
|
+
// so the (actual) next element will have the same index now as we currently have
|
|
244
|
+
// therefore we have to adjust our counters
|
|
245
|
+
length--;
|
|
246
|
+
index--;
|
|
247
|
+
|
|
248
|
+
// we might have corrected everything and the parent nodes could be equal again
|
|
249
|
+
// if so, we can skip the rest of the child node checks
|
|
250
|
+
if (domNode.childNodes.length === templateNode.childNodes.length && domNode.isEqualNode(templateNode)) {
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// fast path for adding DOM nodes - we insert the node now instead of at the end
|
|
258
|
+
// but NOT if we already are at the end of the list of child nodes
|
|
259
|
+
if (index !== domNode.childNodes.length - 1 && domNode.childNodes.length < templateNode.childNodes.length) {
|
|
260
|
+
domChildNodes.splice(index, 0, templateChildNode);
|
|
261
|
+
domNode.insertBefore(templateChildNode, domChildNode);
|
|
262
|
+
|
|
263
|
+
// we might have corrected everything and the parent nodes could be equal again
|
|
264
|
+
// if so, we can skip the rest of the child node checks
|
|
265
|
+
if (domNode.childNodes.length === templateNode.childNodes.length && domNode.isEqualNode(templateNode)) {
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
58
271
|
|
|
59
|
-
//
|
|
60
|
-
|
|
272
|
+
// If node types are not the same, replace the DOM node with the template node
|
|
273
|
+
if (templateChildNode.nodeType !== domChildNode.nodeType) {
|
|
274
|
+
domNode.replaceChild(templateChildNode, domChildNode);
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// If the node is a Text node or a Comment node, update it
|
|
279
|
+
if (domChildNode.nodeType === TEXT_NODE || domChildNode.nodeType === COMMENT_NODE) {
|
|
280
|
+
domChildNode.textContent = templateChildNode.textContent;
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// If the element tag names are not the same, replace the DOM node with the template node
|
|
285
|
+
if (templateChildNode.nodeType === ELEMENT_NODE && templateChildNode.tagName !== domChildNode.tagName) {
|
|
286
|
+
domNode.replaceChild(templateChildNode, domChildNode);
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// If the node is an SVG element, don't even think about diffing it, just replace it
|
|
291
|
+
if (templateChildNode.nodeType === ELEMENT_NODE && templateChildNode.tagName === 'SVG') {
|
|
292
|
+
domNode.replaceChild(templateChildNode, domChildNode);
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// If the node is an Element node, diff the attributes
|
|
297
|
+
if (templateChildNode.nodeType === ELEMENT_NODE) {
|
|
298
|
+
diffAttributes(templateChildNode, domChildNode);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// If there are child nodes, diff them recursively
|
|
302
|
+
if (templateChildNode.hasChildNodes()) {
|
|
303
|
+
if (shouldDiffChildNodes(templateChildNode)) {
|
|
304
|
+
diffNodes(templateChildNode, domChildNode);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export class Directive {
|
|
2
|
+
/** @type {Element} */
|
|
3
|
+
node = undefined;
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @param {Node} node
|
|
7
|
+
*/
|
|
8
|
+
constructor(node) {
|
|
9
|
+
this.node = node;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @abstract
|
|
14
|
+
*/
|
|
15
|
+
update() {}
|
|
16
|
+
|
|
17
|
+
// TODO: this is somewhat custom... maybe we could set the initial values in the constructor and then simply override the default toString() method?!
|
|
18
|
+
/**
|
|
19
|
+
* Implement the stringify method and return a string for rendering the directive in SSR mode
|
|
20
|
+
* @param {...any} values
|
|
21
|
+
* @returns {string}
|
|
22
|
+
*/
|
|
23
|
+
stringify(...values) {
|
|
24
|
+
return '';
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @param {Class<Directive>} directiveClass
|
|
30
|
+
*/
|
|
31
|
+
export const defineDirective = (directiveClass) => {
|
|
32
|
+
return (...values) => {
|
|
33
|
+
return {
|
|
34
|
+
directiveClass,
|
|
35
|
+
values,
|
|
36
|
+
toString: () => {
|
|
37
|
+
const directive = new directiveClass();
|
|
38
|
+
return directive.stringify(...values);
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
};
|
|
42
|
+
};
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { Store } from './Store.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @property {CSSStyleSheet[]} cssStyleSheets
|
|
5
|
+
*/
|
|
6
|
+
class GlobalStylesStore extends Store {
|
|
7
|
+
/** @type {Map<Node, CSSStyleSheet>} */
|
|
8
|
+
globalStyleSheetsCache = new WeakMap();
|
|
9
|
+
|
|
10
|
+
constructor() {
|
|
11
|
+
super();
|
|
12
|
+
if (globalThis.elementJsConfig?.observeGlobalStyles) {
|
|
13
|
+
const mutationObserver = new MutationObserver((mutationRecord) => {
|
|
14
|
+
if (!mutationRecord[0]) return;
|
|
15
|
+
const filteredNodes = Array.from(mutationRecord[0].addedNodes).filter(
|
|
16
|
+
(node) => node.tagName === 'STYLE' || node.tagName === 'LINK',
|
|
17
|
+
);
|
|
18
|
+
if (filteredNodes && filteredNodes[0] && filteredNodes[0].tagName === 'LINK') {
|
|
19
|
+
filteredNodes[0].onload = () => {
|
|
20
|
+
this.requestUpdate();
|
|
21
|
+
};
|
|
22
|
+
} else if (filteredNodes && filteredNodes[0] && filteredNodes[0].tagName === 'STYLE') {
|
|
23
|
+
this.requestUpdate();
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
mutationObserver.observe(globalThis.document, { subtree: true, childList: true });
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
getGlobalStyleSheets(selector) {
|
|
31
|
+
/** @type {CSSStyleSheet[]}*/
|
|
32
|
+
const cssStyleSheets = [];
|
|
33
|
+
|
|
34
|
+
if (selector === false) return cssStyleSheets;
|
|
35
|
+
|
|
36
|
+
if (typeof selector === 'string') {
|
|
37
|
+
selector = [selector];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (selector === true || selector.includes('document')) {
|
|
41
|
+
cssStyleSheets.push(...globalThis.document?.adoptedStyleSheets);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
Array.from(globalThis.document?.styleSheets).map((styleSheet) => {
|
|
45
|
+
if (Array.isArray(selector) && !selector.some((cssSelector) => styleSheet.ownerNode.matches(cssSelector))) {
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// TODO: this will always be null as we never set anything to the cache...
|
|
50
|
+
let cssStyleSheet = this.globalStyleSheetsCache.get(styleSheet.ownerNode);
|
|
51
|
+
if (!cssStyleSheet) {
|
|
52
|
+
cssStyleSheet = new CSSStyleSheet();
|
|
53
|
+
if (styleSheet.ownerNode.tagName === 'STYLE') {
|
|
54
|
+
cssStyleSheet.replaceSync(styleSheet.ownerNode.textContent);
|
|
55
|
+
} else if (styleSheet.ownerNode.tagName === 'LINK') {
|
|
56
|
+
Array.from(styleSheet.cssRules).map((rule) => cssStyleSheet.insertRule(rule.cssText));
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
cssStyleSheets.push(cssStyleSheet);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
return cssStyleSheets;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export const globalStylesStore = new GlobalStylesStore();
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { Store } from './Store.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @typedef {Object} Serializable
|
|
5
|
+
* An interface that classes should implement to enable serialization and deserialization of their state.
|
|
6
|
+
* @property {string} _serializationKey - a unique key to be used for serialization.
|
|
7
|
+
* @property {function} serializeState - Function to retrieve the state for serialization.
|
|
8
|
+
* @property {function} restoreState - Function to set the state during deserialization.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
// TODO: is it ok to expose this like this? Or should we wrap the cache in helper methods also?
|
|
12
|
+
/** @type {Map<string, Serializable>} */
|
|
13
|
+
export const serializableObjectsCache = new Map();
|
|
14
|
+
|
|
15
|
+
/** @type {HTMLScriptElement} */
|
|
16
|
+
let globalElementJsState;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Initializes the global script element that holds the state for all `Serializable` objects.
|
|
20
|
+
*/
|
|
21
|
+
function initGlobalStateObject() {
|
|
22
|
+
if (!globalElementJsState) {
|
|
23
|
+
globalElementJsState = Array.from(globalThis.document.scripts).find((script) => script.type === 'ejs/json');
|
|
24
|
+
if (!globalElementJsState) {
|
|
25
|
+
const script = document.createElement('script');
|
|
26
|
+
script.setAttribute('type', 'ejs/json');
|
|
27
|
+
script.textContent = '{}';
|
|
28
|
+
document.body.appendChild(script);
|
|
29
|
+
globalElementJsState = script;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Takes an object that implements the `Serializable` interface and serializes its state.
|
|
36
|
+
* @param {Serializable} serializableObject
|
|
37
|
+
*/
|
|
38
|
+
export function serializeState(serializableObject) {
|
|
39
|
+
if (!globalThis.elementJsConfig?.serializeState) return;
|
|
40
|
+
|
|
41
|
+
if (!serializableObject._serializationKey && !serializableObject.serializeState) {
|
|
42
|
+
throw new Error('serializableObject does not implement the Serializable interface');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
initGlobalStateObject();
|
|
46
|
+
|
|
47
|
+
const currentState = JSON.parse(globalElementJsState.textContent);
|
|
48
|
+
currentState[serializableObject._serializationKey] = serializableObject.serializeState();
|
|
49
|
+
globalElementJsState.textContent = JSON.stringify(currentState, (key, value) => {
|
|
50
|
+
if (value instanceof Store) {
|
|
51
|
+
return 'Store/' + value._serializationKey;
|
|
52
|
+
} else {
|
|
53
|
+
return value;
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Takes an object that implements the `Serializable` interface and deserializes its state and restores the object.
|
|
60
|
+
* @param {Serializable} serializableObject
|
|
61
|
+
* @param {{[string: any]: *}} [serializedState]
|
|
62
|
+
*/
|
|
63
|
+
export function deserializeState(serializableObject, serializedState) {
|
|
64
|
+
if (!globalThis.elementJsConfig?.serializeState) return;
|
|
65
|
+
|
|
66
|
+
if (!serializableObject._serializationKey && !serializableObject.restoreState) {
|
|
67
|
+
throw new Error('serializableObject does not implement the Serializable interface');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (serializedState) {
|
|
71
|
+
serializableObject.restoreState(serializedState);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
initGlobalStateObject();
|
|
76
|
+
|
|
77
|
+
const unresolvedState = JSON.parse(globalElementJsState.textContent);
|
|
78
|
+
const currentState = JSON.parse(globalElementJsState.textContent, (key, value) => {
|
|
79
|
+
if (typeof value === 'string' && value.startsWith('Store/')) {
|
|
80
|
+
const [_, storeUuid] = value.split('/');
|
|
81
|
+
const serializedState = unresolvedState[storeUuid];
|
|
82
|
+
return new Store(serializedState, { key: storeUuid, serializedState });
|
|
83
|
+
} else {
|
|
84
|
+
return value;
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
serializableObject.restoreState(currentState[serializableObject._serializationKey]);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Checks if a serialized state is available for a given key.
|
|
93
|
+
* @param key
|
|
94
|
+
* @return {boolean}
|
|
95
|
+
*/
|
|
96
|
+
export function hasSerializedState(key) {
|
|
97
|
+
if (!globalThis.elementJsConfig?.serializeState) return false;
|
|
98
|
+
|
|
99
|
+
initGlobalStateObject();
|
|
100
|
+
|
|
101
|
+
const serializedState = JSON.parse(globalElementJsState.textContent);
|
|
102
|
+
return serializedState.hasOwnProperty(key);
|
|
103
|
+
}
|
package/src/util/Store.js
CHANGED
|
@@ -1,18 +1,59 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { deepEquals, isObjectLike } from './AttributeParser.js';
|
|
2
|
+
import {
|
|
3
|
+
hasSerializedState,
|
|
4
|
+
serializeState,
|
|
5
|
+
deserializeState,
|
|
6
|
+
serializableObjectsCache,
|
|
7
|
+
} from './SerializeStateHelper.js';
|
|
8
|
+
import { BaseElement } from '../BaseElement.js';
|
|
3
9
|
|
|
10
|
+
/**
|
|
11
|
+
* Options object for the Store
|
|
12
|
+
* @typedef {Object} StoreOptions
|
|
13
|
+
* @property {string} [key] - A unique key so that the store instance can be serialized and deserialized correctly across multiple shared parents/hosts.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @implements {Serializable}
|
|
18
|
+
*/
|
|
4
19
|
export class Store {
|
|
5
|
-
|
|
20
|
+
_serializationKey;
|
|
21
|
+
_observer = new Set([]);
|
|
6
22
|
_singlePropertyMode = false;
|
|
7
23
|
_state = {};
|
|
8
24
|
|
|
9
|
-
|
|
10
|
-
|
|
25
|
+
/**
|
|
26
|
+
* @param {* | object} [value]
|
|
27
|
+
* @param {StoreOptions} [options]
|
|
28
|
+
*/
|
|
29
|
+
constructor(value, options) {
|
|
30
|
+
if (options?.key && serializableObjectsCache.has(options?.key)) {
|
|
31
|
+
return serializableObjectsCache.get(options?.key);
|
|
32
|
+
}
|
|
33
|
+
|
|
11
34
|
this._singlePropertyMode = arguments.length > 0 && !isObjectLike(value);
|
|
35
|
+
|
|
36
|
+
if (options?.key) {
|
|
37
|
+
this._serializationKey = options?.key;
|
|
38
|
+
} else {
|
|
39
|
+
this._serializationKey = globalThis.crypto.randomUUID();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (options?.serializedState) {
|
|
43
|
+
deserializeState(this, options.serializedState);
|
|
44
|
+
} else if (hasSerializedState(this._serializationKey)) {
|
|
45
|
+
deserializeState(this);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// wrap primitives with a generic "value" field to generate getter and setter
|
|
12
49
|
const specificValues = this._singlePropertyMode ? { value } : value;
|
|
13
|
-
|
|
50
|
+
const properties = { ...(!this._singlePropertyMode && this.properties()), ...specificValues };
|
|
51
|
+
|
|
52
|
+
const serializationKeys = Object.keys(this.serializeState());
|
|
53
|
+
Object.entries(properties).map(([key, value]) => {
|
|
54
|
+
this._state[key] =
|
|
55
|
+
hasSerializedState(this._serializationKey) && serializationKeys.includes(key) ? this[key] : value;
|
|
14
56
|
|
|
15
|
-
Object.entries(this._state).map(([key, value]) => {
|
|
16
57
|
if (value instanceof Store) {
|
|
17
58
|
value.subscribe(this);
|
|
18
59
|
}
|
|
@@ -28,6 +69,8 @@ export class Store {
|
|
|
28
69
|
value.subscribe(this);
|
|
29
70
|
}
|
|
30
71
|
|
|
72
|
+
serializeState(this);
|
|
73
|
+
|
|
31
74
|
if (!deepEquals(newValue, oldValue)) {
|
|
32
75
|
const watch = this.watch();
|
|
33
76
|
if (watch.hasOwnProperty(key) && typeof watch[key] === 'function') {
|
|
@@ -38,8 +81,15 @@ export class Store {
|
|
|
38
81
|
},
|
|
39
82
|
});
|
|
40
83
|
});
|
|
84
|
+
|
|
85
|
+
serializableObjectsCache.set(this._serializationKey, this);
|
|
41
86
|
}
|
|
42
87
|
|
|
88
|
+
/**
|
|
89
|
+
* Properties to be defined and assigned on the store. Can be overridden in extended classes.
|
|
90
|
+
* Properties defined here will also trigger an update cycle when changed.
|
|
91
|
+
* @return {{}}
|
|
92
|
+
*/
|
|
43
93
|
properties() {
|
|
44
94
|
return {};
|
|
45
95
|
}
|
|
@@ -47,34 +97,47 @@ export class Store {
|
|
|
47
97
|
/**
|
|
48
98
|
* When a property as the key was changed in the store, the callback function defined as value
|
|
49
99
|
* will be called with `newValue` and `oldValue`
|
|
50
|
-
* eg. { property: (oldValue, newValue) => { console.log('property changed!, oldValue, newValue); } }
|
|
51
100
|
* @return {{}}
|
|
52
101
|
*/
|
|
53
102
|
watch() {
|
|
54
103
|
return {};
|
|
55
104
|
}
|
|
56
105
|
|
|
106
|
+
/**
|
|
107
|
+
* Overrides `valueOf` to automatically return .value
|
|
108
|
+
* @returns {* | {}}
|
|
109
|
+
*/
|
|
57
110
|
valueOf() {
|
|
58
111
|
return this._singlePropertyMode ? this._state.value : this._state;
|
|
59
112
|
}
|
|
60
113
|
|
|
114
|
+
/**
|
|
115
|
+
* Overrides `toString` to automatically return .value
|
|
116
|
+
* @returns {string}
|
|
117
|
+
*/
|
|
61
118
|
toString() {
|
|
62
119
|
return `${this._singlePropertyMode ? this._state.value : this._state}`;
|
|
63
120
|
}
|
|
64
121
|
|
|
65
122
|
/**
|
|
123
|
+
* Add an observer
|
|
66
124
|
* @param {BaseElement|Function|Store} observer
|
|
67
125
|
*/
|
|
68
126
|
subscribe(observer) {
|
|
69
127
|
this._observer.add(observer);
|
|
70
128
|
}
|
|
129
|
+
|
|
71
130
|
/**
|
|
72
|
-
*
|
|
131
|
+
* Remove an observer
|
|
132
|
+
* @param {BaseElement | Function} observer
|
|
73
133
|
*/
|
|
74
134
|
unsubscribe(observer) {
|
|
75
135
|
this._observer.delete(observer);
|
|
76
136
|
}
|
|
77
137
|
|
|
138
|
+
/**
|
|
139
|
+
* Request and batch an asynchronous update cycle
|
|
140
|
+
*/
|
|
78
141
|
requestUpdate() {
|
|
79
142
|
this._observer.forEach(async (observer) => {
|
|
80
143
|
if (observer instanceof BaseElement || observer instanceof Store) {
|
|
@@ -104,4 +167,24 @@ export class Store {
|
|
|
104
167
|
}
|
|
105
168
|
});
|
|
106
169
|
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* This method will be used to serialize this stores state.
|
|
173
|
+
* By default, it will generate an object with all properties from this.properties().
|
|
174
|
+
* You can override this and return a different object with more/less/different properties.
|
|
175
|
+
* @return {{[string: any]: *}}
|
|
176
|
+
*/
|
|
177
|
+
serializeState() {
|
|
178
|
+
const keys = Object.keys(this.properties());
|
|
179
|
+
return Object.fromEntries(keys.map((key) => [key, this[key]]));
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* This method will be used to restore properties on the store from a previously serialized state.
|
|
184
|
+
* You can override this and handle the deserialization differently if the default is not enough.
|
|
185
|
+
* @param {{[string: any]: *}} serializedState
|
|
186
|
+
*/
|
|
187
|
+
restoreState(serializedState) {
|
|
188
|
+
Object.assign(this, serializedState);
|
|
189
|
+
}
|
|
107
190
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Wrapper for defining custom elements so that registering an element multiple times won't crash
|
|
3
|
-
* @param name for the tag
|
|
4
|
-
* @param constructor for the custom element
|
|
3
|
+
* @param {string} name - name for the element tag
|
|
4
|
+
* @param {CustomElementConstructor} constructor for the custom element
|
|
5
5
|
*/
|
|
6
6
|
export function defineElement(name, constructor) {
|
|
7
7
|
try {
|
package/src/util/toString.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Little wrapper function for JSON.stringify() to easily convert objects and arrays to strings
|
|
3
3
|
* to be able to set them as attributes on custom elements
|
|
4
|
-
* @param value to be stringified
|
|
5
|
-
* @return
|
|
4
|
+
* @param {object | array} value - to be stringified
|
|
5
|
+
* @return {string}
|
|
6
6
|
*/
|
|
7
7
|
export function toString(value) {
|
|
8
8
|
try {
|