@mml-io/networked-dom-document 0.0.42

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/src/common.ts ADDED
@@ -0,0 +1,23 @@
1
+ import { StaticVirtualDomElement } from "@mml-io/observable-dom-common";
2
+ import * as rfc6902 from "rfc6902";
3
+
4
+ export type NodeMapping = {
5
+ clientFacingNodeId: number;
6
+ internalNodeId: number;
7
+ };
8
+
9
+ export type VirtualDOMDiffStruct = {
10
+ originalState: StaticVirtualDomElement;
11
+ nodeIdRemappings: Array<NodeMapping>;
12
+ virtualDOMDiffs: Array<rfc6902.Operation>;
13
+ };
14
+
15
+ // This is similar to the MutationRecord type in the DOM spec, but it references StaticVirtualDomElements instead of DOM nodes.
16
+ export type StaticVirtualDomMutationRecord = {
17
+ type: "attributes" | "characterData" | "childList";
18
+ target: StaticVirtualDomElement;
19
+ addedNodes: Array<StaticVirtualDomElement>;
20
+ removedNodes: Array<StaticVirtualDomElement>;
21
+ previousSibling: StaticVirtualDomElement | null;
22
+ attributeName: string | null;
23
+ };
package/src/diffing.ts ADDED
@@ -0,0 +1,542 @@
1
+ import {
2
+ AttributeChangedDiff,
3
+ ChildrenChangedDiff,
4
+ Diff,
5
+ ElementNodeDescription,
6
+ NodeDescription,
7
+ TextChangedDiff,
8
+ TextNodeDescription,
9
+ } from "@mml-io/networked-dom-protocol";
10
+ import { StaticVirtualDomElement } from "@mml-io/observable-dom-common";
11
+ import * as rfc6902 from "rfc6902";
12
+
13
+ import { NodeMapping, StaticVirtualDomMutationRecord, VirtualDOMDiffStruct } from "./common";
14
+
15
+ export const visibleToAttrName = "visible-to";
16
+ export const hiddenFromAttrName = "hidden-from";
17
+
18
+ // This function does a lot of heavy lifting - it takes a mutation and applies it to the connection's view (affecting which nodes are visible based on attributes etc.)
19
+ // As a result of that application it generates a diff for that client's view of the DOM.
20
+ export function diffFromApplicationOfStaticVirtualDomMutationRecordToConnection(
21
+ mutation: StaticVirtualDomMutationRecord,
22
+ parentNode: StaticVirtualDomElement | null,
23
+ connectionId: number,
24
+ visibleNodesForConnection: Set<number>,
25
+ ): Diff | null {
26
+ const virtualDomElement = mutation.target;
27
+
28
+ if (mutation.type === "attributes") {
29
+ const visible = visibleNodesForConnection.has(virtualDomElement.nodeId);
30
+
31
+ if (!parentNode) {
32
+ throw new Error("Node has no parent");
33
+ }
34
+ const parentNodeId = parentNode.nodeId;
35
+ const shouldBeVisible =
36
+ shouldShowNodeToConnectionId(virtualDomElement, connectionId) &&
37
+ visibleNodesForConnection.has(parentNodeId);
38
+
39
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
40
+ const attributeName = mutation.attributeName!;
41
+
42
+ if (visible && shouldBeVisible) {
43
+ let newValue = null; // null indicates deleted
44
+ if (virtualDomElement.attributes[attributeName] !== undefined) {
45
+ newValue = virtualDomElement.attributes[attributeName];
46
+ }
47
+ const diff: AttributeChangedDiff = {
48
+ type: "attributeChange",
49
+ nodeId: virtualDomElement.nodeId,
50
+ attribute: attributeName,
51
+ newValue,
52
+ };
53
+ return diff;
54
+ } else if (!visible && shouldBeVisible) {
55
+ // Need to add this child to the connection's view now
56
+ visibleNodesForConnection.add(virtualDomElement.nodeId);
57
+
58
+ const index = parentNode.childNodes.indexOf(virtualDomElement);
59
+ if (index === -1) {
60
+ throw new Error("Node not found in parent's children");
61
+ }
62
+
63
+ let previousNodeId = null;
64
+ if (index > 0) {
65
+ previousNodeId = getNodeIdOfPreviousVisibleSibling(
66
+ parentNode,
67
+ index - 1,
68
+ visibleNodesForConnection,
69
+ );
70
+ }
71
+
72
+ const nodeDescription = describeNodeWithChildrenForConnectionId(
73
+ virtualDomElement,
74
+ connectionId,
75
+ visibleNodesForConnection,
76
+ );
77
+ if (!nodeDescription) {
78
+ throw new Error("Node description not found");
79
+ }
80
+ const diff: ChildrenChangedDiff = {
81
+ type: "childrenChanged",
82
+ nodeId: parentNodeId,
83
+ previousNodeId,
84
+ addedNodes: [nodeDescription],
85
+ removedNodes: [],
86
+ };
87
+ return diff;
88
+ } else if (visible && !shouldBeVisible) {
89
+ removeNodeAndChildrenFromVisibleNodes(virtualDomElement, visibleNodesForConnection);
90
+ const diff: ChildrenChangedDiff = {
91
+ type: "childrenChanged",
92
+ nodeId: parentNodeId,
93
+ previousNodeId: null,
94
+ addedNodes: [],
95
+ removedNodes: [virtualDomElement.nodeId],
96
+ };
97
+ return diff;
98
+ } else if (!visible && !shouldBeVisible) {
99
+ return null;
100
+ }
101
+ }
102
+
103
+ if (!visibleNodesForConnection.has(virtualDomElement.nodeId)) {
104
+ // This element is not visible to the connection, so we don't need to send a diff for it or its children
105
+ return null;
106
+ }
107
+
108
+ if (mutation.type === "characterData") {
109
+ const diff: TextChangedDiff = {
110
+ type: "textChanged",
111
+ nodeId: virtualDomElement.nodeId,
112
+ text: virtualDomElement.textContent || "",
113
+ };
114
+ return diff;
115
+ }
116
+
117
+ if (mutation.type === "childList") {
118
+ let previousSibling = mutation.previousSibling;
119
+ let previousNodeId: number | null = null;
120
+ if (previousSibling) {
121
+ let previousIndex = virtualDomElement.childNodes.indexOf(previousSibling);
122
+ while (previousIndex !== -1) {
123
+ previousSibling = virtualDomElement.childNodes[previousIndex];
124
+ if (visibleNodesForConnection.has(previousSibling.nodeId)) {
125
+ previousNodeId = previousSibling.nodeId;
126
+ break;
127
+ }
128
+ previousIndex--;
129
+ }
130
+ }
131
+
132
+ const diff: ChildrenChangedDiff = {
133
+ type: "childrenChanged",
134
+ nodeId: virtualDomElement.nodeId,
135
+ previousNodeId,
136
+ addedNodes: [],
137
+ removedNodes: [],
138
+ };
139
+
140
+ mutation.addedNodes.forEach((childVirtualDomElement: StaticVirtualDomElement) => {
141
+ const describedNode = describeNodeWithChildrenForConnectionId(
142
+ childVirtualDomElement,
143
+ connectionId,
144
+ visibleNodesForConnection,
145
+ );
146
+ if (!describedNode) {
147
+ return;
148
+ }
149
+ diff.addedNodes.push(describedNode);
150
+ });
151
+ mutation.removedNodes.forEach((childVirtualDomElement: StaticVirtualDomElement) => {
152
+ if (visibleNodesForConnection.has(childVirtualDomElement.nodeId)) {
153
+ removeNodeAndChildrenFromVisibleNodes(childVirtualDomElement, visibleNodesForConnection);
154
+ diff.removedNodes.push(childVirtualDomElement.nodeId);
155
+ }
156
+ });
157
+
158
+ if (diff.addedNodes.length > 0 || diff.removedNodes.length > 0) {
159
+ return diff;
160
+ }
161
+ return null;
162
+ }
163
+
164
+ console.error("Unknown mutation type: " + mutation.type);
165
+ return null;
166
+ }
167
+
168
+ function getNodeIdOfPreviousVisibleSibling(
169
+ parentVirtualElement: StaticVirtualDomElement,
170
+ candidateIndex: number,
171
+ visibleNodesForConnection: Set<number>,
172
+ ): number | null {
173
+ if (candidateIndex > 0) {
174
+ let previousSiblingIndex = candidateIndex;
175
+ while (previousSiblingIndex >= 0) {
176
+ const previousSibling = parentVirtualElement.childNodes[previousSiblingIndex];
177
+ if (visibleNodesForConnection.has(previousSibling.nodeId)) {
178
+ return previousSibling.nodeId;
179
+ }
180
+ previousSiblingIndex--;
181
+ }
182
+ }
183
+ return null;
184
+ }
185
+
186
+ function shouldShowNodeToConnectionId(
187
+ virtualDomElement: StaticVirtualDomElement,
188
+ connectionId: number,
189
+ ): boolean {
190
+ const visibleToAttr = virtualDomElement.attributes[visibleToAttrName];
191
+ const hiddenFromAttr = virtualDomElement.attributes[hiddenFromAttrName];
192
+ const connectionIdString = connectionId.toString();
193
+ if (visibleToAttr !== undefined) {
194
+ const visibleToList = visibleToAttr.split(" ");
195
+ const explicityVisible = visibleToList.includes(connectionIdString);
196
+ if (!explicityVisible) {
197
+ return false;
198
+ }
199
+ }
200
+ if (hiddenFromAttr !== undefined) {
201
+ const hiddenFromList = hiddenFromAttr.split(" ");
202
+ const explicityHidden = hiddenFromList.includes(connectionIdString);
203
+ if (explicityHidden) {
204
+ return false;
205
+ }
206
+ }
207
+ return true;
208
+ }
209
+
210
+ export function describeNodeWithChildrenForConnectionId(
211
+ virtualDomElement: StaticVirtualDomElement,
212
+ connectionId: number,
213
+ visibleNodesForConnection: Set<number>,
214
+ ): NodeDescription | null {
215
+ if (!shouldShowNodeToConnectionId(virtualDomElement, connectionId)) {
216
+ return null;
217
+ }
218
+
219
+ let emittedTagName = virtualDomElement.tag;
220
+ if (emittedTagName === "#document") {
221
+ emittedTagName = "DIV";
222
+ }
223
+ if (emittedTagName === "#text") {
224
+ const textNode: TextNodeDescription = {
225
+ type: "text",
226
+ nodeId: virtualDomElement.nodeId,
227
+ text: virtualDomElement.textContent || "",
228
+ };
229
+ visibleNodesForConnection.add(textNode.nodeId);
230
+ return textNode;
231
+ } else {
232
+ const node: ElementNodeDescription = {
233
+ type: "element",
234
+ nodeId: virtualDomElement.nodeId,
235
+ tag: emittedTagName,
236
+ attributes: virtualDomElement.attributes,
237
+ children: [],
238
+ text: virtualDomElement.textContent,
239
+ };
240
+ visibleNodesForConnection.add(node.nodeId);
241
+
242
+ for (const child of virtualDomElement.childNodes) {
243
+ const childNodeDescription = describeNodeWithChildrenForConnectionId(
244
+ child,
245
+ connectionId,
246
+ visibleNodesForConnection,
247
+ );
248
+ if (childNodeDescription) {
249
+ node.children.push(childNodeDescription);
250
+ }
251
+ }
252
+ return node;
253
+ }
254
+ }
255
+
256
+ function removeNodeAndChildrenFromVisibleNodes(
257
+ virtualDomElement: StaticVirtualDomElement,
258
+ visibleNodesForConnection: Set<number>,
259
+ ): void {
260
+ visibleNodesForConnection.delete(virtualDomElement.nodeId);
261
+ for (const child of virtualDomElement.childNodes) {
262
+ if (!visibleNodesForConnection.has(child.nodeId)) {
263
+ console.error("Inner child of removed element was not visible", child.nodeId);
264
+ }
265
+ removeNodeAndChildrenFromVisibleNodes(child, visibleNodesForConnection);
266
+ }
267
+ }
268
+
269
+ export function findParentNodeOfNodeId(
270
+ virtualDomElement: StaticVirtualDomElement,
271
+ targetNodeId: number,
272
+ ): StaticVirtualDomElement | null {
273
+ // TODO - avoid a search of the whole tree for the node's parent
274
+ // depth-first search of the whole virtual dom structure to find the node's parent
275
+ for (const child of virtualDomElement.childNodes) {
276
+ if (child.nodeId === targetNodeId) {
277
+ return virtualDomElement;
278
+ } else {
279
+ const foundParentId = findParentNodeOfNodeId(child, targetNodeId);
280
+ if (foundParentId) {
281
+ return foundParentId;
282
+ }
283
+ }
284
+ }
285
+ return null;
286
+ }
287
+
288
+ export function virtualDOMDiffToVirtualDOMMutationRecord(
289
+ virtualStructure: StaticVirtualDomElement,
290
+ domDiff: rfc6902.Operation,
291
+ ): Array<StaticVirtualDomMutationRecord> {
292
+ const pointer = rfc6902.Pointer.fromJSON(domDiff.path);
293
+ const grandParentTokens = pointer.tokens.slice(0, pointer.tokens.length - 2);
294
+ const lastToken = pointer.tokens[pointer.tokens.length - 1];
295
+ const secondLastToken = pointer.tokens[pointer.tokens.length - 2];
296
+
297
+ if (lastToken === "textContent") {
298
+ const nodePointer = new rfc6902.Pointer(pointer.tokens.slice(0, pointer.tokens.length - 1));
299
+ const node = nodePointer.get(virtualStructure) as StaticVirtualDomElement;
300
+ return [
301
+ {
302
+ type: "characterData",
303
+ target: node,
304
+ addedNodes: [],
305
+ removedNodes: [],
306
+ attributeName: null,
307
+ previousSibling: null,
308
+ },
309
+ ];
310
+ }
311
+
312
+ if (secondLastToken === "attributes") {
313
+ // This handles attribute additions, changes, and removals
314
+ const nodePointer = new rfc6902.Pointer(grandParentTokens);
315
+ const node = nodePointer.get(virtualStructure) as StaticVirtualDomElement;
316
+ return [
317
+ {
318
+ type: "attributes",
319
+ target: node,
320
+ addedNodes: [],
321
+ removedNodes: [],
322
+ attributeName: lastToken,
323
+ previousSibling: null,
324
+ },
325
+ ];
326
+ }
327
+
328
+ // Child changes
329
+
330
+ if (secondLastToken === "childNodes") {
331
+ const nodePointer = new rfc6902.Pointer(grandParentTokens);
332
+ const node = nodePointer.get(virtualStructure) as StaticVirtualDomElement;
333
+
334
+ let previousSibling: StaticVirtualDomElement | null = null;
335
+ if (lastToken === "-") {
336
+ // Append to the end of the children
337
+ } else {
338
+ const index = parseInt(lastToken, 10);
339
+ if (index === 0) {
340
+ previousSibling = null;
341
+ } else {
342
+ previousSibling = node.childNodes[index - 1];
343
+ }
344
+ }
345
+ const addedNodes: Array<StaticVirtualDomElement> = [];
346
+ const removedNodes: Array<StaticVirtualDomElement> = [];
347
+ if (domDiff.op === "add") {
348
+ addedNodes.push(domDiff.value);
349
+ return [
350
+ {
351
+ type: "childList",
352
+ target: node,
353
+ addedNodes,
354
+ removedNodes,
355
+ previousSibling,
356
+ attributeName: null,
357
+ },
358
+ ];
359
+ } else if (domDiff.op === "remove") {
360
+ const removedNode = pointer.get(virtualStructure) as StaticVirtualDomElement;
361
+ removedNodes.push(removedNode);
362
+ return [
363
+ {
364
+ type: "childList",
365
+ target: node,
366
+ addedNodes,
367
+ removedNodes,
368
+ previousSibling,
369
+ attributeName: null,
370
+ },
371
+ ];
372
+ } else if (domDiff.op === "replace") {
373
+ // This is a replacement of a single node
374
+ const removedNode = pointer.get(virtualStructure) as StaticVirtualDomElement;
375
+ removedNodes.push(removedNode);
376
+ addedNodes.push(domDiff.value);
377
+ return [
378
+ {
379
+ type: "childList",
380
+ target: node,
381
+ addedNodes: [],
382
+ removedNodes,
383
+ previousSibling,
384
+ attributeName: null,
385
+ },
386
+ {
387
+ type: "childList",
388
+ target: node,
389
+ addedNodes,
390
+ removedNodes: [],
391
+ previousSibling,
392
+ attributeName: null,
393
+ },
394
+ ];
395
+ }
396
+ }
397
+
398
+ console.error("Unhandled JSON diff:", JSON.stringify(domDiff, null, 2));
399
+ throw new Error("Unhandled diff type");
400
+ }
401
+
402
+ export function calculateStaticVirtualDomDiff(
403
+ originalState: StaticVirtualDomElement,
404
+ latestState: StaticVirtualDomElement,
405
+ ): VirtualDOMDiffStruct {
406
+ const jsonPatchDiffs = rfc6902.createPatch(
407
+ originalState,
408
+ latestState,
409
+ (a, b, ptr: rfc6902.Pointer) => {
410
+ if (a.tag !== b.tag) {
411
+ return [{ op: "replace", path: ptr.toString(), value: b }];
412
+ }
413
+ return;
414
+ },
415
+ );
416
+
417
+ const nodeIdRemappings: Array<NodeMapping> = [];
418
+ const virtualDOMDiffs: Array<rfc6902.Operation> = [];
419
+ for (const diff of jsonPatchDiffs) {
420
+ if (diff.op === "replace" && diff.path.endsWith("/nodeId")) {
421
+ const pointer = rfc6902.Pointer.fromJSON(diff.path);
422
+ const value = pointer.get(originalState);
423
+ nodeIdRemappings.push({
424
+ internalNodeId: diff.value,
425
+ clientFacingNodeId: value,
426
+ });
427
+ } else {
428
+ virtualDOMDiffs.push(diff);
429
+ }
430
+ }
431
+
432
+ return remapDuplicatedNodeIdsInOperations(
433
+ {
434
+ originalState,
435
+ nodeIdRemappings,
436
+ virtualDOMDiffs,
437
+ },
438
+ latestState,
439
+ );
440
+ }
441
+
442
+ function getHighestNodeId(node: StaticVirtualDomElement) {
443
+ let highest = node.nodeId;
444
+ for (const child of node.childNodes) {
445
+ highest = Math.max(highest, getHighestNodeId(child));
446
+ }
447
+ return highest;
448
+ }
449
+
450
+ function getRemovedNodeIds(before: StaticVirtualDomElement, diff: rfc6902.Operation) {
451
+ const removedIds = new Set<number>();
452
+ function addNode(node: StaticVirtualDomElement) {
453
+ removedIds.add(node.nodeId);
454
+ for (const child of node.childNodes) {
455
+ addNode(child);
456
+ }
457
+ }
458
+ if (diff.op === "replace" || diff.op === "remove") {
459
+ const removedNode = rfc6902.Pointer.fromJSON(diff.path).get(before);
460
+ addNode(removedNode);
461
+ }
462
+ return removedIds;
463
+ }
464
+
465
+ function getNodeIdsFromNodeAndChildren(node: StaticVirtualDomElement) {
466
+ const nodeIds = new Set<number>();
467
+ function addNode(node: StaticVirtualDomElement) {
468
+ nodeIds.add(node.nodeId);
469
+ for (const child of node.childNodes) {
470
+ addNode(child);
471
+ }
472
+ }
473
+ addNode(node);
474
+ return nodeIds;
475
+ }
476
+
477
+ // To avoid duplicate node ids at any point in the sequence of operations, apply the operations and determine if any node ids are duplicated at any point. If so, remap the node ids to be unique.
478
+ function remapDuplicatedNodeIdsInOperations(
479
+ virtualDOMDiffStruct: VirtualDOMDiffStruct,
480
+ latestState: StaticVirtualDomElement,
481
+ ): VirtualDOMDiffStruct {
482
+ const { originalState, nodeIdRemappings, virtualDOMDiffs } = virtualDOMDiffStruct;
483
+
484
+ const highestNodeIdAcrossStartAndEnd = Math.max(
485
+ getHighestNodeId(originalState),
486
+ getHighestNodeId(latestState),
487
+ );
488
+ let nextNodeId = highestNodeIdAcrossStartAndEnd + 1;
489
+
490
+ const before = JSON.parse(JSON.stringify(originalState));
491
+
492
+ function checkAndReplaceNodeIdsIfAlreadyInUse(
493
+ node: StaticVirtualDomElement,
494
+ addingNodeIds: Set<number>,
495
+ removedIds: Set<number>,
496
+ ) {
497
+ if (existingNodeIds.has(node.nodeId) && removedIds && !removedIds.has(node.nodeId)) {
498
+ // This node id is already present so it must be replaced
499
+ const newNodeId = nextNodeId++;
500
+ nodeIdRemappings.push({
501
+ internalNodeId: node.nodeId,
502
+ clientFacingNodeId: newNodeId,
503
+ });
504
+ node.nodeId = newNodeId;
505
+ addingNodeIds.add(newNodeId);
506
+ } else {
507
+ addingNodeIds.add(node.nodeId);
508
+ }
509
+ for (const child of node.childNodes) {
510
+ checkAndReplaceNodeIdsIfAlreadyInUse(child, addingNodeIds, removedIds);
511
+ }
512
+ }
513
+
514
+ const existingNodeIds = getNodeIdsFromNodeAndChildren(before);
515
+
516
+ for (const diff of virtualDOMDiffs) {
517
+ const pointer = rfc6902.Pointer.fromJSON(diff.path);
518
+ const secondLastToken = pointer.tokens[pointer.tokens.length - 2];
519
+ if (secondLastToken !== "childNodes") {
520
+ continue;
521
+ }
522
+ const removedIds = getRemovedNodeIds(before, diff);
523
+ const addingNodeIds = new Set<number>();
524
+ if (diff.op === "replace" || diff.op === "add") {
525
+ // The added node can use removed node ids, but it must not use any node ids that are still in use.
526
+ checkAndReplaceNodeIdsIfAlreadyInUse(diff.value, addingNodeIds, removedIds);
527
+ }
528
+ removedIds.forEach((removedId) => {
529
+ existingNodeIds.delete(removedId);
530
+ });
531
+ addingNodeIds.forEach((addingNodeId) => {
532
+ existingNodeIds.add(addingNodeId);
533
+ });
534
+
535
+ const patchErrors = rfc6902.applyPatch(before, [diff]);
536
+ if (patchErrors.length !== 1 || patchErrors[0] !== null) {
537
+ throw new Error("Patch failed");
538
+ }
539
+ }
540
+
541
+ return virtualDOMDiffStruct;
542
+ }
package/src/index.ts ADDED
@@ -0,0 +1,2 @@
1
+ export * from "./NetworkedDOM";
2
+ export * from "./EditableNetworkedDOM";