Support range mutation across nodes

Range mutation will trigger MutationObserver

MutationObserver with characterDataOldValue=true implicitly means
characterData=true

For MutationObserver-characterData test.
This commit is contained in:
Karl Seguin
2026-01-09 20:42:23 +08:00
parent f1b60453bd
commit bb907f5adb
4 changed files with 211 additions and 5 deletions

View File

@@ -820,3 +820,137 @@
}); });
} }
</script> </script>
<script id=deleteContents_crossNode>
{
// Test deleteContents across multiple sibling text nodes
const p = document.createElement('p');
p.appendChild(document.createTextNode('AAAA'));
p.appendChild(document.createTextNode('BBBB'));
p.appendChild(document.createTextNode('CCCC'));
testing.expectEqual(3, p.childNodes.length);
testing.expectEqual('AAAABBBBCCCC', p.textContent);
const range = document.createRange();
// Start at position 2 in first text node ("AA|AA")
range.setStart(p.childNodes[0], 2);
// End at position 2 in third text node ("CC|CC")
range.setEnd(p.childNodes[2], 2);
range.deleteContents();
// Should have truncated first node to "AA" and third node to "CC"
// Middle node should be removed
testing.expectEqual(2, p.childNodes.length);
testing.expectEqual('AA', p.childNodes[0].textContent);
testing.expectEqual('CC', p.childNodes[1].textContent);
testing.expectEqual('AACC', p.textContent);
}
</script>
<script id=deleteContents_crossNode_partial>
{
// Test deleteContents where start node is completely preserved
const p = document.createElement('p');
p.appendChild(document.createTextNode('KEEP'));
p.appendChild(document.createTextNode('DELETE'));
p.appendChild(document.createTextNode('PARTIAL'));
const range = document.createRange();
// Start at end of first text node
range.setStart(p.childNodes[0], 4);
// End in middle of third text node
range.setEnd(p.childNodes[2], 4);
range.deleteContents();
testing.expectEqual(2, p.childNodes.length);
testing.expectEqual('KEEP', p.childNodes[0].textContent);
testing.expectEqual('IAL', p.childNodes[1].textContent);
testing.expectEqual('KEEPIAL', p.textContent);
}
</script>
<script id=extractContents_crossNode>
{
// Test extractContents across multiple sibling text nodes
const p = document.createElement('p');
p.appendChild(document.createTextNode('AAAA'));
p.appendChild(document.createTextNode('BBBB'));
p.appendChild(document.createTextNode('CCCC'));
const range = document.createRange();
range.setStart(p.childNodes[0], 2);
range.setEnd(p.childNodes[2], 2);
const fragment = range.extractContents();
// Original should be truncated
testing.expectEqual(2, p.childNodes.length);
testing.expectEqual('AA', p.childNodes[0].textContent);
testing.expectEqual('CC', p.childNodes[1].textContent);
// Fragment should contain extracted content
testing.expectEqual(3, fragment.childNodes.length);
testing.expectEqual('AA', fragment.childNodes[0].textContent);
testing.expectEqual('BBBB', fragment.childNodes[1].textContent);
testing.expectEqual('CC', fragment.childNodes[2].textContent);
}
</script>
<script id=cloneContents_crossNode>
{
// Test cloneContents across multiple sibling text nodes
const p = document.createElement('p');
p.appendChild(document.createTextNode('AAAA'));
p.appendChild(document.createTextNode('BBBB'));
p.appendChild(document.createTextNode('CCCC'));
const range = document.createRange();
range.setStart(p.childNodes[0], 2);
range.setEnd(p.childNodes[2], 2);
const fragment = range.cloneContents();
// Original should be unchanged
testing.expectEqual(3, p.childNodes.length);
testing.expectEqual('AAAA', p.childNodes[0].textContent);
testing.expectEqual('BBBB', p.childNodes[1].textContent);
testing.expectEqual('CCCC', p.childNodes[2].textContent);
// Fragment should contain cloned content
testing.expectEqual(3, fragment.childNodes.length);
testing.expectEqual('AA', fragment.childNodes[0].textContent);
testing.expectEqual('BBBB', fragment.childNodes[1].textContent);
testing.expectEqual('CC', fragment.childNodes[2].textContent);
}
</script>
<script id=deleteContents_crossNode_withElements>
{
// Test deleteContents with mixed text and element nodes
const div = document.createElement('div');
div.appendChild(document.createTextNode('Start'));
const span = document.createElement('span');
span.textContent = 'Middle';
div.appendChild(span);
div.appendChild(document.createTextNode('End'));
testing.expectEqual(3, div.childNodes.length);
const range = document.createRange();
// Start in middle of first text node
range.setStart(div.childNodes[0], 2);
// End in middle of last text node
range.setEnd(div.childNodes[2], 1);
range.deleteContents();
// Should keep "St" from start, remove span, keep "nd" from end
testing.expectEqual(2, div.childNodes.length);
testing.expectEqual('St', div.childNodes[0].textContent);
testing.expectEqual('nd', div.childNodes[1].textContent);
testing.expectEqual('Stnd', div.textContent);
}
</script>

View File

@@ -69,6 +69,10 @@ pub fn observe(self: *MutationObserver, target: *Node, options: ObserveOptions,
copied_options.attributeFilter = filter_copy; copied_options.attributeFilter = filter_copy;
} }
if (options.characterDataOldValue) {
copied_options.characterData = true;
}
// Check if already observing this target // Check if already observing this target
for (self._observing.items) |*obs| { for (self._observing.items) |*obs| {
if (obs.target == target) { if (obs.target == target) {

View File

@@ -652,9 +652,9 @@ pub fn getData(self: *const Node) []const u8 {
}; };
} }
pub fn setData(self: *Node, data: []const u8) void { pub fn setData(self: *Node, data: []const u8, page: *Page) !void {
switch (self._type) { switch (self._type) {
.cdata => |c| c._data = data, .cdata => |c| try c.setData(data, page),
else => {}, else => {},
} }
} }

View File

@@ -357,7 +357,7 @@ pub fn deleteContents(self: *Range, page: *Page) !void {
u8, u8,
&.{ text_data[0..self._proto._start_offset], text_data[self._proto._end_offset..] }, &.{ text_data[0..self._proto._start_offset], text_data[self._proto._end_offset..] },
); );
self._proto._start_container.setData(new_text); try self._proto._start_container.setData(new_text, page);
} else { } else {
// Delete child nodes in range // Delete child nodes in range
var offset = self._proto._start_offset; var offset = self._proto._start_offset;
@@ -371,8 +371,43 @@ pub fn deleteContents(self: *Range, page: *Page) !void {
return; return;
} }
// Complex case: different containers - simplified implementation // Complex case: different containers
// Just collapse the range for now // Handle start container - if it's a text node, truncate it
if (self._proto._start_container.is(Node.CData)) |_| {
const text_data = self._proto._start_container.getData();
if (self._proto._start_offset < text_data.len) {
// Keep only the part before start_offset
const new_text = text_data[0..self._proto._start_offset];
try self._proto._start_container.setData(new_text, page);
}
}
// Handle end container - if it's a text node, truncate it
if (self._proto._end_container.is(Node.CData)) |_| {
const text_data = self._proto._end_container.getData();
if (self._proto._end_offset < text_data.len) {
// Keep only the part from end_offset onwards
const new_text = text_data[self._proto._end_offset..];
try self._proto._end_container.setData(new_text, page);
} else if (self._proto._end_offset == text_data.len) {
// If we're at the end, set to empty (will be removed if needed)
try self._proto._end_container.setData("", page);
}
}
// 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.?, page);
}
current = next;
}
}
self.collapse(true); self.collapse(true);
} }
@@ -401,6 +436,39 @@ pub fn cloneContents(self: *const Range, page: *Page) !*DocumentFragment {
} }
} }
} }
} 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();
if (self._proto._start_offset < text_data.len) {
// Clone from start_offset to end of text
const cloned_text = text_data[self._proto._start_offset..];
const text_node = try page.createTextNode(cloned_text);
_ = try fragment.asNode().appendChild(text_node, page);
}
}
// 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 cloned = try current.?.cloneNode(true, page);
_ = try fragment.asNode().appendChild(cloned, page);
current = current.?.nextSibling();
}
}
// Clone partial end container
if (self._proto._end_container.is(Node.CData)) |_| {
const text_data = self._proto._end_container.getData();
if (self._proto._end_offset > 0 and self._proto._end_offset <= text_data.len) {
// Clone from start to end_offset
const cloned_text = text_data[0..self._proto._end_offset];
const text_node = try page.createTextNode(cloned_text);
_ = try fragment.asNode().appendChild(text_node, page);
}
}
} }
return fragment; return fragment;