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 <noreply@anthropic.com>
This commit is contained in:
Francis Bouvier
2026-07-11 22:02:02 +02:00
parent db9b170f02
commit e70e3726bc

View File

@@ -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 {