From 437df18a0731bd565dc043c688167069f98bcb0d Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Mon, 22 Dec 2025 19:45:29 +0800 Subject: [PATCH] form submitt --- src/browser/EventManager.zig | 8 +- src/browser/Page.zig | 161 ++++++++++++++------- src/browser/URL.zig | 54 +++++++ src/browser/webapi/Element.zig | 4 +- src/browser/webapi/Event.zig | 25 ++++ src/browser/webapi/KeyValueList.zig | 90 ++++++++++-- src/browser/webapi/element/html/Button.zig | 9 ++ src/browser/webapi/element/html/Form.zig | 5 + src/browser/webapi/event/UIEvent.zig | 13 ++ src/browser/webapi/net/FormData.zig | 15 ++ src/browser/webapi/net/URLSearchParams.zig | 17 +-- 11 files changed, 324 insertions(+), 77 deletions(-) diff --git a/src/browser/EventManager.zig b/src/browser/EventManager.zig index 1f91885b..bcf72e15 100644 --- a/src/browser/EventManager.zig +++ b/src/browser/EventManager.zig @@ -197,10 +197,16 @@ fn dispatchNode(self: *EventManager, target: *Node, event: *Event, was_handled: event._event_phase = .none; // Execute default action if not prevented - if (!event._prevent_default and event._type_string.eqlSlice("click")) { + if (event._prevent_default) { + // can't return in a defer (╯°□°)╯︵ ┻━┻ + } else if (event._type_string.eqlSlice("click")) { self.page.handleClick(target) catch |err| { log.warn(.event, "page.click", .{ .err = err }); }; + } else if (event._type_string.eqlSlice("keydown")) { + self.page.handleKeydown(target, event) catch |err| { + log.warn(.event, "page.keydown", .{ .err = err }); + }; } } diff --git a/src/browser/Page.zig b/src/browser/Page.zig index 0774a532..c9f465fc 100644 --- a/src/browser/Page.zig +++ b/src/browser/Page.zig @@ -39,7 +39,7 @@ const ScriptManager = @import("ScriptManager.zig"); const Parser = @import("parser/Parser.zig"); -const URL = @import("webapi/URL.zig"); +const URL = @import("URL.zig"); const Node = @import("webapi/Node.zig"); const Event = @import("webapi/Event.zig"); const CData = @import("webapi/CData.zig"); @@ -62,7 +62,9 @@ const KeyboardEvent = @import("webapi/event/KeyboardEvent.zig"); const timestamp = @import("../datetime.zig").timestamp; const milliTimestamp = @import("../datetime.zig").milliTimestamp; -var default_url = URL{ ._raw = "about:blank" }; +const WebApiURL = @import("webapi/URL.zig"); + +var default_url = WebApiURL{ ._raw = "about:blank" }; pub var default_location: Location = Location{ ._url = &default_url }; pub const BUF_SIZE = 1024; @@ -297,13 +299,11 @@ pub fn getTitle(self: *Page) !?[]const u8 { } pub fn getOrigin(self: *Page, allocator: Allocator) !?[]const u8 { - const URLRaw = @import("URL.zig"); - return try URLRaw.getOrigin(allocator, self.url); + return try URL.getOrigin(allocator, self.url); } pub fn isSameOrigin(self: *const Page, url: [:0]const u8) !bool { - const URLRaw = @import("URL.zig"); - const current_origin = (try URLRaw.getOrigin(self.call_arena, self.url)) orelse return false; + const current_origin = (try URL.getOrigin(self.call_arena, self.url)) orelse return false; return std.mem.startsWith(u8, url, current_origin); } @@ -440,7 +440,6 @@ pub fn scheduleNavigation(self: *Page, request_url: []const u8, opts: NavigateOp } const session = self._session; - const URLRaw = @import("URL.zig"); const resolved_url = try URL.resolve( session.transfer_arena, @@ -449,7 +448,7 @@ pub fn scheduleNavigation(self: *Page, request_url: []const u8, opts: NavigateOp .{ .always_dupe = true }, ); - if (!opts.force and URLRaw.eqlDocument(self.url, resolved_url)) { + if (!opts.force and URL.eqlDocument(self.url, resolved_url)) { self.url = try self.arena.dupeZ(u8, resolved_url); self.window._location = try Location.init(self.url, self); self.document._location = self.window._location; @@ -2421,50 +2420,73 @@ pub fn triggerMouseClick(self: *Page, x: f64, y: f64) !void { pub fn handleClick(self: *Page, target: *Node) !void { // TODO: Also support elements when implement const element = target.is(Element) orelse return; - const anchor = element.is(Element.Html.Anchor) orelse return; + const html_element = element.is(Element.Html) orelse return; - const href = element.getAttributeSafe("href") orelse return; - if (href.len == 0) { - return; + switch (html_element._type) { + .anchor => |anchor| { + const href = element.getAttributeSafe("href") orelse return; + if (href.len == 0) { + return; + } + + if (std.mem.startsWith(u8, href, "#")) { + // Hash-only links (#foo) should be handled as same-document navigation + return; + } + + if (std.mem.startsWith(u8, href, "javascript:")) { + return; + } + + // Check target attribute - don't navigate if opening in new window/tab + const target_val = anchor.getTarget(); + if (target_val.len > 0 and !std.mem.eql(u8, target_val, "_self")) { + log.warn(.browser, "not implemented", .{ + .feature = "anchor with target attribute click", + }); + return; + } + + if (try element.hasAttribute("download", self)) { + log.warn(.browser, "not implemented", .{ + .feature = "anchor with download attribute click", + }); + return; + } + + try self.scheduleNavigation(href, .{ + .reason = .script, + .kind = .{ .push = null }, + }, .anchor); + }, + .input => |input| switch (input._input_type) { + .submit => return self.submitForm(element, input.getForm(self)), + else => self.window._document._active_element = element, + }, + .button => |button| { + if (std.mem.eql(u8, button.getType(), "submit")) { + return self.submitForm(element, button.getForm(self)); + } + }, + .select, .textarea => self.window._document._active_element = element, + else => {}, } - - if (std.mem.startsWith(u8, href, "#")) { - // Hash-only links (#foo) should be handled as same-document navigation - return; - } - - if (std.mem.startsWith(u8, href, "javascript:")) { - return; - } - - // Check target attribute - don't navigate if opening in new window/tab - const target_val = anchor.getTarget(); - if (target_val.len > 0 and !std.mem.eql(u8, target_val, "_self")) { - log.warn(.browser, "not implemented", .{ - .feature = "anchor with target attribute click", - }); - return; - } - - if (try element.hasAttribute("download", self)) { - log.warn(.browser, "not implemented", .{ - .feature = "anchor with download attribute click", - }); - return; - } - - try self.scheduleNavigation(href, .{ - .reason = .script, - .kind = .{ .push = null }, - }, .anchor); } pub fn triggerKeyboard(self: *Page, keyboard_event: *KeyboardEvent) !void { const element = self.window._document._active_element orelse return; + if (comptime IS_DEBUG) { + log.debug(.page, "page keydown", .{ + .url = self.url, + .node = element, + .key = keyboard_event._key, + }); + } try self._event_manager.dispatch(element.asEventTarget(), keyboard_event.asEvent()); } -pub fn handleKeydown(self: *Page, target: *Element, keyboard_event: *KeyboardEvent) !void { +pub fn handleKeydown(self: *Page, target: *Node, event: *Event) !void { + const keyboard_event = event.as(KeyboardEvent); const key = keyboard_event.getKey(); if (key == .Dead) { @@ -2473,11 +2495,7 @@ pub fn handleKeydown(self: *Page, target: *Element, keyboard_event: *KeyboardEve if (target.is(Element.Html.Input)) |input| { if (key == .Enter) { - if (input.getForm(self)) |form| { - // TODO: Implement form submission - _ = form; - } - return; + return self.submitForm(input.asElement(), input.getForm(self)); } // Don't handle text input for radio/checkbox @@ -2496,14 +2514,59 @@ pub fn handleKeydown(self: *Page, target: *Element, keyboard_event: *KeyboardEve } if (target.is(Element.Html.TextArea)) |textarea| { + // zig fmt: off const append = - if (key == .Enter) "\n" else if (key.isPrintable()) key.asString() else return; + if (key == .Enter) "\n" + else if (key.isPrintable()) key.asString() + else return + ; + // zig fmt: on const current_value = textarea.getValue(); const new_value = try std.mem.concat(self.arena, u8, &.{ current_value, append }); return textarea.setValue(new_value, self); } } +pub fn submitForm(self: *Page, submitter_: ?*Element, form_: ?*Element.Html.Form) !void { + const form = form_ orelse return; + + if (submitter_) |submitter| { + if (submitter.getAttributeSafe("disabled") != null) { + return; + } + } + const form_element = form.asElement(); + + const FormData = @import("webapi/net/FormData.zig"); + // The submitter can be an input box (if enter was entered on the box) + // I don't think this is technically correct, but FormData handles it ok + const form_data = try FormData.init(form, submitter_, self); + + const transfer_arena = self._session.transfer_arena; + + const encoding = form_element.getAttributeSafe("enctype"); + + var buf = std.Io.Writer.Allocating.init(transfer_arena); + try form_data.write(encoding, &buf.writer); + + const method = form_element.getAttributeSafe("method") orelse ""; + var action = form_element.getAttributeSafe("action") orelse self.url; + + var opts = NavigateOpts{ + .reason = .form, + .kind = .{ .push = null }, + }; + if (std.ascii.eqlIgnoreCase(method, "post")) { + opts.method = .POST; + opts.body = buf.written(); + // form_data.write currently only supports this encoding, so we know this has to be the content type + opts.header = "Content-Type: application/x-www-form-urlencoded"; + } else { + action = try URL.concatQueryString(transfer_arena, action, buf.written()); + } + return self.scheduleNavigation(action, opts, .form); +} + const RequestCookieOpts = struct { is_http: bool = true, is_navigation: bool = false, diff --git a/src/browser/URL.zig b/src/browser/URL.zig index ba5d02de..ead1ba37 100644 --- a/src/browser/URL.zig +++ b/src/browser/URL.zig @@ -471,6 +471,30 @@ pub fn setHash(current: [:0]const u8, value: []const u8, allocator: Allocator) ! return buildUrl(allocator, protocol, host, pathname, search, hash); } +pub fn concatQueryString(arena: Allocator, url: []const u8, query_string: []const u8) ![:0]const u8 { + if (query_string.len == 0) { + return arena.dupeZ(u8, url); + } + + var buf: std.ArrayList(u8) = .empty; + + // the most space well need is the url + ('?' or '&') + the query_string + null terminator + try buf.ensureTotalCapacity(arena, url.len + 2 + query_string.len); + buf.appendSliceAssumeCapacity(url); + + if (std.mem.indexOfScalar(u8, url, '?')) |index| { + const last_index = url.len - 1; + if (index != last_index and url[last_index] != '&') { + buf.appendAssumeCapacity('&'); + } + } else { + buf.appendAssumeCapacity('?'); + } + buf.appendSliceAssumeCapacity(query_string); + buf.appendAssumeCapacity(0); + return buf.items[0 .. buf.items.len - 1 :0]; +} + const KnownProtocol = enum { @"http:", @"https:", @@ -707,3 +731,33 @@ test "URL: eqlDocument" { try testing.expectEqual(false, eqlDocument(url1, url2)); } } + +test "URL: concatQueryString" { + defer testing.reset(); + const arena = testing.arena_allocator; + + { + const url = try concatQueryString(arena, "https://www.lightpanda.io/", ""); + try testing.expectEqual("https://www.lightpanda.io/", url); + } + + { + const url = try concatQueryString(arena, "https://www.lightpanda.io/index?", ""); + try testing.expectEqual("https://www.lightpanda.io/index?", url); + } + + { + const url = try concatQueryString(arena, "https://www.lightpanda.io/index?", "a=b"); + try testing.expectEqual("https://www.lightpanda.io/index?a=b", url); + } + + { + const url = try concatQueryString(arena, "https://www.lightpanda.io/index?1=2", "a=b"); + try testing.expectEqual("https://www.lightpanda.io/index?1=2&a=b", url); + } + + { + const url = try concatQueryString(arena, "https://www.lightpanda.io/index?1=2&", "a=b"); + try testing.expectEqual("https://www.lightpanda.io/index?1=2&a=b", url); + } +} diff --git a/src/browser/webapi/Element.zig b/src/browser/webapi/Element.zig index c9a382f7..fe5effac 100644 --- a/src/browser/webapi/Element.zig +++ b/src/browser/webapi/Element.zig @@ -77,7 +77,7 @@ pub fn is(self: *Element, comptime T: type) ?*T { const type_name = @typeName(T); switch (self._type) { .html => |el| { - if (T == *Html) { + if (T == Html) { return el; } if (comptime std.mem.startsWith(u8, type_name, "browser.webapi.element.html.")) { @@ -85,7 +85,7 @@ pub fn is(self: *Element, comptime T: type) ?*T { } }, .svg => |svg| { - if (T == *Svg) { + if (T == Svg) { return svg; } if (comptime std.mem.startsWith(u8, type_name, "webapi.element.svg.")) { diff --git a/src/browser/webapi/Event.zig b/src/browser/webapi/Event.zig index 23a0fa0e..7aa14cff 100644 --- a/src/browser/webapi/Event.zig +++ b/src/browser/webapi/Event.zig @@ -85,6 +85,31 @@ pub fn init(typ: []const u8, opts_: ?Options, page: *Page) !*Event { }); } +pub fn as(self: *Event, comptime T: type) *T { + return self.is(T).?; +} + +pub fn is(self: *Event, comptime T: type) ?*T { + switch (self._type) { + .generic => return if (T == Event) self else null, + .error_event => |e| return if (T == @import("event/ErrorEvent.zig")) e else null, + .custom_event => |e| return if (T == @import("event/CustomEvent.zig")) e else null, + .message_event => |e| return if (T == @import("event/MessageEvent.zig")) e else null, + .progress_event => |e| return if (T == @import("event/ProgressEvent.zig")) e else null, + .composition_event => |e| return if (T == @import("event/CompositionEvent.zig")) e else null, + .navigation_current_entry_change_event => |e| return if (T == @import("event/NavigationCurrentEntryChangeEvent.zig")) e else null, + .page_transition_event => |e| return if (T == @import("event/PageTransitionEvent.zig")) e else null, + .pop_state_event => |e| return if (T == @import("event/PopStateEvent.zig")) e else null, + .ui_event => |e| { + if (T == @import("event/UIEvent.zig")) { + return e; + } + return e.is(T); + }, + } + return null; +} + pub fn getType(self: *const Event) []const u8 { return self._type_string.str(); } diff --git a/src/browser/webapi/KeyValueList.zig b/src/browser/webapi/KeyValueList.zig index d94eacb5..b91e6d3b 100644 --- a/src/browser/webapi/KeyValueList.zig +++ b/src/browser/webapi/KeyValueList.zig @@ -34,6 +34,16 @@ pub fn registerTypes() []const type { } const Normalizer = *const fn ([]const u8, *Page) []const u8; + +pub const Entry = struct { + name: String, + value: String, + + pub fn format(self: Entry, writer: *std.Io.Writer) !void { + return writer.print("{f}: {f}", .{ self.name, self.value }); + } +}; + pub const KeyValueList = @This(); _entries: std.ArrayListUnmanaged(Entry) = .empty, @@ -85,15 +95,6 @@ pub fn fromArray(arena: Allocator, kvs: []const [2][]const u8, comptime normaliz return list; } -pub const Entry = struct { - name: String, - value: String, - - pub fn format(self: Entry, writer: *std.Io.Writer) !void { - return writer.print("{f}: {f}", .{ self.name, self.value }); - } -}; - pub fn init() KeyValueList { return .{}; } @@ -172,6 +173,77 @@ pub fn items(self: *const KeyValueList) []const Entry { return self._entries.items; } +const URLEncodeMode = enum { + form, + query, +}; + +pub fn urlEncode(self: *const KeyValueList, comptime mode: URLEncodeMode, writer: *std.Io.Writer) !void { + const entries = self._entries.items; + if (entries.len == 0) { + return; + } + + try urlEncodeEntry(entries[0], mode, writer); + for (entries[1..]) |entry| { + try writer.writeByte('&'); + try urlEncodeEntry(entry, mode, writer); + } +} + +fn urlEncodeEntry(entry: Entry, comptime mode: URLEncodeMode, writer: *std.Io.Writer) !void { + try urlEncodeValue(entry.name.str(), mode, writer); + + // for a form, for an empty value, we'll do "spice=" + // but for a query, we do "spice" + if ((comptime mode == .query) and entry.value.len == 0) { + return; + } + + try writer.writeByte('='); + try urlEncodeValue(entry.value.str(), mode, writer); +} + +fn urlEncodeValue(value: []const u8, comptime mode: URLEncodeMode, writer: *std.Io.Writer) !void { + if (!urlEncodeShouldEscape(value, mode)) { + return writer.writeAll(value); + } + + for (value) |b| { + if (urlEncodeUnreserved(b, mode)) { + try writer.writeByte(b); + } else if (b == ' ') { + try writer.writeByte('+'); + } else if (b >= 0x80) { + // Double-encode: treat byte as Latin-1 code point, encode to UTF-8, then percent-encode + // For bytes 0x80-0xFF (U+0080 to U+00FF), UTF-8 encoding is 2 bytes: + // [0xC0 | (b >> 6), 0x80 | (b & 0x3F)] + const byte1 = 0xC0 | (b >> 6); + const byte2 = 0x80 | (b & 0x3F); + try writer.print("%{X:0>2}%{X:0>2}", .{ byte1, byte2 }); + } else { + try writer.print("%{X:0>2}", .{b}); + } + } +} + +fn urlEncodeShouldEscape(value: []const u8, comptime mode: URLEncodeMode) bool { + for (value) |b| { + if (!urlEncodeUnreserved(b, mode)) { + return true; + } + } + return false; +} + +fn urlEncodeUnreserved(b: u8, comptime mode: URLEncodeMode) bool { + return switch (b) { + 'A'...'Z', 'a'...'z', '0'...'9', '-', '.', '_', '*' => true, + '~' => comptime mode == .form, + else => false, + }; +} + pub const Iterator = struct { index: u32 = 0, kv: *KeyValueList, diff --git a/src/browser/webapi/element/html/Button.zig b/src/browser/webapi/element/html/Button.zig index d58e0cbb..c13cdcf4 100644 --- a/src/browser/webapi/element/html/Button.zig +++ b/src/browser/webapi/element/html/Button.zig @@ -58,6 +58,14 @@ pub fn setName(self: *Button, name: []const u8, page: *Page) !void { try self.asElement().setAttributeSafe("name", name, page); } +pub fn getType(self: *const Button) []const u8 { + return self.asConstElement().getAttributeSafe("type") orelse "submit"; +} + +pub fn setType(self: *Button, typ: []const u8, page: *Page) !void { + try self.asElement().setAttributeSafe("type", typ, page); +} + pub fn getValue(self: *const Button) []const u8 { return self.asConstElement().getAttributeSafe("value") orelse ""; } @@ -116,6 +124,7 @@ pub const JsApi = struct { pub const required = bridge.accessor(Button.getRequired, Button.setRequired, .{}); pub const form = bridge.accessor(Button.getForm, null, .{}); pub const value = bridge.accessor(Button.getValue, Button.setValue, .{}); + pub const @"type" = bridge.accessor(Button.getType, Button.setType, .{}); }; pub const Build = struct { diff --git a/src/browser/webapi/element/html/Form.zig b/src/browser/webapi/element/html/Form.zig index ac89948b..1c6fe87a 100644 --- a/src/browser/webapi/element/html/Form.zig +++ b/src/browser/webapi/element/html/Form.zig @@ -88,6 +88,10 @@ pub fn getLength(self: *Form, page: *Page) !u32 { return elements.length(page); } +pub fn submit(self: *Form, page: *Page) !void { + return page.submitForm(null, self); +} + pub const JsApi = struct { pub const bridge = js.Bridge(Form); pub const Meta = struct { @@ -100,6 +104,7 @@ pub const JsApi = struct { pub const method = bridge.accessor(Form.getMethod, Form.setMethod, .{}); pub const elements = bridge.accessor(Form.getElements, null, .{}); pub const length = bridge.accessor(Form.getLength, null, .{}); + pub const submit = bridge.function(Form.submit, .{}); }; const testing = @import("../../../../testing.zig"); diff --git a/src/browser/webapi/event/UIEvent.zig b/src/browser/webapi/event/UIEvent.zig index 75a58cd6..c30e8743 100644 --- a/src/browser/webapi/event/UIEvent.zig +++ b/src/browser/webapi/event/UIEvent.zig @@ -61,6 +61,19 @@ pub fn init(typ: []const u8, _opts: ?Options, page: *Page) !*UIEvent { return event; } +pub fn as(self: *UIEvent, comptime T: type) *T { + return self.is(T).?; +} + +pub fn is(self: *UIEvent, comptime T: type) ?*T { + switch (self._type) { + .generic => return if (T == UIEvent) self else null, + .mouse_event => |e| return if (T == @import("MouseEvent.zig")) e else null, + .keyboard_event => |e| return if (T == @import("KeyboardEvent.zig")) e else null, + } + return null; +} + pub fn populateFromOptions(self: *UIEvent, opts: anytype) void { self._detail = opts.detail; self._view = opts.view; diff --git a/src/browser/webapi/net/FormData.zig b/src/browser/webapi/net/FormData.zig index 1c5b8314..0bc72688 100644 --- a/src/browser/webapi/net/FormData.zig +++ b/src/browser/webapi/net/FormData.zig @@ -87,6 +87,21 @@ pub fn forEach(self: *FormData, cb_: js.Function, js_this_: ?js.Object) !void { } } +pub fn write(self: *const FormData, encoding_: ?[]const u8, writer: *std.Io.Writer) !void { + const encoding = encoding_ orelse { + return self._list.urlEncode(.form, writer); + }; + + if (std.ascii.eqlIgnoreCase(encoding, "application/x-www-form-urlencoded")) { + return self._list.urlEncode(.form, writer); + } + + log.debug(.not_implemented, "not implemented", .{ + .feature = "form data encoding", + .encoding = encoding, + }); +} + pub const Iterator = struct { index: u32 = 0, list: *const FormData, diff --git a/src/browser/webapi/net/URLSearchParams.zig b/src/browser/webapi/net/URLSearchParams.zig index 482c4c7f..cb4a8764 100644 --- a/src/browser/webapi/net/URLSearchParams.zig +++ b/src/browser/webapi/net/URLSearchParams.zig @@ -109,22 +109,7 @@ pub fn entries(self: *URLSearchParams, page: *Page) !*KeyValueList.EntryIterator } pub fn toString(self: *const URLSearchParams, writer: *std.Io.Writer) !void { - const items = self._params._entries.items; - if (items.len == 0) { - return; - } - - try writeEntry(&items[0], writer); - for (items[1..]) |entry| { - try writer.writeByte('&'); - try writeEntry(&entry, writer); - } -} - -fn writeEntry(entry: *const KeyValueList.Entry, writer: *std.Io.Writer) !void { - try escape(entry.name.str(), writer); - try writer.writeByte('='); - try escape(entry.value.str(), writer); + return self._params.urlEncode(.query, writer); } pub fn format(self: *const URLSearchParams, writer: *std.Io.Writer) !void {