philo-scratch 1.0.0 → 2.0.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.
- package/dist/philo-scratch.js +344 -16
- package/package.json +4 -1
package/dist/philo-scratch.js
CHANGED
|
@@ -16,12 +16,145 @@ function removeEventListeners(listeners = {}, el) {
|
|
|
16
16
|
});
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
const ARRAY_DIF_OP = {
|
|
20
|
+
ADD: "add",
|
|
21
|
+
REMOVE: "remove",
|
|
22
|
+
MOVE: "move",
|
|
23
|
+
NOOP: "noop",
|
|
24
|
+
};
|
|
19
25
|
function withoutNulls(children) {
|
|
20
26
|
return children.filter((child) => child != null);
|
|
21
27
|
}
|
|
22
28
|
function mapTextNodes(children) {
|
|
23
29
|
return children.map((child) => (typeof child === "string" ? hString(child) : child));
|
|
24
30
|
}
|
|
31
|
+
function arraysDiff(oldArray, newArray) {
|
|
32
|
+
return {
|
|
33
|
+
added: newArray.filter((item) => !oldArray.includes(item)),
|
|
34
|
+
removed: oldArray.filter((item) => !newArray.includes(item)),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
class ArrayWithOriginalIndices {
|
|
38
|
+
#array = [];
|
|
39
|
+
#originalIndices = [];
|
|
40
|
+
#equalsFn;
|
|
41
|
+
constructor(array, equalsFn) {
|
|
42
|
+
this.#array = [...array];
|
|
43
|
+
this.#originalIndices = array.map((_, i) => i);
|
|
44
|
+
this.#equalsFn = equalsFn;
|
|
45
|
+
}
|
|
46
|
+
get length() {
|
|
47
|
+
return this.#array.length;
|
|
48
|
+
}
|
|
49
|
+
isRemoval(index, newArray) {
|
|
50
|
+
if (index >= this.length) {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
const item = this.#array[index];
|
|
54
|
+
const indexInNewArray = newArray.findIndex((newItem) => {
|
|
55
|
+
return this.#equalsFn(item, newItem);
|
|
56
|
+
});
|
|
57
|
+
return indexInNewArray === -1;
|
|
58
|
+
}
|
|
59
|
+
isNoop(index, newArray) {
|
|
60
|
+
if (index >= this.length) {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
const item = this.#array[index];
|
|
64
|
+
const newItem = newArray[index];
|
|
65
|
+
return this.#equalsFn(item, newItem);
|
|
66
|
+
}
|
|
67
|
+
isAddition(item, fromIndex) {
|
|
68
|
+
return this.findIndexFrom(item, fromIndex) === -1;
|
|
69
|
+
}
|
|
70
|
+
originalIndexAt(index) {
|
|
71
|
+
return this.#originalIndices[index];
|
|
72
|
+
}
|
|
73
|
+
findIndexFrom(item, fromIndex) {
|
|
74
|
+
for (let i = fromIndex; i < this.length; i++) {
|
|
75
|
+
if (this.#equalsFn(item, this.#array[i])) {
|
|
76
|
+
return i;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return -1;
|
|
80
|
+
}
|
|
81
|
+
removeItem(index) {
|
|
82
|
+
const operation = {
|
|
83
|
+
op: ARRAY_DIF_OP.REMOVE,
|
|
84
|
+
index,
|
|
85
|
+
item: this.#array[index],
|
|
86
|
+
};
|
|
87
|
+
this.#array.splice(index, 1);
|
|
88
|
+
this.#originalIndices.splice(index, 1);
|
|
89
|
+
return operation;
|
|
90
|
+
}
|
|
91
|
+
noopItem(index) {
|
|
92
|
+
const operation = {
|
|
93
|
+
op: ARRAY_DIF_OP.NOOP,
|
|
94
|
+
originalIndex: this.originalIndexAt(index),
|
|
95
|
+
index,
|
|
96
|
+
item: this.#array[index],
|
|
97
|
+
};
|
|
98
|
+
this.#array.splice(index, 1);
|
|
99
|
+
this.#originalIndices.splice(index, 1);
|
|
100
|
+
return operation;
|
|
101
|
+
}
|
|
102
|
+
addItem(item, index) {
|
|
103
|
+
const operation = {
|
|
104
|
+
op: ARRAY_DIF_OP.ADD,
|
|
105
|
+
index,
|
|
106
|
+
item: item,
|
|
107
|
+
};
|
|
108
|
+
this.#array.splice(index, 0, item);
|
|
109
|
+
this.#originalIndices.splice(index, 0, -1);
|
|
110
|
+
return operation;
|
|
111
|
+
}
|
|
112
|
+
moveItem(item, toIndex) {
|
|
113
|
+
const fromIndex = this.findIndexFrom(item, toIndex);
|
|
114
|
+
const operation = {
|
|
115
|
+
op: ARRAY_DIF_OP.MOVE,
|
|
116
|
+
originalIndex: this.originalIndexAt(fromIndex),
|
|
117
|
+
fromIndex,
|
|
118
|
+
index: toIndex,
|
|
119
|
+
item: this.#array[fromIndex],
|
|
120
|
+
};
|
|
121
|
+
const [_item] = this.#array.splice(fromIndex, 1);
|
|
122
|
+
this.#array.splice(toIndex, 0, _item);
|
|
123
|
+
const originalIndex = this.#originalIndices.splice(fromIndex, 1);
|
|
124
|
+
this.#originalIndices.splice(toIndex, 0, originalIndex);
|
|
125
|
+
return operation;
|
|
126
|
+
}
|
|
127
|
+
removeItemsAfterIndex(index) {
|
|
128
|
+
const operations = [];
|
|
129
|
+
while (this.length > index) {
|
|
130
|
+
operations.push(this.removeItem(index));
|
|
131
|
+
}
|
|
132
|
+
return operations;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
function arrayDiffSequence(oldArray, newArray, equalsFn = (a, b) => a === b) {
|
|
136
|
+
const sequence = [];
|
|
137
|
+
const array = new ArrayWithOriginalIndices(oldArray, equalsFn);
|
|
138
|
+
for (let index = 0; index < newArray.length; index++) {
|
|
139
|
+
if (array.isRemoval(index, newArray)) {
|
|
140
|
+
sequence.push(array.removeItem(index));
|
|
141
|
+
index--;
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
if (array.isNoop(index, newArray)) {
|
|
145
|
+
sequence.push(array.noopItem(index));
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
const item = newArray[index];
|
|
149
|
+
if (array.isAddition(item, index)) {
|
|
150
|
+
sequence.push(array.addItem(item, index));
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
sequence.push(array.moveItem(item, index));
|
|
154
|
+
}
|
|
155
|
+
sequence.push(...array.removeItemsAfterIndex(newArray.length));
|
|
156
|
+
return sequence;
|
|
157
|
+
}
|
|
25
158
|
|
|
26
159
|
const DOM_TYPES = {
|
|
27
160
|
TEXT: "text",
|
|
@@ -48,6 +181,20 @@ function hFragment(vNodes) {
|
|
|
48
181
|
children: mapTextNodes(withoutNulls(vNodes)),
|
|
49
182
|
};
|
|
50
183
|
}
|
|
184
|
+
function extractChildren(vdom) {
|
|
185
|
+
if (vdom.children == null) {
|
|
186
|
+
return [];
|
|
187
|
+
}
|
|
188
|
+
const children = [];
|
|
189
|
+
for (const child of vdom.children) {
|
|
190
|
+
if (child.type === DOM_TYPES.FRAGMENT) {
|
|
191
|
+
children.push(...extractChildren(child));
|
|
192
|
+
} else {
|
|
193
|
+
children.push(child);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return children;
|
|
197
|
+
}
|
|
51
198
|
|
|
52
199
|
function destroyDOM(vdom) {
|
|
53
200
|
const { type } = vdom;
|
|
@@ -114,6 +261,9 @@ function setClass(el, className) {
|
|
|
114
261
|
function setStyle(el, name, value) {
|
|
115
262
|
el.style[name] = value;
|
|
116
263
|
}
|
|
264
|
+
function removeStyle(el, name) {
|
|
265
|
+
el.style[name] = null;
|
|
266
|
+
}
|
|
117
267
|
function setAttribute(el, name, value) {
|
|
118
268
|
if (value == null) {
|
|
119
269
|
removeAttribute(el, name);
|
|
@@ -128,18 +278,18 @@ function removeAttribute(el, name) {
|
|
|
128
278
|
el.removeAttribute(name);
|
|
129
279
|
}
|
|
130
280
|
|
|
131
|
-
function mountDOM(vdom, parentEl) {
|
|
281
|
+
function mountDOM(vdom, parentEl, index) {
|
|
132
282
|
switch (vdom.type) {
|
|
133
283
|
case DOM_TYPES.TEXT: {
|
|
134
|
-
createTextNode(vdom, parentEl);
|
|
284
|
+
createTextNode(vdom, parentEl, index);
|
|
135
285
|
break;
|
|
136
286
|
}
|
|
137
287
|
case DOM_TYPES.ELEMENT: {
|
|
138
|
-
createElementNode(vdom, parentEl);
|
|
288
|
+
createElementNode(vdom, parentEl, index);
|
|
139
289
|
break;
|
|
140
290
|
}
|
|
141
291
|
case DOM_TYPES.FRAGMENT: {
|
|
142
|
-
createFragmentNode(vdom, parentEl);
|
|
292
|
+
createFragmentNode(vdom, parentEl, index);
|
|
143
293
|
break;
|
|
144
294
|
}
|
|
145
295
|
default: {
|
|
@@ -147,24 +297,39 @@ function mountDOM(vdom, parentEl) {
|
|
|
147
297
|
}
|
|
148
298
|
}
|
|
149
299
|
}
|
|
150
|
-
function
|
|
300
|
+
function insert(el, parentEl, index) {
|
|
301
|
+
if (!index) {
|
|
302
|
+
parentEl.append(el);
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
if (index < 0) {
|
|
306
|
+
throw new Error("Index must be a positive value");
|
|
307
|
+
}
|
|
308
|
+
const children = parentEl.childNodes;
|
|
309
|
+
if (index >= children.length) {
|
|
310
|
+
parentEl.append(el);
|
|
311
|
+
} else {
|
|
312
|
+
parentEl.insertBefore(el, children[index]);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
function createFragmentNode(vdom, parentEl, index) {
|
|
151
316
|
const { children } = vdom;
|
|
152
317
|
vdom.el = parentEl;
|
|
153
|
-
children.forEach((child) => mountDOM(child, parentEl));
|
|
318
|
+
children.forEach((child, i) => mountDOM(child, parentEl, index ? index + i : null));
|
|
154
319
|
}
|
|
155
|
-
function createTextNode(vdom, parentEl) {
|
|
320
|
+
function createTextNode(vdom, parentEl, index) {
|
|
156
321
|
const { value } = vdom;
|
|
157
322
|
const textNode = document.createTextNode(value);
|
|
158
323
|
vdom.el = textNode;
|
|
159
|
-
|
|
324
|
+
insert(textNode, parentEl, index);
|
|
160
325
|
}
|
|
161
|
-
function createElementNode(vdom, parentEl) {
|
|
326
|
+
function createElementNode(vdom, parentEl, index) {
|
|
162
327
|
const { tag, props, children } = vdom;
|
|
163
328
|
const element = document.createElement(tag);
|
|
164
329
|
addProps(element, props, vdom);
|
|
165
330
|
vdom.el = element;
|
|
166
331
|
children.forEach((child) => mountDOM(child, element));
|
|
167
|
-
parentEl
|
|
332
|
+
insert(textNode, parentEl, index);
|
|
168
333
|
}
|
|
169
334
|
function addProps(el, props, vdom) {
|
|
170
335
|
const { on: events, ...attrs } = props;
|
|
@@ -206,6 +371,169 @@ class Dispatcher {
|
|
|
206
371
|
}
|
|
207
372
|
}
|
|
208
373
|
|
|
374
|
+
function areNodesEqual(nodeOne, nodeTwo) {
|
|
375
|
+
if (nodeOne.type !== nodeTwo.type) {
|
|
376
|
+
return false;
|
|
377
|
+
}
|
|
378
|
+
if (nodeOne.type === DOM_TYPES.ELEMENT) {
|
|
379
|
+
const { tag: tagOne } = nodeOne;
|
|
380
|
+
const { tag: tagTwo } = nodeTwo;
|
|
381
|
+
return tagOne === tagTwo;
|
|
382
|
+
}
|
|
383
|
+
return true;
|
|
384
|
+
}
|
|
385
|
+
areNodesEqual({ type: "element", tag: "p" }, { type: "element", tag: "div" });
|
|
386
|
+
|
|
387
|
+
function objectsDiff(oldObj, newObj) {
|
|
388
|
+
const oldKeys = Object.keys(oldObj);
|
|
389
|
+
const newKeys = Object.keys(newObj);
|
|
390
|
+
const added = [];
|
|
391
|
+
const updated = [];
|
|
392
|
+
newKeys.forEach((key) => {
|
|
393
|
+
if (!(key in oldObj)) {
|
|
394
|
+
added.push(key);
|
|
395
|
+
}
|
|
396
|
+
if (key in oldObj && oldObj[key] !== newObj[key]) {
|
|
397
|
+
updated.push(key);
|
|
398
|
+
}
|
|
399
|
+
});
|
|
400
|
+
return {
|
|
401
|
+
added,
|
|
402
|
+
removed: oldKeys.filter((k) => !(k in newObj)),
|
|
403
|
+
updated,
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function isNotEmptyString(str) {
|
|
408
|
+
return str !== "";
|
|
409
|
+
}
|
|
410
|
+
function isNotBlankOrEmptyString(str) {
|
|
411
|
+
return isNotEmptyString(str.trim());
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function patchDOM(oldVdom, newVdom, parentEl) {
|
|
415
|
+
if (!areNodesEqual(oldVdom, newVdom)) {
|
|
416
|
+
const index = findIndexInParent(parentEl, oldVdom.el);
|
|
417
|
+
destroyDOM(oldVdom);
|
|
418
|
+
mountDOM(newVdom, parentEl, index);
|
|
419
|
+
return newVdom;
|
|
420
|
+
}
|
|
421
|
+
newVdom.el = oldVdom.el;
|
|
422
|
+
switch (newVdom.type) {
|
|
423
|
+
case DOM_TYPES.TEXT:
|
|
424
|
+
patchText(oldVdom, newVdom);
|
|
425
|
+
return newVdom;
|
|
426
|
+
case DOM_TYPES.ELEMENT:
|
|
427
|
+
patchElement(oldVdom, newVdom);
|
|
428
|
+
break;
|
|
429
|
+
}
|
|
430
|
+
patchChildren(oldVdom, newVdom);
|
|
431
|
+
return newVdom;
|
|
432
|
+
}
|
|
433
|
+
function findIndexInParent(parentEl, el) {
|
|
434
|
+
const index = Array.from(parentEl.childNodes).indexOf(el);
|
|
435
|
+
if (index < 0) {
|
|
436
|
+
return null;
|
|
437
|
+
}
|
|
438
|
+
return index;
|
|
439
|
+
}
|
|
440
|
+
function patchText(oldVdom, newVdom) {
|
|
441
|
+
const el = oldVdom.el;
|
|
442
|
+
const { value: oldText } = oldVdom;
|
|
443
|
+
const { value: newText } = newVdom;
|
|
444
|
+
if (oldText !== newText) {
|
|
445
|
+
el.nodeValue = newText;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
function patchElement(oldVdom, newVdom) {
|
|
449
|
+
const el = oldVdom.el;
|
|
450
|
+
const { class: oldClass, style: oldStyle, on: oldEvents, ...oldAttrs } = oldVdom.props;
|
|
451
|
+
const { class: newClass, style: newStyle, on: newEvents, ...newAttrs } = newVdom.props;
|
|
452
|
+
const { listeners: oldListeners } = oldVdom;
|
|
453
|
+
patchAttrs(el, oldAttrs, newAttrs);
|
|
454
|
+
patchClasses(el, oldClass, newClass);
|
|
455
|
+
patchStyles(el, oldStyle, newStyle);
|
|
456
|
+
newVdom.listeners = patchEvents(el, oldListeners, oldEvents, newEvents);
|
|
457
|
+
}
|
|
458
|
+
function patchAttrs(el, oldAttrs, newAttrs) {
|
|
459
|
+
const { added, removed, updated } = objectsDiff(oldAttrs, newAttrs);
|
|
460
|
+
for (const attr of removed) {
|
|
461
|
+
removeAttribute(el, attr);
|
|
462
|
+
}
|
|
463
|
+
for (const attr of added.concat(updated)) {
|
|
464
|
+
setAttribute(el, attr, newAttrs[attr]);
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
function patchClasses(el, oldClass, newClass) {
|
|
468
|
+
const oldClasses = toClassList(oldClass);
|
|
469
|
+
const newClasses = toClassList(newClass);
|
|
470
|
+
const { added, removed } = arraysDiff(oldClasses, newClasses);
|
|
471
|
+
if (removed.length > 0) {
|
|
472
|
+
el.classList.remove(...removed);
|
|
473
|
+
}
|
|
474
|
+
if (added.length > 0) {
|
|
475
|
+
el.classList.add(...added);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
function toClassList(classes = "") {
|
|
479
|
+
return Array.isArray(classes)
|
|
480
|
+
? classes.filter(isNotBlankOrEmptyString)
|
|
481
|
+
: classes.split(/(\s+)/).filter(isNotBlankOrEmptyString);
|
|
482
|
+
}
|
|
483
|
+
function patchStyles(el, oldStyle = {}, newStyle = {}) {
|
|
484
|
+
const { added, removed, updated } = objectsDiff(oldStyle, newStyle);
|
|
485
|
+
for (const style of removed) {
|
|
486
|
+
removeStyle(el, style);
|
|
487
|
+
}
|
|
488
|
+
for (const style of added.concat(updated)) {
|
|
489
|
+
setStyle(el, style, newStyle[style]);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
function patchEvents(el, oldListeners = {}, oldEvents = {}, newEvents = {}) {
|
|
493
|
+
const { removed, added, updated } = objectsDiff(oldEvents, newEvents);
|
|
494
|
+
for (const eventName of removed.concat(updated)) {
|
|
495
|
+
el.removeEventListener(eventName, oldListeners[eventName]);
|
|
496
|
+
}
|
|
497
|
+
const addedListeners = {};
|
|
498
|
+
for (const eventName of added.concat(updated)) {
|
|
499
|
+
const listener = addEventListener(eventName, newEvents[eventName], el);
|
|
500
|
+
addedListeners[eventName] = listener;
|
|
501
|
+
}
|
|
502
|
+
return addedListeners;
|
|
503
|
+
}
|
|
504
|
+
function patchChildren(oldVdom, newVdom) {
|
|
505
|
+
const oldChildren = extractChildren(oldVdom);
|
|
506
|
+
const newChildren = extractChildren(newVdom);
|
|
507
|
+
const parentEl = oldVdom.el;
|
|
508
|
+
const diffSeq = arrayDiffSequence(oldChildren, newChildren, areNodesEqual);
|
|
509
|
+
for (const operation of diffSeq) {
|
|
510
|
+
const { originalIndex, index, item } = operation;
|
|
511
|
+
switch (operation.op) {
|
|
512
|
+
case ARRAY_DIF_OP.ADD: {
|
|
513
|
+
mountDOM(item, parentEl, index);
|
|
514
|
+
break;
|
|
515
|
+
}
|
|
516
|
+
case ARRAY_DIF_OP.REMOVE: {
|
|
517
|
+
destroyDOM(item);
|
|
518
|
+
break;
|
|
519
|
+
}
|
|
520
|
+
case ARRAY_DIF_OP.MOVE: {
|
|
521
|
+
const oldChild = oldChildren[originalIndex];
|
|
522
|
+
const newChild = newChildren[index];
|
|
523
|
+
const el = oldChild.el;
|
|
524
|
+
const elAtTargetIndex = parentEl.childNodes[index];
|
|
525
|
+
parentEl.insertBefore(el, elAtTargetIndex);
|
|
526
|
+
patchDOM(oldChild, newChild, parentEl);
|
|
527
|
+
break;
|
|
528
|
+
}
|
|
529
|
+
case ARRAY_DIF_OP.NOOP: {
|
|
530
|
+
patchDOM(oldChildren[originalIndex], newChildren[index], parentEl);
|
|
531
|
+
break;
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
209
537
|
function createApp({ state, view, reducers = {} }) {
|
|
210
538
|
let parentEl = null;
|
|
211
539
|
let vdom = null;
|
|
@@ -222,21 +550,21 @@ function createApp({ state, view, reducers = {} }) {
|
|
|
222
550
|
subscriptions.push(subs);
|
|
223
551
|
}
|
|
224
552
|
function renderApp() {
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
}
|
|
228
|
-
vdom = view(state, emit);
|
|
229
|
-
mountDOM(vdom, parentEl);
|
|
553
|
+
const newVDom = view(state, emit);
|
|
554
|
+
vdom = patchDOM(vdom, newVDom, parentEl);
|
|
230
555
|
}
|
|
231
556
|
return {
|
|
232
557
|
mount(_parentEl) {
|
|
233
558
|
parentEl = _parentEl;
|
|
234
|
-
|
|
559
|
+
vdom = view(state, emit);
|
|
560
|
+
mountDOM(vdom, parentEl);
|
|
561
|
+
iMounted = true;
|
|
235
562
|
},
|
|
236
563
|
unmount() {
|
|
237
564
|
destroyDOM(vdom);
|
|
238
565
|
vdom = null;
|
|
239
566
|
subscriptions.forEach((unsubscribe) => unsubscribe());
|
|
567
|
+
iMounted = false;
|
|
240
568
|
},
|
|
241
569
|
};
|
|
242
570
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "philo-scratch",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "
|
|
4
|
+
"version": "2.0.0",
|
|
5
5
|
"description": "A frontend framework to teach web developers how frontend frameworks work.",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"frontend",
|
|
@@ -31,5 +31,8 @@
|
|
|
31
31
|
"rollup-plugin-cleanup": "^3.2.1",
|
|
32
32
|
"rollup-plugin-filesize": "^10.0.0",
|
|
33
33
|
"vitest": "^0.34.3"
|
|
34
|
+
},
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"philo-scratch": "^1.0.0"
|
|
34
37
|
}
|
|
35
38
|
}
|