From fd391681068009c202b4185544b71ac853072d09 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Tue, 2 Dec 2025 10:57:20 +0800 Subject: [PATCH] Range --- src/browser/js/bridge.zig | 1 + src/browser/tests/node/child_nodes.html | 4 + src/browser/tests/range.html | 377 +++++++++++++++ src/browser/webapi/Document.zig | 6 + src/browser/webapi/Node.zig | 55 +++ src/browser/webapi/Range.zig | 493 ++++++++++++++++++++ src/browser/webapi/collections/NodeList.zig | 19 + 7 files changed, 955 insertions(+) create mode 100644 src/browser/tests/range.html create mode 100644 src/browser/webapi/Range.zig 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, .{}); };