form submitt

This commit is contained in:
Karl Seguin
2025-12-22 19:45:29 +08:00
parent 8215f2fd8f
commit 437df18a07
11 changed files with 324 additions and 77 deletions

View File

@@ -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 });
};
}
}

View File

@@ -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 <area> 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,

View File

@@ -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);
}
}

View File

@@ -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.")) {

View File

@@ -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();
}

View File

@@ -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,

View File

@@ -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 {

View File

@@ -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");

View File

@@ -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;

View File

@@ -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,

View File

@@ -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 {