diff --git a/src/browser/Page.zig b/src/browser/Page.zig index e928f173..5a8c458a 100644 --- a/src/browser/Page.zig +++ b/src/browser/Page.zig @@ -2932,16 +2932,7 @@ pub fn handleKeydown(self: *Page, target: *Node, event: *Event) !void { // Handle printable characters if (key.isPrintable()) { - // if the input is selected, replace the content. - if (input._selected) { - const new_value = try self.arena.dupe(u8, key.asString()); - try input.setValue(new_value, self); - input._selected = false; - return; - } - const current_value = input.getValue(); - const new_value = try std.mem.concat(self.arena, u8, &.{ current_value, key.asString() }); - try input.setValue(new_value, self); + try input.innerInsert(key.asString(), self); } return; } @@ -3010,18 +3001,7 @@ pub fn insertText(self: *Page, v: []const u8) !void { return; } - // If the input is selected, replace the existing value - if (input._selected) { - const new_value = try self.arena.dupe(u8, v); - try input.setValue(new_value, self); - input._selected = false; - return; - } - - // Or append the value - const current_value = input.getValue(); - const new_value = try std.mem.concat(self.arena, u8, &.{ current_value, v }); - return input.setValue(new_value, self); + try input.innerInsert(v, self); } if (html_element.is(Element.Html.TextArea)) |textarea| { diff --git a/src/browser/js/bridge.zig b/src/browser/js/bridge.zig index 73eac691..889b74d1 100644 --- a/src/browser/js/bridge.zig +++ b/src/browser/js/bridge.zig @@ -736,4 +736,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/tests/selection.html b/src/browser/tests/selection.html new file mode 100644 index 00000000..b10dcb50 --- /dev/null +++ b/src/browser/tests/selection.html @@ -0,0 +1,543 @@ + + + + +
+

The quick brown fox

+

jumps over the lazy dog

+
+ Hello + World +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/browser/webapi/Document.zig b/src/browser/webapi/Document.zig index 2a9ee7ca..34a96976 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/Selection.zig b/src/browser/webapi/Selection.zig new file mode 100644 index 00000000..69054b17 --- /dev/null +++ b/src/browser/webapi/Selection.zig @@ -0,0 +1,426 @@ +// 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"); + +/// https://w3c.github.io/selection-api/ +const Selection = @This(); + +pub const SelectionDirection = enum { backward, forward, none }; + +_range: ?*Range = null, +_direction: SelectionDirection = .none, + +pub const init: Selection = .{}; + +fn isInTree(self: *const Selection) bool { + if (self._range == null) return false; + const anchor_node = self.getAnchorNode() orelse return false; + const focus_node = self.getFocusNode() orelse return false; + return anchor_node.isConnected() and focus_node.isConnected(); +} + +pub fn getAnchorNode(self: *const Selection) ?*Node { + const range = self._range orelse return null; + + const node = switch (self._direction) { + .backward => range.asAbstractRange().getEndContainer(), + .forward, .none => range.asAbstractRange().getStartContainer(), + }; + + return if (node.isConnected()) node else null; +} + +pub fn getAnchorOffset(self: *const Selection) u32 { + const range = self._range orelse return 0; + + const anchor_node = self.getAnchorNode() orelse return 0; + if (!anchor_node.isConnected()) return 0; + + return switch (self._direction) { + .backward => range.asAbstractRange().getEndOffset(), + .forward, .none => range.asAbstractRange().getStartOffset(), + }; +} + +pub fn getDirection(self: *const Selection) []const u8 { + return @tagName(self._direction); +} + +pub fn getFocusNode(self: *const Selection) ?*Node { + const range = self._range orelse return null; + + const node = switch (self._direction) { + .backward => range.asAbstractRange().getStartContainer(), + .forward, .none => range.asAbstractRange().getEndContainer(), + }; + + return if (node.isConnected()) node else null; +} + +pub fn getFocusOffset(self: *const Selection) u32 { + const range = self._range orelse return 0; + const focus_node = self.getFocusNode() orelse return 0; + if (!focus_node.isConnected()) return 0; + + return switch (self._direction) { + .backward => range.asAbstractRange().getStartOffset(), + .forward, .none => range.asAbstractRange().getEndOffset(), + }; +} + +pub fn getIsCollapsed(self: *const Selection) bool { + const range = self._range orelse return true; + return range.asAbstractRange().getCollapsed(); +} + +pub fn getRangeCount(self: *const Selection) u32 { + if (self._range == null) return 0; + if (!self.isInTree()) return 0; + + return 1; +} + +pub fn getType(self: *const Selection) []const u8 { + if (self._range == null) return "None"; + if (!self.isInTree()) return "None"; + if (self.getIsCollapsed()) return "Caret"; + return "Range"; +} + +pub fn addRange(self: *Selection, range: *Range) !void { + if (self._range != null) return; + self._range = range; +} + +pub fn removeRange(self: *Selection, range: *Range) !void { + if (self._range == range) { + self._range = null; + return; + } else { + return error.NotFound; + } +} + +pub fn removeAllRanges(self: *Selection) void { + self._range = null; + self._direction = .none; +} + +pub fn collapseToEnd(self: *Selection, page: *Page) !void { + const range = self._range orelse return; + + const abstract = range.asAbstractRange(); + const last_node = abstract.getEndContainer(); + const last_offset = abstract.getEndOffset(); + + const new_range = try Range.init(page); + try new_range.setStart(last_node, last_offset); + try new_range.setEnd(last_node, last_offset); + + self._range = new_range; + self._direction = .none; +} + +pub fn collapseToStart(self: *Selection, page: *Page) !void { + const range = self._range orelse return; + + const abstract = range.asAbstractRange(); + const first_node = abstract.getStartContainer(); + const first_offset = abstract.getStartOffset(); + + const new_range = try Range.init(page); + try new_range.setStart(first_node, first_offset); + try new_range.setEnd(first_node, first_offset); + + self._range = new_range; + self._direction = .none; +} + +pub fn containsNode(self: *const Selection, node: *Node, partial: bool) !bool { + const range = self._range orelse return false; + + if (partial) { + if (range.intersectsNode(node)) { + return true; + } + } else { + const abstract = range.asAbstractRange(); + if (abstract.getStartContainer() == node or abstract.getEndContainer() == node) { + return false; + } + + const parent = node.parentNode() orelse return false; + const offset = parent.getChildIndex(node) orelse return false; + const start_cmp = range.comparePoint(parent, offset) catch return false; + const end_cmp = range.comparePoint(parent, offset + 1) catch return false; + + if (start_cmp <= 0 and end_cmp >= 0) { + return true; + } + } + + return false; +} + +pub fn deleteFromDocument(self: *Selection, page: *Page) !void { + const range = self._range orelse return; + + try range.deleteContents(page); +} + +pub fn extend(self: *Selection, node: *Node, _offset: ?u32, page: *Page) !void { + const range = self._range orelse return error.InvalidState; + const offset = _offset orelse 0; + + if (offset > node.getLength()) { + return error.IndexSizeError; + } + + 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 new_range = try Range.init(page); + + const cmp = AbstractRange.compareBoundaryPoints(node, offset, old_anchor, old_anchor_offset); + switch (cmp) { + .before => { + try new_range.setStart(node, offset); + try new_range.setEnd(old_anchor, old_anchor_offset); + self._direction = .backward; + }, + .after => { + try new_range.setStart(old_anchor, old_anchor_offset); + try new_range.setEnd(node, offset); + self._direction = .forward; + }, + .equal => { + try new_range.setStart(old_anchor, old_anchor_offset); + try new_range.setEnd(old_anchor, old_anchor_offset); + self._direction = .none; + }, + } + + self._range = new_range; +} + +pub fn getRangeAt(self: *Selection, index: u32) !*Range { + if (index != 0) return error.IndexSizeError; + if (!self.isInTree()) return error.IndexSizeError; + const range = self._range orelse return error.IndexSizeError; + + return range; +} + +const ModifyAlter = enum { + move, + extend, + + pub fn fromString(str: []const u8) ?ModifyAlter { + return std.meta.stringToEnum(ModifyAlter, str); + } +}; + +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; + + _ = self._range orelse 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.InvalidNodeTypeError; + + const range = try Range.init(page); + try range.setStart(parent, 0); + + const child_count = parent.getLength(); + try range.setEnd(parent, @intCast(child_count)); + + self._range = range; + self._direction = .forward; +} + +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._range = range; +} + +pub fn collapse(self: *Selection, _node: ?*Node, _offset: ?u32, page: *Page) !void { + const node = _node orelse { + self.removeAllRanges(); + return; + }; + + if (node._type == .document_type) return error.InvalidNodeType; + + 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._range = range; + self._direction = .none; +} + +pub fn toString(self: *const Selection, page: *Page) ![]const u8 { + const range = self._range orelse return ""; + return try range.toString(page); +} + +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.collapse, .{ .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 }); + // unimplemented: getComposedRanges + 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, .{ .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.collapse, .{}); + pub const toString = bridge.function(Selection.toString, .{}); +}; + +const testing = @import("../../testing.zig"); +test "WebApi: Selection" { + try testing.htmlRunner("selection.html", .{}); +} diff --git a/src/browser/webapi/Window.zig b/src/browser/webapi/Window.zig index d32c884c..781081c4 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); } @@ -680,6 +685,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 }); diff --git a/src/browser/webapi/element/html/Input.zig b/src/browser/webapi/element/html/Input.zig index 5c04b6a4..f3740073 100644 --- a/src/browser/webapi/element/html/Input.zig +++ b/src/browser/webapi/element/html/Input.zig @@ -24,6 +24,7 @@ const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); const HtmlElement = @import("../Html.zig"); const Form = @import("Form.zig"); +const Selection = @import("../../Selection.zig"); const Input = @This(); @@ -74,9 +75,12 @@ _value: ?[]const u8 = null, _checked: bool = false, _checked_dirty: bool = false, _input_type: Type = .text, -_selected: bool = false, _indeterminate: bool = false, +_selection_start: u32 = 0, +_selection_end: u32 = 0, +_selection_direction: Selection.SelectionDirection = .none, + pub fn asElement(self: *Input) *Element { return self._proto._proto; } @@ -255,8 +259,120 @@ pub fn setRequired(self: *Input, required: bool, page: *Page) !void { } } -pub fn select(self: *Input) void { - self._selected = true; +pub fn select(self: *Input) !void { + const len = if (self._value) |v| @as(u32, @intCast(v.len)) else 0; + try self.setSelectionRange(0, len, null); +} + +fn selectionAvailable(self: *const Input) bool { + switch (self._input_type) { + .text, .search, .url, .tel, .password => return true, + else => return false, + } +} + +const HowSelected = union(enum) { partial: struct { u32, u32 }, full, none }; + +fn howSelected(self: *const Input) HowSelected { + if (!self.selectionAvailable()) return .none; + const value = self._value orelse return .none; + + if (self._selection_start == self._selection_end) return .none; + if (self._selection_start == 0 and self._selection_end == value.len) return .full; + return .{ .partial = .{ self._selection_start, self._selection_end } }; +} + +pub fn innerInsert(self: *Input, str: []const u8, page: *Page) !void { + const arena = page.arena; + + switch (self.howSelected()) { + .full => { + // if the input is fully selected, replace the content. + const new_value = try arena.dupe(u8, str); + try self.setValue(new_value, page); + self._selection_start = @intCast(new_value.len); + self._selection_end = @intCast(new_value.len); + self._selection_direction = .none; + }, + .partial => |range| { + // if the input is partially selected, replace the selected content. + const current_value = self.getValue(); + const before = current_value[0..range[0]]; + const remaining = current_value[range[1]..]; + + const new_value = try std.mem.concat( + arena, + u8, + &.{ before, str, remaining }, + ); + try self.setValue(new_value, page); + + const new_pos = range[0] + str.len; + self._selection_start = @intCast(new_pos); + self._selection_end = @intCast(new_pos); + self._selection_direction = .none; + }, + .none => { + // if the input is not selected, just insert at cursor. + const current_value = self.getValue(); + const new_value = try std.mem.concat(arena, u8, &.{ current_value, str }); + try self.setValue(new_value, page); + }, + } +} + +pub fn getSelectionDirection(self: *const Input) []const u8 { + return @tagName(self._selection_direction); +} + +pub fn getSelectionStart(self: *const Input) !?u32 { + if (!self.selectionAvailable()) return null; + return self._selection_start; +} + +pub fn setSelectionStart(self: *Input, value: u32) !void { + if (!self.selectionAvailable()) return error.InvalidStateError; + self._selection_start = value; +} + +pub fn getSelectionEnd(self: *const Input) !?u32 { + if (!self.selectionAvailable()) return null; + return self._selection_end; +} + +pub fn setSelectionEnd(self: *Input, value: u32) !void { + if (!self.selectionAvailable()) return error.InvalidStateError; + self._selection_end = value; +} + +pub fn setSelectionRange(self: *Input, selection_start: u32, selection_end: u32, selection_dir: ?[]const u8) !void { + if (!self.selectionAvailable()) return error.InvalidStateError; + + const direction = blk: { + if (selection_dir) |sd| { + break :blk std.meta.stringToEnum(Selection.SelectionDirection, sd) orelse .none; + } else break :blk .none; + }; + + const value = self._value orelse { + self._selection_start = 0; + self._selection_end = 0; + self._selection_direction = .none; + return; + }; + + const len_u32: u32 = @intCast(value.len); + var start: u32 = if (selection_start > len_u32) len_u32 else selection_start; + const end: u32 = if (selection_end > len_u32) len_u32 else selection_end; + + // If end is less than start, both are equal to end. + if (end < start) { + start = end; + } + + self._selection_direction = direction; + self._selection_start = start; + self._selection_end = end; } pub fn getForm(self: *Input, page: *Page) ?*Form { @@ -352,6 +468,11 @@ pub const JsApi = struct { pub const form = bridge.accessor(Input.getForm, null, .{}); pub const indeterminate = bridge.accessor(Input.getIndeterminate, Input.setIndeterminate, .{}); pub const select = bridge.function(Input.select, .{}); + + pub const selectionStart = bridge.accessor(Input.getSelectionStart, Input.setSelectionStart, .{}); + pub const selectionEnd = bridge.accessor(Input.getSelectionEnd, Input.setSelectionEnd, .{}); + pub const selectionDirection = bridge.accessor(Input.getSelectionDirection, null, .{}); + pub const setSelectionRange = bridge.function(Input.setSelectionRange, .{ .dom_exception = true }); }; pub const Build = struct { @@ -422,7 +543,9 @@ pub const Build = struct { clone._value = source._value; clone._checked = source._checked; clone._checked_dirty = source._checked_dirty; - clone._selected = source._selected; + clone._selection_direction = source._selection_direction; + clone._selection_start = source._selection_start; + clone._selection_end = source._selection_end; clone._indeterminate = source._indeterminate; } }; diff --git a/src/browser/webapi/element/html/TextArea.zig b/src/browser/webapi/element/html/TextArea.zig index e9a1dcb1..bcac5cb2 100644 --- a/src/browser/webapi/element/html/TextArea.zig +++ b/src/browser/webapi/element/html/TextArea.zig @@ -16,6 +16,7 @@ // 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 js = @import("../../../js/js.zig"); const Page = @import("../../../Page.zig"); @@ -23,12 +24,17 @@ const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); const HtmlElement = @import("../Html.zig"); const Form = @import("Form.zig"); +const Selection = @import("../../Selection.zig"); const TextArea = @This(); _proto: *HtmlElement, _value: ?[]const u8 = null, +_selection_start: u32 = 0, +_selection_end: u32 = 0, +_selection_direction: Selection.SelectionDirection = .none, + pub fn asElement(self: *TextArea) *Element { return self._proto._proto; } @@ -109,6 +115,108 @@ pub fn setRequired(self: *TextArea, required: bool, page: *Page) !void { } } +pub fn select(self: *TextArea) !void { + const len = if (self._value) |v| @as(u32, @intCast(v.len)) else 0; + try self.setSelectionRange(0, len, null); +} + +const HowSelected = union(enum) { partial: struct { u32, u32 }, full, none }; + +fn howSelected(self: *const TextArea) HowSelected { + const value = self._value orelse return .none; + + if (self._selection_start == self._selection_end) return .none; + if (self._selection_start == 0 and self._selection_end == value.len) return .full; + return .{ .partial = .{ self._selection_start, self._selection_end } }; +} + +pub fn innerInsert(self: *TextArea, str: []const u8, page: *Page) !void { + const arena = page.arena; + + switch (self.howSelected()) { + .full => { + // if the text area is fully selected, replace the content. + const new_value = try arena.dupe(u8, str); + try self.setValue(new_value, page); + self._selection_start = @intCast(new_value.len); + self._selection_end = @intCast(new_value.len); + self._selection_direction = .none; + }, + .partial => |range| { + // if the text area is partially selected, replace the selected content. + const current_value = self.getValue(); + const before = current_value[0..range[0]]; + const remaining = current_value[range[1]..]; + + const new_value = try std.mem.concat( + arena, + u8, + &.{ before, str, remaining }, + ); + try self.setValue(new_value, page); + + const new_pos = range[0] + str.len; + self._selection_start = @intCast(new_pos); + self._selection_end = @intCast(new_pos); + self._selection_direction = .none; + }, + .none => { + // if the text area is not selected, just insert at cursor. + const current_value = self.getValue(); + const new_value = try std.mem.concat(arena, u8, &.{ current_value, str }); + try self.setValue(new_value, page); + }, + } +} + +pub fn getSelectionDirection(self: *const TextArea) []const u8 { + return @tagName(self._selection_direction); +} + +pub fn getSelectionStart(self: *const TextArea) u32 { + return self._selection_start; +} + +pub fn setSelectionStart(self: *TextArea, value: u32) void { + self._selection_start = value; +} + +pub fn getSelectionEnd(self: *const TextArea) u32 { + return self._selection_end; +} + +pub fn setSelectionEnd(self: *TextArea, value: u32) void { + self._selection_end = value; +} + +pub fn setSelectionRange(self: *TextArea, selection_start: u32, selection_end: u32, selection_dir: ?[]const u8) !void { + const direction = blk: { + if (selection_dir) |sd| { + break :blk std.meta.stringToEnum(Selection.SelectionDirection, sd) orelse .none; + } else break :blk .none; + }; + + const value = self._value orelse { + self._selection_start = 0; + self._selection_end = 0; + self._selection_direction = .none; + return; + }; + + const len_u32: u32 = @intCast(value.len); + var start: u32 = if (selection_start > len_u32) len_u32 else selection_start; + const end: u32 = if (selection_end > len_u32) len_u32 else selection_end; + + // If end is less than start, both are equal to end. + if (end < start) { + start = end; + } + + self._selection_direction = direction; + self._selection_start = start; + self._selection_end = end; +} + pub fn getForm(self: *TextArea, page: *Page) ?*Form { const element = self.asElement(); diff --git a/tests/wpt b/tests/wpt index 3df84d93..69c6afab 160000 --- a/tests/wpt +++ b/tests/wpt @@ -1 +1 @@ -Subproject commit 3df84d931c47559065c6de3edc07dea95bedcf70 +Subproject commit 69c6afabd893c00c7cb982ba66306e1a68db90c3