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 chain = try PrototypeChain(&.{ AbstractRange, @TypeOf(child) }).allocate(allocator);
|
||||||
|
|
||||||
const doc = page.document.asNode();
|
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)),
|
._type = unionInit(AbstractRange.Type, chain.get(1)),
|
||||||
._end_offset = 0,
|
._end_offset = 0,
|
||||||
._start_offset = 0,
|
._start_offset = 0,
|
||||||
._end_container = doc,
|
._end_container = doc,
|
||||||
._start_container = doc,
|
._start_container = doc,
|
||||||
});
|
};
|
||||||
chain.setLeaf(1, child);
|
chain.setLeaf(1, child);
|
||||||
|
page._live_ranges.append(&abstract_range._range_link);
|
||||||
return chain.get(1);
|
return chain.get(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ const Performance = @import("webapi/Performance.zig");
|
|||||||
const Screen = @import("webapi/Screen.zig");
|
const Screen = @import("webapi/Screen.zig");
|
||||||
const VisualViewport = @import("webapi/VisualViewport.zig");
|
const VisualViewport = @import("webapi/VisualViewport.zig");
|
||||||
const PerformanceObserver = @import("webapi/PerformanceObserver.zig");
|
const PerformanceObserver = @import("webapi/PerformanceObserver.zig");
|
||||||
|
const AbstractRange = @import("webapi/AbstractRange.zig");
|
||||||
const MutationObserver = @import("webapi/MutationObserver.zig");
|
const MutationObserver = @import("webapi/MutationObserver.zig");
|
||||||
const IntersectionObserver = @import("webapi/IntersectionObserver.zig");
|
const IntersectionObserver = @import("webapi/IntersectionObserver.zig");
|
||||||
const CustomElementDefinition = @import("webapi/CustomElementDefinition.zig");
|
const CustomElementDefinition = @import("webapi/CustomElementDefinition.zig");
|
||||||
@@ -143,6 +144,9 @@ _to_load: std.ArrayList(*Element.Html) = .{},
|
|||||||
|
|
||||||
_script_manager: ScriptManager,
|
_script_manager: ScriptManager,
|
||||||
|
|
||||||
|
// List of active live ranges (for mutation updates per DOM spec)
|
||||||
|
_live_ranges: std.DoublyLinkedList = .{},
|
||||||
|
|
||||||
// List of active MutationObservers
|
// List of active MutationObservers
|
||||||
_mutation_observers: std.DoublyLinkedList = .{},
|
_mutation_observers: std.DoublyLinkedList = .{},
|
||||||
_mutation_delivery_scheduled: bool = false,
|
_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 previous_sibling = child.previousSibling();
|
||||||
const next_sibling = child.nextSibling();
|
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.?;
|
const children = parent._children.?;
|
||||||
switch (children.*) {
|
switch (children.*) {
|
||||||
.one => |n| {
|
.one => |n| {
|
||||||
@@ -2462,6 +2472,11 @@ pub fn removeNode(self: *Page, parent: *Node, child: *Node, opts: RemoveNodeOpts
|
|||||||
child._parent = null;
|
child._parent = null;
|
||||||
child._child_link = .{};
|
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
|
// Handle slot assignment removal before mutation observers
|
||||||
if (child.is(Element)) |el| {
|
if (child.is(Element)) |el| {
|
||||||
// Check if the parent was a shadow host
|
// 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;
|
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:
|
// Tri-state behavior for mutations:
|
||||||
// 1. from_parser=true, parse_mode=document -> no mutations (initial document parse)
|
// 1. from_parser=true, parse_mode=document -> no mutations (initial document parse)
|
||||||
// 2. from_parser=true, parse_mode=fragment -> mutations (innerHTML additions)
|
// 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 = '')
|
// TODO: optimize and cleanup, this is called a lot (e.g., innerHTML = '')
|
||||||
pub fn parseHtmlAsChildren(self: *Page, node: *Node, html: []const u8) !void {
|
pub fn parseHtmlAsChildren(self: *Page, node: *Node, html: []const u8) !void {
|
||||||
const previous_parse_mode = self._parse_mode;
|
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,
|
_end_container: *Node,
|
||||||
_start_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) {
|
pub const Type = union(enum) {
|
||||||
range: *Range,
|
range: *Range,
|
||||||
// TODO: static_range: *StaticRange,
|
// TODO: static_range: *StaticRange,
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ _data: String = .empty,
|
|||||||
/// Count UTF-16 code units in a UTF-8 string.
|
/// 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),
|
/// 4-byte UTF-8 sequences (codepoints >= U+10000) produce 2 UTF-16 code units (surrogate pair),
|
||||||
/// everything else produces 1.
|
/// everything else produces 1.
|
||||||
fn utf16Len(data: []const u8) usize {
|
pub fn utf16Len(data: []const u8) usize {
|
||||||
var count: usize = 0;
|
var count: usize = 0;
|
||||||
var i: usize = 0;
|
var i: usize = 0;
|
||||||
while (i < data.len) {
|
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.
|
/// JS bridge wrapper for `data` setter.
|
||||||
/// Handles [LegacyNullToEmptyString]: null → setData(null) → "".
|
/// Per spec, setting .data runs replaceData(0, this.length, value),
|
||||||
/// Passes everything else (including undefined) through V8 toString,
|
/// which includes live range updates.
|
||||||
/// so `undefined` becomes the string "undefined" per spec.
|
/// Handles [LegacyNullToEmptyString]: null → "" per spec.
|
||||||
pub fn _setData(self: *CData, value: js.Value, page: *Page) !void {
|
pub fn _setData(self: *CData, value: js.Value, page: *Page) !void {
|
||||||
if (value.isNull()) {
|
const new_value: []const u8 = if (value.isNull()) "" else try value.toZig([]const u8);
|
||||||
return self.setData(null, page);
|
const length = self.getLength();
|
||||||
}
|
try self.replaceData(0, length, new_value, page);
|
||||||
return self.setData(try value.toZig([]const u8), page);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn format(self: *const CData, writer: *std.io.Writer) !void {
|
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 end_utf16 = std.math.add(usize, offset, count) catch std.math.maxInt(usize);
|
||||||
const range = try utf16RangeToUtf8(self._data.str(), offset, end_utf16);
|
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_data = self._data;
|
||||||
const old_value = old_data.str();
|
const old_value = old_data.str();
|
||||||
if (range.start == 0) {
|
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 {
|
pub fn insertData(self: *CData, offset: usize, data: []const u8, page: *Page) !void {
|
||||||
const byte_offset = try utf16OffsetToUtf8(self._data.str(), offset);
|
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 old_value = self._data;
|
||||||
const existing = old_value.str();
|
const existing = old_value.str();
|
||||||
self._data = try String.concat(page.arena, &.{
|
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 {
|
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 end_utf16 = std.math.add(usize, offset, count) catch std.math.maxInt(usize);
|
||||||
const range = try utf16RangeToUtf8(self._data.str(), offset, end_utf16);
|
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 old_value = self._data;
|
||||||
const existing = old_value.str();
|
const existing = old_value.str();
|
||||||
self._data = try String.concat(page.arena, &.{
|
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);
|
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 => {},
|
||||||
.document_type => {},
|
.document_type => {},
|
||||||
.document_fragment => |frag| {
|
.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 {
|
pub fn setNodeValue(self: *const Node, value: ?String, page: *Page) !void {
|
||||||
switch (self._type) {
|
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),
|
.attribute => |attr| try attr.setValue(value, page),
|
||||||
.element => {},
|
.element => {},
|
||||||
.document => {},
|
.document => {},
|
||||||
|
|||||||
@@ -322,6 +322,11 @@ pub fn insertNode(self: *Range, node: *Node, page: *Page) !void {
|
|||||||
const container = self._proto._start_container;
|
const container = self._proto._start_container;
|
||||||
const offset = self._proto._start_offset;
|
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(Node.CData)) |_| {
|
||||||
// If container is a text node, we need to split it
|
// If container is a text node, we need to split it
|
||||||
const parent = container.parentNode() orelse return error.InvalidNodeType;
|
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);
|
_ = try container.insertBefore(node, ref_child, page);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update range to be after the inserted node
|
// Per spec step 11: if range was collapsed, extend end to include inserted node.
|
||||||
if (self._proto._start_container == self._proto._end_container) {
|
// Non-collapsed ranges are already handled by the live range update in the insert path.
|
||||||
self._proto._end_offset += 1;
|
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);
|
page.characterDataChange(self._proto._start_container, old_value);
|
||||||
} else {
|
} else {
|
||||||
// Delete child nodes in range
|
// Delete child nodes in range.
|
||||||
var offset = self._proto._start_offset;
|
// Capture count before the loop: removeChild triggers live range
|
||||||
while (offset < self._proto._end_offset) : (offset += 1) {
|
// 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| {
|
if (self._proto._start_container.getChildAt(self._proto._start_offset)) |child| {
|
||||||
_ = try self._proto._start_container.removeChild(child, page);
|
_ = try self._proto._start_container.removeChild(child, page);
|
||||||
}
|
}
|
||||||
@@ -717,3 +726,6 @@ const testing = @import("../../testing.zig");
|
|||||||
test "WebApi: Range" {
|
test "WebApi: Range" {
|
||||||
try testing.htmlRunner("range.html", .{});
|
try testing.htmlRunner("range.html", .{});
|
||||||
}
|
}
|
||||||
|
test "WebApi: Range mutations" {
|
||||||
|
try testing.htmlRunner("range_mutations.html", .{});
|
||||||
|
}
|
||||||
|
|||||||
@@ -43,15 +43,25 @@ pub fn splitText(self: *Text, offset: usize, page: *Page) !*Text {
|
|||||||
const new_node = try page.createTextNode(new_data);
|
const new_node = try page.createTextNode(new_data);
|
||||||
const new_text = new_node.as(Text);
|
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();
|
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| {
|
if (node.parentNode()) |parent| {
|
||||||
const next_sibling = node.nextSibling();
|
const next_sibling = node.nextSibling();
|
||||||
_ = try parent.insertBefore(new_node, next_sibling, page);
|
_ = 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;
|
return new_text;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user