Merge pull request #2957 from lightpanda-io/dom-pr-16-range-clone-extract

webapi: Range cloneContents/extractContents; replaceChild live-range ordering
This commit is contained in:
Karl Seguin
2026-07-16 13:51:14 +08:00
committed by GitHub
2 changed files with 225 additions and 98 deletions

View File

@@ -830,11 +830,26 @@ pub fn replaceChild(self: *Node, new_child: *Node, old_child: *Node, frame: *Fra
const notify = Frame.observers.hasMutationObservers(frame);
if (new_child == old_child) {
// Replacing a node with itself doesn't change the tree; observers
// still get a removal record followed by an addition record.
// Replacing a node with itself leaves the tree unchanged, but the
// remove-then-reinsert still happens: live ranges anchored inside the
// node move to its old position, and observers get a removal record
// followed by an addition record.
const prev = old_child.previousSibling();
const next = old_child.nextSibling();
const was_connected = old_child.isConnected();
frame.removeNode(self, old_child, .{ .will_be_reconnected = was_connected, .notify_observers = false });
if (next) |reference| {
try frame.insertNodeRelative(self, old_child, .{ .before = reference }, .{
.child_already_connected = was_connected,
.notify_observers = false,
});
} else {
try frame.appendNode(self, old_child, .{
.child_already_connected = was_connected,
.notify_observers = false,
});
}
if (notify) {
const prev = old_child.previousSibling();
const next = old_child.nextSibling();
const nodes = [_]*Node{old_child};
Frame.observers.notifyChildListChange(frame, self, &.{}, &nodes, prev, next);
Frame.observers.notifyChildListChange(frame, self, &nodes, &.{}, prev, next);
@@ -842,20 +857,17 @@ pub fn replaceChild(self: *Node, new_child: *Node, old_child: *Node, frame: *Fra
return old_child;
}
// The combined record's siblings are captured before new_child is
// removed from its old position (spec: referenceChild and
// previousSibling are set before the adopt step), so new_child itself
// can be the record's previousSibling. A referenceChild that is
// new_child becomes new_child's next sibling.
// Spec order: capture the reference points, remove new_child from its
// current position (notifying normally - internal replacement), remove
// old_child (suppressed), insert new_child before the reference
// (suppressed), and queue one combined record. Removing old_child before
// inserting matters for live ranges anchored on this parent's offsets.
const prev = old_child.previousSibling();
var next = old_child.nextSibling();
if (next == new_child) {
next = new_child.nextSibling();
var reference = old_child.nextSibling();
if (reference == new_child) {
reference = new_child.nextSibling();
}
// Removing new_child from its current position (internal replacement)
// notifies normally; the replacement itself queues one combined record
// with both the added and the removed node.
const child_already_connected = new_child.isConnected();
const child_owner = new_child.ownerDocument(frame);
const parent_owner = self.ownerDocument(frame) orelse self.as(Document);
@@ -872,35 +884,43 @@ pub fn replaceChild(self: *Node, new_child: *Node, old_child: *Node, frame: *Fra
}
var added: std.ArrayList(*Node) = .empty;
if (new_child.is(DocumentFragment)) |_| {
if (notify) {
if (notify) {
if (new_child.is(DocumentFragment)) |_| {
var it = new_child.childrenIterator();
while (it.next()) |fragment_child| {
try added.append(frame.call_arena, fragment_child);
}
}
try frame.moveAllChildren(new_child, self, old_child, .silent_parent);
} else {
if (notify) {
} else {
try added.append(frame.call_arena, new_child);
}
}
frame.removeNode(self, old_child, .{ .will_be_reconnected = false, .notify_observers = false });
if (new_child.is(DocumentFragment)) |_| {
try frame.moveAllChildren(new_child, self, reference, .silent_parent);
} else if (reference) |ref| {
try frame.insertNodeRelative(
self,
new_child,
.{ .before = old_child },
.{ .before = ref },
.{
.child_already_connected = child_already_connected,
.adopting_to_new_document = adopting,
.notify_observers = false,
},
);
} else {
try frame.appendNode(self, new_child, .{
.child_already_connected = child_already_connected,
.adopting_to_new_document = adopting,
.notify_observers = false,
});
}
frame.removeNode(self, old_child, .{ .will_be_reconnected = false, .notify_observers = false });
if (notify) {
const removed = [_]*Node{old_child};
Frame.observers.notifyChildListChange(frame, self, added.items, &removed, prev, next);
Frame.observers.notifyChildListChange(frame, self, added.items, &removed, prev, reference);
}
return old_child;

View File

@@ -470,97 +470,204 @@ fn collectContained(self: *const Range, node: *Node, list: *std.ArrayList(*Node)
}
fn nodeContained(self: *const Range, node: *Node) bool {
const after_start = AbstractRange.compareBoundaryPoints(
return containedBetween(
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 {
const fragment = try DocumentFragment.init(frame);
if (self._proto.getCollapsed()) return fragment;
// Simple case: same container
if (self._proto._start_container == self._proto._end_container) {
if (self._proto._start_container.is(Node.CData)) |_| {
// Clone part of text node
const text_data = self._proto._start_container.getData().str();
const byte_start = byteOffset(text_data, self._proto._start_offset);
const byte_end = byteOffset(text_data, self._proto._end_offset);
if (byte_start < text_data.len and byte_end <= text_data.len) {
const cloned_text = text_data[byte_start..byte_end];
const text_node = try Frame.node_factory.createTextNode(frame, cloned_text);
_ = try fragment.asNode().appendChild(text_node, frame);
}
} else {
// Clone child nodes in range
var offset = self._proto._start_offset;
while (offset < self._proto._end_offset) : (offset += 1) {
if (self._proto._start_container.getChildAt(offset)) |child| {
if (try child.cloneNodeForAppending(true, frame)) |cloned| {
_ = try fragment.asNode().appendChild(cloned, frame);
}
}
}
}
} else {
// Complex case: different containers
// Clone partial start container
if (self._proto._start_container.is(Node.CData)) |_| {
const text_data = self._proto._start_container.getData().str();
const byte_start = byteOffset(text_data, self._proto._start_offset);
if (byte_start < text_data.len) {
// Clone from start_offset to end of text
const cloned_text = text_data[byte_start..];
const text_node = try Frame.node_factory.createTextNode(frame, cloned_text);
_ = try fragment.asNode().appendChild(text_node, frame);
}
}
// Clone nodes between start and end containers (siblings case)
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 (try current.?.cloneNodeForAppending(true, frame)) |cloned| {
_ = try fragment.asNode().appendChild(cloned, frame);
}
current = next;
}
}
// Clone partial end container
if (self._proto._end_container.is(Node.CData)) |_| {
const text_data = self._proto._end_container.getData().str();
const byte_end = byteOffset(text_data, self._proto._end_offset);
if (byte_end > 0 and byte_end <= text_data.len) {
// Clone from start to end_offset
const cloned_text = text_data[0..byte_end];
const text_node = try Frame.node_factory.createTextNode(frame, cloned_text);
_ = try fragment.asNode().appendChild(text_node, frame);
}
}
}
try cloneContentsBetween(
frame,
fragment.asNode(),
self._proto._start_container,
self._proto._start_offset,
self._proto._end_container,
self._proto._end_offset,
);
return fragment;
}
// The DOM "clone the contents of a range" algorithm, on explicit boundary
// points so the partially-contained recursion doesn't need Range objects.
fn cloneContentsBetween(frame: *Frame, out: *Node, start_node: *Node, start_offset: u32, end_node: *Node, end_offset: u32) !void {
if (start_node == end_node) {
if (start_node.is(Node.CData)) |cdata| {
const data = cdata.getData().str();
const cloned = (try start_node.cloneNodeForAppending(false, frame)) orelse return;
try cloned.setData(data[byteOffset(data, start_offset)..byteOffset(data, end_offset)], frame);
_ = try out.appendChild(cloned, frame);
return;
}
}
// The closest common ancestor of the two boundary points.
var common = start_node;
while (common != end_node and !common.contains(end_node)) {
common = common.parentNode() orelse break;
}
var child = common.firstChild();
while (child) |c| : (child = c.nextSibling()) {
const contains_start = c == start_node or c.contains(start_node);
const contains_end = c == end_node or c.contains(end_node);
if (contains_start) {
// First partially contained child.
if (c.is(Node.CData)) |cdata| {
// c is the start node itself.
const data = cdata.getData().str();
const cloned = (try c.cloneNodeForAppending(false, frame)) orelse continue;
try cloned.setData(data[byteOffset(data, start_offset)..], frame);
_ = try out.appendChild(cloned, frame);
} else {
const cloned = (try c.cloneNodeForAppending(false, frame)) orelse continue;
_ = try out.appendChild(cloned, frame);
try cloneContentsBetween(frame, cloned, start_node, start_offset, c, c.getLength());
}
} else if (contains_end) {
// Last partially contained child.
if (c.is(Node.CData)) |cdata| {
// c is the end node itself.
const data = cdata.getData().str();
const cloned = (try c.cloneNodeForAppending(false, frame)) orelse continue;
try cloned.setData(data[0..byteOffset(data, end_offset)], frame);
_ = try out.appendChild(cloned, frame);
} else {
const cloned = (try c.cloneNodeForAppending(false, frame)) orelse continue;
_ = try out.appendChild(cloned, frame);
try cloneContentsBetween(frame, cloned, c, 0, end_node, end_offset);
}
} else if (containedBetween(c, start_node, start_offset, end_node, end_offset)) {
if (c._type == .document_type) {
return error.HierarchyError;
}
const cloned = (try c.cloneNodeForAppending(true, frame)) orelse continue;
_ = try out.appendChild(cloned, frame);
}
}
}
fn containedBetween(node: *Node, start_node: *Node, start_offset: u32, end_node: *Node, end_offset: u32) bool {
const after_start = AbstractRange.compareBoundaryPoints(node, 0, start_node, start_offset) == .after;
if (!after_start) return false;
return AbstractRange.compareBoundaryPoints(node, node.getLength(), end_node, end_offset) == .before;
}
pub fn extractContents(self: *Range, frame: *Frame) !*DocumentFragment {
const fragment = try self.cloneContents(frame);
try self.deleteContents(frame);
const fragment = try DocumentFragment.init(frame);
if (self._proto.getCollapsed()) return fragment;
frame.domChanged();
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;
// Where the range collapses to afterwards; same rule as deleteContents.
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;
}
try extractContentsBetween(frame, fragment.asNode(), start_node, start_offset, end_node, end_offset);
try self.setStart(new_node, new_offset);
try self.setEnd(new_node, new_offset);
return fragment;
}
// The DOM "extract" algorithm: contained nodes MOVE into the output
// (preserving identity); only the partially contained boundary
// CharacterData nodes and the partially contained element shells are cloned.
// Children are classified before anything moves, since moving a contained
// child shifts the indices the boundary comparisons rely on.
fn extractContentsBetween(frame: *Frame, out: *Node, start_node: *Node, start_offset: u32, end_node: *Node, end_offset: u32) !void {
if (start_node == end_node) {
if (start_node.is(Node.CData)) |cdata| {
const data = cdata.getData().str();
const cloned = (try start_node.cloneNodeForAppending(false, frame)) orelse return;
try cloned.setData(data[byteOffset(data, start_offset)..byteOffset(data, end_offset)], frame);
_ = try out.appendChild(cloned, frame);
try cdata.replaceData(start_offset, end_offset - start_offset, "", frame);
return;
}
}
var common = start_node;
while (common != end_node and !common.contains(end_node)) {
common = common.parentNode() orelse break;
}
var first_partial: ?*Node = null;
var last_partial: ?*Node = null;
var contained: std.ArrayList(*Node) = .empty;
var child = common.firstChild();
while (child) |c| : (child = c.nextSibling()) {
if (c == start_node or c.contains(start_node)) {
first_partial = c;
} else if (c == end_node or c.contains(end_node)) {
last_partial = c;
} else if (containedBetween(c, start_node, start_offset, end_node, end_offset)) {
if (c._type == .document_type) {
return error.HierarchyError;
}
try contained.append(frame.call_arena, c);
}
}
if (first_partial) |c| {
if (c.is(Node.CData)) |cdata| {
// c is the start node itself.
const data = cdata.getData().str();
const byte_start = byteOffset(data, start_offset);
const cloned = (try c.cloneNodeForAppending(false, frame)) orelse return;
try cloned.setData(data[byte_start..], frame);
_ = try out.appendChild(cloned, frame);
const length: u32 = @intCast(cdata.getLength());
try cdata.replaceData(start_offset, length - start_offset, "", frame);
} else {
const cloned = (try c.cloneNodeForAppending(false, frame)) orelse return;
_ = try out.appendChild(cloned, frame);
try extractContentsBetween(frame, cloned, start_node, start_offset, c, c.getLength());
}
}
for (contained.items) |c| {
_ = try out.appendChild(c, frame);
}
if (last_partial) |c| {
if (c.is(Node.CData)) |cdata| {
// c is the end node itself.
const data = cdata.getData().str();
const cloned = (try c.cloneNodeForAppending(false, frame)) orelse return;
try cloned.setData(data[0..byteOffset(data, end_offset)], frame);
_ = try out.appendChild(cloned, frame);
try cdata.replaceData(0, end_offset, "", frame);
} else {
const cloned = (try c.cloneNodeForAppending(false, frame)) orelse return;
_ = try out.appendChild(cloned, frame);
try extractContentsBetween(frame, cloned, c, 0, end_node, end_offset);
}
}
}
pub fn surroundContents(self: *Range, new_parent: *Node, frame: *Frame) !void {
// Extract contents
const contents = try self.extractContents(frame);