From 0e6e4db08bcba3eaddae869f1c56bb1e239ccf14 Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Wed, 14 Jan 2026 00:36:23 -0800 Subject: [PATCH 01/17] add Selection WebAPI --- src/browser/js/bridge.zig | 1 + src/browser/webapi/Selection.zig | 288 +++++++++++++++++++++++++++++++ 2 files changed, 289 insertions(+) create mode 100644 src/browser/webapi/Selection.zig diff --git a/src/browser/js/bridge.zig b/src/browser/js/bridge.zig index df48512e..3a87ec88 100644 --- a/src/browser/js/bridge.zig +++ b/src/browser/js/bridge.zig @@ -1272,4 +1272,5 @@ pub const JsApis = flattenTypes(&.{ @import("../webapi/canvas/CanvasRenderingContext2D.zig"), @import("../webapi/canvas/WebGLRenderingContext.zig"), @import("../webapi/SubtleCrypto.zig"), + @import("../webapi/Selection.zig"), }); diff --git a/src/browser/webapi/Selection.zig b/src/browser/webapi/Selection.zig new file mode 100644 index 00000000..164a51a2 --- /dev/null +++ b/src/browser/webapi/Selection.zig @@ -0,0 +1,288 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +const std = @import("std"); +const log = @import("../../log.zig"); + +const js = @import("../js/js.zig"); +const Page = @import("../Page.zig"); +const Range = @import("Range.zig"); +const AbstractRange = @import("AbstractRange.zig"); +const Node = @import("Node.zig"); + +const Selection = @This(); + +const SelectionDirection = enum { backward, forward, none }; + +_ranges: std.ArrayList(*Range) = .empty, +_direction: SelectionDirection = .none, + +pub const init: Selection = .{}; + +pub fn getAnchorNode(self: *const Selection) ?*Node { + if (self._ranges.items.len == 0) return null; + + return switch (self._direction) { + .backward => self._ranges.getLast().asAbstractRange().getEndContainer(), + .forward, .none => self._ranges.items[0].asAbstractRange().getStartContainer(), + }; +} + +pub fn getAnchorOffset(self: *const Selection) u32 { + if (self._ranges.items.len == 0) return 0; + + return switch (self._direction) { + .backward => self._ranges.getLast().asAbstractRange().getEndOffset(), + .forward, .none => self._ranges.items[0].asAbstractRange().getStartOffset(), + }; +} + +pub fn getDirection(self: *const Selection) []const u8 { + return @tagName(self._direction); +} + +pub fn getFocusNode(self: *const Selection) ?*Node { + if (self._ranges.items.len == 0) return null; + + return switch (self._direction) { + .backward => self._ranges.items[0].asAbstractRange().getStartContainer(), + .forward, .none => self._ranges.getLast().asAbstractRange().getEndContainer(), + }; +} + +pub fn getFocusOffset(self: *const Selection) u32 { + if (self._ranges.items.len == 0) return 0; + + return switch (self._direction) { + .backward => self._ranges.items[0].asAbstractRange().getStartOffset(), + .forward, .none => self._ranges.getLast().asAbstractRange().getEndOffset(), + }; +} + +pub fn getIsCollapsed(self: *const Selection) bool { + if (self._ranges.items.len == 0) return true; + if (self._ranges.items.len > 1) return false; + + return self._ranges.items[0].asAbstractRange().getCollapsed(); +} + +pub fn getRangeCount(self: *const Selection) u32 { + return @intCast(self._ranges.items.len); +} + +pub fn getType(self: *const Selection) []const u8 { + if (self._ranges.items.len == 0) return "None"; + if (self.getIsCollapsed()) return "Caret"; + return "Range"; +} + +pub fn addRange(self: *Selection, range: *Range, page: *Page) !void { + for (self._ranges.items) |r| { + if (r == range) return; + } + + return try self._ranges.append(page.arena, range); +} + +pub fn removeRange(self: *Selection, range: *Range) void { + for (self._ranges.items, 0..) |r, i| { + if (r == range) { + _ = self._ranges.orderedRemove(i); + return; + } + } +} + +pub fn removeAllRanges(self: *Selection) void { + self._ranges.clearRetainingCapacity(); + self._direction = .none; +} + +pub fn collapseToEnd(self: *Selection, page: *Page) !void { + if (self._ranges.items.len == 0) return; + + const last_range = self._ranges.getLast().asAbstractRange(); + const last_node = last_range.getEndContainer(); + const last_offset = last_range.getEndOffset(); + + const range = try Range.init(page); + try range.setStart(last_node, last_offset); + try range.setEnd(last_node, last_offset); + + self.removeAllRanges(); + try self._ranges.append(page.arena, range); + self._direction = .none; +} + +pub fn collapseToStart(self: *Selection, page: *Page) !void { + if (self._ranges.items.len == 0) return; + + const first_range = self._ranges.items[0].asAbstractRange(); + const first_node = first_range.getStartContainer(); + const first_offset = first_range.getStartOffset(); + + const range = try Range.init(page); + try range.setStart(first_node, first_offset); + try range.setStart(first_node, first_offset); + + self.removeAllRanges(); + try self._ranges.append(page.arena, range); + self._direction = .none; +} + +pub fn containsNode(self: *const Selection, node: *Node, partial: bool) !bool { + for (self._ranges.items) |r| { + if (partial) { + if (r.intersectsNode(node)) { + return true; + } + } else { + const parent = node.parentNode() orelse continue; + const offset = parent.getChildIndex(node) orelse continue; + + const start_in = r.isPointInRange(parent, offset) catch false; + const end_in = r.isPointInRange(parent, offset + 1) catch false; + + if (start_in and end_in) { + return true; + } + } + } + + return false; +} + +pub fn deleteFromDocument(self: *Selection, page: *Page) !void { + if (self._ranges.items.len == 0) return; + + try self._ranges.items[0].deleteContents(page); +} + +pub fn extend(self: *Selection, node: *Node, _offset: ?u32) !void { + if (self._ranges.items.len == 0) { + return error.InvalidState; + } + + const offset = _offset orelse 0; + + if (offset > node.getLength()) { + return error.IndexSizeError; + } + + const range = self._ranges.items[0]; + const old_anchor = switch (self._direction) { + .backward => range.asAbstractRange().getEndContainer(), + .forward, .none => range.asAbstractRange().getStartContainer(), + }; + const old_anchor_offset = switch (self._direction) { + .backward => range.asAbstractRange().getEndOffset(), + .forward, .none => range.asAbstractRange().getStartOffset(), + }; + + const cmp = AbstractRange.compareBoundaryPoints(node, offset, old_anchor, old_anchor_offset); + switch (cmp) { + .before => { + try range.setStart(node, offset); + try range.setEnd(old_anchor, old_anchor_offset); + self._direction = .backward; + }, + else => { + try range.setStart(old_anchor, old_anchor_offset); + try range.setEnd(node, offset); + self._direction = .forward; + }, + } +} + +// TODO: getComposedRanges + +pub fn getRangeAt(self: *Selection, index: u32) !*Range { + if (index >= self.getRangeCount()) { + return error.IndexSizeError; + } + + return self._ranges.items[index]; +} + +// TODO: modify + +// TODO: selectAllChildren + +// TODO: setBaseAndExtent + +pub fn setPosition(self: *Selection, _node: ?*Node, _offset: ?u32, page: *Page) !void { + const node = _node orelse { + self.removeAllRanges(); + return; + }; + + const offset = _offset orelse 0; + + if (offset > node.getLength()) { + return error.IndexSizeError; + } + + const range = try Range.init(page); + try range.setStart(node, offset); + try range.setEnd(node, offset); + + self.removeAllRanges(); + try self._ranges.append(page.arena, range); + self._direction = .none; +} + +pub const JsApi = struct { + pub const bridge = js.Bridge(Selection); + + pub const Meta = struct { + pub const name = "Selection"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + }; + + pub const anchorNode = bridge.accessor(Selection.getAnchorNode, null, .{}); + pub const anchorOffset = bridge.accessor(Selection.getAnchorOffset, null, .{}); + pub const direction = bridge.accessor(Selection.getDirection, null, .{}); + pub const focusNode = bridge.accessor(Selection.getFocusNode, null, .{}); + pub const focusOffset = bridge.accessor(Selection.getFocusOffset, null, .{}); + pub const isCollapsed = bridge.accessor(Selection.getIsCollapsed, null, .{}); + pub const rangeCount = bridge.accessor(Selection.getRangeCount, null, .{}); + pub const @"type" = bridge.accessor(Selection.getType, null, .{}); + + pub const addRange = bridge.function(Selection.addRange, .{}); + pub const collapse = bridge.function(Selection.setPosition, .{ .dom_exception = true }); + pub const collapseToEnd = bridge.function(Selection.collapseToEnd, .{}); + pub const collapseToStart = bridge.function(Selection.collapseToStart, .{}); + pub const containsNode = bridge.function(Selection.containsNode, .{}); + pub const deleteFromDocument = bridge.function(Selection.deleteFromDocument, .{}); + pub const empty = bridge.function(Selection.removeAllRanges, .{}); + pub const extend = bridge.function(Selection.extend, .{ .dom_exception = true }); + // getComposedRanges + pub const getRangeAt = bridge.function(Selection.getRangeAt, .{ .dom_exception = true }); + // modify + pub const removeAllRanges = bridge.function(Selection.removeAllRanges, .{}); + pub const removeRange = bridge.function(Selection.removeRange, .{}); + // selectAllChildren + // setBaseAndExtent + pub const setPosition = bridge.function(Selection.setPosition, .{}); +}; + +const testing = @import("../../testing.zig"); +test "WebApi: Selection" { + try testing.htmlRunner("selection.html", .{}); +} From a6fc5aa34567b6610e9687bae7d9549c300ca559 Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Wed, 14 Jan 2026 00:36:42 -0800 Subject: [PATCH 02/17] add getSelection to Window, Document --- src/browser/webapi/Document.zig | 7 +++++++ src/browser/webapi/Window.zig | 6 ++++++ 2 files changed, 13 insertions(+) diff --git a/src/browser/webapi/Document.zig b/src/browser/webapi/Document.zig index 57db61fd..8c4e030b 100644 --- a/src/browser/webapi/Document.zig +++ b/src/browser/webapi/Document.zig @@ -36,6 +36,7 @@ const DOMTreeWalker = @import("DOMTreeWalker.zig"); const DOMNodeIterator = @import("DOMNodeIterator.zig"); const DOMImplementation = @import("DOMImplementation.zig"); const StyleSheetList = @import("css/StyleSheetList.zig"); +const Selection = @import("Selection.zig"); pub const XMLDocument = @import("XMLDocument.zig"); pub const HTMLDocument = @import("HTMLDocument.zig"); @@ -55,6 +56,7 @@ _style_sheets: ?*StyleSheetList = null, _write_insertion_point: ?*Node = null, _script_created_parser: ?Parser.Streaming = null, _adopted_style_sheets: ?js.Object.Global = null, +_selection: Selection = .init, pub const Type = union(enum) { generic, @@ -276,6 +278,10 @@ pub fn getDocumentElement(self: *Document) ?*Element { return null; } +pub fn getSelection(self: *Document) *Selection { + return &self._selection; +} + pub fn querySelector(self: *Document, input: []const u8, page: *Page) !?*Element { return Selector.querySelector(self.asNode(), input, page); } @@ -962,6 +968,7 @@ pub const JsApi = struct { pub const querySelector = bridge.function(Document.querySelector, .{ .dom_exception = true }); pub const querySelectorAll = bridge.function(Document.querySelectorAll, .{ .dom_exception = true }); pub const getElementsByTagName = bridge.function(Document.getElementsByTagName, .{}); + pub const getSelection = bridge.function(Document.getSelection, .{}); pub const getElementsByClassName = bridge.function(Document.getElementsByClassName, .{}); pub const getElementsByName = bridge.function(Document.getElementsByName, .{}); pub const adoptNode = bridge.function(Document.adoptNode, .{ .dom_exception = true }); diff --git a/src/browser/webapi/Window.zig b/src/browser/webapi/Window.zig index 7b6938e8..251f0220 100644 --- a/src/browser/webapi/Window.zig +++ b/src/browser/webapi/Window.zig @@ -42,6 +42,7 @@ const storage = @import("storage/storage.zig"); const Element = @import("Element.zig"); const CSSStyleProperties = @import("css/CSSStyleProperties.zig"); const CustomElementRegistry = @import("CustomElementRegistry.zig"); +const Selection = @import("Selection.zig"); const Window = @This(); @@ -129,6 +130,10 @@ pub fn getLocation(self: *const Window) *Location { return self._location; } +pub fn getSelection(self: *const Window) *Selection { + return &self._document._selection; +} + pub fn setLocation(_: *const Window, url: [:0]const u8, page: *Page) !void { return page.scheduleNavigation(url, .{ .reason = .script, .kind = .{ .push = null } }, .script); } @@ -676,6 +681,7 @@ pub const JsApi = struct { pub const atob = bridge.function(Window.atob, .{}); pub const reportError = bridge.function(Window.reportError, .{}); pub const getComputedStyle = bridge.function(Window.getComputedStyle, .{}); + pub const getSelection = bridge.function(Window.getSelection, .{}); pub const isSecureContext = bridge.accessor(Window.getIsSecureContext, null, .{}); pub const frames = bridge.accessor(Window.getWindow, null, .{ .cache = "frames" }); pub const index = bridge.indexed(Window.getFrame, .{ .null_as_undefined = true }); From be1d463775ea6cbb60cbe3f009dd026d4f566db8 Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Wed, 14 Jan 2026 00:36:52 -0800 Subject: [PATCH 03/17] add Selection WebAPI test --- src/browser/tests/selection.html | 408 +++++++++++++++++++++++++++++++ 1 file changed, 408 insertions(+) create mode 100644 src/browser/tests/selection.html diff --git a/src/browser/tests/selection.html b/src/browser/tests/selection.html new file mode 100644 index 00000000..ceae479f --- /dev/null +++ b/src/browser/tests/selection.html @@ -0,0 +1,408 @@ + + + + +
+

The quick brown fox

+

jumps over the lazy dog

+
+ Hello + World +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From 505e0799daf839d9cebc37b1a7140b6d14ac68c6 Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Wed, 14 Jan 2026 07:27:13 -0800 Subject: [PATCH 04/17] add remaining functions to Selection --- src/browser/tests/selection.html | 162 +++++++++++++++++++++++++++++++ src/browser/webapi/Selection.zig | 150 ++++++++++++++++++++++++---- 2 files changed, 295 insertions(+), 17 deletions(-) diff --git a/src/browser/tests/selection.html b/src/browser/tests/selection.html index ceae479f..9fd614f0 100644 --- a/src/browser/tests/selection.html +++ b/src/browser/tests/selection.html @@ -406,3 +406,165 @@ testing.expectEqual("Range", sel.type); } + + + + + + + + diff --git a/src/browser/webapi/Selection.zig b/src/browser/webapi/Selection.zig index 164a51a2..593ba4b9 100644 --- a/src/browser/webapi/Selection.zig +++ b/src/browser/webapi/Selection.zig @@ -108,9 +108,15 @@ pub fn removeRange(self: *Selection, range: *Range) void { } } -pub fn removeAllRanges(self: *Selection) void { +fn removeAllRangesInner(self: *Selection, reset_direction: bool) void { self._ranges.clearRetainingCapacity(); - self._direction = .none; + if (reset_direction) { + self._direction = .none; + } +} + +pub fn removeAllRanges(self: *Selection) void { + self.removeAllRangesInner(true); } pub fn collapseToEnd(self: *Selection, page: *Page) !void { @@ -124,9 +130,8 @@ pub fn collapseToEnd(self: *Selection, page: *Page) !void { try range.setStart(last_node, last_offset); try range.setEnd(last_node, last_offset); - self.removeAllRanges(); + self.removeAllRangesInner(true); try self._ranges.append(page.arena, range); - self._direction = .none; } pub fn collapseToStart(self: *Selection, page: *Page) !void { @@ -140,7 +145,7 @@ pub fn collapseToStart(self: *Selection, page: *Page) !void { try range.setStart(first_node, first_offset); try range.setStart(first_node, first_offset); - self.removeAllRanges(); + self.removeAllRangesInner(true); try self._ranges.append(page.arena, range); self._direction = .none; } @@ -209,8 +214,6 @@ pub fn extend(self: *Selection, node: *Node, _offset: ?u32) !void { } } -// TODO: getComposedRanges - pub fn getRangeAt(self: *Selection, index: u32) !*Range { if (index >= self.getRangeCount()) { return error.IndexSizeError; @@ -219,15 +222,129 @@ pub fn getRangeAt(self: *Selection, index: u32) !*Range { return self._ranges.items[index]; } -// TODO: modify +const ModifyAlter = enum { + move, + extend, -// TODO: selectAllChildren + pub fn fromString(str: []const u8) ?ModifyAlter { + return std.meta.stringToEnum(ModifyAlter, str); + } +}; -// TODO: setBaseAndExtent +const ModifyDirection = enum { + forward, + backward, + left, + right, + + pub fn fromString(str: []const u8) ?ModifyDirection { + return std.meta.stringToEnum(ModifyDirection, str); + } +}; + +const ModifyGranularity = enum { + character, + word, + line, + paragraph, + lineboundary, + // Firefox doesn't implement: + // - sentence + // - paragraph + // - sentenceboundary + // - paragraphboundary + // - documentboundary + // so we won't either for now. + + pub fn fromString(str: []const u8) ?ModifyGranularity { + return std.meta.stringToEnum(ModifyGranularity, str); + } +}; + +pub fn modify( + self: *Selection, + alter_str: []const u8, + direction_str: []const u8, + granularity_str: []const u8, +) !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; + + if (self._ranges.items.len == 0) return; + + log.warn(.not_implemented, "Selection.modify", .{ + .alter = alter, + .direction = direction, + .granularity = granularity, + }); +} + +pub fn selectAllChildren(self: *Selection, parent: *Node, page: *Page) !void { + if (parent._type == .document_type) { + return error.InvalidNodeType; + } + + const range = try Range.init(page); + try range.setStart(parent, 0); + + const child_count = parent.getLength(); + try range.setEnd(parent, @intCast(child_count)); + + self.removeAllRangesInner(true); + try self._ranges.append(page.arena, range); +} + +pub fn setBaseAndExtent( + self: *Selection, + anchor_node: *Node, + anchor_offset: u32, + focus_node: *Node, + focus_offset: u32, + page: *Page, +) !void { + if (anchor_offset > anchor_node.getLength()) { + return error.IndexSizeError; + } + + if (focus_offset > focus_node.getLength()) { + return error.IndexSizeError; + } + + const cmp = AbstractRange.compareBoundaryPoints( + anchor_node, + anchor_offset, + focus_node, + focus_offset, + ); + + const range = try Range.init(page); + + switch (cmp) { + .before => { + try range.setStart(anchor_node, anchor_offset); + try range.setEnd(focus_node, focus_offset); + self._direction = .forward; + }, + .after => { + try range.setStart(focus_node, focus_offset); + try range.setEnd(anchor_node, anchor_offset); + self._direction = .backward; + }, + .equal => { + try range.setStart(anchor_node, anchor_offset); + try range.setEnd(anchor_node, anchor_offset); + self._direction = .none; + }, + } + + self.removeAllRangesInner(false); + try self._ranges.append(page.arena, range); +} pub fn setPosition(self: *Selection, _node: ?*Node, _offset: ?u32, page: *Page) !void { const node = _node orelse { - self.removeAllRanges(); + self.removeAllRangesInner(true); return; }; @@ -241,9 +358,8 @@ pub fn setPosition(self: *Selection, _node: ?*Node, _offset: ?u32, page: *Page) try range.setStart(node, offset); try range.setEnd(node, offset); - self.removeAllRanges(); + self.removeAllRangesInner(true); try self._ranges.append(page.arena, range); - self._direction = .none; } pub const JsApi = struct { @@ -272,13 +388,13 @@ pub const JsApi = struct { pub const deleteFromDocument = bridge.function(Selection.deleteFromDocument, .{}); pub const empty = bridge.function(Selection.removeAllRanges, .{}); pub const extend = bridge.function(Selection.extend, .{ .dom_exception = true }); - // getComposedRanges + // unimplemented: getComposedRanges pub const getRangeAt = bridge.function(Selection.getRangeAt, .{ .dom_exception = true }); - // modify + pub const modify = bridge.function(Selection.modify, .{}); pub const removeAllRanges = bridge.function(Selection.removeAllRanges, .{}); pub const removeRange = bridge.function(Selection.removeRange, .{}); - // selectAllChildren - // setBaseAndExtent + pub const selectAllChildren = bridge.function(Selection.selectAllChildren, .{}); + pub const setBaseAndExtent = bridge.function(Selection.setBaseAndExtent, .{ .dom_exception = true }); pub const setPosition = bridge.function(Selection.setPosition, .{}); }; From 8291044abc955c3c616ee2b1ef4d35a9ebdd96ba Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Thu, 15 Jan 2026 23:01:24 -0800 Subject: [PATCH 05/17] fix collapseToStart on Selection --- src/browser/webapi/Selection.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/browser/webapi/Selection.zig b/src/browser/webapi/Selection.zig index 593ba4b9..36d25eb8 100644 --- a/src/browser/webapi/Selection.zig +++ b/src/browser/webapi/Selection.zig @@ -143,7 +143,7 @@ pub fn collapseToStart(self: *Selection, page: *Page) !void { const range = try Range.init(page); try range.setStart(first_node, first_offset); - try range.setStart(first_node, first_offset); + try range.setEnd(first_node, first_offset); self.removeAllRangesInner(true); try self._ranges.append(page.arena, range); From fa3a23134e02a94ac0cb8aa2c710d5386045a775 Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Thu, 15 Jan 2026 23:15:27 -0800 Subject: [PATCH 06/17] properly return NotFoundError on removeRange --- src/browser/tests/selection.html | 2 +- src/browser/webapi/Selection.zig | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/browser/tests/selection.html b/src/browser/tests/selection.html index 9fd614f0..be7ba464 100644 --- a/src/browser/tests/selection.html +++ b/src/browser/tests/selection.html @@ -143,7 +143,7 @@ testing.expectEqual(range2, sel.getRangeAt(0)); // Removing non-existent range does nothing - sel.removeRange(range1); + testing.expectError('NotFoundError', () => { sel.removeRange(range1); }); testing.expectEqual(1, sel.rangeCount); } diff --git a/src/browser/webapi/Selection.zig b/src/browser/webapi/Selection.zig index 36d25eb8..6bc0981b 100644 --- a/src/browser/webapi/Selection.zig +++ b/src/browser/webapi/Selection.zig @@ -99,13 +99,15 @@ pub fn addRange(self: *Selection, range: *Range, page: *Page) !void { return try self._ranges.append(page.arena, range); } -pub fn removeRange(self: *Selection, range: *Range) void { +pub fn removeRange(self: *Selection, range: *Range) !void { for (self._ranges.items, 0..) |r, i| { if (r == range) { _ = self._ranges.orderedRemove(i); return; } } + + return error.NotFound; } fn removeAllRangesInner(self: *Selection, reset_direction: bool) void { @@ -392,7 +394,7 @@ pub const JsApi = struct { pub const getRangeAt = bridge.function(Selection.getRangeAt, .{ .dom_exception = true }); pub const modify = bridge.function(Selection.modify, .{}); pub const removeAllRanges = bridge.function(Selection.removeAllRanges, .{}); - pub const removeRange = bridge.function(Selection.removeRange, .{}); + pub const removeRange = bridge.function(Selection.removeRange, .{ .dom_exception = true }); pub const selectAllChildren = bridge.function(Selection.selectAllChildren, .{}); pub const setBaseAndExtent = bridge.function(Selection.setBaseAndExtent, .{ .dom_exception = true }); pub const setPosition = bridge.function(Selection.setPosition, .{}); From 12670a3153f59d12aac541d7b6437e66a6aad80f Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Thu, 15 Jan 2026 23:22:17 -0800 Subject: [PATCH 07/17] fix extend direction in Selection --- src/browser/tests/selection.html | 2 +- src/browser/webapi/Selection.zig | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/browser/tests/selection.html b/src/browser/tests/selection.html index be7ba464..af6321a1 100644 --- a/src/browser/tests/selection.html +++ b/src/browser/tests/selection.html @@ -270,7 +270,7 @@ testing.expectEqual(true, sel.isCollapsed); testing.expectEqual(10, sel.anchorOffset); testing.expectEqual(10, sel.focusOffset); - testing.expectEqual("forward", sel.direction); + testing.expectEqual("none", sel.direction); } diff --git a/src/browser/webapi/Selection.zig b/src/browser/webapi/Selection.zig index 6bc0981b..76013001 100644 --- a/src/browser/webapi/Selection.zig +++ b/src/browser/webapi/Selection.zig @@ -208,11 +208,16 @@ pub fn extend(self: *Selection, node: *Node, _offset: ?u32) !void { try range.setEnd(old_anchor, old_anchor_offset); self._direction = .backward; }, - else => { + .after => { try range.setStart(old_anchor, old_anchor_offset); try range.setEnd(node, offset); self._direction = .forward; }, + .equal => { + try range.setStart(old_anchor, old_anchor_offset); + try range.setEnd(old_anchor, old_anchor_offset); + self._direction = .none; + }, } } From 5ebf82874b73b3b2db198d298b798ef85c991c03 Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Fri, 16 Jan 2026 00:21:18 -0800 Subject: [PATCH 08/17] fix selection test inconsistency --- src/browser/tests/selection.html | 63 +++++++++----------------------- 1 file changed, 18 insertions(+), 45 deletions(-) diff --git a/src/browser/tests/selection.html b/src/browser/tests/selection.html index af6321a1..4aad996d 100644 --- a/src/browser/tests/selection.html +++ b/src/browser/tests/selection.html @@ -102,7 +102,10 @@ range2.selectNodeContents(p2); sel.addRange(range2); - testing.expectEqual(2, sel.rangeCount); + + // Firefox does support multiple ranges so it will be 2 here instead of 1. + // Chrome and Safari don't so we don't either. + testing.expectEqual(1, sel.rangeCount); } @@ -136,15 +139,17 @@ sel.addRange(range1); sel.addRange(range2); - testing.expectEqual(2, sel.rangeCount); - - sel.removeRange(range1); + + // Firefox does support multiple ranges so it will be 2 here instead of 1. + // Chrome and Safari don't so we don't either. testing.expectEqual(1, sel.rangeCount); - testing.expectEqual(range2, sel.getRangeAt(0)); - // Removing non-existent range does nothing - testing.expectError('NotFoundError', () => { sel.removeRange(range1); }); + // Chrome doesn't throw an error here even though the spec defines it: + // https://w3c.github.io/selection-api/#dom-selection-removerange + testing.expectError('NotFoundError', () => { sel.removeRange(range2); }); + testing.expectEqual(1, sel.rangeCount); + testing.expectEqual(range1, sel.getRangeAt(0)); } @@ -161,7 +166,10 @@ sel.addRange(range1); sel.addRange(range2); - testing.expectEqual(2, sel.rangeCount); + + // Firefox does support multiple ranges so it will be 2 here instead of 1. + // Chrome and Safari don't so we don't either. + testing.expectEqual(1, sel.rangeCount); sel.removeAllRanges(); testing.expectEqual(0, sel.rangeCount); @@ -312,12 +320,7 @@ sel.removeAllRanges(); sel.addRange(range); - - // Full containment (default) - testing.expectEqual(true, sel.containsNode(s1, false)); - testing.expectEqual(true, sel.containsNode(s2, false)); - testing.expectEqual(false, sel.containsNode(nested, false)); - + // Partial containment testing.expectEqual(true, sel.containsNode(s1, true)); testing.expectEqual(true, sel.containsNode(s2, true)); @@ -361,32 +364,6 @@ } - -