From 4f63f6f204cf3dd018e7a77f6429554c45b6111e Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Thu, 27 Aug 2026 08:32:53 +0800 Subject: [PATCH 01/10] fix: --with-base now uses proper attribute Improve dump's void element list --- src/browser/dump.zig | 39 +++++++++++++++++++++++++++++++-------- 1 file changed, 31 insertions(+), 8 deletions(-) diff --git a/src/browser/dump.zig b/src/browser/dump.zig index 1a6bc1344..6879f00cc 100644 --- a/src/browser/dump.zig +++ b/src/browser/dump.zig @@ -75,7 +75,7 @@ pub fn root(doc: *Node.Document, opts: Opts, writer: *std.Io.Writer, frame: *Fra if (opts.with_base) { const parent = if (html_doc.getHead()) |head| head.asNode() else doc.asNode(); const base = try doc.createElement("base", null, frame); - try base.setAttributeSafe(comptime .wrap("base"), .wrap(frame.base()), frame); + try base.setAttributeSafe(comptime .wrap("href"), .wrap(frame.base()), frame); _ = try parent.insertBefore(base.asNode(), parent.firstChild(), frame); } } @@ -328,13 +328,17 @@ fn dumpSlotContent(slot: *Slot, opts: Opts, writer: *std.Io.Writer, frame: *Fram } } -fn isVoidElement(el: *const Node.Element) bool { - return switch (el._type) { - .html => switch (el.subtype(Node.Element.Html)._type) { - .br, .hr, .img, .input, .link, .meta => true, - else => false, +fn isVoidElement(el: *Node.Element) bool { + return switch (el.getTag()) { + .area, .base, .br, .col, .embed, .hr, .img, .input, .link, .meta, .param, .source, .track => true, + .unknown => { + const unknown = el.as(Node.Element.Html.Unknown); + if (unknown._tag_name.eql(comptime .wrap("wbr"))) { + return true; + } + return false; }, - .svg => false, + else => false, }; } @@ -474,10 +478,29 @@ test "dump: default dumps the whole document" { test "dump: with_base injects a element" { try expectDump(.{ .with_base = true }, \\ - \\

Title

visible & well

+ \\

Title

visible & well

); } +test "dump: void elements have no end tag" { + const frame = try testing.createFrame(); + defer testing.test_session.closeAllPages(); + + const doc = frame.window._document; + const div = try doc.createElement("div", null, frame); + try Frame.parse.htmlAsChildren(frame, div.asNode(), + \\

ab

+ ); + + var aw: std.Io.Writer.Allocating = .init(testing.allocator); + defer aw.deinit(); + try deep(div.asNode(), .{}, &aw.writer, frame); + + try testing.expectString( + \\

ab

+ , aw.written()); +} + test "dump: strip.js removes script and noscript" { try expectDump(.{ .strip = .{ .js = true } }, \\ From 6931db8acafdb526ac90f812ddeecc300afd077b Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Thu, 27 Aug 2026 11:17:48 +0800 Subject: [PATCH 02/10] webapi: edit key support in input/textarea A trusted backspace/delete or printable character now: 1 - fires beforeinput and textInput (which can cancel the edit) 2 - change the input's value Input and TextArea share enough in common and this change has enough selection logic that a new text_entry.zig helper is added to implement the shared logic. --- src/browser/frame/user_input.zig | 83 ++++++- src/browser/tests/event/text.html | 3 +- src/browser/webapi/element/html/Input.zig | 137 +----------- src/browser/webapi/element/html/TextArea.zig | 133 ++--------- src/browser/webapi/element/text_entry.zig | 218 +++++++++++++++++++ src/browser/webapi/event/TextEvent.zig | 13 +- 6 files changed, 329 insertions(+), 258 deletions(-) create mode 100644 src/browser/webapi/element/text_entry.zig diff --git a/src/browser/frame/user_input.zig b/src/browser/frame/user_input.zig index 32a6f8f11..de93d5f19 100644 --- a/src/browser/frame/user_input.zig +++ b/src/browser/frame/user_input.zig @@ -32,6 +32,8 @@ const Node = @import("../webapi/Node.zig"); const Event = @import("../webapi/Event.zig"); const Element = @import("../webapi/Element.zig"); const TreeWalker = @import("../webapi/TreeWalker.zig"); +const TextEvent = @import("../webapi/event/TextEvent.zig"); +const InputEvent = @import("../webapi/event/InputEvent.zig"); const MouseEvent = @import("../webapi/event/MouseEvent.zig"); const WheelEvent = @import("../webapi/event/WheelEvent.zig"); const PointerEvent = @import("../webapi/event/PointerEvent.zig"); @@ -628,22 +630,79 @@ pub fn handleKeydown(frame: *Frame, target: *Node, event: *Event) !void { return; } - // Handle printable characters - if (key.isPrintable()) { - try input.innerInsert(key.asString(), frame); + return editKey(frame, event, input, key); + } + + if (target.is(Element.Html.TextArea)) |textarea| { + if (key == .Enter) { + if (try allowEdit(frame, event, textarea.asElement(), null, "\n", "insertLineBreak")) { + try textarea.innerInsert("\n", frame); + } + return; + } + + return editKey(frame, event, textarea, key); + } +} + +// edit keys are handled by Input and TextArea the same +fn editKey(frame: *Frame, event: *Event, ctl: anytype, key: KeyboardEvent.Key) !void { + if (key == .Backspace or key == .Delete) { + const forward = key == .Delete; + if (try allowEdit(frame, event, ctl.asElement(), null, null, deleteInputType(forward))) { + try ctl.innerDelete(forward, frame); } return; } - if (target.is(Element.Html.TextArea)) |textarea| { - // zig fmt: off - const append = - if (key == .Enter) "\n" - else if (key.isPrintable()) key.asString() - else return - ; - // zig fmt: on - return textarea.innerInsert(append, frame); + if (key.isPrintable()) { + if (try allowEdit(frame, event, ctl.asElement(), key.asString(), key.asString(), "insertText")) { + try ctl.innerInsert(key.asString(), frame); + } + } +} + +fn deleteInputType(forward: bool) []const u8 { + return if (forward) "deleteContentForward" else "deleteContentBackward"; +} + +// pre-edit events for a key's default action, can cancel the edit (i.e. by +// returning false) +fn allowEdit(frame: *Frame, keydown: *Event, target: *Element, before_data: ?[]const u8, text_data: ?[]const u8, input_type: []const u8) !bool { + if (keydown.getIsTrusted() == false) { + // only trusted events fire these events, so for a untrusted event, the + // edit isn't cancelled. + return true; + } + + { + const before = (try InputEvent.initTrusted(comptime .wrap("beforeinput"), .{ + .bubbles = true, + .cancelable = true, + .composed = true, + .data = before_data, + .inputType = input_type, + }, frame)).asEvent(); + before.acquireRef(); // need to check its _prevent_default + defer _ = before.releaseRef(frame._page); + try frame._event_manager.dispatch(target.asEventTarget(), before); + if (before._prevent_default) { + return false; + } + } + + { + const data = text_data orelse return true; + const text_event = (try TextEvent.initTrusted("textInput", .{ + .bubbles = true, + .cancelable = true, + .view = frame.window, + .data = data, + }, frame)).asEvent(); + text_event.acquireRef(); // need to check its _prevent_default + defer _ = text_event.releaseRef(frame._page); + try frame._event_manager.dispatch(target.asEventTarget(), text_event); + return text_event._prevent_default == false; } } diff --git a/src/browser/tests/event/text.html b/src/browser/tests/event/text.html index 618945022..193156663 100644 --- a/src/browser/tests/event/text.html +++ b/src/browser/tests/event/text.html @@ -25,7 +25,8 @@ let evt = document.createEvent('TextEvent'); evt.initTextEvent('foo'); testing.expectEqual('foo', evt.type); - testing.expectEqual('', evt.data); + // legacy IDL default: optional DOMString dataArg = "undefined" + testing.expectEqual('undefined', evt.data); testing.expectEqual(false, evt.bubbles); testing.expectEqual(false, evt.cancelable); } diff --git a/src/browser/webapi/element/html/Input.zig b/src/browser/webapi/element/html/Input.zig index 0664b134c..88276c052 100644 --- a/src/browser/webapi/element/html/Input.zig +++ b/src/browser/webapi/element/html/Input.zig @@ -29,12 +29,12 @@ const HtmlElement = @import("../Html.zig"); const Form = @import("Form.zig"); const Selection = @import("../../Selection.zig"); const Event = @import("../../Event.zig"); -const InputEvent = @import("../../event/InputEvent.zig"); const ValidityState = @import("ValidityState.zig"); const popover = @import("../popover.zig"); const File = @import("../../File.zig"); const FileList = @import("../../FileList.zig"); const reflection = @import("../reflection.zig"); +const text_entry = @import("../text_entry.zig"); const String = lp.String; @@ -113,16 +113,6 @@ pub fn setOnSelectionChange(self: *Input, listener: ?js.Function) !void { } } -fn dispatchSelectionChangeEvent(self: *Input, frame: *Frame) !void { - const event = try Event.init("selectionchange", .{ .bubbles = true }, frame._page); - try frame._event_manager.dispatch(self.asElement().asEventTarget(), event); -} - -fn dispatchInputEvent(self: *Input, data: ?[]const u8, input_type: []const u8, frame: *Frame) !void { - const event = try InputEvent.initTrusted(comptime .wrap("input"), .{ .data = data, .inputType = input_type }, frame); - try frame._event_manager.dispatch(self.asElement().asEventTarget(), event.asEvent()); -} - pub fn asElement(self: *Input) *Element { return Factory.protoOf(self).asElement(); } @@ -623,137 +613,34 @@ pub fn setSrc(self: *Input, src: []const u8, frame: *Frame) !void { try self.asElement().setAttributeSafe(comptime .wrap("src"), .wrap(trimmed), frame); } -pub fn select(self: *Input, frame: *Frame) !void { - const len = if (self._value) |v| @as(u32, @intCast(v.len)) else 0; - try self.setSelectionRange(0, len, null, frame); - const event = try Event.init("select", .{ .bubbles = true }, frame._page); - try frame._event_manager.dispatch(self.asElement().asEventTarget(), event); -} +const entry = text_entry.TextEntry(Input); -fn selectionAvailable(self: *const Input) bool { +pub const select = entry.select; +pub const innerInsert = entry.innerInsert; +pub const innerDelete = entry.innerDelete; +pub const getSelectionDirection = entry.getSelectionDirection; +pub const setSelectionStart = entry.setSelectionStart; +pub const setSelectionEnd = entry.setSelectionEnd; +pub const setSelectionRange = entry.setSelectionRange; + +pub 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, frame: *Frame) !void { - const arena = frame.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, frame); - self._selection_start = @intCast(new_value.len); - self._selection_end = @intCast(new_value.len); - self._selection_direction = .none; - try self.dispatchSelectionChangeEvent(frame); - }, - .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, frame); - - const new_pos = range[0] + str.len; - self._selection_start = @intCast(new_pos); - self._selection_end = @intCast(new_pos); - self._selection_direction = .none; - try self.dispatchSelectionChangeEvent(frame); - }, - .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, frame); - }, - } - try self.dispatchInputEvent(str, "insertText", frame); -} - -pub fn getSelectionDirection(self: *const Input) []const u8 { - return @tagName(self._selection_direction); -} - +// Nullable here, unlike