Merge pull request #1774 from egrs/range-chardata-mutations

update live ranges after CharacterData and DOM mutations
This commit is contained in:
Karl Seguin
2026-03-11 16:04:41 +08:00
committed by GitHub
8 changed files with 549 additions and 25 deletions

View File

@@ -270,14 +270,16 @@ pub fn abstractRange(self: *Factory, child: anytype, page: *Page) !*@TypeOf(chil
const chain = try PrototypeChain(&.{ AbstractRange, @TypeOf(child) }).allocate(allocator);
const doc = page.document.asNode();
chain.set(0, AbstractRange{
const abstract_range = chain.get(0);
abstract_range.* = AbstractRange{
._type = unionInit(AbstractRange.Type, chain.get(1)),
._end_offset = 0,
._start_offset = 0,
._end_container = doc,
._start_container = doc,
});
};
chain.setLeaf(1, child);
page._live_ranges.append(&abstract_range._range_link);
return chain.get(1);
}

View File

@@ -54,6 +54,7 @@ const Performance = @import("webapi/Performance.zig");
const Screen = @import("webapi/Screen.zig");
const VisualViewport = @import("webapi/VisualViewport.zig");
const PerformanceObserver = @import("webapi/PerformanceObserver.zig");
const AbstractRange = @import("webapi/AbstractRange.zig");
const MutationObserver = @import("webapi/MutationObserver.zig");
const IntersectionObserver = @import("webapi/IntersectionObserver.zig");
const CustomElementDefinition = @import("webapi/CustomElementDefinition.zig");
@@ -142,6 +143,9 @@ _to_load: std.ArrayList(*Element.Html) = .{},
_script_manager: ScriptManager,
// List of active live ranges (for mutation updates per DOM spec)
_live_ranges: std.DoublyLinkedList = .{},
// List of active MutationObservers
_mutation_observers: std.DoublyLinkedList = .{},
_mutation_delivery_scheduled: bool = false,
@@ -2394,6 +2398,12 @@ pub fn removeNode(self: *Page, parent: *Node, child: *Node, opts: RemoveNodeOpts
const previous_sibling = child.previousSibling();
const next_sibling = child.nextSibling();
// Capture child's index before removal for live range updates (DOM spec remove steps 4-7)
const child_index_for_ranges: ?u32 = if (self._live_ranges.first != null)
parent.getChildIndex(child)
else
null;
const children = parent._children.?;
switch (children.*) {
.one => |n| {
@@ -2422,6 +2432,11 @@ pub fn removeNode(self: *Page, parent: *Node, child: *Node, opts: RemoveNodeOpts
child._parent = null;
child._child_link = .{};
// Update live ranges for removal (DOM spec remove steps 4-7)
if (child_index_for_ranges) |idx| {
self.updateRangesForNodeRemoval(parent, child, idx);
}
// Handle slot assignment removal before mutation observers
if (child.is(Element)) |el| {
// Check if the parent was a shadow host
@@ -2569,6 +2584,21 @@ pub fn _insertNodeRelative(self: *Page, comptime from_parser: bool, parent: *Nod
}
child._parent = parent;
// Update live ranges for insertion (DOM spec insert step 6).
// For .before/.after the child was inserted at a specific position;
// ranges on parent with offsets past that position must be incremented.
// For .append no range update is needed (spec: "if child is non-null").
if (self._live_ranges.first != null) {
switch (relative) {
.append => {},
.before, .after => {
if (parent.getChildIndex(child)) |idx| {
self.updateRangesForNodeInsertion(parent, idx);
}
},
}
}
// Tri-state behavior for mutations:
// 1. from_parser=true, parse_mode=document -> no mutations (initial document parse)
// 2. from_parser=true, parse_mode=fragment -> mutations (innerHTML additions)
@@ -2827,6 +2857,54 @@ pub fn childListChange(
}
}
// --- Live range update methods (DOM spec §4.2.3, §4.2.4, §4.7, §4.8) ---
/// Update all live ranges after a replaceData mutation on a CharacterData node.
/// Per DOM spec: insertData = replaceData(offset, 0, data),
/// deleteData = replaceData(offset, count, "").
/// All parameters are in UTF-16 code unit offsets.
pub fn updateRangesForCharacterDataReplace(self: *Page, target: *Node, offset: u32, count: u32, data_len: u32) void {
var it: ?*std.DoublyLinkedList.Node = self._live_ranges.first;
while (it) |link| : (it = link.next) {
const ar: *AbstractRange = @fieldParentPtr("_range_link", link);
ar.updateForCharacterDataReplace(target, offset, count, data_len);
}
}
/// Update all live ranges after a splitText operation.
/// Steps 7b-7e of the DOM spec splitText algorithm.
/// Steps 7d-7e complement (not overlap) updateRangesForNodeInsertion:
/// the insert update handles offsets > child_index, while 7d/7e handle
/// offsets == node_index+1 (these are equal values but with > vs == checks).
pub fn updateRangesForSplitText(self: *Page, target: *Node, new_node: *Node, offset: u32, parent: *Node, node_index: u32) void {
var it: ?*std.DoublyLinkedList.Node = self._live_ranges.first;
while (it) |link| : (it = link.next) {
const ar: *AbstractRange = @fieldParentPtr("_range_link", link);
ar.updateForSplitText(target, new_node, offset, parent, node_index);
}
}
/// Update all live ranges after a node insertion.
/// Per DOM spec insert algorithm step 6: only applies when inserting before a
/// non-null reference node.
pub fn updateRangesForNodeInsertion(self: *Page, parent: *Node, child_index: u32) void {
var it: ?*std.DoublyLinkedList.Node = self._live_ranges.first;
while (it) |link| : (it = link.next) {
const ar: *AbstractRange = @fieldParentPtr("_range_link", link);
ar.updateForNodeInsertion(parent, child_index);
}
}
/// Update all live ranges after a node removal.
/// Per DOM spec remove algorithm steps 4-7.
pub fn updateRangesForNodeRemoval(self: *Page, parent: *Node, child: *Node, child_index: u32) void {
var it: ?*std.DoublyLinkedList.Node = self._live_ranges.first;
while (it) |link| : (it = link.next) {
const ar: *AbstractRange = @fieldParentPtr("_range_link", link);
ar.updateForNodeRemoval(parent, child, child_index);
}
}
// TODO: optimize and cleanup, this is called a lot (e.g., innerHTML = '')
pub fn parseHtmlAsChildren(self: *Page, node: *Node, html: []const u8) !void {
const previous_parse_mode = self._parse_mode;

View File

@@ -0,0 +1,315 @@
<!DOCTYPE html>
<script src="testing.js"></script>
<script id=insertData_adjusts_range_offsets>
{
const text = document.createTextNode('abcdef');
const div = document.createElement('div');
div.appendChild(text);
const range = document.createRange();
range.setStart(text, 2);
range.setEnd(text, 5);
// range covers "cde"
// Insert "XX" at offset 1 (before range start)
text.insertData(1, 'XX');
// "aXXbcdef" — range should shift right by 2
testing.expectEqual(4, range.startOffset);
testing.expectEqual(7, range.endOffset);
testing.expectEqual(text, range.startContainer);
}
</script>
<script id=insertData_at_range_start>
{
const text = document.createTextNode('abcdef');
const div = document.createElement('div');
div.appendChild(text);
const range = document.createRange();
range.setStart(text, 2);
range.setEnd(text, 5);
// Insert at exactly the start offset — should not shift start
text.insertData(2, 'YY');
// "abYYcdef" — start stays at 2, end shifts by 2
testing.expectEqual(2, range.startOffset);
testing.expectEqual(7, range.endOffset);
}
</script>
<script id=insertData_inside_range>
{
const text = document.createTextNode('abcdef');
const div = document.createElement('div');
div.appendChild(text);
const range = document.createRange();
range.setStart(text, 2);
range.setEnd(text, 5);
// Insert inside the range
text.insertData(3, 'Z');
// "abcZdef" — start unchanged, end shifts by 1
testing.expectEqual(2, range.startOffset);
testing.expectEqual(6, range.endOffset);
}
</script>
<script id=insertData_after_range>
{
const text = document.createTextNode('abcdef');
const div = document.createElement('div');
div.appendChild(text);
const range = document.createRange();
range.setStart(text, 2);
range.setEnd(text, 5);
// Insert after range end — no change
text.insertData(5, 'ZZ');
testing.expectEqual(2, range.startOffset);
testing.expectEqual(5, range.endOffset);
}
</script>
<script id=deleteData_before_range>
{
const text = document.createTextNode('abcdef');
const div = document.createElement('div');
div.appendChild(text);
const range = document.createRange();
range.setStart(text, 3);
range.setEnd(text, 5);
// range covers "de"
// Delete "ab" (offset 0, count 2) — before range
text.deleteData(0, 2);
// "cdef" — range shifts left by 2
testing.expectEqual(1, range.startOffset);
testing.expectEqual(3, range.endOffset);
}
</script>
<script id=deleteData_overlapping_range_start>
{
const text = document.createTextNode('abcdef');
const div = document.createElement('div');
div.appendChild(text);
const range = document.createRange();
range.setStart(text, 2);
range.setEnd(text, 5);
// Delete from offset 1, count 2 — overlaps range start
text.deleteData(1, 2);
// "adef" — start clamped to offset(1), end adjusted
testing.expectEqual(1, range.startOffset);
testing.expectEqual(3, range.endOffset);
}
</script>
<script id=deleteData_inside_range>
{
const text = document.createTextNode('abcdef');
const div = document.createElement('div');
div.appendChild(text);
const range = document.createRange();
range.setStart(text, 1);
range.setEnd(text, 5);
// Delete inside range: offset 2, count 2
text.deleteData(2, 2);
// "abef" — start unchanged, end shifts by -2
testing.expectEqual(1, range.startOffset);
testing.expectEqual(3, range.endOffset);
}
</script>
<script id=replaceData_adjusts_range>
{
const text = document.createTextNode('abcdef');
const div = document.createElement('div');
div.appendChild(text);
const range = document.createRange();
range.setStart(text, 2);
range.setEnd(text, 5);
// Replace "cd" (offset 2, count 2) with "XXXX" (4 chars)
text.replaceData(2, 2, 'XXXX');
// "abXXXXef" — start clamped to 2, end adjusted by (4-2)=+2
testing.expectEqual(2, range.startOffset);
testing.expectEqual(7, range.endOffset);
}
</script>
<script id=splitText_moves_range_to_new_node>
{
const text = document.createTextNode('abcdef');
const div = document.createElement('div');
div.appendChild(text);
const range = document.createRange();
range.setStart(text, 4);
range.setEnd(text, 6);
// range covers "ef"
const newText = text.splitText(3);
// text = "abc", newText = "def"
// Range was at (text, 4)-(text, 6), with offset > 3:
// start moves to (newText, 4-3=1), end moves to (newText, 6-3=3)
testing.expectEqual(newText, range.startContainer);
testing.expectEqual(1, range.startOffset);
testing.expectEqual(newText, range.endContainer);
testing.expectEqual(3, range.endOffset);
}
</script>
<script id=splitText_range_at_split_point>
{
const text = document.createTextNode('abcdef');
const div = document.createElement('div');
div.appendChild(text);
const range = document.createRange();
range.setStart(text, 0);
range.setEnd(text, 3);
// range covers "abc"
const newText = text.splitText(3);
// text = "abc", newText = "def"
// Range end is at exactly the split offset — should stay on original node
testing.expectEqual(text, range.startContainer);
testing.expectEqual(0, range.startOffset);
testing.expectEqual(text, range.endContainer);
testing.expectEqual(3, range.endOffset);
}
</script>
<script id=appendChild_does_not_affect_range>
{
const div = document.createElement('div');
const p1 = document.createElement('p');
const p2 = document.createElement('p');
div.appendChild(p1);
div.appendChild(p2);
const range = document.createRange();
range.setStart(div, 0);
range.setEnd(div, 2);
// Appending should not affect range offsets (spec: no update for append)
const p3 = document.createElement('p');
div.appendChild(p3);
testing.expectEqual(0, range.startOffset);
testing.expectEqual(2, range.endOffset);
}
</script>
<script id=insertBefore_shifts_range_offsets>
{
const div = document.createElement('div');
const p1 = document.createElement('p');
const p2 = document.createElement('p');
div.appendChild(p1);
div.appendChild(p2);
const range = document.createRange();
range.setStart(div, 1);
range.setEnd(div, 2);
// Insert before p1 (index 0) — range offsets > 0 should increment
const span = document.createElement('span');
div.insertBefore(span, p1);
testing.expectEqual(2, range.startOffset);
testing.expectEqual(3, range.endOffset);
}
</script>
<script id=removeChild_shifts_range_offsets>
{
const div = document.createElement('div');
const p1 = document.createElement('p');
const p2 = document.createElement('p');
const p3 = document.createElement('p');
div.appendChild(p1);
div.appendChild(p2);
div.appendChild(p3);
const range = document.createRange();
range.setStart(div, 1);
range.setEnd(div, 3);
// Remove p1 (index 0) — offsets > 0 should decrement
div.removeChild(p1);
testing.expectEqual(0, range.startOffset);
testing.expectEqual(2, range.endOffset);
}
</script>
<script id=removeChild_moves_range_from_descendant>
{
const div = document.createElement('div');
const p = document.createElement('p');
const text = document.createTextNode('hello');
p.appendChild(text);
div.appendChild(p);
const range = document.createRange();
range.setStart(text, 2);
range.setEnd(text, 4);
// Remove p (which contains text) — range should move to (div, index_of_p)
div.removeChild(p);
testing.expectEqual(div, range.startContainer);
testing.expectEqual(0, range.startOffset);
testing.expectEqual(div, range.endContainer);
testing.expectEqual(0, range.endOffset);
}
</script>
<script id=multiple_ranges_updated>
{
const text = document.createTextNode('abcdefgh');
const div = document.createElement('div');
div.appendChild(text);
const range1 = document.createRange();
range1.setStart(text, 1);
range1.setEnd(text, 3);
const range2 = document.createRange();
range2.setStart(text, 5);
range2.setEnd(text, 7);
// Insert at offset 0 — both ranges should shift
text.insertData(0, 'XX');
testing.expectEqual(3, range1.startOffset);
testing.expectEqual(5, range1.endOffset);
testing.expectEqual(7, range2.startOffset);
testing.expectEqual(9, range2.endOffset);
}
</script>
<script id=data_setter_updates_ranges>
{
const text = document.createTextNode('abcdef');
const div = document.createElement('div');
div.appendChild(text);
const range = document.createRange();
range.setStart(text, 2);
range.setEnd(text, 5);
// Setting data replaces all content — range collapses to offset 0
text.data = 'new content';
testing.expectEqual(text, range.startContainer);
testing.expectEqual(0, range.startOffset);
testing.expectEqual(text, range.endContainer);
testing.expectEqual(0, range.endOffset);
}
</script>

View File

@@ -33,6 +33,9 @@ _start_offset: u32,
_end_container: *Node,
_start_container: *Node,
// Intrusive linked list node for tracking live ranges on the Page.
_range_link: std.DoublyLinkedList.Node = .{},
pub const Type = union(enum) {
range: *Range,
// TODO: static_range: *StaticRange,
@@ -215,6 +218,91 @@ fn isInclusiveAncestorOf(potential_ancestor: *Node, node: *Node) bool {
return isAncestorOf(potential_ancestor, node);
}
/// Update this range's boundaries after a replaceData mutation on target.
/// All parameters are in UTF-16 code unit offsets.
pub fn updateForCharacterDataReplace(self: *AbstractRange, target: *Node, offset: u32, count: u32, data_len: u32) void {
if (self._start_container == target) {
if (self._start_offset > offset and self._start_offset <= offset + count) {
self._start_offset = offset;
} else if (self._start_offset > offset + count) {
// Use i64 intermediate to avoid u32 underflow when count > data_len
self._start_offset = @intCast(@as(i64, self._start_offset) + @as(i64, data_len) - @as(i64, count));
}
}
if (self._end_container == target) {
if (self._end_offset > offset and self._end_offset <= offset + count) {
self._end_offset = offset;
} else if (self._end_offset > offset + count) {
self._end_offset = @intCast(@as(i64, self._end_offset) + @as(i64, data_len) - @as(i64, count));
}
}
}
/// Update this range's boundaries after a splitText operation.
/// Steps 7b-7e of the DOM spec splitText algorithm.
pub fn updateForSplitText(self: *AbstractRange, target: *Node, new_node: *Node, offset: u32, parent: *Node, node_index: u32) void {
// Step 7b: ranges on the original node with start > offset move to new node
if (self._start_container == target and self._start_offset > offset) {
self._start_container = new_node;
self._start_offset = self._start_offset - offset;
}
// Step 7c: ranges on the original node with end > offset move to new node
if (self._end_container == target and self._end_offset > offset) {
self._end_container = new_node;
self._end_offset = self._end_offset - offset;
}
// Step 7d: ranges on parent with start == node_index + 1 increment
if (self._start_container == parent and self._start_offset == node_index + 1) {
self._start_offset += 1;
}
// Step 7e: ranges on parent with end == node_index + 1 increment
if (self._end_container == parent and self._end_offset == node_index + 1) {
self._end_offset += 1;
}
}
/// Update this range's boundaries after a node insertion.
pub fn updateForNodeInsertion(self: *AbstractRange, parent: *Node, child_index: u32) void {
if (self._start_container == parent and self._start_offset > child_index) {
self._start_offset += 1;
}
if (self._end_container == parent and self._end_offset > child_index) {
self._end_offset += 1;
}
}
/// Update this range's boundaries after a node removal.
pub fn updateForNodeRemoval(self: *AbstractRange, parent: *Node, child: *Node, child_index: u32) void {
// Steps 4-5: ranges whose start/end is an inclusive descendant of child
// get moved to (parent, child_index).
if (isInclusiveDescendantOf(self._start_container, child)) {
self._start_container = parent;
self._start_offset = child_index;
}
if (isInclusiveDescendantOf(self._end_container, child)) {
self._end_container = parent;
self._end_offset = child_index;
}
// Steps 6-7: ranges on parent at offsets > child_index get decremented.
if (self._start_container == parent and self._start_offset > child_index) {
self._start_offset -= 1;
}
if (self._end_container == parent and self._end_offset > child_index) {
self._end_offset -= 1;
}
}
fn isInclusiveDescendantOf(node: *Node, potential_ancestor: *Node) bool {
var current: ?*Node = node;
while (current) |n| {
if (n == potential_ancestor) return true;
current = n.parentNode();
}
return false;
}
pub const JsApi = struct {
pub const bridge = js.Bridge(AbstractRange);

View File

@@ -37,7 +37,7 @@ _data: String = .empty,
/// Count UTF-16 code units in a UTF-8 string.
/// 4-byte UTF-8 sequences (codepoints >= U+10000) produce 2 UTF-16 code units (surrogate pair),
/// everything else produces 1.
fn utf16Len(data: []const u8) usize {
pub fn utf16Len(data: []const u8) usize {
var count: usize = 0;
var i: usize = 0;
while (i < data.len) {
@@ -232,14 +232,13 @@ pub fn setData(self: *CData, value: ?[]const u8, page: *Page) !void {
}
/// JS bridge wrapper for `data` setter.
/// Handles [LegacyNullToEmptyString]: null → setData(null) → "".
/// Passes everything else (including undefined) through V8 toString,
/// so `undefined` becomes the string "undefined" per spec.
/// Per spec, setting .data runs replaceData(0, this.length, value),
/// which includes live range updates.
/// Handles [LegacyNullToEmptyString]: null → "" per spec.
pub fn _setData(self: *CData, value: js.Value, page: *Page) !void {
if (value.isNull()) {
return self.setData(null, page);
}
return self.setData(try value.toZig([]const u8), page);
const new_value: []const u8 = if (value.isNull()) "" else try value.toZig([]const u8);
const length = self.getLength();
try self.replaceData(0, length, new_value, page);
}
pub fn format(self: *const CData, writer: *std.io.Writer) !void {
@@ -272,15 +271,20 @@ pub fn isEqualNode(self: *const CData, other: *const CData) bool {
}
pub fn appendData(self: *CData, data: []const u8, page: *Page) !void {
const old_value = self._data;
self._data = try String.concat(page.arena, &.{ self._data.str(), data });
page.characterDataChange(self.asNode(), old_value);
// Per DOM spec, appendData(data) is replaceData(length, 0, data).
const length = self.getLength();
try self.replaceData(length, 0, data, page);
}
pub fn deleteData(self: *CData, offset: usize, count: usize, page: *Page) !void {
const end_utf16 = std.math.add(usize, offset, count) catch std.math.maxInt(usize);
const range = try utf16RangeToUtf8(self._data.str(), offset, end_utf16);
// Update live ranges per DOM spec replaceData steps (deleteData = replaceData with data="")
const length = self.getLength();
const effective_count: u32 = @intCast(@min(count, length - offset));
page.updateRangesForCharacterDataReplace(self.asNode(), @intCast(offset), effective_count, 0);
const old_data = self._data;
const old_value = old_data.str();
if (range.start == 0) {
@@ -299,6 +303,10 @@ pub fn deleteData(self: *CData, offset: usize, count: usize, page: *Page) !void
pub fn insertData(self: *CData, offset: usize, data: []const u8, page: *Page) !void {
const byte_offset = try utf16OffsetToUtf8(self._data.str(), offset);
// Update live ranges per DOM spec replaceData steps (insertData = replaceData with count=0)
page.updateRangesForCharacterDataReplace(self.asNode(), @intCast(offset), 0, @intCast(utf16Len(data)));
const old_value = self._data;
const existing = old_value.str();
self._data = try String.concat(page.arena, &.{
@@ -312,6 +320,12 @@ pub fn insertData(self: *CData, offset: usize, data: []const u8, page: *Page) !v
pub fn replaceData(self: *CData, offset: usize, count: usize, data: []const u8, page: *Page) !void {
const end_utf16 = std.math.add(usize, offset, count) catch std.math.maxInt(usize);
const range = try utf16RangeToUtf8(self._data.str(), offset, end_utf16);
// Update live ranges per DOM spec replaceData steps
const length = self.getLength();
const effective_count: u32 = @intCast(@min(count, length - offset));
page.updateRangesForCharacterDataReplace(self.asNode(), @intCast(offset), effective_count, @intCast(utf16Len(data)));
const old_value = self._data;
const existing = old_value.str();
self._data = try String.concat(page.arena, &.{

View File

@@ -293,7 +293,8 @@ pub fn setTextContent(self: *Node, data: []const u8, page: *Page) !void {
}
return el.replaceChildren(&.{.{ .text = data }}, page);
},
.cdata => |c| c._data = try page.dupeSSO(data),
// Per spec, setting textContent on CharacterData runs replaceData(0, length, value)
.cdata => |c| try c.replaceData(0, c.getLength(), data, page),
.document => {},
.document_type => {},
.document_fragment => |frag| {
@@ -612,7 +613,11 @@ pub fn getNodeValue(self: *const Node) ?String {
pub fn setNodeValue(self: *const Node, value: ?String, page: *Page) !void {
switch (self._type) {
.cdata => |c| try c.setData(if (value) |v| v.str() else null, page),
// Per spec, setting nodeValue on CharacterData runs replaceData(0, length, value)
.cdata => |c| {
const new_value: []const u8 = if (value) |v| v.str() else "";
try c.replaceData(0, c.getLength(), new_value, page);
},
.attribute => |attr| try attr.setValue(value, page),
.element => {},
.document => {},

View File

@@ -322,6 +322,11 @@ pub fn insertNode(self: *Range, node: *Node, page: *Page) !void {
const container = self._proto._start_container;
const offset = self._proto._start_offset;
// Per spec: if range is collapsed, end offset should extend to include
// the inserted node. Capture before insertion since live range updates
// in the insert path will adjust non-collapsed ranges automatically.
const was_collapsed = self._proto.getCollapsed();
if (container.is(Node.CData)) |_| {
// If container is a text node, we need to split it
const parent = container.parentNode() orelse return error.InvalidNodeType;
@@ -351,9 +356,10 @@ pub fn insertNode(self: *Range, node: *Node, page: *Page) !void {
_ = try container.insertBefore(node, ref_child, page);
}
// Update range to be after the inserted node
if (self._proto._start_container == self._proto._end_container) {
self._proto._end_offset += 1;
// Per spec step 11: if range was collapsed, extend end to include inserted node.
// Non-collapsed ranges are already handled by the live range update in the insert path.
if (was_collapsed) {
self._proto._end_offset = self._proto._start_offset + 1;
}
}
@@ -375,9 +381,12 @@ pub fn deleteContents(self: *Range, page: *Page) !void {
);
page.characterDataChange(self._proto._start_container, old_value);
} else {
// Delete child nodes in range
var offset = self._proto._start_offset;
while (offset < self._proto._end_offset) : (offset += 1) {
// 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, page);
}
@@ -717,3 +726,6 @@ const testing = @import("../../testing.zig");
test "WebApi: Range" {
try testing.htmlRunner("range.html", .{});
}
test "WebApi: Range mutations" {
try testing.htmlRunner("range_mutations.html", .{});
}

View File

@@ -43,15 +43,25 @@ pub fn splitText(self: *Text, offset: usize, page: *Page) !*Text {
const new_node = try page.createTextNode(new_data);
const new_text = new_node.as(Text);
const old_data = data[0..byte_offset];
try self._proto.setData(old_data, page);
// If this node has a parent, insert the new node right after this one
const node = self._proto.asNode();
// Per DOM spec splitText: insert first (step 7a), then update ranges (7b-7e),
// then truncate original node (step 8).
if (node.parentNode()) |parent| {
const next_sibling = node.nextSibling();
_ = try parent.insertBefore(new_node, next_sibling, page);
// splitText-specific range updates (steps 7b-7e)
if (parent.getChildIndex(node)) |node_index| {
page.updateRangesForSplitText(node, new_node, @intCast(offset), parent, node_index);
}
}
// Step 8: truncate original node via replaceData(offset, count, "").
// Use replaceData instead of setData so live range updates fire
// (matters for detached text nodes where steps 7b-7e were skipped).
const length = self._proto.getLength();
try self._proto.replaceData(offset, length - offset, "", page);
return new_text;
}