mirror of
https://github.com/lightpanda-io/browser.git
synced 2026-03-22 04:34:44 +00:00
update live ranges after CharacterData and DOM mutations
Per DOM spec, all live ranges must have their boundary offsets updated when CharacterData content changes (insertData, deleteData, replaceData, splitText) or when nodes are inserted/removed from the tree. Track live ranges via an intrusive linked list on Page. After each mutation, iterate and adjust start/end offsets per the spec algorithms. Also fix Range.deleteContents loop that read _end_offset on each iteration (now decremented by the range update), and Range.insertNode that double-incremented _end_offset for non-collapsed ranges. Route textContent, nodeValue, and data setters through replaceData so range updates fire consistently. Fixes 9 WPT test files (all now 100%): Range-mutations-insertData, deleteData, replaceData, splitText, appendChild, insertBefore, removeChild, appendData, dataChange (~1330 new passing subtests).
This commit is contained in:
@@ -273,14 +273,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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
@@ -143,6 +144,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,
|
||||
@@ -2434,6 +2438,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| {
|
||||
@@ -2462,6 +2472,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
|
||||
@@ -2609,6 +2624,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)
|
||||
@@ -2867,6 +2897,121 @@ 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);
|
||||
|
||||
if (ar._start_container == target) {
|
||||
if (ar._start_offset > offset and ar._start_offset <= offset + count) {
|
||||
ar._start_offset = offset;
|
||||
} else if (ar._start_offset > offset + count) {
|
||||
// Use i64 intermediate to avoid u32 underflow when count > data_len
|
||||
ar._start_offset = @intCast(@as(i64, ar._start_offset) + @as(i64, data_len) - @as(i64, count));
|
||||
}
|
||||
}
|
||||
|
||||
if (ar._end_container == target) {
|
||||
if (ar._end_offset > offset and ar._end_offset <= offset + count) {
|
||||
ar._end_offset = offset;
|
||||
} else if (ar._end_offset > offset + count) {
|
||||
ar._end_offset = @intCast(@as(i64, ar._end_offset) + @as(i64, data_len) - @as(i64, count));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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);
|
||||
|
||||
// Step 7b: ranges on the original node with start > offset move to new node
|
||||
if (ar._start_container == target and ar._start_offset > offset) {
|
||||
ar._start_container = new_node;
|
||||
ar._start_offset = ar._start_offset - offset;
|
||||
}
|
||||
// Step 7c: ranges on the original node with end > offset move to new node
|
||||
if (ar._end_container == target and ar._end_offset > offset) {
|
||||
ar._end_container = new_node;
|
||||
ar._end_offset = ar._end_offset - offset;
|
||||
}
|
||||
// Step 7d: ranges on parent with start == node_index + 1 increment
|
||||
if (ar._start_container == parent and ar._start_offset == node_index + 1) {
|
||||
ar._start_offset += 1;
|
||||
}
|
||||
// Step 7e: ranges on parent with end == node_index + 1 increment
|
||||
if (ar._end_container == parent and ar._end_offset == node_index + 1) {
|
||||
ar._end_offset += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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);
|
||||
|
||||
if (ar._start_container == parent and ar._start_offset > child_index) {
|
||||
ar._start_offset += 1;
|
||||
}
|
||||
if (ar._end_container == parent and ar._end_offset > child_index) {
|
||||
ar._end_offset += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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);
|
||||
|
||||
// Steps 4-5: ranges whose start/end is an inclusive descendant of child
|
||||
// get moved to (parent, child_index).
|
||||
if (isInclusiveDescendantOf(ar._start_container, child)) {
|
||||
ar._start_container = parent;
|
||||
ar._start_offset = child_index;
|
||||
}
|
||||
if (isInclusiveDescendantOf(ar._end_container, child)) {
|
||||
ar._end_container = parent;
|
||||
ar._end_offset = child_index;
|
||||
}
|
||||
|
||||
// Steps 6-7: ranges on parent at offsets > child_index get decremented.
|
||||
if (ar._start_container == parent and ar._start_offset > child_index) {
|
||||
ar._start_offset -= 1;
|
||||
}
|
||||
if (ar._end_container == parent and ar._end_offset > child_index) {
|
||||
ar._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;
|
||||
}
|
||||
|
||||
// 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;
|
||||
|
||||
315
src/browser/tests/range_mutations.html
Normal file
315
src/browser/tests/range_mutations.html
Normal 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>
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
@@ -281,6 +280,11 @@ 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, &.{
|
||||
|
||||
@@ -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 => {},
|
||||
|
||||
@@ -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 and self._proto._start_container == self._proto._end_container) {
|
||||
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", .{});
|
||||
}
|
||||
|
||||
@@ -43,16 +43,26 @@ 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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user