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.
Files changed (2) hide show
  1. package/dist/philo-scratch.js +344 -16
  2. package/package.json +4 -1
@@ -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 createFragmentNode(vdom, parentEl) {
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
- parentEl.append(textNode);
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.append(element);
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
- if (vdom) {
226
- destroyDOM();
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
- renderApp();
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": "1.0.0",
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
  }