@webtides/element-js 0.7.3 → 1.0.0-alpha.1
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/dist/index.js +663 -963
- package/dist/index.js.map +4 -4
- package/package.json +16 -20
- package/src/BaseElement.js +2 -43
- package/src/StyledElement.js +1 -9
- package/src/TemplateElement.js +3 -7
- package/src/dom-parts/AttributePart.js +160 -0
- package/src/dom-parts/ChildNodePart.js +343 -0
- package/src/dom-parts/NodePart.js +37 -0
- package/src/dom-parts/Part.js +12 -0
- package/src/dom-parts/TemplatePart.js +140 -0
- package/src/dom-parts/TemplateResult.js +331 -0
- package/src/{renderer/vanilla/util → dom-parts}/directives.js +17 -11
- package/src/dom-parts/html.js +12 -0
- package/src/dom-parts/render.js +10 -0
- package/src/util/AttributeParser.js +8 -8
- package/src/util/DOMHelper.js +213 -0
- package/src/util/Directive.js +22 -0
- package/CHANGELOG.md +0 -142
- package/src/renderer/vanilla/TemplateElement.js +0 -50
- package/src/renderer/vanilla/index.js +0 -18
- package/src/renderer/vanilla/util/html.js +0 -65
- package/src/renderer/vanilla/util/render.js +0 -149
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
import { COMMENT_NODE, getNodePath } from '../util/DOMHelper';
|
|
2
|
+
import { encodeAttribute, isObjectLike } from '../util/AttributeParser.js';
|
|
3
|
+
import { TemplatePart } from './TemplatePart.js';
|
|
4
|
+
|
|
5
|
+
const voidElements = /^(?:area|base|br|col|embed|hr|img|input|link|meta|source|track|wbr)$/i;
|
|
6
|
+
const elements = /<([a-z]+[a-z0-9:._-]*)([^>]*?)(\/?)>/g;
|
|
7
|
+
// TODO: v this will not match any values with escaped quotes like onClick='console.log("\'test")'
|
|
8
|
+
const attributes = /([^\s]*)=((?:")[^"]*(?:")|(?:')[^']*(?:')|[^\s\/>]*)|([^\s\/>]*)/g;
|
|
9
|
+
const partPositions = /[\x01\x02]/g;
|
|
10
|
+
// \x01 Node.ELEMENT_NODE
|
|
11
|
+
// \x02 Node.ATTRIBUTE_NODE
|
|
12
|
+
// \x03 COMMENT.ATTRIBUTE_TOKEN
|
|
13
|
+
// \x04 Node.ATTRIBUTE_TOKEN
|
|
14
|
+
|
|
15
|
+
const interpolation = new RegExp(`(<!--dom-part-(\\d+)--><!--/dom-part-(\\d+)-->|(\\S\\w+)="\x04(")?|\x04)`, 'g');
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Given a template, find part positions as both nodes and attributes and
|
|
19
|
+
* return a string with placeholders as either comment nodes or named attributes.
|
|
20
|
+
* @param {TemplateStringsArray | string[]} templateStrings a template literal tag array
|
|
21
|
+
* @param {string} attributePlaceholders replace placeholders inside attribute values with this value
|
|
22
|
+
* @returns {string} X/HTML with prefixed comments or attributes
|
|
23
|
+
*/
|
|
24
|
+
export const createTemplateString = (templateStrings, attributePlaceholders = '') => {
|
|
25
|
+
// TODO: make it easier to identify attribute and node parts for SSR and leave the comments at those positions to be replaced in toString()
|
|
26
|
+
let partIndex = 0;
|
|
27
|
+
// join all interpolations (for values) with a special placeholder and remove any whitespace
|
|
28
|
+
let template = templateStrings.join('\x01').trim();
|
|
29
|
+
// find (match) all elements to identify their attributes
|
|
30
|
+
template = template.replace(elements, (_, name, attributesString, trailingSlash) => {
|
|
31
|
+
let elementTagWithAttributes = name + attributesString.replaceAll('\x01', attributePlaceholders).trimEnd();
|
|
32
|
+
if (trailingSlash.length) elementTagWithAttributes += voidElements.test(name) ? ' /' : '></' + name;
|
|
33
|
+
// collect all attribute parts so that we can place matching comment nodes
|
|
34
|
+
const attributeParts = attributesString.replace(attributes, (attribute, name, valueWithQuotes, directive) => {
|
|
35
|
+
if (directive) {
|
|
36
|
+
return `<!--\x02$-->`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// remove quotes from attribute value to normalize the value
|
|
40
|
+
const value =
|
|
41
|
+
valueWithQuotes?.startsWith('"') || valueWithQuotes?.startsWith("'")
|
|
42
|
+
? valueWithQuotes.slice(1, -1)
|
|
43
|
+
: valueWithQuotes;
|
|
44
|
+
const partsCount = (attribute.match(/\x01/g) || []).length;
|
|
45
|
+
const parts = [];
|
|
46
|
+
for (let index = 0; index < partsCount; index++) {
|
|
47
|
+
parts.push(`<!--\x02:${name}=${value.replaceAll('\x01', '\x03')}-->`);
|
|
48
|
+
}
|
|
49
|
+
return parts.join('');
|
|
50
|
+
});
|
|
51
|
+
return `
|
|
52
|
+
${attributesString.includes('\x01') ? attributeParts : ''}
|
|
53
|
+
<${elementTagWithAttributes}>
|
|
54
|
+
`;
|
|
55
|
+
});
|
|
56
|
+
// replace interpolation placeholders with our indexed markers
|
|
57
|
+
template = template.replace(partPositions, (partPosition) => {
|
|
58
|
+
if (partPosition === '\x01') {
|
|
59
|
+
return `<!--dom-part-${partIndex}--><!--/dom-part-${partIndex++}-->`;
|
|
60
|
+
}
|
|
61
|
+
if (partPosition === '\x02') {
|
|
62
|
+
return `dom-part-${partIndex++}`;
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
return `
|
|
66
|
+
<!--template-part-->
|
|
67
|
+
${template}
|
|
68
|
+
<!--/template-part-->
|
|
69
|
+
`;
|
|
70
|
+
// TODO: ^ this is actually important because of the whitespace
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* @param {String} name
|
|
75
|
+
* @param {any} value
|
|
76
|
+
* @param {Boolean} isSingleValue
|
|
77
|
+
* @return {String}
|
|
78
|
+
*/
|
|
79
|
+
const attribute = (name, value, isSingleValue = true) => {
|
|
80
|
+
return ` ${name}="${encodeAttribute(isObjectLike(value) ? JSON.stringify(value) : value)}${
|
|
81
|
+
isSingleValue ? '"' : ''
|
|
82
|
+
}`;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* @param {any} value
|
|
87
|
+
* @return {String}
|
|
88
|
+
*/
|
|
89
|
+
const getValue = (value) => {
|
|
90
|
+
switch (typeof value) {
|
|
91
|
+
case 'string':
|
|
92
|
+
return encodeAttribute(value);
|
|
93
|
+
case 'boolean':
|
|
94
|
+
case 'number':
|
|
95
|
+
return String(value);
|
|
96
|
+
case 'object':
|
|
97
|
+
switch (true) {
|
|
98
|
+
case Array.isArray(value):
|
|
99
|
+
return value.map(getValue).join('');
|
|
100
|
+
case value instanceof TemplateResult:
|
|
101
|
+
return value.toString();
|
|
102
|
+
}
|
|
103
|
+
break;
|
|
104
|
+
case 'function':
|
|
105
|
+
return getValue(value());
|
|
106
|
+
}
|
|
107
|
+
return value == null ? '' : encodeAttribute(String(value));
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
/** @type {Map<TemplateStringsArray, *[]>} */
|
|
111
|
+
const parsedUpdates = new WeakMap();
|
|
112
|
+
|
|
113
|
+
/** @type {Map<Element, TemplatePart>} */
|
|
114
|
+
const templateParts = new WeakMap();
|
|
115
|
+
|
|
116
|
+
export class TemplateResult {
|
|
117
|
+
/**
|
|
118
|
+
* @param {TemplateStringsArray} strings
|
|
119
|
+
* @param {any[]} values
|
|
120
|
+
*/
|
|
121
|
+
constructor(strings, ...values) {
|
|
122
|
+
this.strings = strings;
|
|
123
|
+
this.values = values;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* @param {Element} domNode
|
|
128
|
+
*/
|
|
129
|
+
renderInto(domNode) {
|
|
130
|
+
let serverSideRendered = false;
|
|
131
|
+
let templatePart = templateParts.get(domNode);
|
|
132
|
+
if (!templatePart) {
|
|
133
|
+
const startNode = Array.from(domNode.childNodes)
|
|
134
|
+
.filter((node) => node.nodeType === COMMENT_NODE)
|
|
135
|
+
.find((node) => node.data === 'template-part');
|
|
136
|
+
|
|
137
|
+
serverSideRendered = startNode !== undefined;
|
|
138
|
+
|
|
139
|
+
templatePart = new TemplatePart(startNode, this);
|
|
140
|
+
templateParts.set(domNode, templatePart);
|
|
141
|
+
|
|
142
|
+
if (!serverSideRendered) {
|
|
143
|
+
domNode.replaceChildren(...templatePart.childNodes);
|
|
144
|
+
}
|
|
145
|
+
} else {
|
|
146
|
+
templatePart.update(this);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* @param {String[]} strings
|
|
152
|
+
* @return {(() => String)[]}
|
|
153
|
+
*/
|
|
154
|
+
parse(strings) {
|
|
155
|
+
const html = createTemplateString(strings, '\x04');
|
|
156
|
+
const parts = [];
|
|
157
|
+
let i = 0;
|
|
158
|
+
let match = null;
|
|
159
|
+
while ((match = interpolation.exec(html))) {
|
|
160
|
+
const pre = html.slice(i, match.index);
|
|
161
|
+
i = match.index + match[0].length;
|
|
162
|
+
if (match[2]) {
|
|
163
|
+
// ChildNodePart
|
|
164
|
+
const index = match[2];
|
|
165
|
+
parts.push((value) => `${pre}<!--dom-part-${index}-->${getValue(value)}<!--/dom-part-${index}-->`);
|
|
166
|
+
} else if (match[4]) {
|
|
167
|
+
// AttributePart with single interpolation or the first interpolation right after the attribute=
|
|
168
|
+
const isSingleValue = match[5] !== undefined;
|
|
169
|
+
let name = match[4];
|
|
170
|
+
switch (true) {
|
|
171
|
+
case name[0] === '?':
|
|
172
|
+
const booleanName = name.slice(1).toLowerCase();
|
|
173
|
+
parts.push((value) => {
|
|
174
|
+
if (!value) return '';
|
|
175
|
+
return `${pre} ${booleanName}=""`;
|
|
176
|
+
});
|
|
177
|
+
break;
|
|
178
|
+
case name[0] === '.':
|
|
179
|
+
const lower = name.slice(1).toLowerCase();
|
|
180
|
+
parts.push((value) => {
|
|
181
|
+
let result = pre;
|
|
182
|
+
// null, undefined, and false are not shown at all
|
|
183
|
+
if (value === null || value === undefined || value === '') {
|
|
184
|
+
result += attribute(lower, '');
|
|
185
|
+
} else {
|
|
186
|
+
// in all other cases, just escape it in quotes
|
|
187
|
+
result += attribute(lower, value, isSingleValue);
|
|
188
|
+
}
|
|
189
|
+
return result;
|
|
190
|
+
});
|
|
191
|
+
break;
|
|
192
|
+
case name[0] === '@':
|
|
193
|
+
name = 'on' + name.slice(1);
|
|
194
|
+
case name[0] === 'o' && name[1] === 'n':
|
|
195
|
+
parts.push((value) => {
|
|
196
|
+
return pre;
|
|
197
|
+
});
|
|
198
|
+
break;
|
|
199
|
+
default:
|
|
200
|
+
parts.push((value) => {
|
|
201
|
+
let result = pre;
|
|
202
|
+
if (value != null) {
|
|
203
|
+
result += attribute(name, value, isSingleValue);
|
|
204
|
+
}
|
|
205
|
+
return result;
|
|
206
|
+
});
|
|
207
|
+
break;
|
|
208
|
+
}
|
|
209
|
+
} else {
|
|
210
|
+
// AttributePart in the middle of an attribute value or NodePart
|
|
211
|
+
parts.push((value) => {
|
|
212
|
+
let result = pre;
|
|
213
|
+
// TODO: we currently cannot distinguish between NodeParts and AttributeParts before
|
|
214
|
+
if (isObjectLike(value) && value.directiveClass) {
|
|
215
|
+
// NodePart
|
|
216
|
+
// result += encodeAttribute(isObjectLike(value) ? JSON.stringify(value) : value);
|
|
217
|
+
} else if (value != null) {
|
|
218
|
+
result += encodeAttribute(isObjectLike(value) ? JSON.stringify(value) : value);
|
|
219
|
+
}
|
|
220
|
+
return result;
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// We couldn't correctly parse parts from the template
|
|
226
|
+
if (parts.length !== strings.length - 1) {
|
|
227
|
+
throw {
|
|
228
|
+
name: 'ParseTemplateError',
|
|
229
|
+
message: 'Could not parse parts from template correctly. Parts length has not the expected length.',
|
|
230
|
+
strings,
|
|
231
|
+
templateString: html,
|
|
232
|
+
parts,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (parts.length) {
|
|
237
|
+
const last = parts[parts.length - 1];
|
|
238
|
+
const chunk = html.slice(i);
|
|
239
|
+
parts[parts.length - 1] = (value) => last(value) + chunk;
|
|
240
|
+
} else {
|
|
241
|
+
parts.push(() => html);
|
|
242
|
+
}
|
|
243
|
+
return parts;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* @param {Node[]} childNodes
|
|
248
|
+
* @return {object[]}
|
|
249
|
+
*/
|
|
250
|
+
parseParts(childNodes) {
|
|
251
|
+
// we always create a template fragment so that we can start at the root for traversing the node path
|
|
252
|
+
const range = globalThis.document?.createRange();
|
|
253
|
+
range.setStartBefore(childNodes[0]);
|
|
254
|
+
range.setEndAfter(childNodes[childNodes.length - 1]);
|
|
255
|
+
const template = range.cloneContents();
|
|
256
|
+
|
|
257
|
+
const treeWalker = globalThis.document?.createTreeWalker(template, 128);
|
|
258
|
+
let node = treeWalker.currentNode;
|
|
259
|
+
|
|
260
|
+
// this is kinda freaky because the tree walker is shared throughout the recursion - but YOLO
|
|
261
|
+
const getParts = () => {
|
|
262
|
+
const parts = [];
|
|
263
|
+
let currentPart = undefined;
|
|
264
|
+
// search for parts through numbered comment nodes with placeholders
|
|
265
|
+
while ((node = treeWalker.nextNode())) {
|
|
266
|
+
if (/^template-part$/.test(node.data) && currentPart) {
|
|
267
|
+
// start recursion -> add nested parts to previous part
|
|
268
|
+
currentPart.parts = getParts();
|
|
269
|
+
}
|
|
270
|
+
if (/^\/template-part$/.test(node.data)) {
|
|
271
|
+
// end recursion
|
|
272
|
+
return parts;
|
|
273
|
+
}
|
|
274
|
+
if (/^dom-part-\d+$/.test(node.data)) {
|
|
275
|
+
parts.push((currentPart = { type: 'node', path: getNodePath(node), parts: [] }));
|
|
276
|
+
}
|
|
277
|
+
if (/^dom-part-\d+:/.test(node.data)) {
|
|
278
|
+
const [_, attribute] = node.data.split(':');
|
|
279
|
+
const [name, initialValue] = attribute.split('=');
|
|
280
|
+
currentPart = undefined;
|
|
281
|
+
parts.push({ type: 'attribute', path: getNodePath(node), name: name, initialValue });
|
|
282
|
+
}
|
|
283
|
+
if (/^dom-part-\d+\$/.test(node.data)) {
|
|
284
|
+
currentPart = undefined;
|
|
285
|
+
parts.push({ type: 'directive', path: getNodePath(node) });
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
return parts;
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
const parts = getParts();
|
|
292
|
+
|
|
293
|
+
// We couldn't correctly parse parts from the template
|
|
294
|
+
if (parts.length !== this.strings.length - 1) {
|
|
295
|
+
throw {
|
|
296
|
+
name: 'ParseTemplateError',
|
|
297
|
+
message:
|
|
298
|
+
'Could not parse parts from template correctly. Parts length has not the expected length. -> ' +
|
|
299
|
+
JSON.stringify({
|
|
300
|
+
strings: this.strings,
|
|
301
|
+
templateString: this.templateString,
|
|
302
|
+
childNodes,
|
|
303
|
+
template,
|
|
304
|
+
parts,
|
|
305
|
+
}),
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
return parts;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* find interpolations in the given template for nodes and attributes and
|
|
314
|
+
* return a string with placeholders as either comment nodes or named attributes.
|
|
315
|
+
* @returns {string} template with tagged placeholders for values
|
|
316
|
+
*/
|
|
317
|
+
get templateString() {
|
|
318
|
+
return createTemplateString(this.strings);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
toString() {
|
|
322
|
+
let updates = parsedUpdates.get(this.strings);
|
|
323
|
+
|
|
324
|
+
if (!updates) {
|
|
325
|
+
updates = this.parse(this.strings);
|
|
326
|
+
parsedUpdates.set(this.strings, updates);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
return this.values.length ? this.values.map((value, index) => updates[index](value)).join('') : updates[0]();
|
|
330
|
+
}
|
|
331
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { camelToDash, decodeAttribute } from '
|
|
2
|
-
import {
|
|
1
|
+
import { camelToDash, decodeAttribute, encodeAttribute } from '../util/AttributeParser.js';
|
|
2
|
+
import { TemplateResult } from './TemplateResult';
|
|
3
|
+
import { convertStringToHTML } from '../util/DOMHelper';
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Maps a list of classes to an element from an object.
|
|
@@ -32,9 +33,9 @@ const styleMap = (map) => {
|
|
|
32
33
|
/**
|
|
33
34
|
* Renders one of two template parts based on a condition.
|
|
34
35
|
* @param {boolean} condition
|
|
35
|
-
* @param {
|
|
36
|
-
* @param {
|
|
37
|
-
* @returns {
|
|
36
|
+
* @param {TemplateResult | string} trueCase
|
|
37
|
+
* @param {TemplateResult | string} [falseCase]
|
|
38
|
+
* @returns {TemplateResult | string}
|
|
38
39
|
*/
|
|
39
40
|
const when = (condition, trueCase, falseCase) => {
|
|
40
41
|
return condition ? trueCase : falseCase;
|
|
@@ -43,9 +44,9 @@ const when = (condition, trueCase, falseCase) => {
|
|
|
43
44
|
/**
|
|
44
45
|
* Chooses and evaluates a template part from a map of cases based on matching the given value to a case.
|
|
45
46
|
* @param {string} value
|
|
46
|
-
* @param {Object.<string,
|
|
47
|
-
* @param {
|
|
48
|
-
* @returns {
|
|
47
|
+
* @param {Object.<string, TemplateResult | string>} cases
|
|
48
|
+
* @param {TemplateResult | string} [defaultCase]
|
|
49
|
+
* @returns {TemplateResult | string}
|
|
49
50
|
*/
|
|
50
51
|
const choose = (value, cases, defaultCase) => {
|
|
51
52
|
return cases[value] || defaultCase;
|
|
@@ -54,10 +55,12 @@ const choose = (value, cases, defaultCase) => {
|
|
|
54
55
|
/**
|
|
55
56
|
* Renders a given string as HTML instead of text
|
|
56
57
|
* @param {string} string
|
|
57
|
-
* @returns {function():
|
|
58
|
+
* @returns {function(): Node}
|
|
58
59
|
*/
|
|
59
60
|
const unsafeHTML = (string) => {
|
|
60
|
-
|
|
61
|
+
// TODO: write tests for this...
|
|
62
|
+
// I mean should the string always have a single root element? Or could it also handle multiple root elements?!
|
|
63
|
+
return () => convertStringToHTML(string).firstChild;
|
|
61
64
|
};
|
|
62
65
|
|
|
63
66
|
/**
|
|
@@ -69,7 +72,10 @@ const spreadAttributes = (attributes) => {
|
|
|
69
72
|
return () => {
|
|
70
73
|
return Object.keys(attributes)
|
|
71
74
|
.map((key) => {
|
|
72
|
-
|
|
75
|
+
let value = attributes[key];
|
|
76
|
+
return `${camelToDash(key)}='${encodeAttribute(
|
|
77
|
+
typeof value !== 'string' ? JSON.stringify(value) : value,
|
|
78
|
+
)}'`;
|
|
73
79
|
})
|
|
74
80
|
.join(' ');
|
|
75
81
|
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { TemplateResult } from './TemplateResult';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @param {TemplateStringsArray} strings
|
|
5
|
+
* @param {any[]} values
|
|
6
|
+
* @return {TemplateResult}
|
|
7
|
+
*/
|
|
8
|
+
const html = function (strings, ...values) {
|
|
9
|
+
return new TemplateResult(strings, ...values);
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export { html };
|
|
@@ -92,10 +92,10 @@ export function camelToDash(string) {
|
|
|
92
92
|
*/
|
|
93
93
|
export function decodeAttribute(attribute) {
|
|
94
94
|
return `${attribute}`
|
|
95
|
-
.replace(
|
|
96
|
-
.replace(
|
|
97
|
-
.replace(
|
|
98
|
-
.replace(
|
|
95
|
+
.replace(/</g, '<')
|
|
96
|
+
.replace(/>/g, '>')
|
|
97
|
+
.replace(/"/g, '"')
|
|
98
|
+
.replace(/'/g, "'");
|
|
99
99
|
}
|
|
100
100
|
|
|
101
101
|
/**
|
|
@@ -111,10 +111,10 @@ export function decodeAttribute(attribute) {
|
|
|
111
111
|
*/
|
|
112
112
|
export function encodeAttribute(attribute) {
|
|
113
113
|
return `${attribute}`
|
|
114
|
-
.replace(/'/g, '
|
|
115
|
-
.replace(/"/g, '
|
|
116
|
-
.replace(/</g, '
|
|
117
|
-
.replace(/>/g, '
|
|
114
|
+
.replace(/'/g, ''')
|
|
115
|
+
.replace(/"/g, '"')
|
|
116
|
+
.replace(/</g, '<')
|
|
117
|
+
.replace(/>/g, '>')
|
|
118
118
|
.replace(/\r\n/g, '\n')
|
|
119
119
|
.replace(/[\r\n]/g, '\n');
|
|
120
120
|
}
|
package/src/util/DOMHelper.js
CHANGED
|
@@ -1,3 +1,28 @@
|
|
|
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
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Gets list of (childNode) indexes for the given node
|
|
12
|
+
* @param node
|
|
13
|
+
* @return {number[]}
|
|
14
|
+
*/
|
|
15
|
+
export const getNodePath = (node) => {
|
|
16
|
+
const path = [];
|
|
17
|
+
let parentNode = node.parentNode;
|
|
18
|
+
while (parentNode) {
|
|
19
|
+
path.push([...parentNode.childNodes].indexOf(node));
|
|
20
|
+
node = parentNode;
|
|
21
|
+
parentNode = node.parentNode;
|
|
22
|
+
}
|
|
23
|
+
return path;
|
|
24
|
+
};
|
|
25
|
+
|
|
1
26
|
export function getAllParentNodes(domNode) {
|
|
2
27
|
let node = domNode.parentElement;
|
|
3
28
|
let parents = [];
|
|
@@ -44,3 +69,191 @@ export const supportsAdoptingStyleSheets = () =>
|
|
|
44
69
|
|
|
45
70
|
// for IE11 we are using the ShadyDOM Polyfill. With the polyfill we cannot append stylesheets to the shadowRoot
|
|
46
71
|
export const supportsAppendingStyleSheets = !window.ShadyDOM;
|
|
72
|
+
|
|
73
|
+
// better hasChildNodes check because the native check will also count for empty whitespace as child nodes... :(
|
|
74
|
+
export const hasChildNodes = (node) => {
|
|
75
|
+
const childNodesLength = node.childNodes.length;
|
|
76
|
+
|
|
77
|
+
if (childNodesLength === 0) return false;
|
|
78
|
+
|
|
79
|
+
if (childNodesLength === 1 && node.firstChild.nodeType === 3 && node.firstChild.textContent.trim() === '')
|
|
80
|
+
return false;
|
|
81
|
+
|
|
82
|
+
// children will not contain text nodes...
|
|
83
|
+
if (childNodesLength > 0) return true;
|
|
84
|
+
|
|
85
|
+
// TODO: check if more than 1 child nodes, but all childnodes are whitespace text ndoes?!
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Parses the given template string and returns a real DOM element.
|
|
90
|
+
* @param {string} template
|
|
91
|
+
* @returns {HTMLElement}
|
|
92
|
+
*/
|
|
93
|
+
export const convertStringToHTML = (template) => {
|
|
94
|
+
const parser = new DOMParser();
|
|
95
|
+
const document = parser.parseFromString(template.toString(), 'text/html');
|
|
96
|
+
return document.body;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Parses the given string and returns a DocumentFragment
|
|
101
|
+
* which contains the DOM subtree representing the <template> element's template contents.
|
|
102
|
+
* @param {string} string
|
|
103
|
+
* @return {DocumentFragment}
|
|
104
|
+
*/
|
|
105
|
+
export const convertStringToTemplate = (string) => {
|
|
106
|
+
const template = globalThis.document?.createElement('template');
|
|
107
|
+
// TODO: ensure that SSRed and CSRed templates have the same amount of whitespace text nodes
|
|
108
|
+
template.innerHTML = string.trimStart().trimEnd();
|
|
109
|
+
return template.content;
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Diff the attributes for a live DOM element against a template DOM element
|
|
114
|
+
* @param {Element} templateElement The new template
|
|
115
|
+
* @param {Element} domElement The existing DOM node
|
|
116
|
+
*/
|
|
117
|
+
export const diffAttributes = function (templateElement, domElement) {
|
|
118
|
+
// remove or update previous attributes
|
|
119
|
+
for (/** @type {Attr} */ const attribute of Array.from(domElement.attributes)) {
|
|
120
|
+
if (templateElement.hasAttribute(attribute.name)) {
|
|
121
|
+
const oldValue = attribute.value;
|
|
122
|
+
const newValue = templateElement.getAttribute(attribute.name) || '';
|
|
123
|
+
if (oldValue !== newValue) {
|
|
124
|
+
domElement.setAttribute(attribute.name, newValue);
|
|
125
|
+
}
|
|
126
|
+
} else {
|
|
127
|
+
domElement.removeAttribute(attribute.name);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// set newly added attributes
|
|
132
|
+
for (/** @type {Attr} */ const attribute of Array.from(templateElement.attributes)) {
|
|
133
|
+
if (!domElement.hasAttribute(attribute.name)) {
|
|
134
|
+
domElement.setAttribute(attribute.name, attribute.value);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Diff a live DOM node against a template DOM node
|
|
141
|
+
* @param {Node} templateNode
|
|
142
|
+
* @param {Node} domNode
|
|
143
|
+
* @param {(Node) => Boolean} shouldDiffChildNodes
|
|
144
|
+
*/
|
|
145
|
+
export const diffNodes = function (templateNode, domNode, shouldDiffChildNodes = (templateChildNode) => true) {
|
|
146
|
+
// If the DOM node should be empty, remove all child nodes
|
|
147
|
+
if (hasChildNodes(domNode) && !hasChildNodes(templateNode)) {
|
|
148
|
+
domNode.replaceChildren();
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// If the DOM node is empty, but should have children, add child nodes from the template node
|
|
153
|
+
if (!hasChildNodes(domNode) && hasChildNodes(templateNode)) {
|
|
154
|
+
domNode.replaceChildren(...templateNode.childNodes);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const templateChildNodes = [...templateNode.childNodes];
|
|
159
|
+
const domChildNodes = [...domNode.childNodes];
|
|
160
|
+
|
|
161
|
+
// Diff each node in the child node lists
|
|
162
|
+
let length = Math.max(templateChildNodes.length, domChildNodes.length);
|
|
163
|
+
for (let index = 0; index < length; index++) {
|
|
164
|
+
const templateChildNode = templateChildNodes[index];
|
|
165
|
+
const domChildNode = domChildNodes[index];
|
|
166
|
+
|
|
167
|
+
// If the DOM node doesn't exist, append/copy the template node
|
|
168
|
+
if (!domChildNode) {
|
|
169
|
+
domNode.appendChild(templateChildNode);
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// If the template node doesn't exist, remove the node in the DOM
|
|
174
|
+
if (!templateChildNode) {
|
|
175
|
+
domNode.removeChild(domChildNode);
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// If DOM node is equal to the template node, don't do anything
|
|
180
|
+
if (domChildNode.isEqualNode(templateChildNode)) {
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// the following two checks don't have to be correct, but it could bring as back to the fast path
|
|
185
|
+
// in the worst case we still have to compare and swap all the elements
|
|
186
|
+
// but in the best case we find the one element that should actually be inserted or removed
|
|
187
|
+
// and from there on, every node that comes after should be equal again
|
|
188
|
+
|
|
189
|
+
// fast path for removing DOM nodes - we delete the node now instead of at the end
|
|
190
|
+
if (domNode.childNodes.length > templateNode.childNodes.length) {
|
|
191
|
+
domChildNodes.splice(index, 1);
|
|
192
|
+
domNode.removeChild(domChildNode);
|
|
193
|
+
// because domChildNodes will get shorter by splicing it, everything moves up by one
|
|
194
|
+
// so the (actual) next element will have the same index now as we currently have
|
|
195
|
+
// therefore we have to adjust our counters
|
|
196
|
+
length--;
|
|
197
|
+
index--;
|
|
198
|
+
|
|
199
|
+
// we might have corrected everything and the parent nodes could be equal again
|
|
200
|
+
// if so, we can skip the rest of the child node checks
|
|
201
|
+
if (domNode.childNodes.length === templateNode.childNodes.length && domNode.isEqualNode(templateNode)) {
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// fast path for adding DOM nodes - we insert the node now instead of at the end
|
|
209
|
+
// but NOT if we already are at the end of the list of child nodes
|
|
210
|
+
if (index !== domNode.childNodes.length - 1 && domNode.childNodes.length < templateNode.childNodes.length) {
|
|
211
|
+
domChildNodes.splice(index, 0, templateChildNode);
|
|
212
|
+
domNode.insertBefore(templateChildNode, domChildNode);
|
|
213
|
+
|
|
214
|
+
// we might have corrected everything and the parent nodes could be equal again
|
|
215
|
+
// if so, we can skip the rest of the child node checks
|
|
216
|
+
if (domNode.childNodes.length === templateNode.childNodes.length && domNode.isEqualNode(templateNode)) {
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// If node types are not the same, replace the DOM node with the template node
|
|
224
|
+
if (templateChildNode.nodeType !== domChildNode.nodeType) {
|
|
225
|
+
domNode.replaceChild(templateChildNode, domChildNode);
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// If the node is a Text node or a Comment node, update it
|
|
230
|
+
if (domChildNode.nodeType === TEXT_NODE || domChildNode.nodeType === COMMENT_NODE) {
|
|
231
|
+
domChildNode.textContent = templateChildNode.textContent;
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// If the element tag names are not the same, replace the DOM node with the template node
|
|
236
|
+
if (templateChildNode.nodeType === ELEMENT_NODE && templateChildNode.tagName !== domChildNode.tagName) {
|
|
237
|
+
domNode.replaceChild(templateChildNode, domChildNode);
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// If the node is an SVG element, don't even think about diffing it, just replace it
|
|
242
|
+
if (templateChildNode.nodeType === ELEMENT_NODE && templateChildNode.tagName === 'SVG') {
|
|
243
|
+
domNode.replaceChild(templateChildNode, domChildNode);
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// If the node is an Element node, diff the attributes
|
|
248
|
+
if (templateChildNode.nodeType === ELEMENT_NODE) {
|
|
249
|
+
diffAttributes(templateChildNode, domChildNode);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// If there are child nodes, diff them recursively
|
|
253
|
+
if (templateChildNode.hasChildNodes()) {
|
|
254
|
+
if (shouldDiffChildNodes(templateChildNode)) {
|
|
255
|
+
diffNodes(templateChildNode, domChildNode);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export class Directive {
|
|
2
|
+
/** @type {Element} */
|
|
3
|
+
node = undefined;
|
|
4
|
+
|
|
5
|
+
constructor(node) {
|
|
6
|
+
this.node = node;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @abstract
|
|
11
|
+
*/
|
|
12
|
+
update() {}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @param {Class<Directive>} directiveClass
|
|
17
|
+
*/
|
|
18
|
+
export const defineDirective = (directiveClass) => {
|
|
19
|
+
return (...values) => {
|
|
20
|
+
return { directiveClass, values };
|
|
21
|
+
};
|
|
22
|
+
};
|