From c6de444d0bf0135a851595accf84fceb543dacd0 Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Tue, 17 Feb 2026 11:50:30 -0800 Subject: [PATCH 1/8] add support for Selection.modify --- src/browser/webapi/Selection.zig | 199 ++++++++++++++++++++++++++++--- 1 file changed, 180 insertions(+), 19 deletions(-) diff --git a/src/browser/webapi/Selection.zig b/src/browser/webapi/Selection.zig index 88da6d56..99b43464 100644 --- a/src/browser/webapi/Selection.zig +++ b/src/browser/webapi/Selection.zig @@ -291,16 +291,9 @@ const ModifyDirection = enum { const ModifyGranularity = enum { character, word, - line, - paragraph, - lineboundary, - // Firefox doesn't implement: - // - sentence - // - paragraph - // - sentenceboundary - // - paragraphboundary - // - documentboundary - // so we won't either for now. + // The rest are either: + // 1. Layout dependent. + // 2. Not widely supported across browsers. pub fn fromString(str: []const u8) ?ModifyGranularity { return std.meta.stringToEnum(ModifyGranularity, str); @@ -312,18 +305,186 @@ pub fn modify( alter_str: []const u8, direction_str: []const u8, granularity_str: []const u8, + page: *Page, ) !void { - const alter = ModifyAlter.fromString(alter_str) orelse return error.InvalidParams; - const direction = ModifyDirection.fromString(direction_str) orelse return error.InvalidParams; - const granularity = ModifyGranularity.fromString(granularity_str) orelse return error.InvalidParams; + const alter = ModifyAlter.fromString(alter_str) orelse return; + const direction = ModifyDirection.fromString(direction_str) orelse return; + const granularity = ModifyGranularity.fromString(granularity_str) orelse return; - _ = self._range orelse return; + const range = self._range orelse return; - log.warn(.not_implemented, "Selection.modify", .{ - .alter = alter, - .direction = direction, - .granularity = granularity, - }); + const is_forward = switch (direction) { + .forward, .right => true, + .backward, .left => false, + }; + + switch (granularity) { + .character => try self.modifyByCharacter(alter, is_forward, range, page), + .word => try self.modifyByWord(alter, is_forward, range, page), + } +} + +fn isTextNode(node: *const Node) bool { + return switch (node._type) { + .cdata => |cd| cd._type == .text, + else => false, + }; +} + +fn nextTextNode(node: *Node) ?*Node { + var current = node; + + while (true) { + if (current.firstChild()) |child| { + current = child; + } else if (current.nextSibling()) |sib| { + current = sib; + } else { + while (true) { + const parent = current.parentNode() orelse return null; + if (parent.nextSibling()) |uncle| { + current = uncle; + break; + } + current = parent; + } + } + + if (isTextNode(current)) return current; + } +} + +fn prevTextNode(node: *Node) ?*Node { + var current = node; + + while (true) { + if (current.previousSibling()) |sib| { + current = sib; + while (current.lastChild()) |child| { + current = child; + } + } else { + current = current.parentNode() orelse return null; + } + + if (isTextNode(current)) return current; + } +} + +fn modifyByCharacter(self: *Selection, alter: ModifyAlter, forward: bool, range: *Range, page: *Page) !void { + const abstract = range.asAbstractRange(); + + const focus_node = switch (self._direction) { + .forward, .none => abstract.getStartContainer(), + .backward => abstract.getEndContainer(), + }; + + const focus_offset = switch (self._direction) { + .forward, .none => abstract.getStartOffset(), + .backward => abstract.getEndOffset(), + }; + + var new_node = focus_node; + var new_offset = focus_offset; + + if (forward) { + const len = focus_node.getLength(); + if (focus_offset < len) { + new_offset += 1; + } else if (nextTextNode(focus_node)) |next| { + new_node = next; + new_offset = 0; + } + } else { + if (focus_offset > 0) { + new_offset -= 1; + } else if (prevTextNode(focus_node)) |prev| { + new_node = prev; + new_offset = prev.getLength() - 1; + } + } + + switch (alter) { + .move => { + const new_range = try Range.init(page); + try new_range.setStart(new_node, new_offset); + try new_range.setEnd(new_node, new_offset); + self._range = new_range; + self._direction = .none; + try dispatchSelectionChangeEvent(page); + }, + .extend => try self.extend(new_node, new_offset, page), + } +} + +fn isWordChar(c: u8) bool { + return std.ascii.isAlphanumeric(c) or c == '_'; +} + +fn nextWordEnd(text: []const u8, offset: u32) u32 { + var i = offset; + // consumes whitespace till next word + while (i < text.len and !isWordChar(text[i])) : (i += 1) {} + // consumes next word + while (i < text.len and isWordChar(text[i])) : (i += 1) {} + return i; +} + +fn prevWordStart(text: []const u8, offset: u32) u32 { + var i = offset; + if (i > 0) i -= 1; + // consumes the white space + while (i > 0 and !isWordChar(text[i])) : (i -= 1) {} + // consumes the last word + while (i > 0 and isWordChar(text[i - 1])) : (i -= 1) {} + return i; +} + +fn modifyByWord(self: *Selection, alter: ModifyAlter, forward: bool, range: *Range, page: *Page) !void { + const abstract = range.asAbstractRange(); + + const focus_node = switch (self._direction) { + .forward, .none => abstract.getStartContainer(), + .backward => abstract.getEndContainer(), + }; + + const focus_offset = switch (self._direction) { + .forward, .none => abstract.getStartOffset(), + .backward => abstract.getEndOffset(), + }; + + var new_node = focus_node; + var new_offset = focus_offset; + + if (forward) { + const i = nextWordEnd(new_node.getData(), new_offset); + if (i > new_offset) { + new_offset = i; + } else if (nextTextNode(focus_node)) |next| { + new_node = next; + new_offset = nextWordEnd(next.getData(), 0); + } + } else { + const i = prevWordStart(new_node.getData(), new_offset); + if (i < new_offset) { + new_offset = i; + } else if (prevTextNode(focus_node)) |prev| { + new_node = prev; + new_offset = prevWordStart(prev.getData(), @intCast(prev.getData().len)); + } + } + + switch (alter) { + .move => { + const new_range = try Range.init(page); + try new_range.setStart(new_node, new_offset); + try new_range.setEnd(new_node, new_offset); + self._range = new_range; + self._direction = .none; + try dispatchSelectionChangeEvent(page); + }, + .extend => try self.extend(new_node, new_offset, page), + } } pub fn selectAllChildren(self: *Selection, parent: *Node, page: *Page) !void { From e77e4acea964791a77e71ce608a507b127aa137b Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Tue, 17 Feb 2026 11:50:38 -0800 Subject: [PATCH 2/8] add tests for Selection.modify --- src/browser/tests/selection.html | 98 +++++++++++++++++++++++++++++--- 1 file changed, 90 insertions(+), 8 deletions(-) diff --git a/src/browser/tests/selection.html b/src/browser/tests/selection.html index 51319ba5..1adac45e 100644 --- a/src/browser/tests/selection.html +++ b/src/browser/tests/selection.html @@ -546,14 +546,14 @@ { const sel = window.getSelection(); sel.removeAllRanges(); - let eventCount = 0; let lastEvent = null; - document.addEventListener('selectionchange', (e) => { + const listener = (e) => { eventCount++; lastEvent = e; - }); + }; + document.addEventListener('selectionchange', listener); const p1 = document.getElementById("p1"); const textNode = p1.firstChild; @@ -563,27 +563,25 @@ sel.extend(textNode, 10); sel.collapseToStart(); sel.collapseToEnd(); - sel.removeAllRanges(); const range = document.createRange(); range.setStart(textNode, 4); range.setEnd(textNode, 15); sel.addRange(range); - sel.removeRange(range); - const newRange = document.createRange(); newRange.selectNodeContents(p1); sel.addRange(newRange); sel.removeAllRanges(); - sel.selectAllChildren(nested); sel.setBaseAndExtent(textNode, 4, textNode, 15); - sel.collapse(textNode, 5); sel.extend(textNode, 10); sel.deleteFromDocument(); + document.removeEventListener('selectionchange', listener); + textNode.textContent = "The quick brown fox"; + testing.eventually(() => { testing.expectEqual(14, eventCount); testing.expectEqual('selectionchange', lastEvent.type); @@ -593,3 +591,87 @@ }); } + + + + + + + + From f8f99f38783fee0d1c14afdba63c93b8183bc18d Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Tue, 17 Feb 2026 11:58:13 -0800 Subject: [PATCH 3/8] pass selection/modify.tentative.html --- src/browser/webapi/Selection.zig | 51 ++++++++++++++++++++++++-------- 1 file changed, 39 insertions(+), 12 deletions(-) diff --git a/src/browser/webapi/Selection.zig b/src/browser/webapi/Selection.zig index 99b43464..512e18fe 100644 --- a/src/browser/webapi/Selection.zig +++ b/src/browser/webapi/Selection.zig @@ -387,20 +387,47 @@ fn modifyByCharacter(self: *Selection, alter: ModifyAlter, forward: bool, range: var new_node = focus_node; var new_offset = focus_offset; - if (forward) { - const len = focus_node.getLength(); - if (focus_offset < len) { - new_offset += 1; - } else if (nextTextNode(focus_node)) |next| { - new_node = next; - new_offset = 0; + if (!isTextNode(focus_node)) { + if (forward) { + if (focus_node.getChildAt(focus_offset)) |child| { + if (isTextNode(child)) { + new_node = child; + new_offset = 0; + } else if (nextTextNode(child)) |t| { + new_node = t; + new_offset = 0; + } + } + } else { + var idx = focus_offset; + while (idx > 0) { + idx -= 1; + const child = focus_node.getChildAt(idx) orelse break; + var bottom = child; + while (bottom.lastChild()) |c| bottom = c; + if (isTextNode(bottom)) { + new_node = bottom; + new_offset = bottom.getLength(); + break; + } + } } } else { - if (focus_offset > 0) { - new_offset -= 1; - } else if (prevTextNode(focus_node)) |prev| { - new_node = prev; - new_offset = prev.getLength() - 1; + if (forward) { + const len = focus_node.getLength(); + if (focus_offset < len) { + new_offset += 1; + } else if (nextTextNode(focus_node)) |next| { + new_node = next; + new_offset = 0; + } + } else { + if (focus_offset > 0) { + new_offset -= 1; + } else if (prevTextNode(focus_node)) |prev| { + new_node = prev; + new_offset = prev.getLength(); + } } } From 3822e3f8d9f6319204198a38ca2400f0f4d0d3f8 Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Tue, 17 Feb 2026 12:02:34 -0800 Subject: [PATCH 4/8] pass selection/modify-extend-word-trailing-inline-block.tentative.html --- src/browser/webapi/Selection.zig | 78 ++++++++++++++++++++++---------- 1 file changed, 55 insertions(+), 23 deletions(-) diff --git a/src/browser/webapi/Selection.zig b/src/browser/webapi/Selection.zig index 512e18fe..e896ef19 100644 --- a/src/browser/webapi/Selection.zig +++ b/src/browser/webapi/Selection.zig @@ -375,13 +375,12 @@ fn modifyByCharacter(self: *Selection, alter: ModifyAlter, forward: bool, range: const abstract = range.asAbstractRange(); const focus_node = switch (self._direction) { - .forward, .none => abstract.getStartContainer(), - .backward => abstract.getEndContainer(), + .backward => abstract.getStartContainer(), + .forward, .none => abstract.getEndContainer(), }; - const focus_offset = switch (self._direction) { - .forward, .none => abstract.getStartOffset(), - .backward => abstract.getEndOffset(), + .backward => abstract.getStartOffset(), + .forward, .none => abstract.getEndOffset(), }; var new_node = focus_node; @@ -471,36 +470,69 @@ fn modifyByWord(self: *Selection, alter: ModifyAlter, forward: bool, range: *Ran const abstract = range.asAbstractRange(); const focus_node = switch (self._direction) { - .forward, .none => abstract.getStartContainer(), - .backward => abstract.getEndContainer(), + .backward => abstract.getStartContainer(), + .forward, .none => abstract.getEndContainer(), }; - const focus_offset = switch (self._direction) { - .forward, .none => abstract.getStartOffset(), - .backward => abstract.getEndOffset(), + .backward => abstract.getStartOffset(), + .forward, .none => abstract.getEndOffset(), }; var new_node = focus_node; var new_offset = focus_offset; - if (forward) { - const i = nextWordEnd(new_node.getData(), new_offset); - if (i > new_offset) { - new_offset = i; - } else if (nextTextNode(focus_node)) |next| { - new_node = next; - new_offset = nextWordEnd(next.getData(), 0); + if (!isTextNode(focus_node)) { + if (forward) { + const child = focus_node.getChildAt(focus_offset) orelse { + if (nextTextNode(focus_node)) |next| { + new_node = next; + new_offset = nextWordEnd(next.getData(), 0); + } + return self.applyWordModify(alter, new_node, new_offset, page); + }; + const t = if (isTextNode(child)) child else nextTextNode(child) orelse { + return self.applyWordModify(alter, new_node, new_offset, page); + }; + new_node = t; + new_offset = nextWordEnd(t.getData(), 0); + } else { + var idx = focus_offset; + while (idx > 0) { + idx -= 1; + const child = focus_node.getChildAt(idx) orelse break; + var bottom = child; + while (bottom.lastChild()) |c| bottom = c; + if (isTextNode(bottom)) { + new_node = bottom; + new_offset = prevWordStart(bottom.getData(), bottom.getLength()); + break; + } + } } } else { - const i = prevWordStart(new_node.getData(), new_offset); - if (i < new_offset) { - new_offset = i; - } else if (prevTextNode(focus_node)) |prev| { - new_node = prev; - new_offset = prevWordStart(prev.getData(), @intCast(prev.getData().len)); + if (forward) { + const i = nextWordEnd(new_node.getData(), new_offset); + if (i > new_offset) { + new_offset = i; + } else if (nextTextNode(focus_node)) |next| { + new_node = next; + new_offset = nextWordEnd(next.getData(), 0); + } + } else { + const i = prevWordStart(new_node.getData(), new_offset); + if (i < new_offset) { + new_offset = i; + } else if (prevTextNode(focus_node)) |prev| { + new_node = prev; + new_offset = prevWordStart(prev.getData(), @intCast(prev.getData().len)); + } } } + try self.applyWordModify(alter, new_node, new_offset, page); +} + +fn applyWordModify(self: *Selection, alter: ModifyAlter, new_node: *Node, new_offset: u32, page: *Page) !void { switch (alter) { .move => { const new_range = try Range.init(page); From c4391ff058e8341c6d4f77acdec60dcfe4d800f6 Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Tue, 17 Feb 2026 12:11:29 -0800 Subject: [PATCH 5/8] refactor modifyBy implementations --- src/browser/webapi/Selection.zig | 116 ++++++++++++++++--------------- 1 file changed, 61 insertions(+), 55 deletions(-) diff --git a/src/browser/webapi/Selection.zig b/src/browser/webapi/Selection.zig index e896ef19..7309d772 100644 --- a/src/browser/webapi/Selection.zig +++ b/src/browser/webapi/Selection.zig @@ -386,32 +386,7 @@ fn modifyByCharacter(self: *Selection, alter: ModifyAlter, forward: bool, range: var new_node = focus_node; var new_offset = focus_offset; - if (!isTextNode(focus_node)) { - if (forward) { - if (focus_node.getChildAt(focus_offset)) |child| { - if (isTextNode(child)) { - new_node = child; - new_offset = 0; - } else if (nextTextNode(child)) |t| { - new_node = t; - new_offset = 0; - } - } - } else { - var idx = focus_offset; - while (idx > 0) { - idx -= 1; - const child = focus_node.getChildAt(idx) orelse break; - var bottom = child; - while (bottom.lastChild()) |c| bottom = c; - if (isTextNode(bottom)) { - new_node = bottom; - new_offset = bottom.getLength(); - break; - } - } - } - } else { + if (isTextNode(focus_node)) { if (forward) { const len = focus_node.getLength(); if (focus_offset < len) { @@ -428,6 +403,32 @@ fn modifyByCharacter(self: *Selection, alter: ModifyAlter, forward: bool, range: new_offset = prev.getLength(); } } + } else { + if (forward) { + if (focus_node.getChildAt(focus_offset)) |child| { + if (isTextNode(child)) { + new_node = child; + new_offset = 0; + } else if (nextTextNode(child)) |t| { + new_node = t; + new_offset = 0; + } + } + } else { + var idx = focus_offset; + + while (idx > 0) { + idx -= 1; + const child = focus_node.getChildAt(idx) orelse break; + var bottom = child; + while (bottom.lastChild()) |c| bottom = c; + if (isTextNode(bottom)) { + new_node = bottom; + new_offset = bottom.getLength(); + break; + } + } + } } switch (alter) { @@ -481,35 +482,7 @@ fn modifyByWord(self: *Selection, alter: ModifyAlter, forward: bool, range: *Ran var new_node = focus_node; var new_offset = focus_offset; - if (!isTextNode(focus_node)) { - if (forward) { - const child = focus_node.getChildAt(focus_offset) orelse { - if (nextTextNode(focus_node)) |next| { - new_node = next; - new_offset = nextWordEnd(next.getData(), 0); - } - return self.applyWordModify(alter, new_node, new_offset, page); - }; - const t = if (isTextNode(child)) child else nextTextNode(child) orelse { - return self.applyWordModify(alter, new_node, new_offset, page); - }; - new_node = t; - new_offset = nextWordEnd(t.getData(), 0); - } else { - var idx = focus_offset; - while (idx > 0) { - idx -= 1; - const child = focus_node.getChildAt(idx) orelse break; - var bottom = child; - while (bottom.lastChild()) |c| bottom = c; - if (isTextNode(bottom)) { - new_node = bottom; - new_offset = prevWordStart(bottom.getData(), bottom.getLength()); - break; - } - } - } - } else { + if (isTextNode(focus_node)) { if (forward) { const i = nextWordEnd(new_node.getData(), new_offset); if (i > new_offset) { @@ -527,6 +500,39 @@ fn modifyByWord(self: *Selection, alter: ModifyAlter, forward: bool, range: *Ran new_offset = prevWordStart(prev.getData(), @intCast(prev.getData().len)); } } + } else { + // Search and apply rules on the next Text Node. + // This is either next (on forward) or previous (on backward). + + if (forward) { + const child = focus_node.getChildAt(focus_offset) orelse { + if (nextTextNode(focus_node)) |next| { + new_node = next; + new_offset = nextWordEnd(next.getData(), 0); + } + return self.applyWordModify(alter, new_node, new_offset, page); + }; + + const t = if (isTextNode(child)) child else nextTextNode(child) orelse { + return self.applyWordModify(alter, new_node, new_offset, page); + }; + + new_node = t; + new_offset = nextWordEnd(t.getData(), 0); + } else { + var idx = focus_offset; + while (idx > 0) { + idx -= 1; + const child = focus_node.getChildAt(idx) orelse break; + var bottom = child; + while (bottom.lastChild()) |c| bottom = c; + if (isTextNode(bottom)) { + new_node = bottom; + new_offset = prevWordStart(bottom.getData(), bottom.getLength()); + break; + } + } + } } try self.applyWordModify(alter, new_node, new_offset, page); From 90138ed57474c8728470c19a8def1b18d2fbd844 Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Wed, 18 Feb 2026 06:07:06 -0800 Subject: [PATCH 6/8] use applyModify generally --- src/browser/webapi/Selection.zig | 20 +++++--------------- 1 file changed, 5 insertions(+), 15 deletions(-) diff --git a/src/browser/webapi/Selection.zig b/src/browser/webapi/Selection.zig index 7309d772..e7012eda 100644 --- a/src/browser/webapi/Selection.zig +++ b/src/browser/webapi/Selection.zig @@ -431,17 +431,7 @@ fn modifyByCharacter(self: *Selection, alter: ModifyAlter, forward: bool, range: } } - switch (alter) { - .move => { - const new_range = try Range.init(page); - try new_range.setStart(new_node, new_offset); - try new_range.setEnd(new_node, new_offset); - self._range = new_range; - self._direction = .none; - try dispatchSelectionChangeEvent(page); - }, - .extend => try self.extend(new_node, new_offset, page), - } + try self.applyModify(alter, new_node, new_offset, page); } fn isWordChar(c: u8) bool { @@ -510,11 +500,11 @@ fn modifyByWord(self: *Selection, alter: ModifyAlter, forward: bool, range: *Ran new_node = next; new_offset = nextWordEnd(next.getData(), 0); } - return self.applyWordModify(alter, new_node, new_offset, page); + return self.applyModify(alter, new_node, new_offset, page); }; const t = if (isTextNode(child)) child else nextTextNode(child) orelse { - return self.applyWordModify(alter, new_node, new_offset, page); + return self.applyModify(alter, new_node, new_offset, page); }; new_node = t; @@ -535,10 +525,10 @@ fn modifyByWord(self: *Selection, alter: ModifyAlter, forward: bool, range: *Ran } } - try self.applyWordModify(alter, new_node, new_offset, page); + try self.applyModify(alter, new_node, new_offset, page); } -fn applyWordModify(self: *Selection, alter: ModifyAlter, new_node: *Node, new_offset: u32, page: *Page) !void { +fn applyModify(self: *Selection, alter: ModifyAlter, new_node: *Node, new_offset: u32, page: *Page) !void { switch (alter) { .move => { const new_range = try Range.init(page); From f348d85b11b18097c8374984fe1078c536215b76 Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Wed, 25 Feb 2026 07:21:16 -0800 Subject: [PATCH 7/8] add tests for walking past element on selection modify --- src/browser/tests/selection.html | 62 ++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/src/browser/tests/selection.html b/src/browser/tests/selection.html index 1adac45e..a1e73bff 100644 --- a/src/browser/tests/selection.html +++ b/src/browser/tests/selection.html @@ -675,3 +675,65 @@ testing.expectEqual(0, sel.anchorOffset); } + + + + + + + + From dd15f5e052a6159755f4929bfdf3d173a0cd9033 Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Wed, 25 Feb 2026 07:21:30 -0800 Subject: [PATCH 8/8] fix selection modify on nextTextNodeAfter --- src/browser/webapi/Selection.zig | 31 +++++++++++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/src/browser/webapi/Selection.zig b/src/browser/webapi/Selection.zig index e7012eda..16f54026 100644 --- a/src/browser/webapi/Selection.zig +++ b/src/browser/webapi/Selection.zig @@ -354,6 +354,30 @@ fn nextTextNode(node: *Node) ?*Node { } } +fn nextTextNodeAfter(node: *Node) ?*Node { + var current = node; + while (true) { + if (current.nextSibling()) |sib| { + current = sib; + } else { + while (true) { + const parent = current.parentNode() orelse return null; + if (parent.nextSibling()) |uncle| { + current = uncle; + break; + } + current = parent; + } + } + + var descend = current; + while (true) { + if (isTextNode(descend)) return descend; + descend = descend.firstChild() orelse break; + } + } +} + fn prevTextNode(node: *Node) ?*Node { var current = node; @@ -413,10 +437,13 @@ fn modifyByCharacter(self: *Selection, alter: ModifyAlter, forward: bool, range: new_node = t; new_offset = 0; } + } else if (nextTextNodeAfter(focus_node)) |next| { + new_node = next; + new_offset = 1; } } else { + // backward element-node case var idx = focus_offset; - while (idx > 0) { idx -= 1; const child = focus_node.getChildAt(idx) orelse break; @@ -496,7 +523,7 @@ fn modifyByWord(self: *Selection, alter: ModifyAlter, forward: bool, range: *Ran if (forward) { const child = focus_node.getChildAt(focus_offset) orelse { - if (nextTextNode(focus_node)) |next| { + if (nextTextNodeAfter(focus_node)) |next| { new_node = next; new_offset = nextWordEnd(next.getData(), 0); }