From e70e3726bc9cf5ec719cc149e904dd34063e10bf Mon Sep 17 00:00:00 2001 From: Francis Bouvier Date: Sat, 11 Jul 2026 22:02:02 +0200 Subject: [PATCH] webapi: implement the spec algorithm for Range.deleteContents Fixes the 20 failing subtests of WPT /dom/ranges/Range-deleteContents.html (105/125 -> 125/125) and 12 of Range-extractContents.html (which delegates its deletion half): deleteContents only handled same-container ranges and the "boundary containers are siblings" case, and always collapsed to the start point. It now follows the DOM algorithm: - same-CharacterData ranges replace the data in place (replaceData, so live ranges update); - the top-most contained nodes (contained per boundary-point comparison, with no contained parent) are collected before any mutation and removed wherever they live in the tree, not just among siblings; - partially contained CharacterData boundaries are truncated with replaceData; - the range collapses to the spec's "new node/new offset": the start point when the start node is an inclusive ancestor of the end node, otherwise just after the highest partially-contained ancestor of the start node. Coverage: - /dom/ranges/Range-deleteContents.html 105/125 -> 125/125 (fully green) - /dom/ranges/Range-extractContents.html 141/187 -> 153/187 Co-Authored-By: Claude Fable 5 --- src/browser/webapi/Range.zig | 143 +++++++++++++++++++---------------- 1 file changed, 77 insertions(+), 66 deletions(-) diff --git a/src/browser/webapi/Range.zig b/src/browser/webapi/Range.zig index e6f65a574..2ebcad846 100644 --- a/src/browser/webapi/Range.zig +++ b/src/browser/webapi/Range.zig @@ -391,76 +391,87 @@ pub fn deleteContents(self: *Range, frame: *Frame) !void { } frame.domChanged(); - // Simple case: same container - if (self._proto._start_container == self._proto._end_container) { - if (self._proto._start_container.is(Node.CData)) |cdata| { - // Delete part of text node - const old_value = cdata.getData(); - const text_data = old_value.str(); - cdata._data = try String.concat( - frame.arena, - &.{ - text_data[0..byteOffset(text_data, self._proto._start_offset)], - text_data[byteOffset(text_data, self._proto._end_offset)..], - }, - ); - Frame.observers.notifyCharacterDataChange(frame, self._proto._start_container, old_value); + const start_node = self._proto._start_container; + const start_offset = self._proto._start_offset; + const end_node = self._proto._end_container; + const end_offset = self._proto._end_offset; + + // Same CharacterData container: replace the data in place. + if (start_node == end_node) { + if (start_node.is(Node.CData)) |cdata| { + try cdata.replaceData(start_offset, end_offset - start_offset, "", frame); + try self.setStart(start_node, start_offset); + try self.setEnd(start_node, start_offset); + return; + } + } + + // Contained nodes whose parent isn't also contained, in tree order. + var to_remove: std.ArrayList(*Node) = .empty; + try self.collectContained(self._proto.getCommonAncestorContainer(), &to_remove, frame.call_arena); + + // Where the collapsed range ends up: the start point if the start node + // is an inclusive ancestor of the end node; otherwise just after the + // highest partially-contained ancestor of the start node. + var new_node = start_node; + var new_offset = start_offset; + if (start_node != end_node and !start_node.contains(end_node)) { + var reference = start_node; + while (reference.parentNode()) |parent| { + if (parent == end_node or parent.contains(end_node)) break; + reference = parent; + } + new_node = reference.parentNode().?; + new_offset = (new_node.getChildIndex(reference) orelse 0) + 1; + } + + if (start_node.is(Node.CData)) |cdata| { + const length: u32 = @intCast(cdata.getLength()); + try cdata.replaceData(start_offset, length - start_offset, "", frame); + } + + for (to_remove.items) |node| { + if (node.parentNode()) |parent| { + _ = try parent.removeChild(node, frame); + } + } + + if (end_node.is(Node.CData)) |cdata| { + try cdata.replaceData(0, end_offset, "", frame); + } + + try self.setStart(new_node, new_offset); + try self.setEnd(new_node, new_offset); +} + +// A node is contained in the range when its whole extent lies between the +// range's boundary points. Appends the top-most contained nodes (those whose +// parent isn't contained) under `node`, without descending into them. +fn collectContained(self: *const Range, node: *Node, list: *std.ArrayList(*Node), arena: std.mem.Allocator) !void { + var child = node.firstChild(); + while (child) |c| : (child = c.nextSibling()) { + if (self.nodeContained(c)) { + try list.append(arena, c); } else { - // Delete child nodes in range. - // Capture count before the loop: removeChild triggers live range - // updates that decrement _end_offset on each removal. - const count = self._proto._end_offset - self._proto._start_offset; - var i: u32 = 0; - while (i < count) : (i += 1) { - if (self._proto._start_container.getChildAt(self._proto._start_offset)) |child| { - _ = try self._proto._start_container.removeChild(child, frame); - } - } - } - self.collapse(true); - return; - } - - // Complex case: different containers - // Handle start container - if it's a text node, truncate it - if (self._proto._start_container.is(Node.CData)) |cdata| { - const text_data = cdata._data.str(); - const byte_start = byteOffset(text_data, self._proto._start_offset); - if (byte_start < text_data.len) { - // Keep only the part before start_offset - const new_text = text_data[0..byte_start]; - try self._proto._start_container.setData(new_text, frame); + try self.collectContained(c, list, arena); } } +} - // Handle end container - if it's a text node, truncate it - if (self._proto._end_container.is(Node.CData)) |cdata| { - const text_data = cdata._data.str(); - const byte_end = byteOffset(text_data, self._proto._end_offset); - if (byte_end < text_data.len) { - // Keep only the part from end_offset onwards - const new_text = text_data[byte_end..]; - try self._proto._end_container.setData(new_text, frame); - } else if (byte_end == text_data.len) { - // If we're at the end, set to empty (will be removed if needed) - try self._proto._end_container.setData("", frame); - } - } - - // Remove nodes between start and end containers - // For now, handle the common case where they're siblings - if (self._proto._start_container.parentNode() == self._proto._end_container.parentNode()) { - var current = self._proto._start_container.nextSibling(); - while (current != null and current != self._proto._end_container) { - const next = current.?.nextSibling(); - if (current.?.parentNode()) |parent| { - _ = try parent.removeChild(current.?, frame); - } - current = next; - } - } - - self.collapse(true); +fn nodeContained(self: *const Range, node: *Node) bool { + const after_start = AbstractRange.compareBoundaryPoints( + node, + 0, + self._proto._start_container, + self._proto._start_offset, + ) == .after; + if (!after_start) return false; + return AbstractRange.compareBoundaryPoints( + node, + node.getLength(), + self._proto._end_container, + self._proto._end_offset, + ) == .before; } pub fn cloneContents(self: *const Range, frame: *Frame) !*DocumentFragment {