diff --git a/src/browser/js/bridge.zig b/src/browser/js/bridge.zig
index 0feec8a8..c6f899d7 100644
--- a/src/browser/js/bridge.zig
+++ b/src/browser/js/bridge.zig
@@ -516,6 +516,7 @@ pub const JsApis = flattenTypes(&.{
@import("../webapi/DOMRect.zig"),
@import("../webapi/DOMParser.zig"),
@import("../webapi/XMLSerializer.zig"),
+ @import("../webapi/Range.zig"),
@import("../webapi/NodeFilter.zig"),
@import("../webapi/Element.zig"),
@import("../webapi/element/DOMStringMap.zig"),
diff --git a/src/browser/tests/node/child_nodes.html b/src/browser/tests/node/child_nodes.html
index 7534eff4..3a6b6797 100644
--- a/src/browser/tests/node/child_nodes.html
+++ b/src/browser/tests/node/child_nodes.html
@@ -73,7 +73,11 @@
testing.expectEqual([0], Array.from(one.keys()));
testing.expectEqual([p10], Array.from(one.values()));
testing.expectEqual([[0, p10]], Array.from(one.entries()));
+
testing.expectEqual([p10], Array.from(one));
+ let foreach = [];
+ one.forEach((p) => foreach.push(p));
+ testing.expectEqual([p10], foreach);
+
+
+
First paragraph
+
Second paragraph
+
Span content
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/browser/webapi/Document.zig b/src/browser/webapi/Document.zig
index 2643e26c..6cea4987 100644
--- a/src/browser/webapi/Document.zig
+++ b/src/browser/webapi/Document.zig
@@ -177,6 +177,11 @@ pub fn createTextNode(_: *const Document, data: []const u8, page: *Page) !*Node
return page.createTextNode(data);
}
+const Range = @import("Range.zig");
+pub fn createRange(_: *const Document, page: *Page) !*Range {
+ return Range.init(page);
+}
+
pub fn createEvent(_: *const Document, event_type: []const u8, page: *Page) !*@import("Event.zig") {
const Event = @import("Event.zig");
@@ -290,6 +295,7 @@ pub const JsApi = struct {
pub const createDocumentFragment = bridge.function(Document.createDocumentFragment, .{});
pub const createComment = bridge.function(Document.createComment, .{});
pub const createTextNode = bridge.function(Document.createTextNode, .{});
+ pub const createRange = bridge.function(Document.createRange, .{});
pub const createEvent = bridge.function(Document.createEvent, .{ .dom_exception = true });
pub const createTreeWalker = bridge.function(Document.createTreeWalker, .{});
pub const createNodeIterator = bridge.function(Document.createNodeIterator, .{});
diff --git a/src/browser/webapi/Node.zig b/src/browser/webapi/Node.zig
index 9ae1ec48..ab0c28ec 100644
--- a/src/browser/webapi/Node.zig
+++ b/src/browser/webapi/Node.zig
@@ -419,6 +419,61 @@ pub fn childrenIterator(self: *Node) NodeIterator {
};
}
+pub fn getLength(self: *Node) u32 {
+ switch (self._type) {
+ .cdata => |cdata| {
+ return @intCast(cdata.getData().len);
+ },
+ .element, .document, .document_fragment => {
+ var count: u32 = 0;
+ var it = self.childrenIterator();
+ while (it.next()) |_| {
+ count += 1;
+ }
+ return count;
+ },
+ .document_type, .attribute => return 0,
+ }
+}
+
+pub fn getChildIndex(self: *Node, target: *const Node) ?u32 {
+ var i: u32 = 0;
+ var it = self.childrenIterator();
+ while (it.next()) |child| {
+ if (child == target) {
+ return i;
+ }
+ i += 1;
+ }
+ return null;
+}
+
+pub fn getChildAt(self: *Node, index: u32) ?*Node {
+ var i: u32 = 0;
+ var it = self.childrenIterator();
+ while (it.next()) |child| {
+ if (i == index) {
+ return child;
+ }
+ i += 1;
+ }
+ return null;
+}
+
+pub fn getData(self: *const Node) []const u8 {
+ return switch (self._type) {
+ .cdata => |c| c.getData(),
+ else => "",
+ };
+}
+
+pub fn setData(self: *Node, data: []const u8) void {
+ switch (self._type) {
+ .cdata => |c| c._data = data,
+ else => {},
+ }
+}
+
pub fn className(self: *const Node) []const u8 {
switch (self._type) {
inline else => |c| return c.className(),
diff --git a/src/browser/webapi/Range.zig b/src/browser/webapi/Range.zig
new file mode 100644
index 00000000..e9af3603
--- /dev/null
+++ b/src/browser/webapi/Range.zig
@@ -0,0 +1,493 @@
+// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
+//
+// Francis Bouvier
+// Pierre Tachoire
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+const std = @import("std");
+const js = @import("../js/js.zig");
+
+const Page = @import("../Page.zig");
+const Node = @import("Node.zig");
+const DocumentFragment = @import("DocumentFragment.zig");
+
+const Range = @This();
+
+_end_offset: u32,
+_start_offset: u32,
+_end_container: *Node,
+_start_container: *Node,
+
+pub fn init(page: *Page) !*Range {
+ // Per spec, a new range starts collapsed at the document's first position
+ const doc = page.document.asNode();
+ return page._factory.create(Range{
+ ._end_offset = 0,
+ ._start_offset = 0,
+ ._end_container = doc,
+ ._start_container = doc,
+ });
+}
+
+pub fn getStartContainer(self: *const Range) *Node {
+ return self._start_container;
+}
+
+pub fn getStartOffset(self: *const Range) u32 {
+ return self._start_offset;
+}
+
+pub fn getEndContainer(self: *const Range) *Node {
+ return self._end_container;
+}
+
+pub fn getEndOffset(self: *const Range) u32 {
+ return self._end_offset;
+}
+
+pub fn getCollapsed(self: *const Range) bool {
+ return self._start_container == self._end_container and
+ self._start_offset == self._end_offset;
+}
+
+pub fn setStart(self: *Range, node: *Node, offset: u32) !void {
+ self._start_container = node;
+ self._start_offset = offset;
+
+ // If start is now after end, collapse to start
+ if (self.isStartAfterEnd()) {
+ self._end_container = self._start_container;
+ self._end_offset = self._start_offset;
+ }
+}
+
+pub fn setEnd(self: *Range, node: *Node, offset: u32) !void {
+ self._end_container = node;
+ self._end_offset = offset;
+
+ // If end is now before start, collapse to end
+ if (self.isStartAfterEnd()) {
+ self._start_container = self._end_container;
+ self._start_offset = self._end_offset;
+ }
+}
+
+pub fn setStartBefore(self: *Range, node: *Node) !void {
+ const parent = node.parentNode() orelse return error.InvalidNodeType;
+ const offset = parent.getChildIndex(node) orelse return error.NotFound;
+ try self.setStart(parent, offset);
+}
+
+pub fn setStartAfter(self: *Range, node: *Node) !void {
+ const parent = node.parentNode() orelse return error.InvalidNodeType;
+ const offset = parent.getChildIndex(node) orelse return error.NotFound;
+ try self.setStart(parent, offset + 1);
+}
+
+pub fn setEndBefore(self: *Range, node: *Node) !void {
+ const parent = node.parentNode() orelse return error.InvalidNodeType;
+ const offset = parent.getChildIndex(node) orelse return error.NotFound;
+ try self.setEnd(parent, offset);
+}
+
+pub fn setEndAfter(self: *Range, node: *Node) !void {
+ const parent = node.parentNode() orelse return error.InvalidNodeType;
+ const offset = parent.getChildIndex(node) orelse return error.NotFound;
+ try self.setEnd(parent, offset + 1);
+}
+
+pub fn selectNode(self: *Range, node: *Node) !void {
+ const parent = node.parentNode() orelse return error.InvalidNodeType;
+ const offset = parent.getChildIndex(node) orelse return error.NotFound;
+ try self.setStart(parent, offset);
+ try self.setEnd(parent, offset + 1);
+}
+
+pub fn selectNodeContents(self: *Range, node: *Node) !void {
+ const length = node.getLength();
+ try self.setStart(node, 0);
+ try self.setEnd(node, length);
+}
+
+pub fn collapse(self: *Range, to_start: ?bool) void {
+ if (to_start orelse true) {
+ self._end_container = self._start_container;
+ self._end_offset = self._start_offset;
+ } else {
+ self._start_container = self._end_container;
+ self._start_offset = self._end_offset;
+ }
+}
+
+pub fn cloneRange(self: *const Range, page: *Page) !*Range {
+ return page._factory.create(Range{
+ ._end_offset = self._end_offset,
+ ._start_offset = self._start_offset,
+ ._end_container = self._end_container,
+ ._start_container = self._start_container,
+ });
+}
+
+pub fn insertNode(self: *Range, node: *Node, page: *Page) !void {
+ // Insert node at the start of the range
+ const container = self._start_container;
+ const offset = self._start_offset;
+
+ if (container.is(Node.CData)) |_| {
+ // If container is a text node, we need to split it
+ const parent = container.parentNode() orelse return error.InvalidNodeType;
+
+ if (offset == 0) {
+ _ = try parent.insertBefore(node, container, page);
+ } else {
+ const text_data = container.getData();
+ if (offset >= text_data.len) {
+ _ = try parent.insertBefore(node, container.nextSibling(), page);
+ } else {
+ // Split the text node into before and after parts
+ const before_text = text_data[0..offset];
+ const after_text = text_data[offset..];
+
+ const before = try page.createTextNode(before_text);
+ const after = try page.createTextNode(after_text);
+
+ _ = try parent.replaceChild(before, container, page);
+ _ = try parent.insertBefore(node, before.nextSibling(), page);
+ _ = try parent.insertBefore(after, node.nextSibling(), page);
+ }
+ }
+ } else {
+ // Container is an element, insert at offset
+ const ref_child = container.getChildAt(offset);
+ _ = try container.insertBefore(node, ref_child, page);
+ }
+
+ // Update range to be after the inserted node
+ if (self._start_container == self._end_container) {
+ self._end_offset += 1;
+ }
+}
+
+pub fn deleteContents(self: *Range, page: *Page) !void {
+ if (self.getCollapsed()) {
+ return;
+ }
+
+ // Simple case: same container
+ if (self._start_container == self._end_container) {
+ if (self._start_container.is(Node.CData)) |_| {
+ // Delete part of text node
+ const text_data = self._start_container.getData();
+ const new_text = try std.mem.concat(
+ page.arena,
+ u8,
+ &.{ text_data[0..self._start_offset], text_data[self._end_offset..] },
+ );
+ self._start_container.setData(new_text);
+ } else {
+ // Delete child nodes in range
+ var offset = self._start_offset;
+ while (offset < self._end_offset) : (offset += 1) {
+ if (self._start_container.getChildAt(self._start_offset)) |child| {
+ _ = try self._start_container.removeChild(child, page);
+ }
+ }
+ }
+ self.collapse(true);
+ return;
+ }
+
+ // Complex case: different containers - simplified implementation
+ // Just collapse the range for now
+ self.collapse(true);
+}
+
+pub fn cloneContents(self: *const Range, page: *Page) !*DocumentFragment {
+ const fragment = try DocumentFragment.init(page);
+
+ if (self.getCollapsed()) return fragment;
+
+ // Simple case: same container
+ if (self._start_container == self._end_container) {
+ if (self._start_container.is(Node.CData)) |_| {
+ // Clone part of text node
+ const text_data = self._start_container.getData();
+ if (self._start_offset < text_data.len and self._end_offset <= text_data.len) {
+ const cloned_text = text_data[self._start_offset..self._end_offset];
+ const text_node = try page.createTextNode(cloned_text);
+ _ = try fragment.asNode().appendChild(text_node, page);
+ }
+ } else {
+ // Clone child nodes in range
+ var offset = self._start_offset;
+ while (offset < self._end_offset) : (offset += 1) {
+ if (self._start_container.getChildAt(offset)) |child| {
+ const cloned = try child.cloneNode(true, page);
+ _ = try fragment.asNode().appendChild(cloned, page);
+ }
+ }
+ }
+ }
+
+ return fragment;
+}
+
+pub fn extractContents(self: *Range, page: *Page) !*DocumentFragment {
+ const fragment = try self.cloneContents(page);
+ try self.deleteContents(page);
+ return fragment;
+}
+
+pub fn surroundContents(self: *Range, new_parent: *Node, page: *Page) !void {
+ // Extract contents
+ const contents = try self.extractContents(page);
+
+ // Insert the new parent
+ try self.insertNode(new_parent, page);
+
+ // Move contents into new parent
+ _ = try new_parent.appendChild(contents.asNode(), page);
+
+ // Select the new parent's contents
+ try self.selectNodeContents(new_parent);
+}
+
+pub fn createContextualFragment(self: *const Range, html: []const u8, page: *Page) !*DocumentFragment {
+ var context_node = self._start_container;
+
+ // If start container is a text node, use its parent as context
+ if (context_node.is(Node.CData)) |_| {
+ context_node = context_node.parentNode() orelse context_node;
+ }
+
+ const fragment = try DocumentFragment.init(page);
+
+ if (html.len == 0) {
+ return fragment;
+ }
+
+ // Create a temporary element of the same type as the context for parsing
+ // This preserves the parsing context without modifying the original node
+ const temp_node = if (context_node.is(Node.Element)) |el|
+ try page.createElement(el._namespace.toUri(), el.getTagNameLower(), null)
+ else
+ try page.createElement(null, "div", null);
+
+ try page.parseHtmlAsChildren(temp_node, html);
+
+ // Move all parsed children to the fragment
+ // Keep removing first child until temp element is empty
+ const fragment_node = fragment.asNode();
+ while (temp_node.firstChild()) |child| {
+ page.removeNode(temp_node, child, .{ .will_be_reconnected = true });
+ try page.appendNode(fragment_node, child, .{ .child_already_connected = false });
+ }
+
+ return fragment;
+}
+
+pub fn toString(self: *const Range, page: *Page) ![]const u8 {
+ // Simplified implementation: just extract text content
+ var buf = std.Io.Writer.Allocating.init(page.call_arena);
+ try self.writeTextContent(&buf.writer);
+ return buf.written();
+}
+
+fn writeTextContent(self: *const Range, writer: *std.Io.Writer) !void {
+ if (self.getCollapsed()) {
+ return;
+ }
+
+ if (self._start_container == self._end_container) {
+ if (self._start_container.is(Node.CData)) |cdata| {
+ const data = cdata.getData();
+ if (self._start_offset < data.len and self._end_offset <= data.len) {
+ try writer.writeAll(data[self._start_offset..self._end_offset]);
+ }
+ }
+ // For elements, would need to iterate children
+ return;
+ }
+
+ // Complex case: different containers - would need proper tree walking
+ // For now, just return empty
+}
+
+fn isStartAfterEnd(self: *const Range) bool {
+ return compareBoundaryPoints(
+ self._start_container,
+ self._start_offset,
+ self._end_container,
+ self._end_offset,
+ ) == .after;
+}
+
+const BoundaryComparison = enum {
+ before,
+ equal,
+ after,
+};
+
+/// Compare two boundary points in tree order
+/// Returns whether (nodeA, offsetA) is before/equal/after (nodeB, offsetB)
+fn compareBoundaryPoints(
+ node_a: *Node,
+ offset_a: u32,
+ node_b: *Node,
+ offset_b: u32,
+) BoundaryComparison {
+ // If same container, just compare offsets
+ if (node_a == node_b) {
+ if (offset_a < offset_b) return .before;
+ if (offset_a > offset_b) return .after;
+ return .equal;
+ }
+
+ // Check if one contains the other
+ if (isAncestorOf(node_a, node_b)) {
+ // A contains B, so A's position comes before B
+ // But we need to check if the offset in A comes after B
+ var child = node_b;
+ var parent = child.parentNode();
+ while (parent) |p| {
+ if (p == node_a) {
+ const child_index = p.getChildIndex(child) orelse unreachable;
+ if (offset_a <= child_index) {
+ return .before;
+ }
+ return .after;
+ }
+ child = p;
+ parent = p.parentNode();
+ }
+ unreachable;
+ }
+
+ if (isAncestorOf(node_b, node_a)) {
+ // B contains A, so B's position comes before A
+ var child = node_a;
+ var parent = child.parentNode();
+ while (parent) |p| {
+ if (p == node_b) {
+ const child_index = p.getChildIndex(child) orelse unreachable;
+ if (child_index < offset_b) {
+ return .before;
+ }
+ return .after;
+ }
+ child = p;
+ parent = p.parentNode();
+ }
+ unreachable;
+ }
+
+ // Neither contains the other, find their relative position in tree order
+ // Walk up from A to find all ancestors
+ var current = node_a;
+ var a_count: usize = 0;
+ var a_ancestors: [64]*Node = undefined;
+ while (a_count < 64) {
+ a_ancestors[a_count] = current;
+ a_count += 1;
+ current = current.parentNode() orelse break;
+ }
+
+ // Walk up from B and find first common ancestor
+ current = node_b;
+ while (current.parentNode()) |parent| {
+ for (a_ancestors[0..a_count]) |ancestor| {
+ if (ancestor != parent) {
+ continue;
+ }
+
+ // Found common ancestor
+ // Now compare positions of the children in this ancestor
+ const a_child = blk: {
+ var node = node_a;
+ while (node.parentNode()) |p| {
+ if (p == parent) break :blk node;
+ node = p;
+ }
+ unreachable;
+ };
+ const b_child = current;
+
+ const a_index = parent.getChildIndex(a_child) orelse unreachable;
+ const b_index = parent.getChildIndex(b_child) orelse unreachable;
+
+ if (a_index < b_index) {
+ return .before;
+ }
+ if (a_index > b_index) {
+ return .after;
+ }
+ return .equal;
+ }
+ current = parent;
+ }
+
+ // Should not reach here if nodes are in the same tree
+ return .before;
+}
+
+fn isAncestorOf(potential_ancestor: *Node, node: *Node) bool {
+ var current = node.parentNode();
+ while (current) |parent| {
+ if (parent == potential_ancestor) {
+ return true;
+ }
+ current = parent.parentNode();
+ }
+ return false;
+}
+
+pub const JsApi = struct {
+ pub const bridge = js.Bridge(Range);
+
+ pub const Meta = struct {
+ pub const name = "Range";
+ pub const prototype_chain = bridge.prototypeChain();
+ pub var class_id: bridge.ClassId = undefined;
+ };
+
+ pub const constructor = bridge.constructor(Range.init, .{});
+ pub const startContainer = bridge.accessor(Range.getStartContainer, null, .{});
+ pub const startOffset = bridge.accessor(Range.getStartOffset, null, .{});
+ pub const endContainer = bridge.accessor(Range.getEndContainer, null, .{});
+ pub const endOffset = bridge.accessor(Range.getEndOffset, null, .{});
+ pub const collapsed = bridge.accessor(Range.getCollapsed, null, .{});
+ pub const setStart = bridge.function(Range.setStart, .{});
+ pub const setEnd = bridge.function(Range.setEnd, .{});
+ pub const setStartBefore = bridge.function(Range.setStartBefore, .{});
+ pub const setStartAfter = bridge.function(Range.setStartAfter, .{});
+ pub const setEndBefore = bridge.function(Range.setEndBefore, .{});
+ pub const setEndAfter = bridge.function(Range.setEndAfter, .{});
+ pub const selectNode = bridge.function(Range.selectNode, .{});
+ pub const selectNodeContents = bridge.function(Range.selectNodeContents, .{});
+ pub const collapse = bridge.function(Range.collapse, .{});
+ pub const cloneRange = bridge.function(Range.cloneRange, .{});
+ pub const insertNode = bridge.function(Range.insertNode, .{});
+ pub const deleteContents = bridge.function(Range.deleteContents, .{});
+ pub const cloneContents = bridge.function(Range.cloneContents, .{});
+ pub const extractContents = bridge.function(Range.extractContents, .{});
+ pub const surroundContents = bridge.function(Range.surroundContents, .{});
+ pub const createContextualFragment = bridge.function(Range.createContextualFragment, .{});
+ pub const toString = bridge.function(Range.toString, .{});
+};
+
+const testing = @import("../../testing.zig");
+test "WebApi: Range" {
+ try testing.htmlRunner("range.html", .{});
+}
diff --git a/src/browser/webapi/collections/NodeList.zig b/src/browser/webapi/collections/NodeList.zig
index b49a29b6..0e4a3c2e 100644
--- a/src/browser/webapi/collections/NodeList.zig
+++ b/src/browser/webapi/collections/NodeList.zig
@@ -18,6 +18,7 @@
const std = @import("std");
+const log = @import("../../..//log.zig");
const js = @import("../../js/js.zig");
const Page = @import("../../Page.zig");
const Node = @import("../Node.zig");
@@ -63,6 +64,23 @@ pub fn entries(self: *NodeList, page: *Page) !*EntryIterator {
return .init(.{ .list = self }, page);
}
+pub fn forEach(self: *NodeList, cb: js.Function, page: *Page) !void {
+ var i: i32 = 0;
+ var it = try self.values(page);
+ while (true) : (i += 1) {
+ const next = try it.next(page);
+ if (next.done) {
+ return;
+ }
+
+ var result: js.Function.Result = undefined;
+ cb.tryCall(void, .{ next.value, i, self }, &result) catch {
+ log.debug(.js, "forEach callback", .{ .err = result.exception, .stack = result.stack });
+ return;
+ };
+ }
+}
+
const GenericIterator = @import("iterator.zig").Entry;
pub const KeyIterator = GenericIterator(Iterator, "0");
pub const ValueIterator = GenericIterator(Iterator, "1");
@@ -96,5 +114,6 @@ pub const JsApi = struct {
pub const keys = bridge.function(NodeList.keys, .{});
pub const values = bridge.function(NodeList.values, .{});
pub const entries = bridge.function(NodeList.entries, .{});
+ pub const forEach = bridge.function(NodeList.forEach, .{});
pub const symbol_iterator = bridge.iterator(NodeList.values, .{});
};