fauna-api 1.0.765 → 1.0.771
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/src/authentification.d.ts +2 -0
- package/dist/src/authentification.js +5 -2
- package/dist/src/calendar.d.ts +6 -0
- package/dist/src/calendar.js +1 -0
- package/dist/src/cicada/inboxes.d.ts +5 -1
- package/dist/src/cicada/inboxes.js +1 -0
- package/dist/src/groups.d.ts +33 -2
- package/dist/src/groups.js +26 -2
- package/dist/src/marmot/web.d.ts +59 -25
- package/dist/src/marmot/web.js +333 -203
- package/dist/src/parrot/room.d.ts +3 -2
- package/dist/src/parrot/room.js +14 -3
- package/dist/src/payments.d.ts +19 -4
- package/dist/src/payments.js +8 -3
- package/dist/src/termite/os.d.ts +14 -6
- package/dist/src/termite/os.js +9 -4
- package/dist/src/ui/browser.js +35 -10
- package/dist/src/ui/chat.js +2 -2
- package/dist/src/ui/chatPanel.d.ts +3 -3
- package/dist/src/ui/chatPanel.js +23 -9
- package/dist/src/view.d.ts +8 -4
- package/dist/src/view.js +5 -5
- package/dist/src/webhooks.d.ts +3 -2
- package/package.json +1 -1
package/dist/src/marmot/web.js
CHANGED
|
@@ -144,9 +144,10 @@ export function serializeDocumentStyles(doc = document) {
|
|
|
144
144
|
function ownerDocOf(el) {
|
|
145
145
|
return el.ownerDocument || document;
|
|
146
146
|
}
|
|
147
|
-
// Framed documents are inlined into their host page
|
|
148
|
-
//
|
|
149
|
-
//
|
|
147
|
+
// Framed documents are inlined into their host page's copy, and the viewer replays each as a
|
|
148
|
+
// nested frame. Serialized as HTML, their html, head, and body become divs marked
|
|
149
|
+
// `data-fauna-frame`, since a parser keeps no document inside another; recorded as nodes they
|
|
150
|
+
// keep their own tags.
|
|
150
151
|
const FRAME_TAGS = new Set(["IFRAME", "FRAME", "OBJECT", "EMBED"]);
|
|
151
152
|
const FRAME_SKIP_ATTRS = new Set(["src", "srcdoc", "sandbox", "allow", "data"]);
|
|
152
153
|
const FRAME_PLACEHOLDER_TEXT = "Embedded frame unavailable in share";
|
|
@@ -224,11 +225,8 @@ export function deepFreezeClone(source) {
|
|
|
224
225
|
}
|
|
225
226
|
catch { /* skip */ }
|
|
226
227
|
}
|
|
227
|
-
if (frameRoot)
|
|
228
|
+
if (frameRoot)
|
|
228
229
|
clone.setAttribute("data-fauna-frame", frameRoot);
|
|
229
|
-
if (frameRoot === "head")
|
|
230
|
-
clone.setAttribute("hidden", "");
|
|
231
|
-
}
|
|
232
230
|
// A framed document's inputs aren't this window's HTMLInputElement, so go by tag.
|
|
233
231
|
if (el.tagName === "INPUT" && el.type === "password") {
|
|
234
232
|
clone.setAttribute("value", "");
|
|
@@ -539,13 +537,7 @@ export class DomRecorder {
|
|
|
539
537
|
if (childId !== null)
|
|
540
538
|
children.push(childId);
|
|
541
539
|
}
|
|
542
|
-
|
|
543
|
-
if (frameRoot) {
|
|
544
|
-
attributes["data-fauna-frame"] = frameRoot;
|
|
545
|
-
if (frameRoot === "head")
|
|
546
|
-
attributes.hidden = "";
|
|
547
|
-
}
|
|
548
|
-
into[id] = { id, kind: "element", tag: frameRoot ? "div" : el.tagName.toLowerCase(), attributes, children };
|
|
540
|
+
into[id] = { id, kind: "element", tag: el.localName, attributes, children };
|
|
549
541
|
return id;
|
|
550
542
|
}
|
|
551
543
|
/** The one child a frame's wrapper holds: its document root, or a placeholder text. */
|
|
@@ -697,39 +689,25 @@ export class DomRecorder {
|
|
|
697
689
|
this.detachResize = null;
|
|
698
690
|
}
|
|
699
691
|
}
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
const
|
|
705
|
-
const
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
* pointed at the divs standing in for them. Null for rules `@scope` can't hold
|
|
709
|
-
* (fonts, keyframes, imports), which stay where they are. */
|
|
710
|
-
function frameScopedRule(rule) {
|
|
711
|
-
const text = rule.cssText;
|
|
712
|
-
const open = text.indexOf("{");
|
|
713
|
-
if (open < 0)
|
|
714
|
-
return null;
|
|
715
|
-
const selector = rule.selectorText;
|
|
716
|
-
if (typeof selector === "string") {
|
|
717
|
-
const scoped = selector.replace(FRAME_ROOT_SELECTOR, "$1:scope").replace(FRAME_BODY_SELECTOR, '$1[data-fauna-frame="body"]');
|
|
718
|
-
return `${scoped} ${text.slice(open)}`;
|
|
719
|
-
}
|
|
720
|
-
const children = rule.cssRules;
|
|
721
|
-
if (!children || !/^@(media|supports|container|layer)\b/.test(text))
|
|
722
|
-
return null;
|
|
723
|
-
const inner = Array.from(children).map(frameScopedRule);
|
|
724
|
-
if (inner.some((t) => t === null))
|
|
725
|
-
return null;
|
|
726
|
-
return `${text.slice(0, open)}{ ${inner.join("\n")} }`;
|
|
727
|
-
}
|
|
692
|
+
// A wrapper replays as an iframe that never loads anything: the replayer writes its document.
|
|
693
|
+
const FRAME_BOX_SKIP = new Set(["src", "srcdoc", "sandbox", "allow", "data", "loading", "name"]);
|
|
694
|
+
/** A frame showing a placeholder instead of a document centers it. */
|
|
695
|
+
const FRAME_PLACEHOLDER_BODY = "margin:0;height:100vh;display:flex;align-items:center;justify-content:center;color:#666;font:12px/1.4 system-ui,sans-serif";
|
|
696
|
+
const SVG_NS = "http://www.w3.org/2000/svg";
|
|
697
|
+
const MATH_NS = "http://www.w3.org/1998/Math/MathML";
|
|
698
|
+
/** How long a scroll keeps being reapplied while its document grows into it. */
|
|
699
|
+
const SCROLL_SETTLE_MS = 3000;
|
|
728
700
|
/**
|
|
729
701
|
* Re-render a shared page into a frame inside `mount` and apply incoming `WebMutation`s.
|
|
730
702
|
* The page is someone else's, so the frame is sandboxed without scripts: the replayer
|
|
731
703
|
* writes into it from this page, and nothing the page carries ever runs. The replayer
|
|
732
704
|
* keeps its own id->Node map mirroring the recorder's synthetic ids.
|
|
705
|
+
*
|
|
706
|
+
* Every frame the page holds replays as a nested iframe of that frame, which inherits the
|
|
707
|
+
* sandbox, so a framed page lays out against its own viewport: fixed positions, viewport
|
|
708
|
+
* units and media queries resolve against the frame, as they did on the host. Nothing
|
|
709
|
+
* inside the copy can run, so no event there ever reaches this page; the replayer places
|
|
710
|
+
* each frame's document right after the frame joins the copy.
|
|
733
711
|
*/
|
|
734
712
|
export class DomReplayer {
|
|
735
713
|
constructor(mount) {
|
|
@@ -745,6 +723,12 @@ export class DomReplayer {
|
|
|
745
723
|
writable: true,
|
|
746
724
|
value: new Map()
|
|
747
725
|
});
|
|
726
|
+
Object.defineProperty(this, "ids", {
|
|
727
|
+
enumerable: true,
|
|
728
|
+
configurable: true,
|
|
729
|
+
writable: true,
|
|
730
|
+
value: new WeakMap()
|
|
731
|
+
});
|
|
748
732
|
Object.defineProperty(this, "_viewport", {
|
|
749
733
|
enumerable: true,
|
|
750
734
|
configurable: true,
|
|
@@ -771,25 +755,34 @@ export class DomReplayer {
|
|
|
771
755
|
writable: true,
|
|
772
756
|
value: 0
|
|
773
757
|
});
|
|
774
|
-
Object.defineProperty(this, "
|
|
758
|
+
Object.defineProperty(this, "boxes", {
|
|
775
759
|
enumerable: true,
|
|
776
760
|
configurable: true,
|
|
777
761
|
writable: true,
|
|
778
|
-
value:
|
|
762
|
+
value: new WeakMap()
|
|
779
763
|
});
|
|
780
|
-
|
|
764
|
+
/** The box each framed document's node sits in. */
|
|
765
|
+
Object.defineProperty(this, "boxOf", {
|
|
781
766
|
enumerable: true,
|
|
782
767
|
configurable: true,
|
|
783
768
|
writable: true,
|
|
784
|
-
value: new
|
|
769
|
+
value: new WeakMap()
|
|
785
770
|
});
|
|
786
|
-
|
|
771
|
+
/** Boxes built before they joined the copy, so without a document yet. */
|
|
772
|
+
Object.defineProperty(this, "unplaced", {
|
|
787
773
|
enumerable: true,
|
|
788
774
|
configurable: true,
|
|
789
775
|
writable: true,
|
|
790
|
-
value:
|
|
776
|
+
value: new Set()
|
|
791
777
|
});
|
|
792
|
-
|
|
778
|
+
/** Where the host has each scrolled element, and since when. */
|
|
779
|
+
Object.defineProperty(this, "scrolls", {
|
|
780
|
+
enumerable: true,
|
|
781
|
+
configurable: true,
|
|
782
|
+
writable: true,
|
|
783
|
+
value: new Map()
|
|
784
|
+
});
|
|
785
|
+
Object.defineProperty(this, "scrollFrame", {
|
|
793
786
|
enumerable: true,
|
|
794
787
|
configurable: true,
|
|
795
788
|
writable: true,
|
|
@@ -844,7 +837,7 @@ export class DomReplayer {
|
|
|
844
837
|
* The `<html>/<head>/<body>` structure and DFS ids match the host's.
|
|
845
838
|
*/
|
|
846
839
|
async applyHtml(html, viewport) {
|
|
847
|
-
this.
|
|
840
|
+
this.reset();
|
|
848
841
|
this._viewport = viewport ?? null;
|
|
849
842
|
const load = ++this.loads;
|
|
850
843
|
// Drop scripts that survived capture so Chrome doesn't log a sandbox error for each.
|
|
@@ -860,10 +853,14 @@ export class DomReplayer {
|
|
|
860
853
|
if (!iframe)
|
|
861
854
|
return;
|
|
862
855
|
this.bindIds();
|
|
863
|
-
this.
|
|
864
|
-
|
|
865
|
-
for (const
|
|
866
|
-
|
|
856
|
+
this.unfold(iframe.contentDocument);
|
|
857
|
+
this.settle();
|
|
858
|
+
for (const doc of this.documents()) {
|
|
859
|
+
this.mirrorStates(doc);
|
|
860
|
+
// A textarea shows its text, not its value attribute.
|
|
861
|
+
for (const field of Array.from(doc.querySelectorAll("textarea[value]"))) {
|
|
862
|
+
field.value = field.getAttribute("value") ?? "";
|
|
863
|
+
}
|
|
867
864
|
}
|
|
868
865
|
this.flushQueued();
|
|
869
866
|
}
|
|
@@ -879,7 +876,7 @@ export class DomReplayer {
|
|
|
879
876
|
let nextId = 1;
|
|
880
877
|
const walk = (node) => {
|
|
881
878
|
if (node.nodeType === Node.TEXT_NODE) {
|
|
882
|
-
this.
|
|
879
|
+
this.bind(nextId++, node);
|
|
883
880
|
return;
|
|
884
881
|
}
|
|
885
882
|
if (node.nodeType !== Node.ELEMENT_NODE)
|
|
@@ -888,11 +885,11 @@ export class DomReplayer {
|
|
|
888
885
|
// The snapshot's combined sheet isn't a node on the host.
|
|
889
886
|
if (BLOCKED_TAGS.has(el.tagName) || el.hasAttribute("data-fauna-sheets"))
|
|
890
887
|
return;
|
|
891
|
-
this.
|
|
892
|
-
for (const child of Array.from(el.childNodes))
|
|
888
|
+
this.bind(nextId++, el);
|
|
889
|
+
for (const child of this.boxes.get(el)?.content ?? Array.from(el.childNodes))
|
|
893
890
|
walk(child);
|
|
894
891
|
};
|
|
895
|
-
walk(
|
|
892
|
+
walk(root);
|
|
896
893
|
}
|
|
897
894
|
/** Elements carry `data-fauna-node`, text-only elements name their text with
|
|
898
895
|
* `data-fauna-text`, and every other text node follows a `fauna:N` comment. A marker
|
|
@@ -905,13 +902,15 @@ export class DomReplayer {
|
|
|
905
902
|
const el = node;
|
|
906
903
|
const id = el.getAttribute("data-fauna-node");
|
|
907
904
|
if (id)
|
|
908
|
-
this.
|
|
905
|
+
this.bind(Number(id), el);
|
|
909
906
|
const textId = el.getAttribute("data-fauna-text");
|
|
910
907
|
if (textId) {
|
|
911
908
|
const text = Array.from(el.childNodes).find((child) => child.nodeType === Node.TEXT_NODE)
|
|
912
909
|
?? el.appendChild(doc.createTextNode(""));
|
|
913
|
-
this.
|
|
910
|
+
this.bind(Number(textId), text);
|
|
914
911
|
}
|
|
912
|
+
for (const child of this.boxes.get(el)?.content ?? [])
|
|
913
|
+
this.bindMarked(child);
|
|
915
914
|
continue;
|
|
916
915
|
}
|
|
917
916
|
const match = /^fauna:(\d+)$/.exec(node.nodeValue ?? "");
|
|
@@ -921,127 +920,249 @@ export class DomReplayer {
|
|
|
921
920
|
if (!text || text.nodeType !== Node.TEXT_NODE)
|
|
922
921
|
text = node.parentNode?.insertBefore(doc.createTextNode(""), node.nextSibling) ?? null;
|
|
923
922
|
if (text)
|
|
924
|
-
this.
|
|
923
|
+
this.bind(Number(match[1]), text);
|
|
925
924
|
}
|
|
926
925
|
}
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
926
|
+
bind(id, node) {
|
|
927
|
+
this.nodes.set(id, node);
|
|
928
|
+
this.ids.set(node, id);
|
|
930
929
|
}
|
|
931
|
-
/**
|
|
932
|
-
|
|
933
|
-
this.
|
|
934
|
-
|
|
935
|
-
|
|
930
|
+
/** `next` takes over the id `old` was bound to. */
|
|
931
|
+
rebind(old, next) {
|
|
932
|
+
const id = this.ids.get(old);
|
|
933
|
+
if (id !== undefined)
|
|
934
|
+
this.bind(id, next);
|
|
935
|
+
}
|
|
936
|
+
reset() {
|
|
937
|
+
this.nodes.clear();
|
|
938
|
+
this.ids = new WeakMap();
|
|
939
|
+
this.boxes = new WeakMap();
|
|
940
|
+
this.boxOf = new WeakMap();
|
|
941
|
+
this.unplaced.clear();
|
|
942
|
+
this.scrolls.clear();
|
|
936
943
|
}
|
|
937
944
|
/**
|
|
938
|
-
*
|
|
939
|
-
*
|
|
940
|
-
*
|
|
941
|
-
*
|
|
942
|
-
*
|
|
945
|
+
* A replay's HTML can't hold one document inside another, so an inlined frame's html,
|
|
946
|
+
* head and body arrive as divs marked `data-fauna-frame`. Every wrapper becomes a frame
|
|
947
|
+
* box, holding them as the html, head and body they stand for, under the ids they were
|
|
948
|
+
* bound to. Wrappers come in document order, so a frame's box is in the copy, with its
|
|
949
|
+
* document, before the frames inside it are unfolded.
|
|
943
950
|
*/
|
|
944
|
-
|
|
945
|
-
const
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
this.
|
|
951
|
-
|
|
952
|
-
}
|
|
953
|
-
const size = (value) => {
|
|
954
|
-
const v = value?.trim() ?? "";
|
|
955
|
-
return /^\d+(\.\d+)?$/.test(v) ? `${v}px` : /^\d+(\.\d+)?(px|%)$/.test(v) ? v : "";
|
|
951
|
+
unfold(doc) {
|
|
952
|
+
const real = (div, tag) => {
|
|
953
|
+
const el = doc.createElement(tag);
|
|
954
|
+
for (const attr of Array.from(div.attributes))
|
|
955
|
+
if (attr.name !== "data-fauna-frame")
|
|
956
|
+
el.setAttribute(attr.name, attr.value);
|
|
957
|
+
this.rebind(div, el);
|
|
958
|
+
return el;
|
|
956
959
|
};
|
|
957
|
-
const
|
|
958
|
-
|
|
959
|
-
const
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
960
|
+
for (const wrapper of Array.from(doc.querySelectorAll("[data-fauna-iframe]"))) {
|
|
961
|
+
const frame = this.createBox(doc, Array.from(wrapper.attributes, (attr) => [attr.name, attr.value]));
|
|
962
|
+
const content = Array.from(wrapper.childNodes, (child) => {
|
|
963
|
+
if (child.nodeType !== Node.ELEMENT_NODE || child.getAttribute("data-fauna-frame") !== "html")
|
|
964
|
+
return child;
|
|
965
|
+
const html = real(child, "html");
|
|
966
|
+
for (const part of Array.from(child.childNodes)) {
|
|
967
|
+
const name = part.nodeType === Node.ELEMENT_NODE ? part.getAttribute("data-fauna-frame") : null;
|
|
968
|
+
if (name !== "head" && name !== "body") {
|
|
969
|
+
html.appendChild(part);
|
|
970
|
+
continue;
|
|
971
|
+
}
|
|
972
|
+
const el = real(part, name);
|
|
973
|
+
el.append(...Array.from(part.childNodes));
|
|
974
|
+
html.appendChild(el);
|
|
975
|
+
}
|
|
976
|
+
return html;
|
|
977
|
+
});
|
|
978
|
+
this.rebind(wrapper, frame);
|
|
979
|
+
wrapper.replaceWith(frame);
|
|
980
|
+
for (const node of content)
|
|
981
|
+
this.insertInto(frame, node, null);
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
/** An iframe standing for a frame's wrapper, which never loads anything of its own. */
|
|
985
|
+
createBox(doc, attributes) {
|
|
986
|
+
const frame = doc.createElement("iframe");
|
|
987
|
+
for (const [name, value] of attributes) {
|
|
988
|
+
if (FRAME_BOX_SKIP.has(name.toLowerCase()))
|
|
963
989
|
continue;
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
box = String(++this.boxes);
|
|
967
|
-
el.setAttribute("data-fauna-box", box);
|
|
990
|
+
try {
|
|
991
|
+
frame.setAttribute(name, value);
|
|
968
992
|
}
|
|
969
|
-
|
|
970
|
-
rules.push(`:where([data-fauna-box="${box}"]) { ${declarations.join("; ")} }`);
|
|
993
|
+
catch { /* invalid attr name — skip */ }
|
|
971
994
|
}
|
|
972
|
-
this.
|
|
995
|
+
this.boxes.set(frame, { content: [] });
|
|
996
|
+
return frame;
|
|
973
997
|
}
|
|
974
|
-
/**
|
|
975
|
-
|
|
976
|
-
const
|
|
977
|
-
|
|
978
|
-
|
|
998
|
+
/** Insert into `parent`, or, for a frame box, into the framed document it shows. */
|
|
999
|
+
insertInto(parent, node, before) {
|
|
1000
|
+
const frame = parent;
|
|
1001
|
+
const box = this.boxes.get(frame);
|
|
1002
|
+
if (!box) {
|
|
1003
|
+
parent.insertBefore(node, before?.parentNode === parent ? before : null);
|
|
1004
|
+
return;
|
|
1005
|
+
}
|
|
1006
|
+
const at = before ? box.content.indexOf(before) : -1;
|
|
1007
|
+
box.content.splice(at < 0 ? box.content.length : at, 0, node);
|
|
1008
|
+
this.boxOf.set(node, frame);
|
|
1009
|
+
this.place(frame, box);
|
|
979
1010
|
}
|
|
980
1011
|
/**
|
|
981
|
-
*
|
|
982
|
-
*
|
|
983
|
-
*
|
|
984
|
-
*
|
|
1012
|
+
* Put a box's framed document into the box's own document: its html as the document's
|
|
1013
|
+
* root, and anything else (a placeholder) into a body of its own. A box gets its
|
|
1014
|
+
* document the moment it joins the copy; until then what it shows waits in `content`,
|
|
1015
|
+
* where changes apply all the same.
|
|
985
1016
|
*/
|
|
986
|
-
|
|
987
|
-
const doc =
|
|
988
|
-
if (!doc)
|
|
1017
|
+
place(frame, box) {
|
|
1018
|
+
const doc = frame.isConnected ? frame.contentDocument : null;
|
|
1019
|
+
if (!doc) {
|
|
1020
|
+
this.unplaced.add(frame);
|
|
989
1021
|
return;
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
if (
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1022
|
+
}
|
|
1023
|
+
this.unplaced.delete(frame);
|
|
1024
|
+
const root = box.content.find((node) => node.nodeName === "HTML");
|
|
1025
|
+
if (root && doc.documentElement !== root) {
|
|
1026
|
+
if (doc.documentElement)
|
|
1027
|
+
doc.replaceChild(root, doc.documentElement);
|
|
1028
|
+
else
|
|
1029
|
+
doc.appendChild(root);
|
|
1030
|
+
this.mirrorStates(doc);
|
|
1031
|
+
}
|
|
1032
|
+
const rest = box.content.filter((node) => node !== root);
|
|
1033
|
+
if (!rest.length)
|
|
1034
|
+
return;
|
|
1035
|
+
const html = doc.documentElement ?? doc.appendChild(doc.createElement("html"));
|
|
1036
|
+
const body = doc.body ?? html.appendChild(doc.createElement("body"));
|
|
1037
|
+
if (!root)
|
|
1038
|
+
body.setAttribute("style", FRAME_PLACEHOLDER_BODY);
|
|
1039
|
+
for (const node of rest)
|
|
1040
|
+
if (node.parentNode !== body)
|
|
1041
|
+
body.appendChild(node);
|
|
1042
|
+
}
|
|
1043
|
+
/** Give every box that has joined the copy its document, the boxes inside them included. */
|
|
1044
|
+
settle() {
|
|
1045
|
+
for (let placed = true; placed && this.unplaced.size;) {
|
|
1046
|
+
placed = false;
|
|
1047
|
+
for (const frame of [...this.unplaced]) {
|
|
1048
|
+
if (!frame.isConnected)
|
|
1049
|
+
continue;
|
|
1050
|
+
const box = this.boxes.get(frame);
|
|
1051
|
+
if (box)
|
|
1052
|
+
this.place(frame, box);
|
|
1053
|
+
placed || (placed = !this.unplaced.has(frame));
|
|
1002
1054
|
}
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
/** The copy's document and every framed document in it. */
|
|
1058
|
+
documents() {
|
|
1059
|
+
const out = [];
|
|
1060
|
+
const visit = (doc) => {
|
|
1061
|
+
if (!doc)
|
|
1062
|
+
return;
|
|
1063
|
+
out.push(doc);
|
|
1064
|
+
for (const frame of Array.from(doc.querySelectorAll("iframe")))
|
|
1065
|
+
visit(frame.contentDocument);
|
|
1066
|
+
};
|
|
1067
|
+
visit(this.iframe?.contentDocument);
|
|
1068
|
+
return out;
|
|
1069
|
+
}
|
|
1070
|
+
/** Take a removed node out of the copy, and every id under it, framed documents included. */
|
|
1071
|
+
drop(node) {
|
|
1072
|
+
const frame = this.boxOf.get(node);
|
|
1073
|
+
const content = frame ? this.boxes.get(frame)?.content : undefined;
|
|
1074
|
+
const at = content?.indexOf(node) ?? -1;
|
|
1075
|
+
if (at >= 0)
|
|
1076
|
+
content.splice(at, 1);
|
|
1077
|
+
this.boxOf.delete(node);
|
|
1078
|
+
node.parentNode?.removeChild(node);
|
|
1079
|
+
for (const stack = [node]; stack.length;) {
|
|
1080
|
+
const next = stack.pop();
|
|
1081
|
+
const id = this.ids.get(next);
|
|
1082
|
+
if (id !== undefined && this.nodes.get(id) === next)
|
|
1083
|
+
this.nodes.delete(id);
|
|
1084
|
+
if (next.nodeType === Node.ELEMENT_NODE) {
|
|
1085
|
+
this.scrolls.delete(next);
|
|
1086
|
+
this.unplaced.delete(next);
|
|
1008
1087
|
}
|
|
1009
|
-
for (
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1088
|
+
for (const child of this.boxes.get(next)?.content ?? Array.from(next.childNodes))
|
|
1089
|
+
stack.push(child);
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
/** Keep `el` (a framed document's html for the frame's own scroll) where the host has it. */
|
|
1093
|
+
scrollTo(el) {
|
|
1094
|
+
const [x = 0, y = 0] = (el.getAttribute("data-fauna-scroll") ?? "").split(",").map((n) => Number(n) || 0);
|
|
1095
|
+
if (!x && !y)
|
|
1096
|
+
this.scrolls.delete(el);
|
|
1097
|
+
else
|
|
1098
|
+
this.scrolls.set(el, { x, y, since: Date.now() });
|
|
1099
|
+
this.scrollOnce(el, x, y);
|
|
1100
|
+
this.rescroll();
|
|
1101
|
+
}
|
|
1102
|
+
/** Whether `el` is where the host has it, after moving it there. */
|
|
1103
|
+
scrollOnce(el, x, y) {
|
|
1104
|
+
if (!el.isConnected)
|
|
1105
|
+
return false;
|
|
1106
|
+
const doc = el.ownerDocument;
|
|
1107
|
+
if (el === doc.documentElement) {
|
|
1108
|
+
const view = doc.defaultView;
|
|
1109
|
+
if (!view)
|
|
1110
|
+
return false;
|
|
1111
|
+
if (view.scrollX !== x || view.scrollY !== y)
|
|
1112
|
+
view.scrollTo(x, y);
|
|
1113
|
+
return view.scrollX === x && view.scrollY === y;
|
|
1114
|
+
}
|
|
1115
|
+
if (el.scrollLeft !== x)
|
|
1116
|
+
el.scrollLeft = x;
|
|
1117
|
+
if (el.scrollTop !== y)
|
|
1118
|
+
el.scrollTop = y;
|
|
1119
|
+
return el.scrollLeft === x && el.scrollTop === y;
|
|
1120
|
+
}
|
|
1121
|
+
/**
|
|
1122
|
+
* A scroll lands only once its document is long enough, which can be after the changes
|
|
1123
|
+
* that grow it, or after its images and fonts load. Once a frame after any change, and
|
|
1124
|
+
* every frame while a recent scroll hasn't landed, scrolls go back where the host has them.
|
|
1125
|
+
*/
|
|
1126
|
+
rescroll() {
|
|
1127
|
+
const view = this.mount.ownerDocument?.defaultView;
|
|
1128
|
+
if (this.scrollFrame || !this.scrolls.size || !view)
|
|
1129
|
+
return;
|
|
1130
|
+
this.scrollFrame = view.requestAnimationFrame(() => {
|
|
1131
|
+
this.scrollFrame = 0;
|
|
1132
|
+
const now = Date.now();
|
|
1133
|
+
let short = false;
|
|
1134
|
+
for (const [el, at] of this.scrolls) {
|
|
1135
|
+
if (!el.isConnected)
|
|
1013
1136
|
continue;
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
sheet.insertRule(`@scope ([data-fauna-scope="${scope}"]) { ${inner} }`, i);
|
|
1017
|
-
}
|
|
1018
|
-
catch {
|
|
1019
|
-
sheet.insertRule(original, i);
|
|
1020
|
-
}
|
|
1137
|
+
if (!this.scrollOnce(el, at.x, at.y) && now - at.since < SCROLL_SETTLE_MS)
|
|
1138
|
+
short = true;
|
|
1021
1139
|
}
|
|
1022
|
-
|
|
1140
|
+
if (short)
|
|
1141
|
+
this.rescroll();
|
|
1142
|
+
});
|
|
1143
|
+
}
|
|
1144
|
+
/** The document the replay renders into: its iframe's for full documents, else the mount's. */
|
|
1145
|
+
getDocument() {
|
|
1146
|
+
return this.ownerDoc();
|
|
1023
1147
|
}
|
|
1024
1148
|
/**
|
|
1025
1149
|
* `:hover` and `:focus` never apply in a replay. The host marks what they would match
|
|
1026
1150
|
* with `data-fauna-hover` and the focus attributes, so point those selectors at the
|
|
1027
|
-
* attributes instead; both weigh the same in the cascade. Only
|
|
1028
|
-
* frame
|
|
1151
|
+
* attributes instead; both weigh the same in the cascade. Only the copy's own documents
|
|
1152
|
+
* are rewritten: its frame's, and each framed document's.
|
|
1029
1153
|
*/
|
|
1030
|
-
mirrorStates() {
|
|
1031
|
-
|
|
1032
|
-
if (!doc)
|
|
1154
|
+
mirrorStates(doc) {
|
|
1155
|
+
if (doc === this.mount.ownerDocument)
|
|
1033
1156
|
return;
|
|
1034
1157
|
const visit = (rules) => {
|
|
1035
1158
|
for (const rule of Array.from(rules)) {
|
|
1036
1159
|
const style = rule;
|
|
1037
|
-
if (typeof style.selectorText === "string" && /:(hover|focus)
|
|
1038
|
-
// Inlined frames are divs, so rules for iframes point at their marker.
|
|
1160
|
+
if (typeof style.selectorText === "string" && /:(hover|focus)/.test(style.selectorText)) {
|
|
1039
1161
|
style.selectorText = style.selectorText
|
|
1040
1162
|
.replace(/:hover\b/g, "[data-fauna-hover]")
|
|
1041
1163
|
.replace(/:focus-visible\b/g, "[data-fauna-focus-visible]")
|
|
1042
1164
|
.replace(/:focus-within\b/g, "[data-fauna-focus-within]")
|
|
1043
|
-
.replace(/:focus(?![-\w])/g, "[data-fauna-focus]")
|
|
1044
|
-
.replace(FRAME_ELEMENT_SELECTOR, "$1[data-fauna-iframe]");
|
|
1165
|
+
.replace(/:focus(?![-\w])/g, "[data-fauna-focus]");
|
|
1045
1166
|
}
|
|
1046
1167
|
const nested = rule.cssRules;
|
|
1047
1168
|
if (nested)
|
|
@@ -1058,58 +1179,79 @@ export class DomReplayer {
|
|
|
1058
1179
|
ownerDoc() {
|
|
1059
1180
|
return this.iframe?.contentDocument ?? this.mount.ownerDocument;
|
|
1060
1181
|
}
|
|
1061
|
-
/**
|
|
1062
|
-
|
|
1182
|
+
/**
|
|
1183
|
+
* `seen` keeps a subtree that names a node twice (or itself) from building forever. SVG
|
|
1184
|
+
* and MathML take their namespace from where they sit, as the HTML parser gives it.
|
|
1185
|
+
*/
|
|
1186
|
+
build(id, map, ns, seen = new Set()) {
|
|
1063
1187
|
const spec = map[id];
|
|
1064
1188
|
if (!spec || seen.has(id))
|
|
1065
1189
|
return null;
|
|
1066
1190
|
seen.add(id);
|
|
1191
|
+
const doc = this.ownerDoc();
|
|
1067
1192
|
if (spec.kind === "text") {
|
|
1068
|
-
const text =
|
|
1069
|
-
this.
|
|
1193
|
+
const text = doc.createTextNode(spec.text);
|
|
1194
|
+
this.bind(id, text);
|
|
1070
1195
|
return text;
|
|
1071
1196
|
}
|
|
1072
1197
|
let el;
|
|
1198
|
+
const box = "data-fauna-iframe" in spec.attributes;
|
|
1199
|
+
const space = spec.tag === "svg" ? SVG_NS : spec.tag === "math" ? MATH_NS : ns;
|
|
1073
1200
|
try {
|
|
1074
|
-
|
|
1201
|
+
if (box)
|
|
1202
|
+
el = this.createBox(doc, Object.entries(spec.attributes));
|
|
1203
|
+
else
|
|
1204
|
+
el = space ? doc.createElementNS(space, spec.tag) : doc.createElement(spec.tag);
|
|
1075
1205
|
}
|
|
1076
1206
|
catch {
|
|
1077
1207
|
return null;
|
|
1078
1208
|
}
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1209
|
+
if (!box) {
|
|
1210
|
+
for (const [name, value] of Object.entries(spec.attributes)) {
|
|
1211
|
+
try {
|
|
1212
|
+
el.setAttribute(name, value);
|
|
1213
|
+
}
|
|
1214
|
+
catch { /* invalid attr name — skip */ }
|
|
1082
1215
|
}
|
|
1083
|
-
catch { /* invalid attr name — skip */ }
|
|
1084
1216
|
}
|
|
1085
|
-
this.
|
|
1217
|
+
this.bind(id, el);
|
|
1218
|
+
const inner = space === SVG_NS && spec.tag === "foreignObject" ? null : space;
|
|
1086
1219
|
for (const childId of spec.children) {
|
|
1087
|
-
const child = this.build(childId, map, seen);
|
|
1220
|
+
const child = this.build(childId, map, inner, seen);
|
|
1088
1221
|
if (child)
|
|
1089
|
-
|
|
1222
|
+
this.insertInto(el, child, null);
|
|
1090
1223
|
}
|
|
1224
|
+
if (el.localName === "textarea")
|
|
1225
|
+
el.value = el.getAttribute("value") ?? "";
|
|
1226
|
+
if (el.hasAttribute("data-fauna-scroll"))
|
|
1227
|
+
this.scrollTo(el);
|
|
1091
1228
|
return el;
|
|
1092
1229
|
}
|
|
1093
|
-
buildFromList(node, list) {
|
|
1230
|
+
buildFromList(node, list, parent) {
|
|
1094
1231
|
const map = {};
|
|
1095
1232
|
for (const n of list)
|
|
1096
1233
|
map[n.id] = n;
|
|
1097
|
-
|
|
1234
|
+
const el = parent;
|
|
1235
|
+
const foreign = (el.namespaceURI === SVG_NS || el.namespaceURI === MATH_NS) && el.localName !== "foreignObject";
|
|
1236
|
+
return this.build(node.id, map, foreign ? el.namespaceURI : null);
|
|
1098
1237
|
}
|
|
1099
1238
|
/** Render a fresh snapshot, replacing whatever the mount currently holds. */
|
|
1100
1239
|
async apply(snapshot) {
|
|
1101
|
-
this.
|
|
1240
|
+
this.reset();
|
|
1102
1241
|
this._viewport = snapshot.viewport ?? null;
|
|
1103
1242
|
const load = ++this.loads;
|
|
1104
1243
|
const iframe = await this.openFrame("<!doctype html><html><head></head><body></body></html>", load);
|
|
1105
1244
|
if (!iframe)
|
|
1106
1245
|
return;
|
|
1107
1246
|
const doc = iframe.contentDocument;
|
|
1108
|
-
const root = this.build(snapshot.root, snapshot.nodes);
|
|
1247
|
+
const root = this.build(snapshot.root, snapshot.nodes, null);
|
|
1109
1248
|
if (root?.nodeName === "HTML")
|
|
1110
1249
|
doc.replaceChild(root, doc.documentElement);
|
|
1111
1250
|
else if (root)
|
|
1112
1251
|
doc.body.replaceChildren(root);
|
|
1252
|
+
this.settle();
|
|
1253
|
+
this.mirrorStates(doc);
|
|
1254
|
+
this.rescroll();
|
|
1113
1255
|
this.flushQueued();
|
|
1114
1256
|
}
|
|
1115
1257
|
/** Apply one incremental mutation to the rendered tree. */
|
|
@@ -1121,10 +1263,12 @@ export class DomReplayer {
|
|
|
1121
1263
|
switch (mutation.kind) {
|
|
1122
1264
|
case "text": {
|
|
1123
1265
|
const node = this.nodes.get(mutation.id);
|
|
1124
|
-
if (node)
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1266
|
+
if (!node)
|
|
1267
|
+
return;
|
|
1268
|
+
node.textContent = mutation.text;
|
|
1269
|
+
if (node.parentNode?.nodeName === "STYLE")
|
|
1270
|
+
this.mirrorStates(node.ownerDocument);
|
|
1271
|
+
this.rescroll();
|
|
1128
1272
|
return;
|
|
1129
1273
|
}
|
|
1130
1274
|
case "attribute": {
|
|
@@ -1134,6 +1278,8 @@ export class DomReplayer {
|
|
|
1134
1278
|
if (node?.nodeType !== Node.ELEMENT_NODE)
|
|
1135
1279
|
return;
|
|
1136
1280
|
const el = node;
|
|
1281
|
+
if (this.boxes.has(el) && FRAME_BOX_SKIP.has(mutation.name.toLowerCase()))
|
|
1282
|
+
return;
|
|
1137
1283
|
if (mutation.value === null)
|
|
1138
1284
|
el.removeAttribute(mutation.name);
|
|
1139
1285
|
else {
|
|
@@ -1147,32 +1293,31 @@ export class DomReplayer {
|
|
|
1147
1293
|
el.value = mutation.value ?? "";
|
|
1148
1294
|
}
|
|
1149
1295
|
if (mutation.name === "data-fauna-scroll")
|
|
1150
|
-
this.
|
|
1151
|
-
|
|
1152
|
-
this.
|
|
1153
|
-
}
|
|
1296
|
+
this.scrollTo(el);
|
|
1297
|
+
else
|
|
1298
|
+
this.rescroll();
|
|
1154
1299
|
return;
|
|
1155
1300
|
}
|
|
1156
1301
|
case "remove": {
|
|
1157
1302
|
const node = this.nodes.get(mutation.id);
|
|
1158
|
-
if (node
|
|
1159
|
-
|
|
1160
|
-
this.nodes.delete(mutation.id);
|
|
1303
|
+
if (node)
|
|
1304
|
+
this.drop(node);
|
|
1161
1305
|
return;
|
|
1162
1306
|
}
|
|
1163
1307
|
case "insert": {
|
|
1164
1308
|
const parent = this.nodes.get(mutation.parent);
|
|
1165
1309
|
if (!parent || (parent.nodeType !== Node.ELEMENT_NODE && parent.nodeType !== Node.DOCUMENT_FRAGMENT_NODE))
|
|
1166
1310
|
return;
|
|
1167
|
-
const built = this.buildFromList(mutation.node, mutation.subtree);
|
|
1311
|
+
const built = this.buildFromList(mutation.node, mutation.subtree, parent);
|
|
1168
1312
|
if (!built)
|
|
1169
1313
|
return;
|
|
1170
1314
|
const sibling = mutation.before !== null ? this.nodes.get(mutation.before) ?? null : null;
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1315
|
+
this.insertInto(parent, built, sibling);
|
|
1316
|
+
this.settle();
|
|
1317
|
+
if (parent.nodeName === "STYLE" || mutation.subtree.some((n) => n.kind === "element" && n.tag === "style")) {
|
|
1318
|
+
this.mirrorStates(built.ownerDocument);
|
|
1319
|
+
}
|
|
1320
|
+
this.rescroll();
|
|
1176
1321
|
return;
|
|
1177
1322
|
}
|
|
1178
1323
|
case "viewport":
|
|
@@ -1419,6 +1564,7 @@ export function pageToHtml(root = document.documentElement) {
|
|
|
1419
1564
|
return style;
|
|
1420
1565
|
}
|
|
1421
1566
|
}
|
|
1567
|
+
// A framed document's html, head and body go out as divs the replay turns back into them.
|
|
1422
1568
|
const rootTag = el.ownerDocument !== document ? el.tagName.toLowerCase() : "";
|
|
1423
1569
|
const frameRoot = rootTag === "html" || rootTag === "head" || rootTag === "body" ? rootTag : "";
|
|
1424
1570
|
const clone = doc.createElement(frameRoot ? "div" : el.tagName.toLowerCase());
|
|
@@ -1431,11 +1577,8 @@ export function pageToHtml(root = document.documentElement) {
|
|
|
1431
1577
|
catch { /* skip */ }
|
|
1432
1578
|
}
|
|
1433
1579
|
mark(clone, el);
|
|
1434
|
-
if (frameRoot)
|
|
1580
|
+
if (frameRoot)
|
|
1435
1581
|
clone.setAttribute("data-fauna-frame", frameRoot);
|
|
1436
|
-
if (frameRoot === "head")
|
|
1437
|
-
clone.setAttribute("hidden", "");
|
|
1438
|
-
}
|
|
1439
1582
|
if (RAW_TEXT.has(el.nodeName)) {
|
|
1440
1583
|
const text = Array.from(el.childNodes).find((child) => child.nodeType === Node.TEXT_NODE);
|
|
1441
1584
|
const textId = text ? recorder?.id(text) : undefined;
|
|
@@ -1508,8 +1651,8 @@ export function pageToHtml(root = document.documentElement) {
|
|
|
1508
1651
|
}
|
|
1509
1652
|
/** Install a MutationObserver that posts WebMutations to `window[pushName]` as
|
|
1510
1653
|
* `{ token, mutation }`, so the host knows which recorder sent each one.
|
|
1511
|
-
* Framed documents are walked, observed, and re-inlined on navigation,
|
|
1512
|
-
*
|
|
1654
|
+
* Framed documents are walked, observed, and re-inlined on navigation, each
|
|
1655
|
+
* as its own html, head, and body, which viewers replay as a nested frame.
|
|
1513
1656
|
* With `remoteFrames`, a frame whose document this page can't read is left to
|
|
1514
1657
|
* the host, which streams it from the frame itself: its wrapper says
|
|
1515
1658
|
* `data-fauna-iframe="remote"` and holds nothing. With `framed`, this document
|
|
@@ -1762,12 +1905,6 @@ export function pageInstallRecorder(pushName, options) {
|
|
|
1762
1905
|
return null;
|
|
1763
1906
|
}
|
|
1764
1907
|
};
|
|
1765
|
-
const frameRootTag = (el) => {
|
|
1766
|
-
if (el.ownerDocument === document && !framed)
|
|
1767
|
-
return "";
|
|
1768
|
-
const tag = el.tagName.toLowerCase();
|
|
1769
|
-
return tag === "html" || tag === "head" || tag === "body" ? tag : "";
|
|
1770
|
-
};
|
|
1771
1908
|
// URLs resolve against their own document. The copy, and the host fetching assets
|
|
1772
1909
|
// from the top page, would resolve a framed document's against something else, so
|
|
1773
1910
|
// they go out absolute. Same rules as pageToHtml.
|
|
@@ -1810,8 +1947,7 @@ export function pageInstallRecorder(pushName, options) {
|
|
|
1810
1947
|
};
|
|
1811
1948
|
// A framed document's linked stylesheet goes out in place as a <style> with one text
|
|
1812
1949
|
// node holding its rules, like pageToHtml, so both number the same nodes. Viewers fetch
|
|
1813
|
-
// nothing cross origin
|
|
1814
|
-
// rules when it loads.
|
|
1950
|
+
// nothing cross origin. A sheet still loading sends its rules when it loads.
|
|
1815
1951
|
const isFrameSheet = (el) => el.tagName === "LINK" && (el.ownerDocument !== document || framed) && /\bstylesheet\b/i.test(el.getAttribute("rel") ?? "");
|
|
1816
1952
|
const sheetCss = (link) => {
|
|
1817
1953
|
try {
|
|
@@ -2016,13 +2152,7 @@ export function pageInstallRecorder(pushName, options) {
|
|
|
2016
2152
|
const shadow = shadowOf(el);
|
|
2017
2153
|
if (shadow)
|
|
2018
2154
|
children.push(serializeShadow(shadow, into));
|
|
2019
|
-
|
|
2020
|
-
if (frameRoot) {
|
|
2021
|
-
attributes["data-fauna-frame"] = frameRoot;
|
|
2022
|
-
if (frameRoot === "head")
|
|
2023
|
-
attributes.hidden = "";
|
|
2024
|
-
}
|
|
2025
|
-
into[id] = { id, kind: "element", tag: frameRoot ? "div" : el.tagName.toLowerCase(), attributes, children };
|
|
2155
|
+
into[id] = { id, kind: "element", tag: el.localName, attributes, children };
|
|
2026
2156
|
return id;
|
|
2027
2157
|
};
|
|
2028
2158
|
const reinline = (el) => {
|