mirror of
https://github.com/lightpanda-io/browser.git
synced 2026-03-22 04:34:44 +00:00
Merge pull request #1774 from egrs/range-chardata-mutations
update live ranges after CharacterData and DOM mutations
This commit is contained in:
@@ -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 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");
|
||||||
@@ -142,6 +143,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,
|
||||||
@@ -2394,6 +2398,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| {
|
||||||
@@ -2422,6 +2432,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
|
||||||
@@ -2569,6 +2584,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)
|
||||||
@@ -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 = '')
|
// 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,
|
||||||
@@ -215,6 +218,91 @@ fn isInclusiveAncestorOf(potential_ancestor: *Node, node: *Node) bool {
|
|||||||
return isAncestorOf(potential_ancestor, node);
|
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 JsApi = struct {
|
||||||
pub const bridge = js.Bridge(AbstractRange);
|
pub const bridge = js.Bridge(AbstractRange);
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -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 {
|
pub fn appendData(self: *CData, data: []const u8, page: *Page) !void {
|
||||||
const old_value = self._data;
|
// Per DOM spec, appendData(data) is replaceData(length, 0, data).
|
||||||
self._data = try String.concat(page.arena, &.{ self._data.str(), data });
|
const length = self.getLength();
|
||||||
page.characterDataChange(self.asNode(), old_value);
|
try self.replaceData(length, 0, data, page);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn deleteData(self: *CData, offset: usize, count: usize, page: *Page) !void {
|
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) {
|
||||||
|
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