Add ShadowRoot get/set innerHTML

Adds event.composedPath()

This depends on https://github.com/lightpanda-io/libdom/pull/34
This commit is contained in:
Karl Seguin
2025-08-11 15:01:51 +08:00
parent 6a2dd1111c
commit 4b1eb2794f
5 changed files with 154 additions and 4 deletions

View File

@@ -558,6 +558,8 @@ pub const Element = struct {
.proto = fragment,
};
state.shadow_root = sr;
parser.documentFragmentSetHost(sr.proto, @alignCast(@ptrCast(self)));
return sr;
}

View File

@@ -17,10 +17,12 @@
// along with this program. If not, see <https://www.gnu.org/licenses/>.
const std = @import("std");
const dump = @import("../dump.zig");
const parser = @import("../netsurf.zig");
const Env = @import("../env.zig").Env;
const Page = @import("../page.zig").Page;
const Node = @import("node.zig").Node;
const Element = @import("element.zig").Element;
const ElementUnion = @import("element.zig").Union;
@@ -56,13 +58,48 @@ pub const ShadowRoot = struct {
pub fn set_adoptedStyleSheets(self: *ShadowRoot, sheets: Env.JsObject) !void {
self.adopted_style_sheets = try sheets.persist();
}
pub fn get_innerHTML(self: *ShadowRoot, page: *Page) ![]const u8 {
var buf = std.ArrayList(u8).init(page.call_arena);
try dump.writeChildren(parser.documentFragmentToNode(self.proto), .{}, buf.writer());
return buf.items;
}
pub fn set_innerHTML(self: *ShadowRoot, str_: ?[]const u8) !void {
const sr_doc = parser.documentFragmentToNode(self.proto);
const doc = try parser.nodeOwnerDocument(sr_doc) orelse return parser.DOMError.WrongDocument;
try Node.removeChildren(sr_doc);
const str = str_ orelse return;
const fragment = try parser.documentParseFragmentFromStr(doc, str);
const fragment_node = parser.documentFragmentToNode(fragment);
// Element.set_innerHTML also has some weirdness here. It isn't clear
// what should and shouldn't be set. Whatever string you pass to libdom,
// it always creates a full HTML document, with an html, head and body
// element.
// For ShadowRoot, it appears the only the children within the body should
// be set.
const html = try parser.nodeFirstChild(fragment_node) orelse return;
const head = try parser.nodeFirstChild(html) orelse return;
const body = try parser.nodeNextSibling(head) orelse return;
const children = try parser.nodeGetChildNodes(body);
const ln = try parser.nodeListLength(children);
for (0..ln) |_| {
// always index 0, because nodeAppendChild moves the node out of
// the nodeList and into the new tree
const child = try parser.nodeListItem(children, 0) orelse continue;
_ = try parser.nodeAppendChild(sr_doc, child);
}
}
};
const testing = @import("../../testing.zig");
test "Browser.DOM.ShadowRoot" {
defer testing.reset();
var runner = try testing.jsRunner(testing.tracking_allocator, .{ .html =
var runner = try testing.jsRunner(testing.tracking_allocator, .{ .html =
\\ <div id=conflict>nope</div>
});
defer runner.deinit();
@@ -94,8 +131,8 @@ test "Browser.DOM.ShadowRoot" {
try runner.testCases(&.{
.{ "sr2.getElementById('conflict')", "null" },
.{ "const n1 = document.createElement('div')", null },
.{ "n1.id = 'conflict'", null},
.{ "sr2.append(n1)", null},
.{ "n1.id = 'conflict'", null },
.{ "sr2.append(n1)", null },
.{ "sr2.getElementById('conflict') == n1", "true" },
}, .{});
@@ -105,4 +142,14 @@ test "Browser.DOM.ShadowRoot" {
.{ "acss.push(new CSSStyleSheet())", null },
.{ "sr2.adoptedStyleSheets.length", "1" },
}, .{});
try runner.testCases(&.{
.{ "sr1.innerHTML = '<p>hello</p>'", null },
.{ "sr1.innerHTML", "<p>hello</p>" },
.{ "sr1.querySelector('*')", "[object HTMLParagraphElement]" },
.{ "sr1.innerHTML = null", null },
.{ "sr1.innerHTML", "" },
.{ "sr1.querySelector('*')", "null" },
}, .{});
}

View File

@@ -24,6 +24,7 @@ const parser = @import("../netsurf.zig");
const generate = @import("../../runtime/generate.zig");
const Page = @import("../page.zig").Page;
const Node = @import("../dom/node.zig").Node;
const DOMException = @import("../dom/exceptions.zig").DOMException;
const EventTarget = @import("../dom/event_target.zig").EventTarget;
const EventTargetUnion = @import("../dom/event_target.zig").Union;
@@ -139,6 +140,64 @@ pub const Event = struct {
pub fn _preventDefault(self: *parser.Event) !void {
return try parser.eventPreventDefault(self);
}
pub fn _composedPath(self: *parser.Event, page: *Page) ![]const EventTargetUnion {
const et_ = try parser.eventTarget(self);
const et = et_ orelse return &.{};
var node: ?*parser.Node = switch (try parser.eventTargetInternalType(et)) {
.libdom_node => @as(*parser.Node, @ptrCast(et)),
.plain => parser.eventTargetToNode(et),
else => {
// Window, XHR, MessagePort, etc...no path beyond the event itself
return &.{try EventTarget.toInterface(et, page)};
},
};
const arena = page.call_arena;
var path: std.ArrayListUnmanaged(EventTargetUnion) = .empty;
while (node) |n| {
try path.append(arena, .{
.node = try Node.toInterface(n),
});
node = try parser.nodeParentNode(n);
if (node == null and try parser.nodeType(n) == .document_fragment) {
// we have a non-continuous hook from a shadowroot to its host (
// it's parent element). libdom doesn't really support ShdowRoots
// and, for the most part, that works out well since it naturally
// provides isolation. But events don't follow the same
// shadowroot isolation as most other things, so, if this is
// a parent-less document fragment, we need to check if it has
// a host.
if (parser.documentFragmentGetHost(@ptrCast(n))) |host| {
node = host;
// If a document fragment has a host, then that host
// _has_ to have a state and that state _has_ to have
// a shadow_root field. All of this is set in Element._attachShadow
if (page.getNodeState(host).?.shadow_root.?.mode == .closed) {
// if the shadow root is closed, then the composedPath
// starts at the host element.
path.clearRetainingCapacity();
}
} else {
// Our document fragement has no parent and no host, we
// can break out of the loop.
break;
}
}
}
if (path.getLastOrNull()) |last| {
// the Window isn't part of the DOM hierarchy, but for events, it
// is, so we need to glue it on.
if (last.node == .HTMLDocument and last.node.HTMLDocument == page.window.document) {
try path.append(arena, .{ .node = .{ .Window = &page.window } });
}
}
return path.items;
}
};
pub const EventHandler = struct {
@@ -446,4 +505,37 @@ test "Browser.Event" {
.{ "nb", "2" },
.{ "document.removeEventListener('count', cbk)", "undefined" },
}, .{});
try runner.testCases(&.{
.{ "new Event('').composedPath()", "" },
.{
\\ let div1 = document.createElement('div');
\\ let sr1 = div1.attachShadow({mode: 'open'});
\\ sr1.innerHTML = "<p id=srp1></p>";
\\ document.getElementsByTagName('body')[0].appendChild(div1);
\\ let cp = null;
\\ div1.addEventListener('click', (e) => {
\\ cp = e.composedPath().map((n) => n.id || n.nodeName || n.toString());
\\ });
\\ sr1.getElementById('srp1').click();
\\ cp.join(', ');
,
"srp1, #document-fragment, DIV, BODY, HTML, #document, [object Window]",
},
.{
\\ let div2 = document.createElement('div');
\\ let sr2 = div2.attachShadow({mode: 'closed'});
\\ sr2.innerHTML = "<p id=srp2></p>";
\\ document.getElementsByTagName('body')[0].appendChild(div2);
\\ cp = null;
\\ div2.addEventListener('click', (e) => {
\\ cp = e.composedPath().map((n) => n.id || n.nodeName || n.toString());
\\ });
\\ sr2.getElementById('srp2').click();
\\ cp.join(', ');
,
"DIV, BODY, HTML, #document, [object Window]",
},
}, .{});
}

View File

@@ -1993,6 +1993,15 @@ pub inline fn documentFragmentToNode(doc: *DocumentFragment) *Node {
return @as(*Node, @alignCast(@ptrCast(doc)));
}
pub fn documentFragmentGetHost(frag: *DocumentFragment) ?*Node {
var node: ?*NodeExternal = undefined;
c._dom_document_fragment_get_host(frag, &node);
return if (node) |n| @ptrCast(n) else null;
}
pub fn documentFragmentSetHost(frag: *DocumentFragment, host: *Node) void {
c._dom_document_fragment_set_host(frag, host);
}
// Document Position
pub const DocumentPosition = enum(u32) {