Merge branch 'main' into agent

This commit is contained in:
Adrià Arrufat
2026-06-05 12:43:46 +02:00
43 changed files with 3661 additions and 207 deletions

View File

@@ -73,6 +73,21 @@ pub fn pop(self: *Inbox) ?*Message {
return @fieldParentPtr("node", node);
}
// Peek for a message matching `predicate` without removing it. Used by
// syncRequest to notice a queued teardown command (which sync_wait can't
// safely dispatch mid-parse) so it can abort the blocking fetch instead
// of stalling for the full per-request timeout.
pub fn contains(self: *Inbox, predicate: *const fn (*Message) bool) bool {
self.mutex.lock();
defer self.mutex.unlock();
var it = self.queue.first;
while (it) |node| : (it = node.next) {
const msg: *Message = @fieldParentPtr("node", node);
if (predicate(msg)) return true;
}
return false;
}
// Cherry-pick the first message for which `predicate(msg)` returns
// true, removing it from the queue. Walks the queue in FIFO order;
// non-matching messages stay in place. Used to dispatch only the

View File

@@ -36,6 +36,7 @@ const CustomElementReactions = @import("CustomElementReactions.zig");
const URL = @import("URL.zig");
const Blob = @import("webapi/Blob.zig");
const FileList = @import("webapi/FileList.zig");
const Node = @import("webapi/Node.zig");
const Event = @import("webapi/Event.zig");
const EventTarget = @import("webapi/EventTarget.zig");
@@ -57,9 +58,11 @@ const CSSStyleSheet = @import("webapi/css/CSSStyleSheet.zig");
const CustomElementDefinition = @import("webapi/CustomElementDefinition.zig");
const PageTransitionEvent = @import("webapi/event/PageTransitionEvent.zig");
const SubmitEvent = @import("webapi/event/SubmitEvent.zig");
const popover = @import("webapi/element/popover.zig");
const NavigationKind = @import("webapi/navigation/root.zig").NavigationKind;
const KeyboardEvent = @import("webapi/event/KeyboardEvent.zig");
const MouseEvent = @import("webapi/event/MouseEvent.zig");
const WheelEvent = @import("webapi/event/WheelEvent.zig");
const HttpClient = @import("HttpClient.zig");
@@ -145,6 +148,10 @@ _event_target_attr_listeners: GlobalEventHandlersLookup = .empty,
// Blob URL registry for URL.createObjectURL/revokeObjectURL
_blob_urls: std.StringHashMapUnmanaged(*Blob) = .{},
// FileLists owned by `<input type=file>` elements. Each holds refs on its
// File objects (reference counted via their Blob proto); released at teardown.
_file_lists: std.ArrayList(*FileList) = .{},
/// `load` events that'll be fired before window's `load` event.
/// A call to `documentIsComplete` (which calls `_documentIsComplete`) resets it.
/// Double-buffered so that dispatching load events (which may trigger JS that
@@ -403,6 +410,12 @@ pub fn deinit(self: *Frame) void {
}
}
for (self._file_lists.items) |file_list| {
for (file_list._files) |file| {
file._proto.releaseRef(page);
}
}
{
var node: ?*std.DoublyLinkedList.Node = self._mutation_observers.first;
while (node) |n| {
@@ -887,7 +900,10 @@ pub fn abortTransfers(self: *Frame) void {
for (self.child_frames.items) |child| {
child.abortTransfers();
}
self._session.browser.http_client.abortOwner(&self._http_owner);
const http_client = &self._session.browser.http_client;
http_client.abortOwner(&self._http_owner);
// abortOwner misses deferred contexts whose transfer already completed.
http_client.deferring_layer.cancelFrame(self._frame_id);
}
pub fn documentIsLoaded(self: *Frame) void {
@@ -1646,6 +1662,11 @@ pub fn registerIntersectionObserver(self: *Frame, observer: *IntersectionObserve
try self._intersection_observers.append(self.arena, observer);
}
// Tracks a file input's FileList so its File refs are released at teardown.
pub fn trackFileList(self: *Frame, file_list: *FileList) !void {
try self._file_lists.append(self.arena, file_list);
}
pub fn unregisterIntersectionObserver(self: *Frame, observer: *IntersectionObserver) void {
for (self._intersection_observers.items, 0..) |obs, i| {
if (obs == observer) {
@@ -3044,24 +3065,11 @@ pub fn removeNode(self: *Frame, parent: *Node, child: *Node, opts: RemoveNodeOpt
null;
const children = parent._children.?;
switch (children.*) {
.one => |n| {
lp.assert(n == child, "Frame.removeNode.one", .{});
parent._children = null;
self._factory.destroy(children);
},
.list => |list| {
list.remove(&child._child_link);
// Should not be possible to get a child list with a single node.
// While it doesn't cause any problems, it indicates an bug in the
// code as these should always be represented as .{.one = node}
const first = list.first.?;
if (first.next == null) {
children.* = .{ .one = Node.linkToNode(first) };
self._factory.destroy(list);
}
},
children.remove(&child._child_link);
if (children.first == null) {
// last child removed; drop the list so a childless node holds no allocation
parent._children = null;
self._factory.destroy(children);
}
// grab this before we null the parent
const was_connected = child.isConnected();
@@ -3127,6 +3135,8 @@ pub fn removeNode(self: *Frame, parent: *Node, child: *Node, opts: RemoveNodeOpt
Element.Html.Custom.enqueueDisconnectedCallbackOnElement(el, self);
popover.removeFromOpen(el, self);
// If a <style> element is being removed, remove its sheet from the list
if (el.is(Element.Html.Style)) |style| {
if (style._sheet) |sheet| {
@@ -3204,44 +3214,23 @@ pub fn _insertNodeRelative(self: *Frame, comptime from_parser: bool, parent: *No
lp.assert(child._parent == null, "Frame.insertNodeRelative parent", .{});
const children = blk: {
// expand parent._children so that it can take another child
if (parent._children) |c| {
switch (c.*) {
.list => {},
.one => |node| {
const list = try self._factory.create(std.DoublyLinkedList{});
list.append(&node._child_link);
c.* = .{ .list = list };
},
}
break :blk c;
} else {
const Children = @import("webapi/children.zig").Children;
const c = try self._factory.create(Children{ .one = child });
parent._children = c;
break :blk c;
}
const children = parent._children orelse blk: {
const list = try self._factory.create(std.DoublyLinkedList{});
parent._children = list;
break :blk list;
};
switch (relative) {
.append => switch (children.*) {
.one => {}, // already set in the expansion above
.list => |list| list.append(&child._child_link),
},
.append => children.append(&child._child_link),
.after => |ref_node| {
// caller should have made sure this was the case
lp.assert(ref_node._parent.? == parent, "Frame.insertNodeRelative after", .{ .url = self.url });
// if ref_node is in parent, and expanded _children above to
// accommodate another child, then `children` must be a list
children.list.insertAfter(&ref_node._child_link, &child._child_link);
children.insertAfter(&ref_node._child_link, &child._child_link);
},
.before => |ref_node| {
// caller should have made sure this was the case
lp.assert(ref_node._parent.? == parent, "Frame.insertNodeRelative before", .{ .url = self.url });
// if ref_node is in parent, and expanded _children above to
// accommodate another child, then `children` must be a list
children.list.insertBefore(&ref_node._child_link, &child._child_link);
children.insertBefore(&ref_node._child_link, &child._child_link);
},
}
child._parent = parent;
@@ -3383,6 +3372,9 @@ pub fn attributeChange(self: *Frame, element: *Element, name: String, value: Str
if (element.is(Element.Html.Slot)) |slot| {
self.signalSlotChange(slot);
}
} else if (name.eql(comptime .wrap("popover"))) {
const old = if (old_value) |o| o.str() else null;
popover.attributeChanged(element, old, value.str(), self);
}
}
@@ -3409,6 +3401,8 @@ pub fn attributeRemove(self: *Frame, element: *Element, name: String, old_value:
if (element.is(Element.Html.Slot)) |slot| {
self.signalSlotChange(slot);
}
} else if (name.eql(comptime .wrap("popover"))) {
popover.attributeChanged(element, old_value.str(), null, self);
}
}
@@ -3609,7 +3603,7 @@ fn parseHtmlAsChildrenInner(self: *Frame, node: *Node, html: []const u8, allow_d
// we expect, and nodes might be altogether removed. We deal with this in a
// few different places, but always the same way: leave it as-is.
const children = node._children orelse return;
const first = children.first();
const first = Node.linkToNode(children.first.?);
if (first.is(Element.Html.Html) == null) {
return;
}
@@ -3935,6 +3929,114 @@ pub fn triggerMouseClick(self: *Frame, x: f64, y: f64) !void {
try self._event_manager.dispatch(target.asEventTarget(), mouse_event.asEvent());
}
pub fn triggerMouseMove(self: *Frame, x: f64, y: f64) !void {
const target = (try self.window._document.elementFromPoint(x, y, self)) orelse return;
if (comptime IS_DEBUG) {
log.debug(.frame, "frame mouse move", .{
.url = self.url,
.node = target,
.x = x,
.y = y,
.type = self._type,
});
}
const move_event: *MouseEvent = try .initTrusted(comptime .wrap("mousemove"), .{
.bubbles = true,
.cancelable = true,
.composed = true,
.clientX = x,
.clientY = y,
}, self);
try self._event_manager.dispatch(target.asEventTarget(), move_event.asEvent());
const over_event: *MouseEvent = try .initTrusted(comptime .wrap("mouseover"), .{
.bubbles = true,
.cancelable = true,
.composed = true,
.clientX = x,
.clientY = y,
}, self);
try self._event_manager.dispatch(target.asEventTarget(), over_event.asEvent());
const enter_event: *MouseEvent = try .initTrusted(comptime .wrap("mouseenter"), .{
.composed = true,
.clientX = x,
.clientY = y,
}, self);
try self._event_manager.dispatch(target.asEventTarget(), enter_event.asEvent());
}
pub fn triggerMouseRelease(self: *Frame, x: f64, y: f64) !void {
const target = (try self.window._document.elementFromPoint(x, y, self)) orelse return;
if (comptime IS_DEBUG) {
log.debug(.frame, "frame mouse release", .{
.url = self.url,
.node = target,
.x = x,
.y = y,
.type = self._type,
});
}
const up_event: *MouseEvent = try .initTrusted(comptime .wrap("mouseup"), .{
.bubbles = true,
.cancelable = true,
.composed = true,
.clientX = x,
.clientY = y,
}, self);
try self._event_manager.dispatch(target.asEventTarget(), up_event.asEvent());
}
pub fn triggerMouseWheel(self: *Frame, x: f64, y: f64, delta_x: f64, delta_y: f64) !void {
const target = (try self.window._document.elementFromPoint(x, y, self)) orelse return;
if (comptime IS_DEBUG) {
log.debug(.frame, "frame mouse wheel", .{
.url = self.url,
.node = target,
.x = x,
.y = y,
.delta_x = delta_x,
.delta_y = delta_y,
.type = self._type,
});
}
const wheel_event: *WheelEvent = try .initTrusted("wheel", .{
.bubbles = true,
.cancelable = true,
.composed = true,
.clientX = x,
.clientY = y,
.deltaX = delta_x,
.deltaY = delta_y,
}, self);
// Keep the event alive past dispatch so we can read _prevent_default.
wheel_event.asEvent().acquireRef();
defer _ = wheel_event.asEvent().releaseRef(self._page);
try self._event_manager.dispatch(target.asEventTarget(), wheel_event.asEvent());
if (wheel_event.asEvent()._prevent_default) {
return;
}
// Apply the scroll and fire a trusted scroll event, mirroring WebDriver wheel.
// CDP deltas are untrusted, so guard NaN and saturate the addition.
const new_left: i32 = @as(i32, @intCast(target.getScrollLeft(self))) +| deltaToScroll(delta_x);
const new_top: i32 = @as(i32, @intCast(target.getScrollTop(self))) +| deltaToScroll(delta_y);
try target.setScrollLeft(new_left, self);
try target.setScrollTop(new_top, self);
const scroll_event = try Event.initTrusted(comptime .wrap("scroll"), .{ .bubbles = true }, self._page);
try self._event_manager.dispatch(target.asEventTarget(), scroll_event);
}
fn deltaToScroll(d: f64) i32 {
if (std.math.isNan(d)) return 0;
return @intFromFloat(std.math.clamp(d, std.math.minInt(i32), std.math.maxInt(i32)));
}
// callback when the "click" event reaches the frame.
pub fn handleClick(self: *Frame, target: *Node) !void {
// TODO: Also support <area> elements when implement
@@ -4133,6 +4235,21 @@ pub fn submitForm(self: *Frame, submitter_: ?*Element, form_: ?*Element.Html.For
form._firing_submission_events = true;
defer form._firing_submission_events = false;
// Per the HTML "submit a form element" algorithm: unless the form (or the
// submitter, via formnovalidate) is in the no-validate state, interactively
// validate the form's constraints and abort submission if it fails.
// checkValidity() fires the `invalid` events on the offending controls.
// https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#concept-form-submit
const skip_validation = form.getNoValidate() or blk: {
const s = submit_button orelse break :blk false;
if (s.is(Element.Html.Form.Input)) |input| break :blk input.getFormNoValidate();
if (s.is(Element.Html.Form.Button)) |button| break :blk button.getFormNoValidate();
break :blk false;
};
if (!skip_validation and !try form.checkValidity(self)) {
return;
}
// Per HTML spec "submit a form element" algorithm: SubmitEvent.submitter
// must be null when the submitter is the form itself, which is what
// Form.requestSubmit() passes when called with no submitter argument.

View File

@@ -802,6 +802,14 @@ pub fn syncRequest(self: *Client, allocator: Allocator, req: Request) !SyncRespo
}
return err;
};
if (sync_ctx.completion == .in_progress and self.inbox.contains(isSyncWaitInterrupt)) {
// A teardown/close command is queued but sync_wait can't dispatch
// it mid-parse (it would free the Page/Frame this stack holds).
// Abort the blocking fetch so the parser unwinds to the next safe
// drain and the command runs there, instead of stalling for the
// full per-request timeout per blocking script.
transfer.abort(error.SyncWaitInterrupted);
}
}
switch (sync_ctx.completion) {
@@ -1019,6 +1027,24 @@ fn isFetchInterceptionMethod(method: []const u8) bool {
std.mem.eql(u8, method, "Fetch.continueWithAuth");
}
// True for inbox messages that mean "this page/connection is going away".
// syncRequest uses this to bail out of a blocking-script wait promptly
// rather than holding the worker for the per-request timeout while a
// teardown command sits undispatched behind the sync_wait allowlist.
fn isSyncWaitInterrupt(msg: *Inbox.Message) bool {
return switch (msg.payload) {
.close, .disconnect => true,
.ping => false,
.cdp => |c| isTeardownMethod(c.input.method),
};
}
fn isTeardownMethod(method: []const u8) bool {
return std.mem.eql(u8, method, "Target.closeTarget") or
std.mem.eql(u8, method, "Target.disposeBrowserContext") or
std.mem.eql(u8, method, "Page.close");
}
fn processOneMessage(self: *Client, msg: http.Handles.MultiMessage, transfer: *Transfer) !bool {
// State at entry: .inflight = conn (multi just delivered a completion).
if (msg.err == null or msg.err.? == error.RecvError) {
@@ -2249,3 +2275,51 @@ test "HttpClient: allowDuringSyncWait denies non-Fetch CDP methods" {
try testing.expect(!allowDuringSyncWait(&msg));
}
}
test "HttpClient: isSyncWaitInterrupt matches teardown methods, close and disconnect" {
var raw_buf: [16]u8 = undefined;
inline for ([_][]const u8{
"Target.closeTarget",
"Target.disposeBrowserContext",
"Page.close",
}) |method| {
var msg = Inbox.Message{
.arena = testing.allocator,
.payload = .{ .cdp = .{
.raw = &raw_buf,
.input = .{ .method = method },
} },
};
try testing.expect(isSyncWaitInterrupt(&msg));
}
var close_msg = Inbox.Message{ .arena = testing.allocator, .payload = .close };
try testing.expect(isSyncWaitInterrupt(&close_msg));
var disconnect_msg = Inbox.Message{ .arena = testing.allocator, .payload = .{ .disconnect = null } };
try testing.expect(isSyncWaitInterrupt(&disconnect_msg));
}
test "HttpClient: isSyncWaitInterrupt ignores ping and non-teardown CDP methods" {
var ping_msg = Inbox.Message{ .arena = testing.allocator, .payload = .{ .ping = "" } };
try testing.expect(!isSyncWaitInterrupt(&ping_msg));
var raw_buf: [16]u8 = undefined;
inline for ([_][]const u8{
"Page.navigate",
"Runtime.evaluate",
"Target.createTarget",
"Fetch.continueRequest",
"",
}) |method| {
var msg = Inbox.Message{
.arena = testing.allocator,
.payload = .{ .cdp = .{
.raw = &raw_buf,
.input = .{ .method = method },
} },
};
try testing.expect(!isSyncWaitInterrupt(&msg));
}
}

View File

@@ -20,16 +20,12 @@ const std = @import("std");
const Mime = @This();
content_type: ContentType,
params: []const u8 = "",
// IANA defines max. charset value length as 40.
// We keep 41 for null-termination since HTML parser expects in this format.
charset: [41]u8 = default_charset,
charset_len: usize = default_charset_len,
is_default_charset: bool = true,
type_buf: [127]u8 = @splat(0),
sub_type_buf: [127]u8 = @splat(0),
/// String "UTF-8" continued by null characters.
const default_charset = .{ 'U', 'T', 'F', '-', '8' } ++ .{0} ** 36;
const default_charset_len = 5;
@@ -64,10 +60,9 @@ pub const ContentType = union(ContentTypeEnum) {
image_webp: void,
application_json: void,
unknown: void,
other: struct {
type: []const u8,
sub_type: []const u8,
},
// A valid but unrecognized type/subtype. Keeping it would require some
// memory management of the input. Nothing needs it right now, so why bother.
other: void,
};
pub fn contentTypeString(mime: *const Mime) []const u8 {
@@ -125,11 +120,10 @@ pub fn parse(input: []const u8) !Mime {
var buf: [255]u8 = undefined;
const normalized = std.ascii.lowerString(&buf, std.mem.trim(u8, input, &std.ascii.whitespace));
_ = std.ascii.lowerString(normalized, normalized);
var mime = Mime{ .content_type = undefined };
const content_type, const type_len = try parseContentType(normalized, &mime.type_buf, &mime.sub_type_buf);
const content_type, const type_len = try parseContentType(normalized);
if (type_len >= normalized.len) {
return .{ .content_type = content_type };
}
@@ -170,7 +164,6 @@ pub fn parse(input: []const u8) !Mime {
}
}
mime.params = params;
mime.charset = charset;
mime.charset_len = charset_len;
mime.content_type = content_type;
@@ -401,7 +394,7 @@ pub fn isText(mime: *const Mime) bool {
}
// we expect value to be lowercase
fn parseContentType(value: []const u8, type_buf: []u8, sub_type_buf: []u8) !struct { ContentType, usize } {
fn parseContentType(value: []const u8) !struct { ContentType, usize } {
const end = std.mem.indexOfScalarPos(u8, value, 0, ';') orelse value.len;
const type_name = trimRight(value[0..end]);
const attribute_start = end + 1;
@@ -450,18 +443,7 @@ fn parseContentType(value: []const u8, type_buf: []u8, sub_type_buf: []u8) !stru
return error.Invalid;
}
@memcpy(type_buf[0..main_type.len], main_type);
@memcpy(sub_type_buf[0..sub_type.len], sub_type);
return .{
.{
.other = .{
.type = type_buf[0..main_type.len],
.sub_type = sub_type_buf[0..sub_type.len],
},
},
attribute_start,
};
return .{ .{ .other = {} }, attribute_start };
}
const VALID_CODEPOINTS = blk: {
@@ -475,13 +457,6 @@ const VALID_CODEPOINTS = blk: {
break :blk v;
};
pub fn typeString(self: *const Mime) []const u8 {
return switch (self.content_type) {
.other => |o| o.type[0..o.type_len],
else => "",
};
}
fn validType(value: []const u8) bool {
for (value) |b| {
if (VALID_CODEPOINTS[b] == false) {
@@ -581,17 +556,14 @@ test "Mime: parse uncommon" {
defer testing.reset();
const text_csv = Expectation{
.content_type = .{ .other = .{ .type = "text", .sub_type = "csv" } },
.content_type = .{ .other = {} },
};
try expect(text_csv, "text/csv");
try expect(text_csv, "text/csv;");
try expect(text_csv, " text/csv\t ");
try expect(text_csv, " text/csv\t ;");
try expect(
.{ .content_type = .{ .other = .{ .type = "text", .sub_type = "csv" } } },
"Text/CSV",
);
try expect(.{ .content_type = .{ .other = {} } }, "Text/CSV");
}
test "Mime: parse charset" {
@@ -600,37 +572,31 @@ test "Mime: parse charset" {
try expect(.{
.content_type = .{ .text_xml = {} },
.charset = "utf-8",
.params = "charset=utf-8",
}, "text/xml; charset=utf-8");
try expect(.{
.content_type = .{ .text_xml = {} },
.charset = "utf-8",
.params = "charset=\"utf-8\"",
}, "text/xml;charset=\"UTF-8\"");
try expect(.{
.content_type = .{ .text_html = {} },
.charset = "iso-8859-1",
.params = "charset=\"iso-8859-1\"",
}, "text/html; charset=\"iso-8859-1\"");
try expect(.{
.content_type = .{ .text_html = {} },
.charset = "iso-8859-1",
.params = "charset=\"iso-8859-1\"",
}, "text/html; charset=\"ISO-8859-1\"");
try expect(.{
.content_type = .{ .text_xml = {} },
.charset = "custom-non-standard-charset-value",
.params = "charset=\"custom-non-standard-charset-value\"",
}, "text/xml;charset=\"custom-non-standard-charset-value\"");
try expect(.{
.content_type = .{ .text_html = {} },
.charset = "UTF-8",
.params = "x=\"",
}, "text/html;x=\"");
}
@@ -737,7 +703,6 @@ test "Mime: sniff" {
const Expectation = struct {
content_type: Mime.ContentType,
params: []const u8 = "",
charset: ?[]const u8 = null,
};
@@ -750,17 +715,6 @@ fn expect(expected: Expectation, input: []const u8) !void {
std.meta.activeTag(actual.content_type),
);
switch (expected.content_type) {
.other => |e| {
const a = actual.content_type.other;
try testing.expectEqual(e.type, a.type);
try testing.expectEqual(e.sub_type, a.sub_type);
},
else => {}, // already asserted above
}
try testing.expectEqual(expected.params, actual.params);
if (expected.charset) |ec| {
// We remove the null characters for testing purposes here.
try testing.expectEqual(ec, actual.charsetString());

View File

@@ -514,6 +514,14 @@ pub fn isExecutionTerminating(self: *const Env) bool {
return v8.v8__Isolate__IsExecutionTerminating(self.isolate.handle);
}
// Whether a forcible terminate has been requested (and not yet cleared by
// cancelTerminate). Unlike isExecutionTerminating, this is our own sticky
// flag, so it stays true after V8 consumes the terminate on the JSEntry
// unwind. Callers about to enter a fresh eval use it to refuse to run.
pub fn terminatePending(self: *const Env) bool {
return self.terminate_requested.load(.acquire);
}
pub fn terminate(self: *Env) void {
self.terminate_mutex.lock();
defer self.terminate_mutex.unlock();

View File

@@ -822,6 +822,8 @@ pub const PageJsApis = flattenTypes(&.{
@import("../webapi/DOMTreeWalker.zig"),
@import("../webapi/DOMNodeIterator.zig"),
@import("../webapi/DOMRect.zig"),
@import("../webapi/DOMMatrixReadOnly.zig"),
@import("../webapi/DOMMatrix.zig"),
@import("../webapi/DOMParser.zig"),
@import("../webapi/XMLSerializer.zig"),
@import("../webapi/AbstractRange.zig"),
@@ -929,6 +931,7 @@ pub const PageJsApis = flattenTypes(&.{
@import("../webapi/event/PromiseRejectionEvent.zig"),
@import("../webapi/event/SubmitEvent.zig"),
@import("../webapi/event/FormDataEvent.zig"),
@import("../webapi/event/ToggleEvent.zig"),
@import("../webapi/MessageChannel.zig"),
@import("../webapi/MessagePort.zig"),
@import("../webapi/Worker.zig"),
@@ -949,6 +952,7 @@ pub const PageJsApis = flattenTypes(&.{
@import("../webapi/net/URLSearchParams.zig"),
@import("../webapi/net/XMLHttpRequest.zig"),
@import("../webapi/net/XMLHttpRequestEventTarget.zig"),
@import("../webapi/net/XMLHttpRequestUpload.zig"),
@import("../webapi/net/WebSocket.zig"),
@import("../webapi/event/CloseEvent.zig"),
@import("../webapi/streams/ReadableStream.zig"),
@@ -1008,6 +1012,8 @@ pub const WorkerJsApis = flattenTypes(&.{
@import("../webapi/event/PromiseRejectionEvent.zig"),
@import("../webapi/event/CloseEvent.zig"),
@import("../webapi/DOMException.zig"),
@import("../webapi/DOMMatrixReadOnly.zig"),
@import("../webapi/DOMMatrix.zig"),
@import("../webapi/net/URLSearchParams.zig"),
@import("../webapi/encoding/TextEncoder.zig"),
@import("../webapi/encoding/TextDecoder.zig"),
@@ -1036,6 +1042,7 @@ pub const WorkerJsApis = flattenTypes(&.{
@import("../webapi/canvas/OffscreenCanvasRenderingContext2D.zig"),
@import("../webapi/net/XMLHttpRequest.zig"),
@import("../webapi/net/XMLHttpRequestEventTarget.zig"),
@import("../webapi/net/XMLHttpRequestUpload.zig"),
@import("../webapi/net/WebSocket.zig"),
@import("../webapi/FileReader.zig"),
@import("../webapi/ImageData.zig"),

View File

@@ -0,0 +1 @@
<input id="upload" type="file">

View File

@@ -0,0 +1,401 @@
<!DOCTYPE html>
<head>
<title>DOMMatrix Test</title>
<script src="testing.js"></script>
</head>
<body>
</body>
<script id=identity_no_args>
{
const m = new DOMMatrix();
testing.expectEqual(1, m.a);
testing.expectEqual(0, m.b);
testing.expectEqual(0, m.c);
testing.expectEqual(1, m.d);
testing.expectEqual(0, m.e);
testing.expectEqual(0, m.f);
testing.expectTrue(m.is2D);
testing.expectTrue(m.isIdentity);
testing.expectEqual(1, m.m11);
testing.expectEqual(1, m.m22);
testing.expectEqual(1, m.m33);
testing.expectEqual(1, m.m44);
}
</script>
<script id=from_6_element_array>
{
const m = new DOMMatrix([1, 2, 3, 4, 5, 6]);
testing.expectEqual(1, m.a);
testing.expectEqual(2, m.b);
testing.expectEqual(3, m.c);
testing.expectEqual(4, m.d);
testing.expectEqual(5, m.e);
testing.expectEqual(6, m.f);
testing.expectTrue(m.is2D);
testing.expectFalse(m.isIdentity);
}
</script>
<script id=from_16_element_array>
{
const m = new DOMMatrix([
1, 2, 3, 4,
5, 6, 7, 8,
9, 10, 11, 12,
13, 14, 15, 16,
]);
testing.expectEqual(1, m.m11);
testing.expectEqual(6, m.m22);
testing.expectEqual(11, m.m33);
testing.expectEqual(16, m.m44);
testing.expectEqual(13, m.m41);
testing.expectFalse(m.is2D);
}
</script>
<script id=from_matrix_string>
{
const m = new DOMMatrix('matrix(1, 0, 0, 1, 10, 20)');
testing.expectEqual(1, m.a);
testing.expectEqual(0, m.b);
testing.expectEqual(10, m.e);
testing.expectEqual(20, m.f);
testing.expectTrue(m.is2D);
}
</script>
<script id=from_none_and_empty>
{
const none = new DOMMatrix('none');
testing.expectTrue(none.isIdentity);
const empty = new DOMMatrix('');
testing.expectTrue(empty.isIdentity);
}
</script>
<script id=from_translate_string>
{
const m = new DOMMatrix('translate(40px, 50px)');
testing.expectEqual(40, m.e);
testing.expectEqual(50, m.f);
testing.expectTrue(m.is2D);
}
</script>
<script id=from_scale_string>
{
const m = new DOMMatrix('scale(2)');
testing.expectEqual(2, m.a);
testing.expectEqual(2, m.d);
}
</script>
<script id=from_matrix3d_string>
{
const m = new DOMMatrix('matrix3d(1,0,0,0, 0,1,0,0, 0,0,1,0, 5,6,7,1)');
testing.expectEqual(5, m.m41);
testing.expectEqual(6, m.m42);
testing.expectEqual(7, m.m43);
testing.expectFalse(m.is2D);
}
</script>
<script id=rotate_90>
{
const m = new DOMMatrix('rotate(90deg)');
// cos(90)=0, sin(90)=1
testing.expectTrue(Math.abs(m.a - 0) < 1e-9);
testing.expectTrue(Math.abs(m.b - 1) < 1e-9);
testing.expectTrue(Math.abs(m.c - -1) < 1e-9);
testing.expectTrue(Math.abs(m.d - 0) < 1e-9);
}
</script>
<script id=constructor_copies_matrix_via_string>
{
// Per WebIDL the init arg is (string or sequence); a matrix is not a
// sequence, so it is stringified and re-parsed — round-tripping a copy.
const src = new DOMMatrix([
2, 1, 0, 0,
1, 2, 0, 0,
0, 0, 1, 0,
10, 10, 0, 1,
]);
const m = new DOMMatrix(src);
testing.expectEqual(2, m.m11);
testing.expectEqual(1, m.m21);
testing.expectEqual(10, m.m41);
testing.expectFalse(m.is2D);
}
</script>
<script id=constructor_invalid_string_throws_syntaxerror>
{
let name = null;
try { new DOMMatrix('not a transform'); } catch (e) { name = e.name; }
testing.expectEqual('SyntaxError', name);
}
</script>
<script id=from_matrix_dict>
{
const m = DOMMatrix.fromMatrix({ a: 2, d: 3, e: 10, f: 20 });
testing.expectEqual(2, m.a);
testing.expectEqual(3, m.d);
testing.expectEqual(10, m.e);
testing.expectEqual(20, m.f);
testing.expectTrue(m.is2D);
const ro = DOMMatrixReadOnly.fromMatrix({ m11: 1, m22: 1, m33: 1, m44: 1, is2D: false });
testing.expectTrue(ro instanceof DOMMatrixReadOnly);
testing.expectFalse(ro.is2D);
}
</script>
<script id=from_matrix_alias_conflict_throws>
{
// a and m11 both present but unequal -> TypeError
let threw = false;
try { DOMMatrix.fromMatrix({ a: 1, m11: 2 }); } catch (e) { threw = (e instanceof TypeError); }
testing.expectTrue(threw);
}
</script>
<script id=from_float_arrays>
{
const m6 = DOMMatrix.fromFloat64Array(new Float64Array([1, 0, 0, 1, 7, 8]));
testing.expectEqual(7, m6.e);
testing.expectEqual(8, m6.f);
testing.expectTrue(m6.is2D);
const m16 = DOMMatrix.fromFloat32Array(new Float32Array([
1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1,
]));
testing.expectFalse(m16.is2D);
testing.expectTrue(m16.isIdentity);
}
</script>
<script id=multiply_translate>
{
const a = new DOMMatrix().translate(10, 0);
const b = new DOMMatrix().translate(0, 20);
const m = a.multiply(b);
testing.expectEqual(10, m.e);
testing.expectEqual(20, m.f);
}
</script>
<script id=scale_self>
{
const m = new DOMMatrix();
const r = m.scaleSelf(3);
testing.expectEqual(3, m.a);
testing.expectEqual(3, m.d);
// self methods return the same object
testing.expectTrue(r === m);
}
</script>
<script id=inverse_translate>
{
const m = new DOMMatrix().translate(10, 20);
const inv = m.inverse();
testing.expectEqual(-10, inv.e);
testing.expectEqual(-20, inv.f);
}
</script>
<script id=set_a_mutates>
{
const m = new DOMMatrix();
m.a = 5;
testing.expectEqual(5, m.m11);
}
</script>
<script id=to_string_throws_on_non_finite>
{
for (const num of [NaN, Infinity, -Infinity]) {
let name = null;
const m = new DOMMatrix([1, 0, 0, 1, 0, num]);
try { String(m); } catch (e) { name = e.name; }
testing.expectEqual('InvalidStateError', name);
}
// finite matrix stringifies fine
testing.expectEqual('matrix(1, 0, 0, 1, 0, 0)', String(new DOMMatrix()));
}
</script>
<script id=to_string_2d>
{
const m = new DOMMatrix([1, 0, 0, 1, 10, 20]);
testing.expectEqual('matrix(1, 0, 0, 1, 10, 20)', m.toString());
}
</script>
<script id=to_float32_array>
{
const m = new DOMMatrix();
const arr = m.toFloat32Array();
testing.expectTrue(arr instanceof Float32Array);
testing.expectEqual(16, arr.length);
testing.expectEqual(1, arr[0]);
testing.expectEqual(1, arr[15]);
}
</script>
<script id=set_3d_element_zero_preserves_is2d>
{
// Setting a z/w element to 0 (or -0) must preserve is2D; only a non-zero
// value clears it.
for (const attr of ['m13', 'm14', 'm23', 'm24', 'm31', 'm32', 'm34', 'm43']) {
const m = new DOMMatrix();
m[attr] = 0;
testing.expectTrue(m.is2D);
m[attr] = -0;
testing.expectTrue(m.is2D);
m[attr] = 42;
testing.expectFalse(m.is2D);
}
// m33/m44 identity is 1: setting to 1 preserves, anything else clears.
const a = new DOMMatrix();
a.m33 = 1;
testing.expectTrue(a.is2D);
a.m33 = 2;
testing.expectFalse(a.is2D);
}
</script>
<script id=multiply_no_args_is_copy>
{
const m = new DOMMatrix([1, 2, 3, 4, 5, 6]);
const r = m.multiply();
testing.expectEqual(1, r.a);
testing.expectEqual(6, r.f);
testing.expectTrue(r !== m);
}
</script>
<script id=new_object_methods>
{
const m = new DOMMatrix();
for (const method of ['scale3d', 'rotateAxisAngle', 'rotateFromVector', 'skewX', 'skewY', 'flipX', 'flipY', 'multiply']) {
const r = m[method]();
testing.expectTrue(r instanceof DOMMatrix);
testing.expectTrue(r !== m);
}
}
</script>
<script id=skew_keeps_2d>
{
const m = new DOMMatrix().skewX(30);
testing.expectTrue(m.is2D);
// tan(30deg) ~= 0.5774 lands in c (m21)
testing.expectTrue(Math.abs(m.c - Math.tan(30 * Math.PI / 180)) < 1e-9);
}
</script>
<script id=readonly_exists_and_inheritance>
{
const ro = new DOMMatrixReadOnly([1, 0, 0, 1, 10, 20]);
testing.expectEqual(10, ro.e);
testing.expectEqual(20, ro.f);
testing.expectTrue(ro instanceof DOMMatrixReadOnly);
// DOMMatrix extends DOMMatrixReadOnly
const m = new DOMMatrix();
testing.expectTrue(m instanceof DOMMatrix);
testing.expectTrue(m instanceof DOMMatrixReadOnly);
testing.expectFalse(ro instanceof DOMMatrix);
}
</script>
<script id=readonly_is_immutable>
{
const ro = new DOMMatrixReadOnly([1, 0, 0, 1, 10, 20]);
// Setting a component on a read-only matrix must not change it (the
// accessor has no setter).
try { ro.a = 5; } catch (e) {}
testing.expectEqual(1, ro.a);
}
</script>
<script id=readonly_methods_return_mutable>
{
const ro = new DOMMatrixReadOnly();
const t = ro.translate(10, 20);
// DOMMatrixReadOnly methods return a (new, mutable) DOMMatrix
testing.expectTrue(t instanceof DOMMatrix);
testing.expectEqual(10, t.e);
testing.expectEqual(20, t.f);
// original is untouched
testing.expectEqual(0, ro.e);
}
</script>
<script id=mutable_self_methods_chain>
{
const m = new DOMMatrix();
const r = m.translateSelf(5, 6).scaleSelf(2);
testing.expectTrue(r === m);
testing.expectEqual(2, m.a);
testing.expectEqual(5, m.e);
testing.expectEqual(6, m.f);
}
</script>
<script id=multiply_accepts_dict>
{
const m = new DOMMatrix();
const r = m.multiply({ m11: 1, m12: 2, m21: 0, m22: 1, m41: 0, m42: 0 });
testing.expectEqual(2, r.b);
// original not mutated
testing.expectEqual(0, m.b);
}
</script>
<script id=multiply_accepts_matrix_instance>
{
const a = new DOMMatrix().translate(10, 0);
const b = new DOMMatrix().translate(0, 20);
const m = a.multiply(b);
testing.expectEqual(10, m.e);
testing.expectEqual(20, m.f);
}
</script>
<script id=invert_self_identity_and_singular>
{
// returns self
const m = new DOMMatrix().translate(10, -20.5);
const ret = m.invertSelf();
testing.expectTrue(ret === m);
testing.expectEqual(-10, m.e);
testing.expectEqual(20.5, m.f);
// singular matrix inverts to all-NaN, is2D false
const s = new DOMMatrix([0, 0, 0, 0, 0, 0]);
s.invertSelf();
testing.expectTrue(Number.isNaN(s.m11));
testing.expectTrue(Number.isNaN(s.m44));
testing.expectFalse(s.is2D);
}
</script>
<script id=read_all_m_components>
{
// framer-motion reads m11..m44 off a matrix built from a transform string
const m = new DOMMatrix('matrix(1, 0, 0, 1, 0, 0)');
let sum = 0;
for (let r = 1; r < 5; r++) {
for (let c = 1; c < 5; c++) {
sum += m[`m${r}${c}`];
}
}
testing.expectEqual(4, sum); // identity has four 1s on the diagonal
}
</script>

View File

@@ -13,6 +13,8 @@
<form id="empty-form"></form>
<form id="novalidate-form" novalidate></form>
<script id="surface">
{
const f = $('#valid-form');
@@ -46,6 +48,22 @@
}
</script>
<script id="no_validate_reflects_attribute">
{
// novalidate content attribute reflects to the noValidate IDL boolean
testing.expectEqual(true, $('#novalidate-form').noValidate);
testing.expectEqual(false, $('#valid-form').noValidate);
const f = $('#empty-form');
f.noValidate = true;
testing.expectEqual(true, f.noValidate);
testing.expectEqual('', f.getAttribute('novalidate'));
f.noValidate = false;
testing.expectEqual(false, f.noValidate);
testing.expectEqual(null, f.getAttribute('novalidate'));
}
</script>
<script id="report_validity_matches">
{
testing.expectEqual(false, $('#invalid-form').reportValidity());

View File

@@ -490,6 +490,44 @@
}
</script>
<!-- Test: interactive submission validates constraints unless no-validate is set -->
<form id="test_form_validate" action="/should-not-navigate-v" method="get">
<input type="text" name="q" required>
<button id="v_submit" type="submit">Go</button>
<button id="v_submit_novalidate" type="submit" formnovalidate>Go</button>
</form>
<script id="requestSubmit_validates_constraints">
{
const form = $('#test_form_validate');
const field = form.querySelector('input[name=q]');
let submitFired = 0;
let invalidFired = 0;
form.addEventListener('submit', (e) => { e.preventDefault(); submitFired++; });
field.addEventListener('invalid', () => { invalidFired++; });
// Required field is empty: submission is blocked and `invalid` fires.
form.requestSubmit($('#v_submit'));
testing.expectEqual(0, submitFired);
testing.expectEqual(1, invalidFired);
// A submitter with formnovalidate bypasses validation.
form.requestSubmit($('#v_submit_novalidate'));
testing.expectEqual(1, submitFired);
// form.noValidate also bypasses validation.
form.noValidate = true;
form.requestSubmit($('#v_submit'));
testing.expectEqual(2, submitFired);
// Once valid, validation passes and submission proceeds.
form.noValidate = false;
field.value = 'ok';
form.requestSubmit($('#v_submit'));
testing.expectEqual(3, submitFired);
}
</script>
<!-- Test: requestSubmit() with submitter not owned by form throws NotFoundError -->
<form id="test_form_rs2" action="/should-not-navigate5" method="get">
<input type="text" name="q" value="test">

View File

@@ -0,0 +1,60 @@
<!DOCTYPE html>
<script src="../../testing.js"></script>
<input id="f1" type="file">
<input id="f2" type="file" multiple>
<input id="t1" type="text">
<script id="files_initial">
{
const f1 = document.getElementById('f1');
testing.expectEqual(true, f1.files instanceof FileList);
testing.expectEqual(0, f1.files.length);
testing.expectEqual(null, f1.files.item(0));
testing.expectEqual('', f1.value);
// Identity preserved across reads.
testing.expectEqual(true, f1.files === f1.files);
}
</script>
<script id="files_iteration_empty">
{
// The list can't be populated from JS (no DataTransfer), but the indexed
// getter and iterator protocol must still be wired up correctly on an
// empty list. Populated indexing/iteration is covered by the CDP test
// "setFileInputFiles exposes files to JS".
const f1 = document.getElementById('f1');
// Indexed getter: out-of-range is undefined (not null, unlike item()).
testing.expectEqual(undefined, f1.files[0]);
// Iterable: spread, Array.from and for-of all work and yield nothing.
testing.expectEqual('function', typeof f1.files[Symbol.iterator]);
testing.expectEqual(0, [...f1.files].length);
testing.expectEqual(0, Array.from(f1.files).length);
let count = 0;
for (const _ of f1.files) count++;
testing.expectEqual(0, count);
}
</script>
<script id="files_non_file_input_is_null">
{
const t1 = document.getElementById('t1');
testing.expectEqual(null, t1.files);
}
</script>
<script id="value_setter">
{
const f1 = document.getElementById('f1');
// Per spec: setting value to "" is a no-op; anything else throws.
f1.value = '';
testing.expectEqual('', f1.value);
let threw = false;
try { f1.value = 'foo'; } catch (e) { threw = true; }
testing.expectEqual(true, threw);
}
</script>

View File

@@ -306,6 +306,107 @@
}
</script>
<script id=xhr_override_mime type=module>
{
// overrideMimeType is callable in state UNSENT (no open() yet)
const req = new XMLHttpRequest();
testing.expectEqual(0, req.readyState);
req.overrideMimeType('text/xml');
}
{
// overrideMimeType is callable in state OPENED
const req = new XMLHttpRequest();
req.open('GET', 'http://127.0.0.1:9582/xhr');
testing.expectEqual(1, req.readyState);
req.overrideMimeType('text/xml; charset=utf-8');
}
{
// Invalid mime values must NOT throw; they fall back to
// application/octet-stream per spec.
const req = new XMLHttpRequest();
req.overrideMimeType('!!!');
req.overrideMimeType('');
req.overrideMimeType('not a mime');
}
</script>
<script id=xhr_override_mime_done type=module>
{
// After send() reaches DONE, overrideMimeType throws InvalidStateError.
const state = await testing.async();
const req = new XMLHttpRequest();
req.onload = () => state.resolve();
req.open('GET', 'http://127.0.0.1:9582/xhr');
req.send();
await state.done(() => {
testing.expectEqual(4, req.readyState);
testing.withError((err) => {
testing.expectEqual('InvalidStateError', err.name);
}, () => {
req.overrideMimeType('text/xml');
});
});
}
</script>
<script id=xhr_override_mime_survives_open type=module>
{
// Override survives open() (spec: open() resets state but keeps the
// override MIME type). Re-opening then sending must not throw on the
// prior override.
const state = await testing.async();
const req = new XMLHttpRequest();
req.overrideMimeType('text/xml');
req.open('GET', 'http://127.0.0.1:9582/xhr');
req.open('GET', 'http://127.0.0.1:9582/xhr');
req.onload = () => state.resolve();
req.send();
await state.done(() => {
testing.expectEqual(200, req.status);
});
}
</script>
<script id=xhr_override_mime_responsexml type=module>
{
// End-to-end: server returns text/html, but overrideMimeType("text/xml")
// makes responseXML lazily parse the body as a Document, while
// responseText is unaffected (responseType is still the default "").
const state = await testing.async();
const req = new XMLHttpRequest();
req.overrideMimeType('text/xml');
req.open('GET', 'http://127.0.0.1:9582/xhr');
req.onload = () => state.resolve();
req.send();
await state.done(() => {
testing.expectEqual(200, req.status);
testing.expectEqual(100, req.responseText.length);
testing.expectEqual(true, req.responseXML instanceof Document);
// Cached across calls.
testing.expectEqual(req.responseXML, req.responseXML);
});
}
</script>
<script id=xhr_override_mime_no_override type=module>
{
// Without overrideMimeType, a text/html response still yields
// responseXML === null when responseType is the default.
const state = await testing.async();
const req = new XMLHttpRequest();
req.open('GET', 'http://127.0.0.1:9582/xhr');
req.onload = () => state.resolve();
req.send();
await state.done(() => {
testing.expectEqual(200, req.status);
testing.expectEqual(null, req.responseXML);
});
}
</script>
<script id=xhr_timeout type=module>
{
// timeout property: default is 0
@@ -329,3 +430,22 @@
});
}
</script>
<script id=xhr_upload type=module>
{
// upload is an XMLHttpRequestUpload (an XMLHttpRequestEventTarget) and
// exposes addEventListener; htmx relies on this not throwing.
const req = new XMLHttpRequest();
testing.expectEqual('XMLHttpRequestUpload', req.upload.constructor.name);
testing.expectEqual(true, req.upload instanceof XMLHttpRequestEventTarget);
testing.expectEqual(true, req.upload instanceof EventTarget);
testing.expectEqual('function', typeof req.upload.addEventListener);
// The same instance is returned on every access so listeners stick.
testing.expectEqual(req.upload, req.upload);
let registered = false;
req.upload.addEventListener('progress', () => { registered = true; });
testing.expectEqual(false, registered);
}
</script>

View File

@@ -4,6 +4,11 @@
let eventuallies = [];
let async_capture = null;
let current_script_id = null;
// can only have 1 async test per <script>, this tracks it
let async_seen = new Set();
// runner will wait until this is empty (or timeout)
let async_pending = new Set();
function expectTrue(actual) {
@@ -74,11 +79,15 @@
const script_id = (IS_TEST_RUNNER) ? document.currentScript.id : 'cannot track module id in FF/Chrome';
if (cb == undefined) {
if (async_seen.has(script_id)) {
throw new Error(`testing.async() called more than once for script '${script_id}'. A script may only register one async block (the runner can declare success in the gap between two of them); split the test into separate <script> tags.`);
}
async_seen.add(script_id);
let resolve = null
const promise = new Promise((r) => { resolve = r});
async_pending.add(script_id);
return {
promise: promise,
resolve: resolve,
@@ -140,7 +149,7 @@
}
function printTimeoutState() {
console.warn('Pending count:', Array.from(async_pending));
console.warn('Pending count:', Array.from(async_pending.keys()));
}
const IS_TEST_RUNNER = window.navigator.userAgent.startsWith("Lightpanda/");

View File

@@ -0,0 +1,301 @@
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// 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 <https://www.gnu.org/licenses/>.
const std = @import("std");
const js = @import("../js/js.zig");
const Page = @import("../Page.zig");
const RO = @import("DOMMatrixReadOnly.zig");
const DOMMatrix = @This();
_proto: *RO,
pub fn init(init_: ?js.Value, exec: *const js.Execution) !*DOMMatrix {
const parsed = try RO.Parsed.init(init_, exec);
return create(parsed.m, parsed.is_2d, exec.page);
}
// Builds the [DOMMatrixReadOnly, DOMMatrix] prototype chain on a single arena
// (owned by the base) and cross-links them, the same way File wraps Blob.
pub fn create(m: [16]f64, is_2d: bool, page: *Page) !*DOMMatrix {
const proto = try RO.createBare(m, is_2d, page);
errdefer proto.deinit(page);
const self = try proto._arena.create(DOMMatrix);
self.* = .{ ._proto = proto };
proto._type = .{ .mutable = self };
return self;
}
pub fn fromMatrix(other_: ?RO.DOMMatrixInit, page: *Page) !*DOMMatrix {
const parsed = try RO.fixupDict(other_ orelse .{});
return create(parsed.m, parsed.is_2d, page);
}
pub fn fromFloat32Array(array: js.TypedArray(f32), page: *Page) !*DOMMatrix {
const parsed = try RO.floatsToParsed(f32, array.values);
return create(parsed.m, parsed.is_2d, page);
}
pub fn fromFloat64Array(array: js.TypedArray(f64), page: *Page) !*DOMMatrix {
const parsed = try RO.floatsToParsed(f64, array.values);
return create(parsed.m, parsed.is_2d, page);
}
// The base already exposes read-only getters, but a redeclared accessor's
// getter must be typed to this (owner) struct, so we provide DOMMatrix-typed
// getters that read through `_proto`.
pub fn getA(self: *const DOMMatrix) f64 {
return self._proto._m[0];
}
pub fn getB(self: *const DOMMatrix) f64 {
return self._proto._m[1];
}
pub fn getC(self: *const DOMMatrix) f64 {
return self._proto._m[4];
}
pub fn getD(self: *const DOMMatrix) f64 {
return self._proto._m[5];
}
pub fn getE(self: *const DOMMatrix) f64 {
return self._proto._m[12];
}
pub fn getF(self: *const DOMMatrix) f64 {
return self._proto._m[13];
}
pub fn setA(self: *DOMMatrix, v: f64) void {
self._proto._m[0] = v;
}
pub fn setB(self: *DOMMatrix, v: f64) void {
self._proto._m[1] = v;
}
pub fn setC(self: *DOMMatrix, v: f64) void {
self._proto._m[4] = v;
}
pub fn setD(self: *DOMMatrix, v: f64) void {
self._proto._m[5] = v;
}
pub fn setE(self: *DOMMatrix, v: f64) void {
self._proto._m[12] = v;
}
pub fn setF(self: *DOMMatrix, v: f64) void {
self._proto._m[13] = v;
}
pub fn translateSelf(self: *DOMMatrix, tx_: ?f64, ty_: ?f64, tz_: ?f64) *DOMMatrix {
const tz = tz_ orelse 0;
const p = self._proto;
p._m = RO.multiplyMatrix(p._m, RO.translationMatrix(tx_ orelse 0, ty_ orelse 0, tz));
if (tz != 0) p._is_2d = false;
return self;
}
pub fn scaleSelf(self: *DOMMatrix, sx_: ?f64, sy_: ?f64, sz_: ?f64, ox_: ?f64, oy_: ?f64, oz_: ?f64) *DOMMatrix {
const sx = sx_ orelse 1;
const sy = sy_ orelse sx;
const sz = sz_ orelse 1;
const ox = ox_ orelse 0;
const oy = oy_ orelse 0;
const oz = oz_ orelse 0;
const p = self._proto;
var m = RO.multiplyMatrix(p._m, RO.translationMatrix(ox, oy, oz));
m = RO.multiplyMatrix(m, RO.scaleMatrix(sx, sy, sz));
m = RO.multiplyMatrix(m, RO.translationMatrix(-ox, -oy, -oz));
p._m = m;
if (sz != 1 or oz != 0) p._is_2d = false;
return self;
}
pub fn scale3dSelf(self: *DOMMatrix, scale_: ?f64, ox_: ?f64, oy_: ?f64, oz_: ?f64) *DOMMatrix {
const s = scale_ orelse 1;
const ox = ox_ orelse 0;
const oy = oy_ orelse 0;
const oz = oz_ orelse 0;
const p = self._proto;
var m = RO.multiplyMatrix(p._m, RO.translationMatrix(ox, oy, oz));
m = RO.multiplyMatrix(m, RO.scaleMatrix(s, s, s));
m = RO.multiplyMatrix(m, RO.translationMatrix(-ox, -oy, -oz));
p._m = m;
if (s != 1) p._is_2d = false;
return self;
}
pub fn rotateSelf(self: *DOMMatrix, rx_: ?f64, ry_: ?f64, rz_: ?f64) *DOMMatrix {
const p = self._proto;
if (ry_ == null and rz_ == null) {
p._m = RO.multiplyMatrix(p._m, RO.rotateZMatrix(RO.toRadians(rx_ orelse 0, .deg)));
} else {
p._m = RO.multiplyMatrix(p._m, RO.rotateXMatrix(RO.toRadians(rx_ orelse 0, .deg)));
p._m = RO.multiplyMatrix(p._m, RO.rotateYMatrix(RO.toRadians(ry_ orelse 0, .deg)));
p._m = RO.multiplyMatrix(p._m, RO.rotateZMatrix(RO.toRadians(rz_ orelse 0, .deg)));
p._is_2d = false;
}
return self;
}
pub fn rotateFromVectorSelf(self: *DOMMatrix, x_: ?f64, y_: ?f64) *DOMMatrix {
const x = x_ orelse 0;
const y = y_ orelse 0;
const rad = if (x == 0 and y == 0) 0 else std.math.atan2(y, x);
const p = self._proto;
p._m = RO.multiplyMatrix(p._m, RO.rotateZMatrix(rad));
return self;
}
pub fn rotateAxisAngleSelf(self: *DOMMatrix, x_: ?f64, y_: ?f64, z_: ?f64, angle_: ?f64) *DOMMatrix {
const p = self._proto;
p._m = RO.multiplyMatrix(p._m, RO.axisAngleMatrix(x_ orelse 0, y_ orelse 0, z_ orelse 0, RO.toRadians(angle_ orelse 0, .deg)));
if ((x_ orelse 0) != 0 or (y_ orelse 0) != 0) p._is_2d = false;
return self;
}
pub fn skewXSelf(self: *DOMMatrix, sx_: ?f64) *DOMMatrix {
const p = self._proto;
p._m = RO.multiplyMatrix(p._m, RO.skewMatrix(RO.toRadians(sx_ orelse 0, .deg), 0));
return self;
}
pub fn skewYSelf(self: *DOMMatrix, sy_: ?f64) *DOMMatrix {
const p = self._proto;
p._m = RO.multiplyMatrix(p._m, RO.skewMatrix(0, RO.toRadians(sy_ orelse 0, .deg)));
return self;
}
pub fn multiplySelf(self: *DOMMatrix, other_: ?RO.DOMMatrixInit) !*DOMMatrix {
const p = self._proto;
const other = try RO.fixupDict(other_ orelse .{});
p._m = RO.multiplyMatrix(p._m, other.m);
p._is_2d = p._is_2d and other.is_2d;
return self;
}
pub fn preMultiplySelf(self: *DOMMatrix, other_: ?RO.DOMMatrixInit) !*DOMMatrix {
const p = self._proto;
const other = try RO.fixupDict(other_ orelse .{});
p._m = RO.multiplyMatrix(other.m, p._m);
p._is_2d = p._is_2d and other.is_2d;
return self;
}
pub fn invertSelf(self: *DOMMatrix) *DOMMatrix {
const p = self._proto;
if (RO.invertMatrix(p._m)) |v| {
p._m = v;
} else {
p._m = .{std.math.nan(f64)} ** 16;
p._is_2d = false;
}
return self;
}
pub fn setMatrixValue(self: *DOMMatrix, transform: []const u8) !*DOMMatrix {
var m = RO.identity();
var is_2d = true;
try RO.parseTransformList(transform, &m, &is_2d);
self._proto._m = m;
self._proto._is_2d = is_2d;
return self;
}
pub const JsApi = struct {
pub const bridge = js.Bridge(DOMMatrix);
pub const Meta = struct {
pub const name = "DOMMatrix";
pub const prototype_chain = bridge.prototypeChain();
pub var class_id: bridge.ClassId = undefined;
};
pub const constructor = bridge.constructor(DOMMatrix.init, .{ .dom_exception = true });
pub const fromMatrix = bridge.function(DOMMatrix.fromMatrix, .{ .static = true });
pub const fromFloat32Array = bridge.function(DOMMatrix.fromFloat32Array, .{ .static = true });
pub const fromFloat64Array = bridge.function(DOMMatrix.fromFloat64Array, .{ .static = true });
// Make the components writable (the read-only getters are reused from the
// base; the setters are ours).
pub const a = bridge.accessor(DOMMatrix.getA, DOMMatrix.setA, .{});
pub const b = bridge.accessor(DOMMatrix.getB, DOMMatrix.setB, .{});
pub const c = bridge.accessor(DOMMatrix.getC, DOMMatrix.setC, .{});
pub const d = bridge.accessor(DOMMatrix.getD, DOMMatrix.setD, .{});
pub const e = bridge.accessor(DOMMatrix.getE, DOMMatrix.setE, .{});
pub const f = bridge.accessor(DOMMatrix.getF, DOMMatrix.setF, .{});
pub const m11 = bridge.accessor(getM(0), setM(0), .{});
pub const m12 = bridge.accessor(getM(1), setM(1), .{});
pub const m13 = bridge.accessor(getM(2), setM(2), .{});
pub const m14 = bridge.accessor(getM(3), setM(3), .{});
pub const m21 = bridge.accessor(getM(4), setM(4), .{});
pub const m22 = bridge.accessor(getM(5), setM(5), .{});
pub const m23 = bridge.accessor(getM(6), setM(6), .{});
pub const m24 = bridge.accessor(getM(7), setM(7), .{});
pub const m31 = bridge.accessor(getM(8), setM(8), .{});
pub const m32 = bridge.accessor(getM(9), setM(9), .{});
pub const m33 = bridge.accessor(getM(10), setM(10), .{});
pub const m34 = bridge.accessor(getM(11), setM(11), .{});
pub const m41 = bridge.accessor(getM(12), setM(12), .{});
pub const m42 = bridge.accessor(getM(13), setM(13), .{});
pub const m43 = bridge.accessor(getM(14), setM(14), .{});
pub const m44 = bridge.accessor(getM(15), setM(15), .{});
pub const translateSelf = bridge.function(DOMMatrix.translateSelf, .{});
pub const scaleSelf = bridge.function(DOMMatrix.scaleSelf, .{});
pub const scale3dSelf = bridge.function(DOMMatrix.scale3dSelf, .{});
pub const rotateSelf = bridge.function(DOMMatrix.rotateSelf, .{});
pub const rotateFromVectorSelf = bridge.function(DOMMatrix.rotateFromVectorSelf, .{});
pub const rotateAxisAngleSelf = bridge.function(DOMMatrix.rotateAxisAngleSelf, .{});
pub const skewXSelf = bridge.function(DOMMatrix.skewXSelf, .{});
pub const skewYSelf = bridge.function(DOMMatrix.skewYSelf, .{});
pub const multiplySelf = bridge.function(DOMMatrix.multiplySelf, .{});
pub const preMultiplySelf = bridge.function(DOMMatrix.preMultiplySelf, .{});
pub const invertSelf = bridge.function(DOMMatrix.invertSelf, .{});
// setMatrixValue parses a CSS transform string; Window-only.
pub const setMatrixValue = bridge.function(DOMMatrix.setMatrixValue, .{ .dom_exception = true, .exposed = .window });
fn getM(comptime idx: usize) fn (*const DOMMatrix) f64 {
return struct {
fn get(self: *const DOMMatrix) f64 {
return self._proto._m[idx];
}
}.get;
}
fn setM(comptime idx: usize) fn (*DOMMatrix, f64) void {
return struct {
fn set(self: *DOMMatrix, v: f64) void {
self._proto._m[idx] = v;
// Assigning a z/w element a value other than its identity drops the
// 2D flag. Setting it back to the identity value (0 for the
// off-diagonal elements, 1 for m33/m44) preserves is2D. Note `-0`
// compares equal to `0`, so it preserves it too, per spec.
switch (idx) {
2, 3, 6, 7, 8, 9, 11, 14 => if (v != 0) {
self._proto._is_2d = false;
},
10, 15 => if (v != 1) {
self._proto._is_2d = false;
},
else => {},
}
}
}.set;
}
};

View File

@@ -0,0 +1,870 @@
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// 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 <https://www.gnu.org/licenses/>.
const std = @import("std");
const lp = @import("lightpanda");
const js = @import("../js/js.zig");
const Page = @import("../Page.zig");
const DOMMatrix = @import("DOMMatrix.zig");
const Allocator = std.mem.Allocator;
const DOMMatrixReadOnly = @This();
pub const _prototype_root = true;
_type: Type,
_rc: lp.RC(u8),
_arena: Allocator,
// Stored column-major, matching the spec's mAB naming where A is the column
// and B is the row:
// _m[0.._4] = m11, m12, m13, m14 (first column)
// _m[4.._8] = m21, m22, m23, m24
// _m[8..12] = m31, m32, m33, m34
// _m[12..16] = m41, m42, m43, m44
//
// A point (x, y, z, w) is transformed as:
// out[row] = sum_col _m[col*4 + row] * in[col]
_m: [16]f64,
_is_2d: bool,
pub const Type = union(enum) {
generic,
mutable: *DOMMatrix,
};
pub fn init(init_: ?js.Value, exec: *const js.Execution) !*DOMMatrixReadOnly {
const parsed = try Parsed.init(init_, exec);
return createBare(parsed.m, parsed.is_2d, exec.page);
}
pub fn deinit(self: *DOMMatrixReadOnly, page: *Page) void {
page.releaseArena(self._arena);
}
pub fn acquireRef(self: *DOMMatrixReadOnly) void {
self._rc.acquire();
}
pub fn releaseRef(self: *DOMMatrixReadOnly, page: *Page) void {
self._rc.release(self, page);
}
pub fn createBare(m: [16]f64, is_2d: bool, page: *Page) !*DOMMatrixReadOnly {
const arena = try page.getArena(.tiny, "DOMMatrix");
errdefer page.releaseArena(arena);
const self = try arena.create(DOMMatrixReadOnly);
self.* = .{
._rc = .{},
._arena = arena,
._type = .generic,
._m = m,
._is_2d = is_2d,
};
return self;
}
pub const DOMMatrixInit = struct {
a: ?f64 = null,
b: ?f64 = null,
c: ?f64 = null,
d: ?f64 = null,
e: ?f64 = null,
f: ?f64 = null,
m11: ?f64 = null,
m12: ?f64 = null,
m13: ?f64 = null,
m14: ?f64 = null,
m21: ?f64 = null,
m22: ?f64 = null,
m23: ?f64 = null,
m24: ?f64 = null,
m31: ?f64 = null,
m32: ?f64 = null,
m33: ?f64 = null,
m34: ?f64 = null,
m41: ?f64 = null,
m42: ?f64 = null,
m43: ?f64 = null,
m44: ?f64 = null,
is2D: ?bool = null,
};
// Implements "validate and fixup a DOMMatrixInit dictionary".
pub fn fixupDict(d: DOMMatrixInit) !Parsed {
if (aliasConflict(d.m11, d.a) or aliasConflict(d.m12, d.b) or
aliasConflict(d.m21, d.c) or aliasConflict(d.m22, d.d) or
aliasConflict(d.m41, d.e) or aliasConflict(d.m42, d.f))
{
return error.TypeError;
}
// An explicit is2D:true is incompatible with any 3D member being set.
if (d.is2D) |is_2d| {
if (is_2d and has3dMembers(d)) {
return error.TypeError;
}
}
const m: [16]f64 = .{
d.m11 orelse d.a orelse 1, d.m12 orelse d.b orelse 0, d.m13 orelse 0, d.m14 orelse 0,
d.m21 orelse d.c orelse 0, d.m22 orelse d.d orelse 1, d.m23 orelse 0, d.m24 orelse 0,
d.m31 orelse 0, d.m32 orelse 0, d.m33 orelse 1, d.m34 orelse 0,
d.m41 orelse d.e orelse 0, d.m42 orelse d.f orelse 0, d.m43 orelse 0, d.m44 orelse 1,
};
const is_2d = d.is2D orelse !has3dMembers(d);
return .{ .m = m, .is_2d = is_2d };
}
// Builds a matrix from a 6- or 16-element float sequence (toFloat*Array order).
pub fn floatsToParsed(comptime T: type, values: []const T) !Parsed {
var m = identity();
if (values.len == 6) {
m = .{
values[0], values[1], 0, 0,
values[2], values[3], 0, 0,
0, 0, 1, 0,
values[4], values[5], 0, 1,
};
return .{ .m = m, .is_2d = true };
}
if (values.len == 16) {
for (0..16) |i| m[i] = values[i];
return .{ .m = m, .is_2d = false };
}
return error.TypeError;
}
pub fn fromMatrix(other_: ?DOMMatrixInit, page: *Page) !*DOMMatrixReadOnly {
const parsed = try fixupDict(other_ orelse .{});
return createBare(parsed.m, parsed.is_2d, page);
}
pub fn fromFloat32Array(array: js.TypedArray(f32), page: *Page) !*DOMMatrixReadOnly {
const parsed = try floatsToParsed(f32, array.values);
return createBare(parsed.m, parsed.is_2d, page);
}
pub fn fromFloat64Array(array: js.TypedArray(f64), page: *Page) !*DOMMatrixReadOnly {
const parsed = try floatsToParsed(f64, array.values);
return createBare(parsed.m, parsed.is_2d, page);
}
pub fn identity() [16]f64 {
return .{
1, 0, 0, 0,
0, 1, 0, 0,
0, 0, 1, 0,
0, 0, 0, 1,
};
}
// Returns lhs * rhs (composition: applying the result is lhs(rhs(point))).
pub fn multiplyMatrix(lhs: [16]f64, rhs: [16]f64) [16]f64 {
var out: [16]f64 = undefined;
for (0..4) |col| {
for (0..4) |row| {
var sum: f64 = 0;
for (0..4) |k| {
sum += lhs[k * 4 + row] * rhs[col * 4 + k];
}
out[col * 4 + row] = sum;
}
}
return out;
}
pub fn translationMatrix(tx: f64, ty: f64, tz: f64) [16]f64 {
return .{
1, 0, 0, 0,
0, 1, 0, 0,
0, 0, 1, 0,
tx, ty, tz, 1,
};
}
pub fn scaleMatrix(sx: f64, sy: f64, sz: f64) [16]f64 {
return .{
sx, 0, 0, 0,
0, sy, 0, 0,
0, 0, sz, 0,
0, 0, 0, 1,
};
}
pub fn rotateZMatrix(rad: f64) [16]f64 {
const c = @cos(rad);
const s = @sin(rad);
return .{
c, s, 0, 0,
-s, c, 0, 0,
0, 0, 1, 0,
0, 0, 0, 1,
};
}
pub fn rotateXMatrix(rad: f64) [16]f64 {
const c = @cos(rad);
const s = @sin(rad);
return .{
1, 0, 0, 0,
0, c, s, 0,
0, -s, c, 0,
0, 0, 0, 1,
};
}
pub fn rotateYMatrix(rad: f64) [16]f64 {
const c = @cos(rad);
const s = @sin(rad);
return .{
c, 0, -s, 0,
0, 1, 0, 0,
s, 0, c, 0,
0, 0, 0, 1,
};
}
// Rotation by `rad` about the (possibly unnormalised) axis (x, y, z).
pub fn axisAngleMatrix(x_in: f64, y_in: f64, z_in: f64, rad: f64) [16]f64 {
var x = x_in;
var y = y_in;
var z = z_in;
const len = @sqrt(x * x + y * y + z * z);
if (len == 0) {
return identity();
}
x /= len;
y /= len;
z /= len;
const c = @cos(rad);
const s = @sin(rad);
const t = 1 - c;
return .{
t * x * x + c, t * x * y + s * z, t * x * z - s * y, 0,
t * x * y - s * z, t * y * y + c, t * y * z + s * x, 0,
t * x * z + s * y, t * y * z - s * x, t * z * z + c, 0,
0, 0, 0, 1,
};
}
pub fn skewMatrix(ax_rad: f64, ay_rad: f64) [16]f64 {
return .{
1, @tan(ay_rad), 0, 0,
@tan(ax_rad), 1, 0, 0,
0, 0, 1, 0,
0, 0, 0, 1,
};
}
// Inverse of a 4x4 matrix; returns null if non-invertible.
pub fn invertMatrix(m: [16]f64) ?[16]f64 {
var inv: [16]f64 = undefined;
inv[0] = m[5] * m[10] * m[15] - m[5] * m[11] * m[14] - m[9] * m[6] * m[15] + m[9] * m[7] * m[14] + m[13] * m[6] * m[11] - m[13] * m[7] * m[10];
inv[4] = -m[4] * m[10] * m[15] + m[4] * m[11] * m[14] + m[8] * m[6] * m[15] - m[8] * m[7] * m[14] - m[12] * m[6] * m[11] + m[12] * m[7] * m[10];
inv[8] = m[4] * m[9] * m[15] - m[4] * m[11] * m[13] - m[8] * m[5] * m[15] + m[8] * m[7] * m[13] + m[12] * m[5] * m[11] - m[12] * m[7] * m[9];
inv[12] = -m[4] * m[9] * m[14] + m[4] * m[10] * m[13] + m[8] * m[5] * m[14] - m[8] * m[6] * m[13] - m[12] * m[5] * m[10] + m[12] * m[6] * m[9];
inv[1] = -m[1] * m[10] * m[15] + m[1] * m[11] * m[14] + m[9] * m[2] * m[15] - m[9] * m[3] * m[14] - m[13] * m[2] * m[11] + m[13] * m[3] * m[10];
inv[5] = m[0] * m[10] * m[15] - m[0] * m[11] * m[14] - m[8] * m[2] * m[15] + m[8] * m[3] * m[14] + m[12] * m[2] * m[11] - m[12] * m[3] * m[10];
inv[9] = -m[0] * m[9] * m[15] + m[0] * m[11] * m[13] + m[8] * m[1] * m[15] - m[8] * m[3] * m[13] - m[12] * m[1] * m[11] + m[12] * m[3] * m[9];
inv[13] = m[0] * m[9] * m[14] - m[0] * m[10] * m[13] - m[8] * m[1] * m[14] + m[8] * m[2] * m[13] + m[12] * m[1] * m[10] - m[12] * m[2] * m[9];
inv[2] = m[1] * m[6] * m[15] - m[1] * m[7] * m[14] - m[5] * m[2] * m[15] + m[5] * m[3] * m[14] + m[13] * m[2] * m[7] - m[13] * m[3] * m[6];
inv[6] = -m[0] * m[6] * m[15] + m[0] * m[7] * m[14] + m[4] * m[2] * m[15] - m[4] * m[3] * m[14] - m[12] * m[2] * m[7] + m[12] * m[3] * m[6];
inv[10] = m[0] * m[5] * m[15] - m[0] * m[7] * m[13] - m[4] * m[1] * m[15] + m[4] * m[3] * m[13] + m[12] * m[1] * m[7] - m[12] * m[3] * m[5];
inv[14] = -m[0] * m[5] * m[14] + m[0] * m[6] * m[13] + m[4] * m[1] * m[14] - m[4] * m[2] * m[13] - m[12] * m[1] * m[6] + m[12] * m[2] * m[5];
inv[3] = -m[1] * m[6] * m[11] + m[1] * m[7] * m[10] + m[5] * m[2] * m[11] - m[5] * m[3] * m[10] - m[9] * m[2] * m[7] + m[9] * m[3] * m[6];
inv[7] = m[0] * m[6] * m[11] - m[0] * m[7] * m[10] - m[4] * m[2] * m[11] + m[4] * m[3] * m[10] + m[8] * m[2] * m[7] - m[8] * m[3] * m[6];
inv[11] = -m[0] * m[5] * m[11] + m[0] * m[7] * m[9] + m[4] * m[1] * m[11] - m[4] * m[3] * m[9] - m[8] * m[1] * m[7] + m[8] * m[3] * m[5];
inv[15] = m[0] * m[5] * m[10] - m[0] * m[6] * m[9] - m[4] * m[1] * m[10] + m[4] * m[2] * m[9] + m[8] * m[1] * m[6] - m[8] * m[2] * m[5];
var det = m[0] * inv[0] + m[1] * inv[4] + m[2] * inv[8] + m[3] * inv[12];
if (det == 0) {
return null;
}
det = 1.0 / det;
var out: [16]f64 = undefined;
for (0..16) |i| {
out[i] = inv[i] * det;
}
return out;
}
// Parses a CSS <transform-list> (e.g. "matrix(1,0,0,1,10,20) scale(2)") and
// accumulates it into `m`. "none"/empty leave the matrix as identity.
pub fn parseTransformList(input: []const u8, m: *[16]f64, is_2d: *bool) !void {
const trimmed = std.mem.trim(u8, input, " \t\r\n");
if (trimmed.len == 0 or std.mem.eql(u8, trimmed, "none")) {
return;
}
var i: usize = 0;
while (i < trimmed.len) {
// skip whitespace and separating commas
while (i < trimmed.len and (std.ascii.isWhitespace(trimmed[i]) or trimmed[i] == ',')) : (i += 1) {}
if (i >= trimmed.len) {
break;
}
const name_start = i;
while (i < trimmed.len and trimmed[i] != '(') : (i += 1) {}
if (i >= trimmed.len) {
return error.SyntaxError;
}
const name = std.mem.trim(u8, trimmed[name_start..i], " \t\r\n");
i += 1; // consume '('
const args_start = i;
while (i < trimmed.len and trimmed[i] != ')') : (i += 1) {}
if (i >= trimmed.len) {
return error.SyntaxError;
}
const args = trimmed[args_start..i];
i += 1; // consume ')'
const func = try parseFunction(name, args, is_2d);
m.* = multiplyMatrix(m.*, func);
}
}
fn parseFunction(name: []const u8, args: []const u8, is_2d: *bool) ![16]f64 {
var nums: [16]f64 = undefined;
var units: [16]ParsedValue.Unit = undefined;
var count: usize = 0;
var it = std.mem.splitScalar(u8, args, ',');
while (it.next()) |raw| {
const tok = std.mem.trim(u8, raw, " \t\r\n");
if (tok.len == 0) {
continue;
}
if (count >= 16) {
return error.SyntaxError;
}
const parsed = try ParsedValue.parse(tok);
nums[count] = parsed.value;
units[count] = parsed.unit;
count += 1;
}
const Eql = std.mem.eql;
if (Eql(u8, name, "matrix")) {
if (count != 6) {
return error.SyntaxError;
}
return .{
nums[0], nums[1], 0, 0,
nums[2], nums[3], 0, 0,
0, 0, 1, 0,
nums[4], nums[5], 0, 1,
};
}
if (Eql(u8, name, "matrix3d")) {
if (count != 16) {
return error.SyntaxError;
}
is_2d.* = false;
return nums;
}
if (Eql(u8, name, "translate")) {
const tx = nums[0];
const ty = if (count > 1) nums[1] else 0;
return translationMatrix(tx, ty, 0);
}
if (Eql(u8, name, "translateX")) {
return translationMatrix(nums[0], 0, 0);
}
if (Eql(u8, name, "translateY")) {
return translationMatrix(0, nums[0], 0);
}
if (Eql(u8, name, "translateZ")) {
is_2d.* = false;
return translationMatrix(0, 0, nums[0]);
}
if (Eql(u8, name, "translate3d")) {
is_2d.* = false;
return translationMatrix(nums[0], nums[1], nums[2]);
}
if (Eql(u8, name, "scale")) {
const sx = nums[0];
const sy = if (count > 1) nums[1] else sx;
return scaleMatrix(sx, sy, 1);
}
if (Eql(u8, name, "scaleX")) {
return scaleMatrix(nums[0], 1, 1);
}
if (Eql(u8, name, "scaleY")) {
return scaleMatrix(1, nums[0], 1);
}
if (Eql(u8, name, "scaleZ")) {
is_2d.* = false;
return scaleMatrix(1, 1, nums[0]);
}
if (Eql(u8, name, "scale3d")) {
is_2d.* = false;
return scaleMatrix(nums[0], nums[1], nums[2]);
}
if (Eql(u8, name, "rotate") or Eql(u8, name, "rotateZ")) {
if (Eql(u8, name, "rotateZ")) is_2d.* = false;
return rotateZMatrix(toRadians(nums[0], units[0]));
}
if (Eql(u8, name, "rotateX")) {
is_2d.* = false;
return rotateXMatrix(toRadians(nums[0], units[0]));
}
if (Eql(u8, name, "rotateY")) {
is_2d.* = false;
return rotateYMatrix(toRadians(nums[0], units[0]));
}
if (Eql(u8, name, "rotate3d")) {
is_2d.* = false;
if (count != 4) {
return error.SyntaxError;
}
return axisAngleMatrix(nums[0], nums[1], nums[2], toRadians(nums[3], units[3]));
}
if (Eql(u8, name, "skew")) {
const ax = toRadians(nums[0], units[0]);
const ay = if (count > 1) toRadians(nums[1], units[1]) else 0;
return skewMatrix(ax, ay);
}
if (Eql(u8, name, "skewX")) {
return skewMatrix(toRadians(nums[0], units[0]), 0);
}
if (Eql(u8, name, "skewY")) {
return skewMatrix(0, toRadians(nums[0], units[0]));
}
if (Eql(u8, name, "perspective")) {
is_2d.* = false;
var out = identity();
if (nums[0] != 0) out[11] = -1.0 / nums[0];
return out;
}
return error.SyntaxError;
}
pub fn toRadians(value: f64, unit: ParsedValue.Unit) f64 {
return switch (unit) {
.rad => value,
.grad => value * std.math.pi / 200.0,
.turn => value * std.math.tau,
// bare numbers in rotate()/skew() are interpreted as degrees
.deg, .none => value * std.math.pi / 180.0,
};
}
pub fn getA(self: *const DOMMatrixReadOnly) f64 {
return self._m[0];
}
pub fn getB(self: *const DOMMatrixReadOnly) f64 {
return self._m[1];
}
pub fn getC(self: *const DOMMatrixReadOnly) f64 {
return self._m[4];
}
pub fn getD(self: *const DOMMatrixReadOnly) f64 {
return self._m[5];
}
pub fn getE(self: *const DOMMatrixReadOnly) f64 {
return self._m[12];
}
pub fn getF(self: *const DOMMatrixReadOnly) f64 {
return self._m[13];
}
pub fn getIs2D(self: *const DOMMatrixReadOnly) bool {
return self._is_2d;
}
pub fn getIsIdentity(self: *const DOMMatrixReadOnly) bool {
const id = identity();
for (0..16) |i| {
if (self._m[i] != id[i]) {
return false;
}
}
return true;
}
pub fn translate(self: *const DOMMatrixReadOnly, tx_: ?f64, ty_: ?f64, tz_: ?f64, page: *Page) !*DOMMatrix {
const tz = tz_ orelse 0;
return DOMMatrix.create(
multiplyMatrix(self._m, translationMatrix(tx_ orelse 0, ty_ orelse 0, tz)),
self._is_2d and tz == 0,
page,
);
}
pub fn scale(self: *const DOMMatrixReadOnly, sx_: ?f64, sy_: ?f64, sz_: ?f64, ox_: ?f64, oy_: ?f64, oz_: ?f64, page: *Page) !*DOMMatrix {
const sx = sx_ orelse 1;
const sy = sy_ orelse sx;
const sz = sz_ orelse 1;
const ox = ox_ orelse 0;
const oy = oy_ orelse 0;
const oz = oz_ orelse 0;
var m = multiplyMatrix(self._m, translationMatrix(ox, oy, oz));
m = multiplyMatrix(m, scaleMatrix(sx, sy, sz));
m = multiplyMatrix(m, translationMatrix(-ox, -oy, -oz));
return DOMMatrix.create(m, self._is_2d and sz == 1 and oz == 0, page);
}
pub fn scaleNonUniform(self: *const DOMMatrixReadOnly, sx_: ?f64, sy_: ?f64, page: *Page) !*DOMMatrix {
const sx = sx_ orelse 1;
const sy = sy_ orelse 1;
return DOMMatrix.create(multiplyMatrix(self._m, scaleMatrix(sx, sy, 1)), self._is_2d, page);
}
pub fn scale3d(self: *const DOMMatrixReadOnly, scale_: ?f64, ox_: ?f64, oy_: ?f64, oz_: ?f64, page: *Page) !*DOMMatrix {
const s = scale_ orelse 1;
const ox = ox_ orelse 0;
const oy = oy_ orelse 0;
const oz = oz_ orelse 0;
var m = multiplyMatrix(self._m, translationMatrix(ox, oy, oz));
m = multiplyMatrix(m, scaleMatrix(s, s, s));
m = multiplyMatrix(m, translationMatrix(-ox, -oy, -oz));
return DOMMatrix.create(m, self._is_2d and s == 1, page);
}
pub fn rotate(self: *const DOMMatrixReadOnly, rx_: ?f64, ry_: ?f64, rz_: ?f64, page: *Page) !*DOMMatrix {
var out = self._m;
var is_2d = self._is_2d;
// With a single argument, it is the Z rotation.
if (ry_ == null and rz_ == null) {
out = multiplyMatrix(out, rotateZMatrix(toRadians(rx_ orelse 0, .deg)));
} else {
out = multiplyMatrix(out, rotateXMatrix(toRadians(rx_ orelse 0, .deg)));
out = multiplyMatrix(out, rotateYMatrix(toRadians(ry_ orelse 0, .deg)));
out = multiplyMatrix(out, rotateZMatrix(toRadians(rz_ orelse 0, .deg)));
is_2d = false;
}
return DOMMatrix.create(out, is_2d, page);
}
pub fn rotateFromVector(self: *const DOMMatrixReadOnly, x_: ?f64, y_: ?f64, page: *Page) !*DOMMatrix {
const x = x_ orelse 0;
const y = y_ orelse 0;
const rad = if (x == 0 and y == 0) 0 else std.math.atan2(y, x);
return DOMMatrix.create(multiplyMatrix(self._m, rotateZMatrix(rad)), self._is_2d, page);
}
pub fn rotateAxisAngle(self: *const DOMMatrixReadOnly, x_: ?f64, y_: ?f64, z_: ?f64, angle_: ?f64, page: *Page) !*DOMMatrix {
return DOMMatrix.create(
multiplyMatrix(self._m, axisAngleMatrix(x_ orelse 0, y_ orelse 0, z_ orelse 0, toRadians(angle_ orelse 0, .deg))),
// Only a rotation purely about the z axis stays 2D.
self._is_2d and (x_ orelse 0) == 0 and (y_ orelse 0) == 0,
page,
);
}
pub fn skewX(self: *const DOMMatrixReadOnly, sx_: ?f64, page: *Page) !*DOMMatrix {
return DOMMatrix.create(multiplyMatrix(self._m, skewMatrix(toRadians(sx_ orelse 0, .deg), 0)), self._is_2d, page);
}
pub fn skewY(self: *const DOMMatrixReadOnly, sy_: ?f64, page: *Page) !*DOMMatrix {
return DOMMatrix.create(multiplyMatrix(self._m, skewMatrix(0, toRadians(sy_ orelse 0, .deg))), self._is_2d, page);
}
pub fn multiply(self: *const DOMMatrixReadOnly, other_: ?DOMMatrixInit, page: *Page) !*DOMMatrix {
const other = try fixupDict(other_ orelse .{});
return DOMMatrix.create(multiplyMatrix(self._m, other.m), self._is_2d and other.is_2d, page);
}
pub fn flipX(self: *const DOMMatrixReadOnly, page: *Page) !*DOMMatrix {
return DOMMatrix.create(multiplyMatrix(self._m, scaleMatrix(-1, 1, 1)), self._is_2d, page);
}
pub fn flipY(self: *const DOMMatrixReadOnly, page: *Page) !*DOMMatrix {
return DOMMatrix.create(multiplyMatrix(self._m, scaleMatrix(1, -1, 1)), self._is_2d, page);
}
pub fn inverse(self: *const DOMMatrixReadOnly, page: *Page) !*DOMMatrix {
if (invertMatrix(self._m)) |v| {
return DOMMatrix.create(v, self._is_2d, page);
}
// Non-invertible matrices become all-NaN with is2D = false.
return DOMMatrix.create(.{std.math.nan(f64)} ** 16, false, page);
}
pub fn toFloat32Array(self: *const DOMMatrixReadOnly, exec: *const js.Execution) !js.TypedArray(f32) {
const out = try exec.call_arena.alloc(f32, 16);
for (0..16) |i| {
out[i] = @floatCast(self._m[i]);
}
return .{ .values = out };
}
pub fn toFloat64Array(self: *const DOMMatrixReadOnly, exec: *const js.Execution) !js.TypedArray(f64) {
const out = try exec.call_arena.dupe(f64, &self._m);
return .{ .values = out };
}
pub fn toString(self: *const DOMMatrixReadOnly, exec: *const js.Execution) ![]const u8 {
const m = self._m;
if (self._is_2d) {
// Per the stringifier: throw if any serialized component is non-finite.
for ([_]f64{ m[0], m[1], m[4], m[5], m[12], m[13] }) |v| {
if (!std.math.isFinite(v)) {
return error.InvalidStateError;
}
}
return std.fmt.allocPrint(exec.call_arena, "matrix({d}, {d}, {d}, {d}, {d}, {d})", .{
m[0], m[1], m[4], m[5], m[12], m[13],
});
}
for (m) |v| {
if (!std.math.isFinite(v)) {
return error.InvalidStateError;
}
}
return std.fmt.allocPrint(exec.call_arena, "matrix3d({d}, {d}, {d}, {d}, {d}, {d}, {d}, {d}, {d}, {d}, {d}, {d}, {d}, {d}, {d}, {d})", .{
m[0], m[1], m[2], m[3],
m[4], m[5], m[6], m[7],
m[8], m[9], m[10], m[11],
m[12], m[13], m[14], m[15],
});
}
fn aliasConflict(x: ?f64, y: ?f64) bool {
const a = x orelse return false;
const b = y orelse return false;
if (std.math.isNan(a) and std.math.isNan(b)) {
return false;
}
return a != b;
}
// True when the dict specifies any 3D-only member away from its identity value.
fn has3dMembers(d: DOMMatrixInit) bool {
return (d.m13 orelse 0) != 0 or (d.m14 orelse 0) != 0 or
(d.m23 orelse 0) != 0 or (d.m24 orelse 0) != 0 or
(d.m31 orelse 0) != 0 or (d.m32 orelse 0) != 0 or
(d.m34 orelse 0) != 0 or (d.m43 orelse 0) != 0 or
(d.m33 orelse 1) != 1 or (d.m44 orelse 1) != 1;
}
pub const Parsed = struct {
m: [16]f64,
is_2d: bool,
pub fn init(init_: ?js.Value, exec: *const js.Execution) !Parsed {
var m: [16]f64 = identity();
var is_2d = true;
if (init_) |in| {
if (!in.isUndefined()) {
if (in.isArray()) {
try sequenceToMatrix(in.toArray(), &m, &is_2d);
} else {
// Per WebIDL the union is `(DOMString or sequence)`: a value
// that isn't a sequence is converted to a DOMString. So a
// string parses directly, and any other value (a number, null,
// or another matrix) is stringified first — which is how
// `new DOMMatrix(otherMatrix)` round-trips via its
// matrix()/matrix3d() serialization.
if (exec.js.global == .worker) {
return error.TypeError;
}
const str = try in.toStringSmart();
try parseTransformList(str, &m, &is_2d);
}
}
}
return .{ .m = m, .is_2d = is_2d };
}
};
const ParsedValue = struct {
value: f64,
unit: Unit,
const Unit = enum {
none,
deg,
rad,
grad,
turn,
};
// Parses a single CSS dimension token: a number with an optional unit suffix.
// Length units are ignored (we don't resolve layout), so the numeric part is
// taken verbatim; angle units are recorded so they can be normalised.
fn parse(tok: []const u8) !ParsedValue {
var end: usize = 0;
while (end < tok.len) : (end += 1) {
const c = tok[end];
if ((c >= '0' and c <= '9') or c == '.' or c == '+' or c == '-' or c == 'e' or c == 'E') {
// 'e'/'E' is ambiguous with exponents; only treat as exponent when
// followed by a digit/sign.
if ((c == 'e' or c == 'E') and end > 0) {
if (end + 1 >= tok.len) break;
const nx = tok[end + 1];
if (!((nx >= '0' and nx <= '9') or nx == '+' or nx == '-')) break;
}
continue;
}
break;
}
if (end == 0) {
return error.SyntaxError;
}
const value = try std.fmt.parseFloat(f64, tok[0..end]);
const suffix = tok[end..];
var unit: Unit = .none;
if (std.ascii.eqlIgnoreCase(suffix, "deg")) {
unit = .deg;
} else if (std.ascii.eqlIgnoreCase(suffix, "rad")) {
unit = .rad;
} else if (std.ascii.eqlIgnoreCase(suffix, "grad")) {
unit = .grad;
} else if (std.ascii.eqlIgnoreCase(suffix, "turn")) {
unit = .turn;
}
return .{ .value = value, .unit = unit };
}
};
fn sequenceToMatrix(arr: js.Array, m: *[16]f64, is_2d: *bool) !void {
const n = arr.len();
if (n == 6) {
// matrix(a, b, c, d, e, f)
var v: [6]f64 = undefined;
for (0..6) |i| {
v[i] = try (try arr.get(@intCast(i))).toF64();
}
m.* = .{
v[0], v[1], 0, 0,
v[2], v[3], 0, 0,
0, 0, 1, 0,
v[4], v[5], 0, 1,
};
is_2d.* = true;
return;
}
if (n == 16) {
for (0..16) |i| {
m[i] = try (try arr.get(@intCast(i))).toF64();
}
is_2d.* = false;
return;
}
return error.TypeError;
}
pub const JsApi = struct {
pub const bridge = js.Bridge(DOMMatrixReadOnly);
pub const Meta = struct {
pub const name = "DOMMatrixReadOnly";
pub const prototype_chain = bridge.prototypeChain();
pub var class_id: bridge.ClassId = undefined;
};
pub const constructor = bridge.constructor(DOMMatrixReadOnly.init, .{ .dom_exception = true });
pub const fromMatrix = bridge.function(DOMMatrixReadOnly.fromMatrix, .{ .static = true });
pub const fromFloat32Array = bridge.function(DOMMatrixReadOnly.fromFloat32Array, .{ .static = true });
pub const fromFloat64Array = bridge.function(DOMMatrixReadOnly.fromFloat64Array, .{ .static = true });
pub const a = bridge.accessor(DOMMatrixReadOnly.getA, null, .{});
pub const b = bridge.accessor(DOMMatrixReadOnly.getB, null, .{});
pub const c = bridge.accessor(DOMMatrixReadOnly.getC, null, .{});
pub const d = bridge.accessor(DOMMatrixReadOnly.getD, null, .{});
pub const e = bridge.accessor(DOMMatrixReadOnly.getE, null, .{});
pub const f = bridge.accessor(DOMMatrixReadOnly.getF, null, .{});
pub const m11 = bridge.accessor(getM(0), null, .{});
pub const m12 = bridge.accessor(getM(1), null, .{});
pub const m13 = bridge.accessor(getM(2), null, .{});
pub const m14 = bridge.accessor(getM(3), null, .{});
pub const m21 = bridge.accessor(getM(4), null, .{});
pub const m22 = bridge.accessor(getM(5), null, .{});
pub const m23 = bridge.accessor(getM(6), null, .{});
pub const m24 = bridge.accessor(getM(7), null, .{});
pub const m31 = bridge.accessor(getM(8), null, .{});
pub const m32 = bridge.accessor(getM(9), null, .{});
pub const m33 = bridge.accessor(getM(10), null, .{});
pub const m34 = bridge.accessor(getM(11), null, .{});
pub const m41 = bridge.accessor(getM(12), null, .{});
pub const m42 = bridge.accessor(getM(13), null, .{});
pub const m43 = bridge.accessor(getM(14), null, .{});
pub const m44 = bridge.accessor(getM(15), null, .{});
pub const is2D = bridge.accessor(DOMMatrixReadOnly.getIs2D, null, .{});
pub const isIdentity = bridge.accessor(DOMMatrixReadOnly.getIsIdentity, null, .{});
pub const translate = bridge.function(DOMMatrixReadOnly.translate, .{});
pub const scale = bridge.function(DOMMatrixReadOnly.scale, .{});
pub const scaleNonUniform = bridge.function(DOMMatrixReadOnly.scaleNonUniform, .{});
pub const scale3d = bridge.function(DOMMatrixReadOnly.scale3d, .{});
pub const rotate = bridge.function(DOMMatrixReadOnly.rotate, .{});
pub const rotateFromVector = bridge.function(DOMMatrixReadOnly.rotateFromVector, .{});
pub const rotateAxisAngle = bridge.function(DOMMatrixReadOnly.rotateAxisAngle, .{});
pub const skewX = bridge.function(DOMMatrixReadOnly.skewX, .{});
pub const skewY = bridge.function(DOMMatrixReadOnly.skewY, .{});
pub const multiply = bridge.function(DOMMatrixReadOnly.multiply, .{});
pub const flipX = bridge.function(DOMMatrixReadOnly.flipX, .{});
pub const flipY = bridge.function(DOMMatrixReadOnly.flipY, .{});
pub const inverse = bridge.function(DOMMatrixReadOnly.inverse, .{});
pub const toFloat32Array = bridge.function(DOMMatrixReadOnly.toFloat32Array, .{});
pub const toFloat64Array = bridge.function(DOMMatrixReadOnly.toFloat64Array, .{});
// The stringifier depends on CSS serialization and is Window-only.
pub const toString = bridge.function(DOMMatrixReadOnly.toString, .{ .dom_exception = true, .exposed = .window });
// m11..m44 getters are generated from the storage index.
fn getM(comptime idx: usize) fn (*const DOMMatrixReadOnly) f64 {
return struct {
fn get(self: *const DOMMatrixReadOnly) f64 {
return self._m[idx];
}
}.get;
}
};
const testing = @import("../../testing.zig");
test "WebApi: DOMMatrixReadOnly" {
try testing.htmlRunner("dommatrix.html", .{});
}

View File

@@ -153,7 +153,7 @@ fn filterNode(self: *const DOMNodeIterator, node: *Node, frame: *Frame) !i32 {
fn getNextInTree(self: *const DOMNodeIterator, node: *Node) ?*Node {
// Depth-first traversal within the root subtree
if (node._children) |children| {
return children.first();
return Node.linkToNode(children.first.?);
}
var current = node;

View File

@@ -66,6 +66,8 @@ _script_created_parser: ?Parser.Streaming = null,
_close_requested: bool = false,
_adopted_style_sheets: ?js.Object.Global = null,
_selection: Selection = .{ ._rc = .init(1) },
// Ordered stack of currently-showing popovers
_open_popovers: std.ArrayList(*Element) = .empty,
// https://html.spec.whatwg.org/multipage/dynamic-markup-insertion.html#throw-on-dynamic-markup-insertion-counter
// Incremented during custom element reactions when parsing. When > 0,
@@ -806,7 +808,7 @@ fn writeInternal(self: *Document, text: []const []const u8, append_newline: bool
// Extract children from wrapper HTML element (html5ever wraps fragments)
// https://github.com/servo/html5ever/issues/583
const children = fragment_node._children orelse return;
const first = children.first();
const first = Node.linkToNode(children.first.?);
// Collect all children to insert (to avoid iterator invalidation)
var children_to_insert: std.ArrayList(*Node) = .empty;
@@ -883,6 +885,7 @@ pub fn open(self: *Document, call_frame: *Frame) !*Document {
// reset the document
self._elements_by_id.clearAndFree(frame.arena);
self._active_element = null;
self._open_popovers = .empty;
self._style_sheets = null;
self._implementation = null;
self._ready_state = .loading;

View File

@@ -81,6 +81,7 @@ pub const Type = union(enum) {
form_data_event: *@import("event/FormDataEvent.zig"),
close_event: *@import("event/CloseEvent.zig"),
cookie_change_event: *@import("event/CookieChangeEvent.zig"),
toggle_event: *@import("event/ToggleEvent.zig"),
};
pub const Options = struct {
@@ -172,6 +173,7 @@ pub fn is(self: *Event, comptime T: type) ?*T {
.form_data_event => |e| return if (T == @import("event/FormDataEvent.zig")) e else null,
.close_event => |e| return if (T == @import("event/CloseEvent.zig")) e else null,
.cookie_change_event => |e| return if (T == @import("event/CookieChangeEvent.zig")) e else null,
.toggle_event => |e| return if (T == @import("event/ToggleEvent.zig")) e else null,
.ui_event => |e| {
if (T == @import("event/UIEvent.zig")) {
return e;

View File

@@ -1,18 +1,49 @@
const js = @import("../js/js.zig");
const File = @import("File.zig");
pub fn registerTypes() []const type {
return &.{
FileList,
FileList.Iterator,
};
}
const FileList = @This();
/// Padding to avoid zero-size struct, which causes identity_map pointer collisions.
_pad: bool = false,
_files: []*File = &.{},
pub fn getLength(_: *const FileList) u32 {
return 0;
pub fn getLength(self: *const FileList) u32 {
return @intCast(self._files.len);
}
pub fn item(_: *const FileList, _: u32) ?*@import("File.zig") {
return null;
pub fn item(self: *const FileList, index: u32) ?*File {
if (index >= self._files.len) {
return null;
}
return self._files[index];
}
pub fn iterator(self: *FileList, exec: *const js.Execution) !*Iterator {
return Iterator.init(.{
.index = 0,
.list = self,
}, exec);
}
const GenericIterator = @import("collections/iterator.zig").Entry;
pub const Iterator = GenericIterator(struct {
index: u32,
list: *FileList,
pub fn next(self: *@This(), _: *const js.Execution) ?*File {
const index = self.index;
const file = self.list.item(index) orelse return null;
self.index = index + 1;
return file;
}
}, null);
pub const JsApi = struct {
pub const bridge = js.Bridge(FileList);
@@ -20,9 +51,19 @@ pub const JsApi = struct {
pub const name = "FileList";
pub const prototype_chain = bridge.prototypeChain();
pub var class_id: bridge.ClassId = undefined;
pub const empty_with_no_proto = true;
};
pub const length = bridge.accessor(FileList.getLength, null, .{});
pub const item = bridge.function(FileList.item, .{});
pub const @"[]" = bridge.indexed(FileList.item, getIndexes, .{ .null_as_undefined = true });
pub const symbol_iterator = bridge.iterator(FileList.iterator, .{});
fn getIndexes(self: *FileList, exec: *const js.Execution) !js.Array {
const len = self.getLength();
var arr = exec.js.local.?.newArray(len);
for (0..len) |i| {
_ = try arr.set(@intCast(i), i, .{});
}
return arr;
}
};

View File

@@ -31,7 +31,6 @@ pub const CData = @import("CData.zig");
pub const Element = @import("Element.zig");
pub const Document = @import("Document.zig");
pub const HTMLDocument = @import("HTMLDocument.zig");
pub const Children = @import("children.zig").Children;
pub const DocumentFragment = @import("DocumentFragment.zig");
pub const DocumentType = @import("DocumentType.zig");
pub const ShadowRoot = @import("ShadowRoot.zig");
@@ -46,7 +45,9 @@ const Node = @This();
_type: Type,
_proto: *EventTarget,
_parent: ?*Node = null,
_children: ?*Children = null,
// A node with no children leaves this null (no allocation). Otherwise it
// points to a heap-allocated intrusive list of the node's `_child_link`s.
_children: ?*LinkedList = null,
_child_link: LinkedList.Node = .{},
// Lookup for nodes that have a different owner document than frame.document
@@ -171,12 +172,12 @@ pub fn findAdjacentNodes(self: *Node, position: []const u8) !struct { *Node, ?*N
pub fn firstChild(self: *const Node) ?*Node {
const children = self._children orelse return null;
return children.first();
return linkToNodeOrNull(children.first);
}
pub fn lastChild(self: *const Node) ?*Node {
const children = self._children orelse return null;
return children.last();
return linkToNodeOrNull(children.last);
}
pub fn nextSibling(self: *const Node) ?*Node {
@@ -714,7 +715,7 @@ pub fn childrenIterator(self: *Node) NodeIterator {
};
return .{
.node = children.first(),
.node = linkToNodeOrNull(children.first),
};
}

View File

@@ -16,11 +16,21 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
const lp = @import("lightpanda");
const js = @import("../js/js.zig");
const Page = @import("../Page.zig");
const Frame = @import("../Frame.zig");
const Element = @import("Element.zig");
const Event = @import("Event.zig");
const EventTarget = @import("EventTarget.zig");
const MouseEvent = @import("event/MouseEvent.zig");
const PointerEvent = @import("event/PointerEvent.zig");
const KeyboardEvent = @import("event/KeyboardEvent.zig");
const WheelEvent = @import("event/WheelEvent.zig");
const log = lp.log;
// This type is only included when the binary is built with the -Dwpt_extensions flag
const WebDriver = @This();
@@ -37,6 +47,221 @@ pub fn getComputedLabel(_: *const WebDriver, element: *Element, frame: *Frame) !
return (try axnode.getName(frame, frame.call_arena)) orelse "";
}
// Implements testdriver's `action_sequence` (the WebDriver "Perform Actions"
// command) for the renderless browser. We can't do real hit-testing, so we only
// support the subset that targets a concrete element via `origin`. Each input
// source is the serialized form produced by testdriver-actions.js:
// { type: "pointer", actions: [{type: "pointerMove", x, y, origin}, ...] }
// { type: "key", actions: [{type: "keyDown", value}, ...] }
// { type: "wheel", actions: [{type: "scroll", deltaX, deltaY, origin}, ...] }
pub fn actionSequence(_: *const WebDriver, sources: []js.Object, frame: *Frame) !void {
for (sources) |source| {
const source_type = (try source.get("type")).toSSO(false) catch continue;
if (source_type.eql(comptime .wrap("pointer"))) {
try performPointerSource(source, frame);
} else if (source_type.eql(comptime .wrap("key"))) {
try performKeySource(source, frame);
} else if (source_type.eql(comptime .wrap("wheel"))) {
try performWheelSource(source, frame);
}
// "none" sources only carry pauses, which have no observable effect here.
}
}
fn performPointerSource(source: js.Object, frame: *Frame) !void {
const actions_val = try source.get("actions");
if (!actions_val.isArray()) {
return;
}
const actions = actions_val.toArray();
// The element the pointer is currently over, set by the last pointerMove
// whose origin resolved to an element.
var target: ?*Element = null;
for (0..actions.len()) |i| {
const action_val = try actions.get(@intCast(i));
if (!action_val.isObject()) {
continue;
}
const action = action_val.toObject();
const action_type = (try action.get("type")).toSSO(false) catch continue;
if (action_type.eql(comptime .wrap("pointerMove"))) {
const origin = try action.get("origin");
if (origin.isObject()) {
target = origin.local.jsValueToZig(*Element, origin) catch null;
}
const el = target orelse continue;
dispatchPointer(el, "pointermove", 0, 0, frame);
dispatchMouse(el, "mousemove", 0, 0, frame);
} else if (action_type.eql(comptime .wrap("pointerDown"))) {
const el = target orelse continue;
const button = readI32(action, "button", 0);
dispatchPointer(el, "pointerdown", button, 1, frame);
dispatchMouse(el, "mousedown", button, 1, frame);
} else if (action_type.eql(comptime .wrap("pointerUp"))) {
const el = target orelse continue;
const button = readI32(action, "button", 0);
dispatchPointer(el, "pointerup", button, 0, frame);
dispatchMouse(el, "mouseup", button, 0, frame);
dispatchMouse(el, "click", button, 0, frame);
}
// "pause" carries timing only and is ignored. ("pointerCancel" is not
// emitted by the testdriver Actions builder.)
}
}
fn performWheelSource(source: js.Object, frame: *Frame) !void {
const actions_val = try source.get("actions");
if (!actions_val.isArray()) {
return;
}
const actions = actions_val.toArray();
for (0..actions.len()) |i| {
const action_val = try actions.get(@intCast(i));
if (!action_val.isObject()) {
continue;
}
const action = action_val.toObject();
const action_type = (try action.get("type")).toSSO(false) catch continue;
if (action_type.eql(comptime .wrap("scroll")) == false) {
// "pause" is the only other action and has no observable effect.
continue;
}
const origin = try action.get("origin");
if (!origin.isObject()) {
continue;
}
const el = origin.local.jsValueToZig(*Element, origin) catch continue;
const delta_x = readI32(action, "deltaX", 0);
const delta_y = readI32(action, "deltaY", 0);
dispatchWheel(el, delta_x, delta_y, frame);
}
}
fn performKeySource(source: js.Object, frame: *Frame) !void {
const actions_val = try source.get("actions");
if (!actions_val.isArray()) return;
const actions = actions_val.toArray();
// Key actions have no explicit target; they go to the focused element, or
// the document if nothing is focused.
const target = if (frame.document._active_element) |el|
el.asEventTarget()
else
frame.document.asNode().asEventTarget();
for (0..actions.len()) |i| {
const action_val = try actions.get(@intCast(i));
if (!action_val.isObject()) continue;
const action = action_val.toObject();
const action_type = (try action.get("type")).toSSO(false) catch continue;
const key = (try action.get("value")).toStringSlice() catch "";
if (action_type.eql(comptime .wrap("keyDown"))) {
dispatchKey(target, comptime .wrap("keydown"), key, frame);
} else if (action_type.eql(comptime .wrap("keyUp"))) {
dispatchKey(target, comptime .wrap("keyup"), key, frame);
}
}
}
fn dispatchKey(target: *EventTarget, typ: lp.String, key: []const u8, frame: *Frame) void {
const event = KeyboardEvent.initTrusted(typ, .{
.bubbles = true,
.cancelable = true,
.composed = true,
.key = key,
}, frame) catch |err| {
log.warn(.app, "webdriver key event", .{ .err = err });
return;
};
dispatch(target, event.asEvent(), frame, typ.str());
}
fn readI32(obj: js.Object, key: []const u8, default: i32) i32 {
const val = obj.get(key) catch return default;
if (val.isNullOrUndefined()) {
return default;
}
return val.toI32() catch default;
}
fn dispatchPointer(el: *Element, comptime typ: []const u8, button: i32, buttons: u16, frame: *Frame) void {
const event = PointerEvent.initTrusted(typ, .{
.bubbles = true,
.cancelable = true,
.composed = true,
.button = button,
.buttons = buttons,
.pointerId = 1,
.pointerType = "mouse",
.isPrimary = true,
}, frame) catch |err| {
log.warn(.app, "webdriver pointer event", .{ .err = err, .type = typ });
return;
};
dispatch(el.asEventTarget(), event.asEvent(), frame, typ);
}
fn dispatchMouse(el: *Element, comptime typ: []const u8, button: i32, buttons: u16, frame: *Frame) void {
const event = MouseEvent.initTrusted(comptime .wrap(typ), .{
.bubbles = true,
.cancelable = true,
.composed = true,
.button = button,
.buttons = buttons,
}, frame) catch |err| {
log.warn(.app, "webdriver mouse event", .{ .err = err, .type = typ });
return;
};
dispatch(el.asEventTarget(), event.asEvent(), frame, typ);
}
fn dispatchWheel(el: *Element, delta_x: i32, delta_y: i32, frame: *Frame) void {
const event = WheelEvent.initTrusted("wheel", .{
.bubbles = true,
.cancelable = true,
.composed = true,
.deltaX = @floatFromInt(delta_x),
.deltaY = @floatFromInt(delta_y),
}, frame) catch |err| {
log.warn(.app, "webdriver wheel event", .{ .err = err });
return;
};
// Keep the event alive past dispatch so we can read _prevent_default.
event.asEvent().acquireRef();
defer _ = event.asEvent().releaseRef(frame._page);
dispatch(el.asEventTarget(), event.asEvent(), frame, "wheel");
if (event.asEvent()._prevent_default) {
return;
}
// Apply the scroll and fire a trusted scroll event, mirroring actions.scroll.
const new_left: i32 = @as(i32, @intCast(el.getScrollLeft(frame))) + delta_x;
const new_top: i32 = @as(i32, @intCast(el.getScrollTop(frame))) + delta_y;
el.setScrollLeft(new_left, frame) catch {};
el.setScrollTop(new_top, frame) catch {};
const scroll_evt = Event.initTrusted(comptime .wrap("scroll"), .{ .bubbles = true }, frame._page) catch |err| {
log.warn(.app, "webdriver scroll event", .{ .err = err });
return;
};
dispatch(el.asEventTarget(), scroll_evt, frame, "scroll");
}
fn dispatch(target: *EventTarget, event: *Event, frame: *Frame, typ: []const u8) void {
frame._event_manager.dispatch(target, event) catch |err| {
log.warn(.app, "webdriver dispatch", .{ .err = err, .type = typ });
};
}
pub const JsApi = struct {
pub const bridge = js.Bridge(WebDriver);
@@ -48,4 +273,5 @@ pub const JsApi = struct {
};
pub const deleteAllCookies = bridge.function(WebDriver.deleteAllCookies, .{});
pub const getComputedLabel = bridge.function(WebDriver.getComputedLabel, .{});
pub const actionSequence = bridge.function(WebDriver.actionSequence, .{});
};

View File

@@ -182,6 +182,12 @@ fn httpDoneCallback(ctx: *anyopaque) !void {
}
fn loadInitialScript(self: *Worker, script: []const u8) !void {
const js_context = self._worker_scope.js;
if (js_context.env.terminatePending()) {
return;
}
// Keep buffering throughout the entire outer eval (including any
// runMacrotasks pumped by importScripts via the synchronous CDP path,
// see WorkerGlobalScope.importScripts). The flip-and-drain happens
@@ -200,7 +206,7 @@ fn loadInitialScript(self: *Worker, script: []const u8) !void {
}
var ls: js.Local.Scope = undefined;
self._worker_scope.js.localScope(&ls);
js_context.localScope(&ls);
defer ls.deinit();
var try_catch: js.TryCatch = undefined;
@@ -213,12 +219,20 @@ fn loadInitialScript(self: *Worker, script: []const u8) !void {
// synchronously through ScriptManagerBase (client.tick sync_wait).
switch (self._type) {
.classic => _ = ls.local.eval(script, self._url) catch |err| {
if (js_context.env.terminatePending()) {
return;
}
const caught = try_catch.caughtOrError(self._arena, err);
log.err(.browser, "worker script error", .{ .url = self._url, .caught = caught });
self.fireErrorEvent(caught.exception orelse @errorName(err), null);
return;
},
.module => self._worker_scope.js.module(false, &ls.local, script, self._url, true) catch |err| {
.module => js_context.module(false, &ls.local, script, self._url, true) catch |err| {
if (js_context.env.terminatePending()) {
return;
}
const caught = try_catch.caughtOrError(self._arena, err);
log.err(.browser, "worker module error", .{ .url = self._url, .caught = caught });
self.fireErrorEvent(caught.exception orelse @errorName(err), null);

View File

@@ -1,39 +0,0 @@
const std = @import("std");
const Node = @import("Node.zig");
const LinkedList = std.DoublyLinkedList;
// Our node._children is of type ?*NodeList. The extra (extra) indirection is to
// keep memory size down.
// First, a lot of nodes have no children. For these nodes, `?*NodeList = null`
// will take 8 bytes and require no allocations (because an optional pointer in
// Zig uses the address 0 to represent null, rather than a separate field).
// Second, a lot of nodes will have one child. For these nodes, we'll also only
// use 8 bytes, because @sizeOf(NodeList) == 8. This is the reason the
// list: *LinkedList is behind a pointer.
pub const Children = union(enum) {
one: *Node,
list: *LinkedList,
pub fn first(self: *const Children) *Node {
return switch (self.*) {
.one => |n| n,
.list => |list| Node.linkToNode(list.first.?),
};
}
pub fn last(self: *const Children) *Node {
return switch (self.*) {
.one => |n| n,
.list => |list| Node.linkToNode(list.last.?),
};
}
pub fn len(self: *const Children) u32 {
return switch (self.*) {
.one => 1,
.list => |list| @intCast(list.len()),
};
}
};

View File

@@ -69,7 +69,7 @@ pub fn length(self: *ChildNodes, frame: *const Frame) !u32 {
const children = self._node._children orelse return 0;
// O(N)
const len = children.len();
const len: u32 = @intCast(children.len());
self._last_length = len;
return len;
}
@@ -100,7 +100,7 @@ pub fn getAtIndex(self: *ChildNodes, index: usize, frame: *const Frame) !?*Node
}
pub fn first(self: *const ChildNodes) ?*std.DoublyLinkedList.Node {
return &(self._node._children orelse return null).first()._child_link;
return (self._node._children orelse return null).first;
}
pub fn keys(self: *ChildNodes, frame: *Frame) !*KeyIterator {

View File

@@ -296,11 +296,14 @@ pub const List = struct {
frame.removeElementId(element, entry._value.str());
}
frame.domChanged();
frame.attributeRemove(element, result.normalized, old_value);
// remove this BEFORE triggering anything, incase that re-enters delete
// or some other callback.
_ = frame._attribute_lookup.remove(@intFromPtr(entry));
self._list.remove(&entry._node);
self._len -= 1;
frame.domChanged();
frame.attributeRemove(element, result.normalized, old_value);
frame._factory.destroy(entry);
}

View File

@@ -27,6 +27,7 @@ const GlobalEventHandler = global_event_handlers.Handler;
const Frame = @import("../../Frame.zig");
const Node = @import("../Node.zig");
const Element = @import("../Element.zig");
const popover = @import("popover.zig");
pub const Anchor = @import("html/Anchor.zig");
pub const Area = @import("html/Area.zig");
@@ -326,7 +327,23 @@ pub fn click(self: *HtmlElement, frame: *Frame) !void {
.clientX = 0,
.clientY = 0,
}, frame)).asEvent();
// Keep the event alive past dispatch (which runs handlers/microtasks) so we
// can read _prevent_default afterwards.
event.acquireRef();
defer _ = event.releaseRef(frame._page);
try frame._event_manager.dispatch(self.asEventTarget(), event);
if (event._prevent_default == false) {
// toggle the popover_target
const explicit: ?*Element = switch (self._type) {
.button => |b| b._popover_target,
.input => |i| i._popover_target,
else => null,
};
try popover.runInvokerActivation(self, explicit, frame);
}
}
// TODO: Per spec, hidden is a tristate: true | false | "until-found".
@@ -343,6 +360,32 @@ pub fn setHidden(self: *HtmlElement, hidden: bool, frame: *Frame) !void {
}
}
pub fn getPopover(self: *HtmlElement) ?[]const u8 {
const s = popover.getState(self.asElement()) orelse return null;
return @tagName(s);
}
pub fn setPopover(self: *HtmlElement, value_: ?[]const u8, frame: *Frame) !void {
if (value_) |value| {
// does not validate the value, stores it as-is
try self.asElement().setAttribute(comptime .wrap("popover"), .wrap(value), frame);
} else {
try self.asElement().removeAttribute(comptime .wrap("popover"), frame);
}
}
pub fn showPopover(self: *HtmlElement, frame: *Frame) !void {
return popover.show(self.asElement(), frame);
}
pub fn hidePopover(self: *HtmlElement, frame: *Frame) !void {
return popover.hide(self.asElement(), frame);
}
pub fn togglePopover(self: *HtmlElement, force: ?bool, frame: *Frame) !bool {
return popover.toggle(self.asElement(), force, frame);
}
pub fn getTabIndex(self: *HtmlElement) i32 {
const default: i32 = switch (self._type) {
.anchor, .area, .button, .input, .select, .textarea, .iframe => 0,
@@ -1326,6 +1369,10 @@ pub const JsApi = struct {
pub const autofocus = bridge.accessor(HtmlElement.getAutofocus, HtmlElement.setAutofocus, .{ .ce_reactions = true });
pub const dir = bridge.accessor(HtmlElement.getDir, HtmlElement.setDir, .{ .ce_reactions = true });
pub const hidden = bridge.accessor(HtmlElement.getHidden, HtmlElement.setHidden, .{ .ce_reactions = true });
pub const popover = bridge.accessor(HtmlElement.getPopover, HtmlElement.setPopover, .{ .ce_reactions = true });
pub const showPopover = bridge.function(HtmlElement.showPopover, .{ .dom_exception = true });
pub const hidePopover = bridge.function(HtmlElement.hidePopover, .{ .dom_exception = true });
pub const togglePopover = bridge.function(HtmlElement.togglePopover, .{ .dom_exception = true });
pub const isContentEditable = bridge.accessor(HtmlElement.getIsContentEditable, null, .{});
pub const lang = bridge.accessor(HtmlElement.getLang, HtmlElement.setLang, .{ .ce_reactions = true });
pub const nonce = bridge.accessor(HtmlElement.getNonce, HtmlElement.setNonce, .{ .ce_reactions = true });

View File

@@ -28,11 +28,14 @@ const Form = @import("Form.zig");
const Event = @import("../../Event.zig");
const ValidityState = @import("ValidityState.zig");
const popover = @import("../popover.zig");
const Button = @This();
_proto: *HtmlElement,
_custom_validity: ?[]const u8 = null,
_validity: ?*ValidityState = null,
_popover_target: ?*Element = null,
pub fn asElement(self: *Button) *Element {
return self._proto._proto;
@@ -228,6 +231,27 @@ pub fn hasCustomValidity(self: *const Button) bool {
return self._custom_validity != null;
}
pub fn getPopoverTargetElement(self: *Button, frame: *Frame) ?*Element {
return popover.invokerTarget(self.asNode(), self._popover_target, frame);
}
pub fn setPopoverTargetElement(self: *Button, value: ?*Element, frame: *Frame) !void {
self._popover_target = value;
if (value == null) {
try self.asElement().removeAttribute(.wrap("popovertarget"), frame);
} else {
try self.asElement().setAttribute(.wrap("popovertarget"), .wrap(""), frame);
}
}
pub fn getPopoverTargetAction(self: *Button) []const u8 {
return @tagName(popover.getInvokerAction(self.asElement()));
}
pub fn setPopoverTargetAction(self: *Button, value: []const u8, frame: *Frame) !void {
try self.asElement().setAttribute(.wrap("popovertargetaction"), .wrap(value), frame);
}
pub const JsApi = struct {
pub const bridge = js.Bridge(Button);
@@ -249,6 +273,8 @@ pub const JsApi = struct {
pub const value = bridge.accessor(Button.getValue, Button.setValue, .{ .ce_reactions = true });
pub const @"type" = bridge.accessor(Button.getType, Button.setType, .{ .ce_reactions = true });
pub const labels = bridge.accessor(Button.getLabels, null, .{});
pub const popoverTargetElement = bridge.accessor(Button.getPopoverTargetElement, Button.setPopoverTargetElement, .{ .ce_reactions = true });
pub const popoverTargetAction = bridge.accessor(Button.getPopoverTargetAction, Button.setPopoverTargetAction, .{ .ce_reactions = true });
pub const willValidate = bridge.accessor(Button.getWillValidate, null, .{});
pub const validity = bridge.accessor(Button.getValidity, null, .{});
pub const validationMessage = bridge.accessor(Button.getValidationMessage, null, .{});

View File

@@ -142,6 +142,18 @@ pub fn setAcceptCharset(self: *Form, value: []const u8, frame: *Frame) !void {
try self.asElement().setAttributeSafe(.wrap("accept-charset"), .wrap(value), frame);
}
pub fn getNoValidate(self: *const Form) bool {
return self.asConstElement().getAttributeSafe(comptime .wrap("novalidate")) != null;
}
pub fn setNoValidate(self: *Form, value: bool, frame: *Frame) !void {
if (value) {
try self.asElement().setAttributeSafe(comptime .wrap("novalidate"), .wrap(""), frame);
} else {
try self.asElement().removeAttribute(comptime .wrap("novalidate"), frame);
}
}
pub fn getEnctype(self: *const Form) []const u8 {
return normalizeEnctype(self.asConstElement().getAttributeSafe(comptime .wrap("enctype")), "application/x-www-form-urlencoded");
}
@@ -241,6 +253,7 @@ pub const JsApi = struct {
pub const target = bridge.accessor(Form.getTarget, Form.setTarget, .{ .ce_reactions = true });
pub const acceptCharset = bridge.accessor(Form.getAcceptCharset, Form.setAcceptCharset, .{ .ce_reactions = true });
pub const enctype = bridge.accessor(Form.getEnctype, Form.setEnctype, .{ .ce_reactions = true });
pub const noValidate = bridge.accessor(Form.getNoValidate, Form.setNoValidate, .{ .ce_reactions = true });
pub const elements = bridge.accessor(Form.getElements, null, .{});
pub const length = bridge.accessor(Form.getLength, null, .{});
pub const submit = bridge.function(Form.submit, .{});

View File

@@ -30,6 +30,9 @@ 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 String = lp.String;
@@ -85,6 +88,8 @@ _input_type: Type = .text,
_indeterminate: bool = false,
_custom_validity: ?[]const u8 = null,
_validity: ?*ValidityState = null,
_popover_target: ?*Element = null,
_files: ?*FileList = null,
_selection_start: u32 = 0,
_selection_end: u32 = 0,
@@ -232,6 +237,74 @@ pub fn getValidity(self: *Input, frame: *Frame) !*ValidityState {
return v;
}
/// Lazily allocates this input's FileList and registers it with the frame so
/// the refcounted File objects it holds are released at frame teardown.
fn ensureFileList(self: *Input, frame: *Frame) !*FileList {
if (self._files) |fl| {
return fl;
}
const fl = try frame._factory.create(FileList{});
try frame.trackFileList(fl);
self._files = fl;
return fl;
}
/// Returns the FileList for a `type="file"` input (lazily allocated, identity preserved).
/// Non-file inputs return null per HTMLInputElement IDL.
pub fn getFiles(self: *Input, frame: *Frame) !?*FileList {
if (self._input_type != .file) {
return null;
}
return try self.ensureFileList(frame);
}
/// Replaces the selected file list and fires `input` + `change` events.
/// Used by CDP `DOM.setFileInputFiles` and any future DataTransfer-style setter.
///
/// The FileList holds a reference on each File (whose backing arena is reference
/// counted via its Blob proto), so we acquire on the incoming files and release
/// the outgoing ones; the frame releases whatever remains at teardown.
pub fn setFiles(self: *Input, files: []const *File, frame: *Frame) !void {
if (self._input_type != .file) {
return error.InvalidStateError;
}
const fl = try self.ensureFileList(frame);
const dupe = try frame.arena.dupe(*File, files);
for (dupe) |file| {
file._proto.acquireRef();
}
for (fl._files) |old| {
old._proto.releaseRef(frame._page);
}
fl._files = dupe;
// A file input fires `input` then `change`, both as plain bubbling Events
// (not InputEvents — `inputType`/`data` only apply to editable text inputs).
const input_evt = try Event.initTrusted(comptime .wrap("input"), .{ .bubbles = true }, frame._page);
try frame._event_manager.dispatch(self.asElement().asEventTarget(), input_evt);
const change_evt = try Event.initTrusted(comptime .wrap("change"), .{ .bubbles = true }, frame._page);
try frame._event_manager.dispatch(self.asElement().asEventTarget(), change_evt);
}
/// JS-binding wrapper for the `value` getter: for type=file, return the spec
/// "C:\\fakepath\\<name>" string; otherwise delegate to plain getValue().
pub fn getValueForJS(self: *const Input, frame: *Frame) ![]const u8 {
if (self._input_type != .file) {
return self.getValue();
}
const fl = self._files orelse return "";
if (fl._files.len == 0) {
return "";
}
return try std.fmt.allocPrint(frame.call_arena, "C:\\fakepath\\{s}", .{fl._files[0]._name});
}
pub fn getValidationMessage(self: *const Input, frame: *Frame) []const u8 {
if (!self.getWillValidate()) return "";
if (self._custom_validity) |msg| return msg;
@@ -282,8 +355,7 @@ pub fn suffersValueMissing(self: *const Input, frame: *Frame) bool {
return switch (self._input_type) {
.checkbox => !self._checked,
.radio => !self.radioGroupHasChecked(frame),
// TODO: file inputs aren't supported yet (#2175); treat as always-empty when required.
.file => true,
.file => if (self._files) |fl| fl._files.len == 0 else true,
.text, .password, .email, .url, .tel, .search, .number, .date, .time, .@"datetime-local", .month, .week, .color => blk: {
const v = self._value orelse self._default_value orelse "";
break :blk v.len == 0;
@@ -1292,6 +1364,27 @@ fn uncheckRadioGroup(self: *Input, frame: *Frame) void {
}
}
pub fn getPopoverTargetElement(self: *Input, frame: *Frame) ?*Element {
return popover.invokerTarget(self.asNode(), self._popover_target, frame);
}
pub fn setPopoverTargetElement(self: *Input, value: ?*Element, frame: *Frame) !void {
self._popover_target = value;
if (value == null) {
try self.asElement().removeAttribute(.wrap("popovertarget"), frame);
} else {
try self.asElement().setAttribute(.wrap("popovertarget"), .wrap(""), frame);
}
}
pub fn getPopoverTargetAction(self: *Input) []const u8 {
return @tagName(popover.getInvokerAction(self.asElement()));
}
pub fn setPopoverTargetAction(self: *Input, value: []const u8, frame: *Frame) !void {
try self.asElement().setAttribute(.wrap("popovertargetaction"), .wrap(value), frame);
}
pub const JsApi = struct {
pub const bridge = js.Bridge(Input);
@@ -1311,7 +1404,8 @@ pub const JsApi = struct {
pub const onselectionchange = bridge.accessor(Input.getOnSelectionChange, Input.setOnSelectionChange, .{});
pub const @"type" = bridge.accessor(Input.getType, Input.setType, .{ .ce_reactions = true });
pub const value = bridge.accessor(Input.getValue, setValueFromJS, .{ .dom_exception = true });
pub const value = bridge.accessor(Input.getValueForJS, setValueFromJS, .{ .dom_exception = true, .ce_reactions = true });
pub const files = bridge.accessor(Input.getFiles, null, .{});
pub const defaultValue = bridge.accessor(Input.getDefaultValue, Input.setDefaultValue, .{ .ce_reactions = true });
pub const checked = bridge.accessor(Input.getChecked, Input.setChecked, .{});
pub const defaultChecked = bridge.accessor(Input.getDefaultChecked, Input.setDefaultChecked, .{ .ce_reactions = true });
@@ -1332,6 +1426,8 @@ pub const JsApi = struct {
pub const formNoValidate = bridge.accessor(Input.getFormNoValidate, Input.setFormNoValidate, .{});
pub const formTarget = bridge.accessor(Input.getFormTarget, Input.setFormTarget, .{});
pub const labels = bridge.accessor(Input.getLabels, null, .{});
pub const popoverTargetElement = bridge.accessor(Input.getPopoverTargetElement, Input.setPopoverTargetElement, .{ .ce_reactions = true });
pub const popoverTargetAction = bridge.accessor(Input.getPopoverTargetAction, Input.setPopoverTargetAction, .{ .ce_reactions = true });
pub const indeterminate = bridge.accessor(Input.getIndeterminate, Input.setIndeterminate, .{});
pub const placeholder = bridge.accessor(Input.getPlaceholder, Input.setPlaceholder, .{ .ce_reactions = true });
pub const pattern = bridge.accessor(Input.getPattern, Input.setPattern, .{ .ce_reactions = true });
@@ -1452,6 +1548,7 @@ test "WebApi: HTML.Input" {
try testing.htmlRunner("element/html/input_radio.html", .{});
try testing.htmlRunner("element/html/input-attrs.html", .{});
try testing.htmlRunner("element/html/input-validity.html", .{});
try testing.htmlRunner("element/html/input_file.html", .{});
}
test "isValidFloatingPoint" {

View File

@@ -0,0 +1,320 @@
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// 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 <https://www.gnu.org/licenses/>.
// We don't have a layout/rendering engine, so this only models the DOM state:
// a popover is "showing" iff it's a member of its document's `_open_popovers` list
const std = @import("std");
const lp = @import("lightpanda");
const Frame = @import("../../Frame.zig");
const Node = @import("../Node.zig");
const Element = @import("../Element.zig");
const ToggleEvent = @import("../event/ToggleEvent.zig");
const HtmlElement = @import("Html.zig");
const log = lp.log;
const String = lp.String;
pub fn getState(el: *Element) ?State {
return State.parse(el.getAttributeSafe(comptime .wrap("popover")));
}
pub fn getInvokerAction(el: *Element) Action {
return Action.parse(el.getAttributeSafe(.wrap("popovertargetaction")));
}
pub fn isOpen(el: *Element, frame: *Frame) bool {
for (frame.document._open_popovers.items) |p| {
if (p == el) {
return true;
}
}
return false;
}
pub fn show(el: *Element, frame: *Frame) !void {
const original = getState(el) orelse return error.NotSupported;
if (el.asNode().isConnected() == false) {
return error.InvalidStateError;
}
if (isOpen(el, frame)) {
// already showing: no-op (must not throw)
return;
}
if (try fireToggle(el, comptime .wrap("beforetoggle"), "closed", "open", true, frame)) {
// was canceled
return;
}
if (hasChanged(el, frame, original)) {
return error.InvalidStateError;
}
if (original != .manual) {
// showing an auto/hint popover dismisses other auto/hint popovers
try hideUnrelatedAutos(el, frame);
if (hasChanged(el, frame, original)) {
return error.InvalidStateError;
}
}
try frame.document._open_popovers.append(frame.arena, el);
frame.domChanged();
_ = try fireToggle(el, comptime .wrap("toggle"), "closed", "open", false, frame);
}
pub fn hide(el: *Element, frame: *Frame) !void {
if (getState(el) == null) {
return error.NotSupported;
}
if (isOpen(el, frame) == false) {
// already hidden
return;
}
try hideThroughStack(el, frame);
}
pub fn toggle(el: *Element, force: ?bool, frame: *Frame) !bool {
if (getState(el) == null) {
return error.NotSupported;
}
const open = isOpen(el, frame);
if (force) |f| {
if (f and !open) try show(el, frame);
if (!f and open) try hide(el, frame);
} else if (open) {
try hide(el, frame);
} else {
try show(el, frame);
}
return isOpen(el, frame);
}
// Hide every popover shown above `el` in the stack, then `el` itself, firing
// events top-down so nested popovers close before their ancestors.
fn hideThroughStack(el: *Element, frame: *Frame) !void {
const open = &frame.document._open_popovers;
while (isOpen(el, frame)) {
const top = open.items[open.items.len - 1];
try hideOne(top, frame);
if (top == el) {
break;
}
}
}
fn hideUnrelatedAutos(el: *Element, frame: *Frame) !void {
// This is a rescan on each round because the event we fire can result in the
// list being mutated
const node = el.asNode();
while (true) {
const items = frame.document._open_popovers.items;
var target: ?*Element = null;
var i: usize = items.len;
while (i > 0) {
i -= 1;
const p = items[i];
if (p == el) {
continue;
}
const ps = getState(p) orelse continue;
if (ps == .manual) {
continue;
}
if (p.asNode().contains(node)) {
continue; // ancestor: keep open
}
target = p;
break;
}
// nothing left to dismiss
const t = target orelse break;
try hideOne(t, frame);
}
}
fn hideOne(el: *Element, frame: *Frame) !void {
removeFromOpen(el, frame);
frame.domChanged();
_ = try fireToggle(el, comptime .wrap("beforetoggle"), "open", "closed", false, frame);
_ = try fireToggle(el, comptime .wrap("toggle"), "open", "closed", false, frame);
}
// Called when an element's `popover` content attribute is set, changed, or
// removed. A showing popover must be hidden if its type changes.
pub fn attributeChanged(el: *Element, old_value: ?[]const u8, new_value: ?[]const u8, frame: *Frame) void {
if (isOpen(el, frame) == false) {
return;
}
if (State.parse(old_value) == State.parse(new_value)) {
return;
}
hideThroughStack(el, frame) catch |err| {
log.err(.bug, "popover.forceClose", .{ .err = err });
};
}
pub fn removeFromOpen(el: *Element, frame: *Frame) void {
const list = &frame.document._open_popovers;
var i: usize = 0;
while (i < list.items.len) : (i += 1) {
if (list.items[i] == el) {
_ = list.orderedRemove(i);
return;
}
}
}
// The popover targeted by an invoker (a <button>, or input button), resolving
// the explicitly IDL-set element or the `popovertarget` IDREF content attribute.
pub fn invokerTarget(invoker: *Node, explicit: ?*Element, frame: *Frame) ?*Element {
if (explicit) |target| {
if (target.asNode().getRootNode(null) == invoker.getRootNode(null)) {
// The invoker and target must share the same root
return target;
}
return null;
}
const el = invoker.is(Element) orelse return null;
const id = el.getAttributeSafe(.wrap("popovertarget")) orelse return null;
return frame.document.getElementById(id, frame);
}
pub fn runInvokerActivation(invoker: *HtmlElement, explicit: ?*Element, frame: *Frame) !void {
switch (invoker._type) {
.button => {},
.input => |input| switch (input._input_type) {
.button, .submit, .reset, .image => {},
else => return, // not an invoker
},
else => return, // not an invoker
}
const invoker_elem = invoker.asElement();
const invoker_node = invoker_elem.asNode();
const target = invokerTarget(invoker_node, explicit, frame) orelse return;
if (getState(target) == null) {
return;
}
const open = isOpen(target, frame);
const result: anyerror!void = switch (getInvokerAction(invoker_elem)) {
.toggle => if (open) hide(target, frame) else show(target, frame),
.show => if (open) {} else show(target, frame),
.hide => if (open) hide(target, frame) else {},
};
// swallow activation errors, they don't propagate
result catch |err| switch (err) {
error.InvalidStateError, error.NotSupported => {},
else => return err,
};
}
fn fireToggle(
el: *Element,
typ: String,
old_state: []const u8,
new_state: []const u8,
cancelable: bool,
frame: *Frame,
) !bool {
const event = (try ToggleEvent.initTrusted(typ, .{
.cancelable = cancelable,
.oldState = old_state,
.newState = new_state,
}, frame)).asEvent();
// Keep the event alive while dispatching so we can read _prevent_default.
event.acquireRef();
defer _ = event.releaseRef(frame._page);
try frame._event_manager.dispatch(el.asEventTarget(), event);
return event._prevent_default;
}
fn hasChanged(el: *Element, frame: *Frame, original: State) bool {
const now = getState(el) orelse return true;
if (now != original) {
return true;
}
if (el.asNode().isConnected() == false) {
return true;
}
if (isOpen(el, frame)) {
return true;
}
return false;
}
const State = enum {
auto,
hint,
manual,
fn parse(value_: ?[]const u8) ?State {
const value = value_ orelse return null;
if (value.len == 0) {
return .auto;
}
if (std.ascii.eqlIgnoreCase(value, "auto")) {
return .auto;
}
if (std.ascii.eqlIgnoreCase(value, "manual")) {
return .manual;
}
if (std.ascii.eqlIgnoreCase(value, "hint")) {
return .hint;
}
// default for an invalid value
return .manual;
}
};
const Action = enum {
toggle,
show,
hide,
fn parse(value_: ?[]const u8) Action {
const value = value_ orelse return .toggle;
if (std.ascii.eqlIgnoreCase(value, "show")) {
return .show;
}
if (std.ascii.eqlIgnoreCase(value, "hide")) {
return .hide;
}
// missing/invalid value default
return .toggle;
}
};

View File

@@ -86,6 +86,14 @@ const Options = Event.inheritOptions(
);
pub fn init(typ: []const u8, _opts: ?Options, frame: *Frame) !*PointerEvent {
return initWithTrusted(typ, _opts, false, frame);
}
pub fn initTrusted(typ: []const u8, _opts: ?Options, frame: *Frame) !*PointerEvent {
return initWithTrusted(typ, _opts, true, frame);
}
fn initWithTrusted(typ: []const u8, _opts: ?Options, trusted: bool, frame: *Frame) !*PointerEvent {
const arena = try frame.getArena(.tiny, "PointerEvent");
errdefer frame.releaseArena(arena);
const type_string = try String.init(arena, typ, .{});
@@ -126,7 +134,7 @@ pub fn init(typ: []const u8, _opts: ?Options, frame: *Frame) !*PointerEvent {
},
);
Event.populatePrototypes(event, opts, false);
Event.populatePrototypes(event, opts, trusted);
return event;
}

View File

@@ -0,0 +1,107 @@
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// 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 <https://www.gnu.org/licenses/>.
const std = @import("std");
const lp = @import("lightpanda");
const js = @import("../../js/js.zig");
const Frame = @import("../../Frame.zig");
const Event = @import("../Event.zig");
const HtmlElement = @import("../element/Html.zig");
const String = lp.String;
const Allocator = std.mem.Allocator;
/// https://html.spec.whatwg.org/multipage/popover.html#toggleevent
const ToggleEvent = @This();
_proto: *Event,
_old_state: []const u8 = "",
_new_state: []const u8 = "",
_source: ?*HtmlElement = null,
const ToggleEventOptions = struct {
oldState: []const u8 = "",
newState: []const u8 = "",
source: ?*HtmlElement = null,
};
const Options = Event.inheritOptions(ToggleEvent, ToggleEventOptions);
pub fn init(typ: []const u8, opts_: ?Options, frame: *Frame) !*ToggleEvent {
const arena = try frame.getArena(.tiny, "ToggleEvent");
errdefer frame.releaseArena(arena);
const type_string = try String.init(arena, typ, .{});
return initWithTrusted(arena, type_string, opts_, false, frame);
}
pub fn initTrusted(typ: String, _opts: ?Options, frame: *Frame) !*ToggleEvent {
const arena = try frame.getArena(.tiny, "ToggleEvent.trusted");
errdefer frame.releaseArena(arena);
return initWithTrusted(arena, typ, _opts, true, frame);
}
fn initWithTrusted(arena: Allocator, typ: String, _opts: ?Options, trusted: bool, frame: *Frame) !*ToggleEvent {
const opts = _opts orelse Options{};
const event = try frame._factory.event(
arena,
typ,
ToggleEvent{
._proto = undefined,
._old_state = if (opts.oldState.len > 0) try arena.dupe(u8, opts.oldState) else "",
._new_state = if (opts.newState.len > 0) try arena.dupe(u8, opts.newState) else "",
._source = opts.source,
},
);
Event.populatePrototypes(event, opts, trusted);
return event;
}
pub fn asEvent(self: *ToggleEvent) *Event {
return self._proto;
}
pub fn getOldState(self: *const ToggleEvent) []const u8 {
return self._old_state;
}
pub fn getNewState(self: *const ToggleEvent) []const u8 {
return self._new_state;
}
pub fn getSource(self: *const ToggleEvent) ?*HtmlElement {
return self._source;
}
pub const JsApi = struct {
pub const bridge = js.Bridge(ToggleEvent);
pub const Meta = struct {
pub const name = "ToggleEvent";
pub const prototype_chain = bridge.prototypeChain();
pub var class_id: bridge.ClassId = undefined;
};
pub const constructor = bridge.constructor(ToggleEvent.init, .{});
pub const oldState = bridge.accessor(ToggleEvent.getOldState, null, .{});
pub const newState = bridge.accessor(ToggleEvent.getNewState, null, .{});
pub const source = bridge.accessor(ToggleEvent.getSource, null, .{});
};

View File

@@ -52,6 +52,14 @@ pub const Options = Event.inheritOptions(
);
pub fn init(typ: []const u8, _opts: ?Options, frame: *Frame) !*WheelEvent {
return initWithTrusted(typ, _opts, false, frame);
}
pub fn initTrusted(typ: []const u8, _opts: ?Options, frame: *Frame) !*WheelEvent {
return initWithTrusted(typ, _opts, true, frame);
}
fn initWithTrusted(typ: []const u8, _opts: ?Options, trusted: bool, frame: *Frame) !*WheelEvent {
const arena = try frame.getArena(.medium, "WheelEvent");
errdefer frame.releaseArena(arena);
const type_string = try String.init(arena, typ, .{});
@@ -85,7 +93,7 @@ pub fn init(typ: []const u8, _opts: ?Options, frame: *Frame) !*WheelEvent {
},
);
Event.populatePrototypes(event, opts, false);
Event.populatePrototypes(event, opts, trusted);
return event;
}

View File

@@ -34,6 +34,7 @@ const EventTarget = @import("../EventTarget.zig");
const Headers = @import("Headers.zig");
const BodyInit = @import("body_init.zig").BodyInit;
const XMLHttpRequestEventTarget = @import("XMLHttpRequestEventTarget.zig");
const XMLHttpRequestUpload = @import("XMLHttpRequestUpload.zig");
const log = lp.log;
const Execution = js.Execution;
@@ -44,6 +45,7 @@ const XMLHttpRequest = @This();
_rc: lp.RC(u8) = .{},
_exec: *const Execution,
_proto: *XMLHttpRequestEventTarget,
_upload: ?*XMLHttpRequestUpload = null,
_arena: Allocator,
_http_response: ?HttpClient.Response = null,
_active_request: bool = false,
@@ -59,6 +61,8 @@ _response_status: u16 = 0,
_response_len: ?usize = 0,
_response_url: [:0]const u8 = "",
_response_mime: ?Mime = null,
_override_mime: ?Mime = null,
_response_xml: ?*Node.Document = null,
_response_headers: std.ArrayList([]const u8) = .empty,
_response_type: ResponseType = .text,
@@ -112,31 +116,10 @@ pub fn deinit(self: *XMLHttpRequest, page: *Page) void {
func.release();
}
{
const proto = self._proto;
if (proto._on_abort) |func| {
func.release();
}
if (proto._on_error) |func| {
func.release();
}
if (proto._on_load) |func| {
func.release();
}
if (proto._on_load_end) |func| {
func.release();
}
if (proto._on_load_start) |func| {
func.release();
}
if (proto._on_progress) |func| {
func.release();
}
if (proto._on_timeout) |func| {
func.release();
}
self._proto.releaseListeners();
if (self._upload) |upload| {
upload._proto.releaseListeners();
}
page.releaseArena(self._arena);
}
@@ -200,8 +183,10 @@ pub fn open(self: *XMLHttpRequest, method_: []const u8, url: [:0]const u8) !void
self._http_response = null;
}
// Reset internal state
// Reset internal state. _override_mime intentionally survives open()
// per https://xhr.spec.whatwg.org/#the-overridemimetype()-method.
self._response = null;
self._response_xml = null;
self._response_data.clearRetainingCapacity();
self._response_status = 0;
self._response_len = 0;
@@ -223,6 +208,15 @@ pub fn setRequestHeader(self: *XMLHttpRequest, name: []const u8, value: []const
return self._request_headers.append(name, value, exec);
}
// https://xhr.spec.whatwg.org/#the-overridemimetype()-method
pub fn overrideMimeType(self: *XMLHttpRequest, mime: []const u8) !void {
if (self._ready_state == .loading or self._ready_state == .done) {
return error.InvalidStateError;
}
self._override_mime = Mime.parse(mime) catch
Mime.parse("application/octet-stream") catch unreachable;
}
pub fn send(self: *XMLHttpRequest, body_: ?BodyInit, exec_: *const Execution) !void {
if (comptime IS_DEBUG) {
log.debug(.http, "XMLHttpRequest.send", .{ .url = self._url });
@@ -289,6 +283,21 @@ pub fn send(self: *XMLHttpRequest, body_: ?BodyInit, exec_: *const Execution) !v
};
}
// https://xhr.spec.whatwg.org/#the-upload-attribute
// The XMLHttpRequestUpload object is created lazily and cached: scripts expect
// the same instance on every access so their event listeners stick.
pub fn getUpload(self: *XMLHttpRequest) !*XMLHttpRequestUpload {
if (self._upload) |upload| {
return upload;
}
const upload = try self._exec._factory.xhrEventTarget(
self._arena,
XMLHttpRequestUpload{ ._proto = undefined, ._xhr = self },
);
self._upload = upload;
return upload;
}
pub fn getReadyState(self: *const XMLHttpRequest) u32 {
return @intFromEnum(self._ready_state);
}
@@ -338,6 +347,10 @@ pub fn setResponseType(self: *XMLHttpRequest, value: []const u8) void {
}
pub fn getResponseText(self: *const XMLHttpRequest) []const u8 {
// TODO: per WHATWG XHR "get a text response", the bytes must be decoded
// using the final encoding derived from the final MIME type
// (_override_mime ?? _response_mime). Currently the raw bytes are
// returned and V8 treats them as UTF-8.
return self._response_data.items;
}
@@ -373,6 +386,12 @@ pub fn getResponse(self: *XMLHttpRequest, exec: *const Execution) !?Response {
.document => blk: {
// responseType=document is only meaningful in a Frame; workers
// have no DOM. Drastically different impls -> switch on global.
//
// TODO: per WHATWG XHR "set a document response", the final MIME
// type (_override_mime ?? _response_mime) should select an XML
// parser when it is an XML MIME type, and the HTML parser
// otherwise. We only have an HTML parser today, so the body is
// always parsed as HTML regardless of the override.
switch (exec.js.global) {
.frame => |frame| {
const document = try exec._factory.node(Node.Document{ ._proto = undefined, ._type = .generic });
@@ -390,11 +409,41 @@ pub fn getResponse(self: *XMLHttpRequest, exec: *const Execution) !?Response {
}
pub fn getResponseXML(self: *XMLHttpRequest, exec: *const Execution) !?*Node.Document {
const res = (try self.getResponse(exec)) orelse return null;
return switch (res) {
.document => |doc| doc,
else => null,
};
if (self._ready_state != .done) {
return null;
}
// responseType="document": getResponse already parses + caches it.
if (self._response_type == .document) {
const res = (try self.getResponse(exec)) orelse return null;
return switch (res) {
.document => |doc| doc,
else => null,
};
}
// responseType="" (we map "" to .text — see setResponseType): lazily
// produce a Document when the final MIME type is XML, per WHATWG XHR
// "set a document response". For an HTML final MIME the spec returns
// null in this branch, so we only act on text/xml.
if (self._response_type != .text) return null;
if (self._response_xml) |document| {
return document;
}
const final = self._override_mime orelse self._response_mime orelse return null;
if (final.content_type != .text_xml) return null;
switch (exec.js.global) {
.frame => |frame| {
const document = try exec._factory.node(Node.Document{ ._proto = undefined, ._type = .generic });
try frame.parseHtmlAsChildren(document.asNode(), self._response_data.items);
self._response_xml = document;
return document;
},
.worker => return error.NotSupportedInWorker,
}
}
fn httpStartCallback(response: HttpClient.Response) !void {
@@ -611,6 +660,7 @@ pub const JsApi = struct {
pub const DONE = bridge.property(@intFromEnum(XMLHttpRequest.ReadyState.done), .{ .template = true });
pub const onreadystatechange = bridge.accessor(XMLHttpRequest.getOnReadyStateChange, XMLHttpRequest.setOnReadyStateChange, .{});
pub const upload = bridge.accessor(XMLHttpRequest.getUpload, null, .{});
pub const timeout = bridge.accessor(XMLHttpRequest.getTimeout, XMLHttpRequest.setTimeout, .{});
pub const withCredentials = bridge.accessor(XMLHttpRequest.getWithCredentials, XMLHttpRequest.setWithCredentials, .{ .dom_exception = true });
pub const open = bridge.function(XMLHttpRequest.open, .{});
@@ -624,6 +674,7 @@ pub const JsApi = struct {
pub const responseXML = bridge.accessor(XMLHttpRequest.getResponseXML, null, .{});
pub const responseURL = bridge.accessor(XMLHttpRequest.getResponseURL, null, .{});
pub const setRequestHeader = bridge.function(XMLHttpRequest.setRequestHeader, .{ .dom_exception = true });
pub const overrideMimeType = bridge.function(XMLHttpRequest.overrideMimeType, .{ .dom_exception = true });
pub const getResponseHeader = bridge.function(XMLHttpRequest.getResponseHeader, .{});
pub const getAllResponseHeaders = bridge.function(XMLHttpRequest.getAllResponseHeaders, .{});
pub const abort = bridge.function(XMLHttpRequest.abort, .{});

View File

@@ -37,13 +37,24 @@ _on_timeout: ?js.Function.Temp = null,
pub const Type = union(enum) {
request: *@import("XMLHttpRequest.zig"),
// TODO: xml_http_request_upload
upload: *@import("XMLHttpRequestUpload.zig"),
};
pub fn asEventTarget(self: *XMLHttpRequestEventTarget) *EventTarget {
return self._proto;
}
pub fn releaseListeners(self: *XMLHttpRequestEventTarget) void {
inline for (.{
"_on_abort", "_on_error", "_on_load", "_on_load_end",
"_on_load_start", "_on_progress", "_on_timeout",
}) |field| {
if (@field(self, field)) |func| {
func.release();
}
}
}
pub fn dispatch(self: *XMLHttpRequestEventTarget, comptime event_type: DispatchType, progress_: ?Progress, exec: *const Execution) !void {
const field, const typ = comptime blk: {
break :blk switch (event_type) {

View File

@@ -0,0 +1,62 @@
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// 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 <https://www.gnu.org/licenses/>.
const js = @import("../../js/js.zig");
const Page = @import("../../Page.zig");
const EventTarget = @import("../EventTarget.zig");
const XMLHttpRequest = @import("XMLHttpRequest.zig");
const XMLHttpRequestEventTarget = @import("XMLHttpRequestEventTarget.zig");
// https://xhr.spec.whatwg.org/#xmlhttprequestupload
//
// Returned by XMLHttpRequest.upload. It only inherits from
// XMLHttpRequestEventTarget; it has no members of its own. We don't yet emit
// upload progress events, but the object still needs to exist so scripts (e.g.
// htmx) can call addEventListener on it without throwing.
const XMLHttpRequestUpload = @This();
_proto: *XMLHttpRequestEventTarget,
_xhr: *XMLHttpRequest,
// pub fn deinit(self: *XMLHttpRequestUpload, _: *Page) void {
// self._proto.releaseListeners();
// }
pub fn releaseRef(self: *XMLHttpRequestUpload, page: *Page) void {
self._xhr.releaseRef(page);
}
pub fn acquireRef(self: *XMLHttpRequestUpload) void {
self._xhr.acquireRef();
}
pub fn asEventTarget(self: *XMLHttpRequestUpload) *EventTarget {
return self._proto.asEventTarget();
}
pub const JsApi = struct {
pub const bridge = js.Bridge(XMLHttpRequestUpload);
pub const Meta = struct {
pub const name = "XMLHttpRequestUpload";
pub const prototype_chain = bridge.prototypeChain();
pub var class_id: bridge.ClassId = undefined;
};
};

View File

@@ -530,6 +530,7 @@ fn matchesPseudoClass(el: *Node.Element, pseudo: Selector.PseudoClass, scope: *N
switch (pseudo) {
// State pseudo-classes
.modal => return false,
.popover_open => return @import("../element/popover.zig").isOpen(el, frame),
.checked => {
const input = el.is(Node.Element.Html.Input) orelse return false;
return input.getChecked();

View File

@@ -611,6 +611,7 @@ fn pseudoClass(self: *Parser, arena: Allocator) !Selector.PseudoClass {
if (fastEql(name, "last-of-type")) return .last_of_type;
if (fastEql(name, "focus-within")) return .focus_within;
if (fastEql(name, "out-of-range")) return .out_of_range;
if (fastEql(name, "popover-open")) return .popover_open;
},
13 => {
if (fastEql(name, "first-of-type")) return .first_of_type;

View File

@@ -158,6 +158,7 @@ pub const AttributeMatcher = union(enum) {
pub const PseudoClass = union(enum) {
// State pseudo-classes
modal,
popover_open,
checked,
disabled,
enabled,

View File

@@ -28,6 +28,10 @@ const js = @import("../../browser/js/js.zig");
const DOMNode = @import("../../browser/webapi/Node.zig");
const Selector = @import("../../browser/webapi/selector/Selector.zig");
const xpath = @import("../../browser/xpath/Evaluator.zig");
const Input = @import("../../browser/webapi/element/html/Input.zig");
const File = @import("../../browser/webapi/File.zig");
const Blob = @import("../../browser/webapi/Blob.zig");
const Page = @import("../../browser/Page.zig");
const log = lp.log;
const Allocator = std.mem.Allocator;
@@ -50,6 +54,7 @@ pub fn processMessage(cmd: *CDP.Command) !void {
getFrameOwner,
getOuterHTML,
requestNode,
setFileInputFiles,
}, cmd.input.action) orelse return error.UnknownMethod;
switch (action) {
@@ -69,6 +74,7 @@ pub fn processMessage(cmd: *CDP.Command) !void {
.getFrameOwner => return getFrameOwner(cmd),
.getOuterHTML => return getOuterHTML(cmd),
.requestNode => return requestNode(cmd),
.setFileInputFiles => return setFileInputFiles(cmd),
}
}
@@ -607,6 +613,100 @@ fn requestNode(cmd: *CDP.Command) !void {
return cmd.sendResult(.{ .nodeId = node.id }, .{});
}
// Cap matches Chrome's effective per-file limit; large enough for realistic
// uploads, small enough to avoid runaway reads from a misbehaving driver.
const MAX_FILE_BYTES: usize = 100 * 1024 * 1024;
// https://chromedevtools.github.io/devtools-protocol/tot/DOM/#method-setFileInputFiles
fn setFileInputFiles(cmd: *CDP.Command) !void {
const params = (try cmd.params(struct {
files: []const []const u8,
nodeId: ?Node.Id = null,
backendNodeId: ?Node.Id = null,
objectId: ?[]const u8 = null,
})) orelse return error.InvalidParams;
const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;
const frame = bc.session.currentFrame() orelse return error.FrameNotLoaded;
const node = try getNode(cmd.arena, bc, params.nodeId, params.backendNodeId, params.objectId);
const element = node.dom.is(DOMNode.Element) orelse return error.NodeIsNotAnElement;
const input = element.is(Input) orelse return error.NotAnInputElement;
if (input._input_type != .file) return error.NotAFileInput;
var files = try cmd.arena.alloc(*File, params.files.len);
{
// Files are created at refcount 0; setFiles takes ownership. If a later
// path fails to load, release the arenas of the ones already created.
var created: usize = 0;
errdefer for (files[0..created]) |f| f._proto.deinit(frame._page);
for (params.files, 0..) |path, i| {
files[i] = try fileFromDiskPath(path, frame._page);
created = i + 1;
}
}
try input.setFiles(files, frame);
return cmd.sendResult(null, .{});
}
fn fileFromDiskPath(path: []const u8, page: *Page) !*File {
// Mirror File.init: a Blob and File sharing one reference-counted arena,
// but read the bytes straight off disk into it (single copy, no JS parts).
const arena = try page.getArena(.large, "File");
errdefer page.releaseArena(arena);
const data = try std.fs.cwd().readFileAlloc(arena, path, MAX_FILE_BYTES);
const stat = try std.fs.cwd().statFile(path);
const basename = std.fs.path.basename(path);
const blob = try arena.create(Blob);
const file = try arena.create(File);
blob.* = .{
._rc = .{},
._arena = arena,
._type = .{ .file = file },
._slice = data,
._mime = mimeFromExtension(basename),
};
file.* = .{
._proto = blob,
._name = try arena.dupe(u8, basename),
._last_modified = @intCast(@divTrunc(stat.mtime, std.time.ns_per_ms)),
};
return file;
}
fn mimeFromExtension(name: []const u8) []const u8 {
const dot = std.mem.lastIndexOfScalar(u8, name, '.') orelse return "application/octet-stream";
if (dot + 1 >= name.len) return "application/octet-stream";
var buf: [16]u8 = undefined;
const ext_raw = name[dot + 1 ..];
if (ext_raw.len > buf.len) return "application/octet-stream";
const ext = std.ascii.lowerString(buf[0..ext_raw.len], ext_raw);
const Map = std.StaticStringMap([]const u8);
const map = Map.initComptime(.{
.{ "txt", "text/plain" },
.{ "html", "text/html" },
.{ "htm", "text/html" },
.{ "css", "text/css" },
.{ "js", "text/javascript" },
.{ "json", "application/json" },
.{ "xml", "application/xml" },
.{ "pdf", "application/pdf" },
.{ "png", "image/png" },
.{ "jpg", "image/jpeg" },
.{ "jpeg", "image/jpeg" },
.{ "gif", "image/gif" },
.{ "webp", "image/webp" },
.{ "svg", "image/svg+xml" },
.{ "csv", "text/csv" },
.{ "zip", "application/zip" },
});
return map.get(ext) orelse "application/octet-stream";
}
const testing = @import("../testing.zig");
test "cdp.dom: getSearchResults unknown search id" {
var ctx = try testing.context();
@@ -717,6 +817,203 @@ test "cdp.dom: performSearch with XPath" {
try ctx.expectSentResult(.{ .searchId = "4", .resultCount = 2 }, .{ .id = 24 });
}
test "cdp.dom: setFileInputFiles on file input" {
var ctx = try testing.context();
defer ctx.deinit();
_ = try ctx.loadBrowserContext(.{ .id = "BID-A", .url = "cdp/input_file.html" });
// Find the file input via performSearch → getSearchResults.
try ctx.processMessage(.{
.id = 1,
.method = "DOM.performSearch",
.params = .{ .query = "input" },
});
try ctx.expectSentResult(.{ .searchId = "0", .resultCount = 1 }, .{ .id = 1 });
try ctx.processMessage(.{
.id = 2,
.method = "DOM.getSearchResults",
.params = .{ .searchId = "0", .fromIndex = 0, .toIndex = 1 },
});
try ctx.expectSentResult(.{ .nodeIds = &.{1} }, .{ .id = 2 });
// Drop a temp file we can upload.
var tmp_dir = try std.fs.cwd().makeOpenPath(".zig-cache/tmp", .{});
defer tmp_dir.close();
{
const f = try tmp_dir.createFile("upload.txt", .{ .truncate = true });
defer f.close();
try f.writeAll("hello upload");
}
try ctx.processMessage(.{
.id = 3,
.method = "DOM.setFileInputFiles",
.params = .{
.nodeId = 1,
.files = &[_][]const u8{".zig-cache/tmp/upload.txt"},
},
});
try ctx.expectSentResult(null, .{ .id = 3 });
}
test "cdp.dom: setFileInputFiles exposes files to JS" {
var ctx = try testing.context();
defer ctx.deinit();
const bc = try ctx.loadBrowserContext(.{ .id = "BID-A", .url = "cdp/input_file.html" });
try ctx.processMessage(.{ .id = 1, .method = "DOM.performSearch", .params = .{ .query = "input" } });
try ctx.expectSentResult(.{ .searchId = "0", .resultCount = 1 }, .{ .id = 1 });
try ctx.processMessage(.{
.id = 2,
.method = "DOM.getSearchResults",
.params = .{ .searchId = "0", .fromIndex = 0, .toIndex = 1 },
});
try ctx.expectSentResult(.{ .nodeIds = &.{1} }, .{ .id = 2 });
// Two files, so we can assert ordering as well as identity and iteration.
var tmp_dir = try std.fs.cwd().makeOpenPath(".zig-cache/tmp", .{});
defer tmp_dir.close();
{
const a = try tmp_dir.createFile("a.txt", .{ .truncate = true });
defer a.close();
try a.writeAll("aaa");
const b = try tmp_dir.createFile("b.txt", .{ .truncate = true });
defer b.close();
try b.writeAll("bbbb");
}
const frame = bc.session.currentFrame().?;
var ls: lp.js.Local.Scope = undefined;
frame.js.localScope(&ls);
defer ls.deinit();
// Listen on `document` so reaching the handler also proves the events
// bubbled up from the input. Record interface + bubbles + order + target.
_ = try ls.local.compileAndRun(
\\window.__evts = [];
\\const rec = (e) => window.__evts.push(
\\ [e.type, e instanceof InputEvent, e.bubbles, e.target.id].join(':'));
\\document.addEventListener('input', rec);
\\document.addEventListener('change', rec);
, null);
try ctx.processMessage(.{
.id = 3,
.method = "DOM.setFileInputFiles",
.params = .{
.nodeId = 1,
.files = &[_][]const u8{ ".zig-cache/tmp/a.txt", ".zig-cache/tmp/b.txt" },
},
});
try ctx.expectSentResult(null, .{ .id = 3 });
// The only way to observe a populated FileList from JS: set it via CDP,
// then read it back. Covers length, item(), the indexed getter and the
// iterator (spread / Array.from), which can't be exercised on an empty list.
// Also asserts the fired events: `input` then `change`, both plain bubbling
// Events (not InputEvents) targeting the input.
const result = try ls.local.compileAndRun(
\\const f = document.getElementById('upload');
\\f.files.length === 2 &&
\\f.files.item(0).name === 'a.txt' &&
\\f.files[0] instanceof File && f.files[0].name === 'a.txt' &&
\\f.files[1].name === 'b.txt' &&
\\f.files[2] === undefined &&
\\[...f.files].map((x) => x.name).join(',') === 'a.txt,b.txt' &&
\\Array.from(f.files, (x) => x.name).join(',') === 'a.txt,b.txt' &&
\\Object.keys(f.files).join(',') === '0,1' &&
\\window.__evts.join(',') === 'input:false:true:upload,change:false:true:upload'
, null);
try testing.expect(result.isTrue());
}
test "cdp.dom: setFileInputFiles rejects non-input node" {
var ctx = try testing.context();
defer ctx.deinit();
_ = try ctx.loadBrowserContext(.{ .id = "BID-A", .url = "cdp/dom1.html" });
// dom1.html has <p> elements — pick one by search.
try ctx.processMessage(.{
.id = 1,
.method = "DOM.performSearch",
.params = .{ .query = "p" },
});
try ctx.expectSentResult(.{ .searchId = "0", .resultCount = 2 }, .{ .id = 1 });
try ctx.processMessage(.{
.id = 2,
.method = "DOM.getSearchResults",
.params = .{ .searchId = "0", .fromIndex = 0, .toIndex = 1 },
});
try ctx.expectSentResult(.{ .nodeIds = &.{1} }, .{ .id = 2 });
try ctx.processMessage(.{
.id = 3,
.method = "DOM.setFileInputFiles",
.params = .{
.nodeId = 1,
.files = &[_][]const u8{".zig-cache/tmp/upload.txt"},
},
});
try ctx.expectSentError(-31998, "NotAnInputElement", .{ .id = 3 });
}
test "cdp.dom: setFileInputFiles requires a node identifier" {
var ctx = try testing.context();
defer ctx.deinit();
_ = try ctx.loadBrowserContext(.{ .id = "BID-A", .url = "cdp/input_file.html" });
try ctx.processMessage(.{
.id = 1,
.method = "DOM.setFileInputFiles",
.params = .{
.files = &[_][]const u8{".zig-cache/tmp/upload.txt"},
},
});
try ctx.expectSentError(-31998, "MissingParams", .{ .id = 1 });
}
test "cdp.dom: setFileInputFiles errors (and leaks nothing) when a path is missing" {
var ctx = try testing.context();
defer ctx.deinit();
_ = try ctx.loadBrowserContext(.{ .id = "BID-A", .url = "cdp/input_file.html" });
try ctx.processMessage(.{ .id = 1, .method = "DOM.performSearch", .params = .{ .query = "input" } });
try ctx.expectSentResult(.{ .searchId = "0", .resultCount = 1 }, .{ .id = 1 });
try ctx.processMessage(.{
.id = 2,
.method = "DOM.getSearchResults",
.params = .{ .searchId = "0", .fromIndex = 0, .toIndex = 1 },
});
try ctx.expectSentResult(.{ .nodeIds = &.{1} }, .{ .id = 2 });
// First path exists, second does not: the first File is created then must be
// freed when the second read fails (the test runner panics on a leak).
var tmp_dir = try std.fs.cwd().makeOpenPath(".zig-cache/tmp", .{});
defer tmp_dir.close();
{
const f = try tmp_dir.createFile("upload.txt", .{ .truncate = true });
defer f.close();
try f.writeAll("hello upload");
}
try ctx.processMessage(.{
.id = 3,
.method = "DOM.setFileInputFiles",
.params = .{
.nodeId = 1,
.files = &[_][]const u8{ ".zig-cache/tmp/upload.txt", ".zig-cache/tmp/does-not-exist.txt" },
},
});
try ctx.expectSentError(-31998, "FileNotFound", .{ .id = 3 });
}
test "cdp.dom: isXPathQuery heuristic" {
// XPath-shaped queries — each line covers a distinct heuristic branch.
try std.testing.expect(isXPathQuery("/html"));

View File

@@ -82,6 +82,8 @@ fn dispatchMouseEvent(cmd: *CDP.Command) !void {
x: f64,
y: f64,
type: Type,
deltaX: f64 = 0,
deltaY: f64 = 0,
// Many optional parameters are not implemented yet, see documentation url.
const Type = enum {
@@ -94,15 +96,14 @@ fn dispatchMouseEvent(cmd: *CDP.Command) !void {
try cmd.sendResult(null, .{});
// quickly ignore types we know we don't handle
switch (params.type) {
.mouseMoved, .mouseWheel, .mouseReleased => return,
else => {},
}
const bc = cmd.browser_context orelse return;
const frame = bc.session.currentFrame() orelse return;
try frame.triggerMouseClick(params.x, params.y);
switch (params.type) {
.mousePressed => try frame.triggerMouseClick(params.x, params.y),
.mouseReleased => try frame.triggerMouseRelease(params.x, params.y),
.mouseMoved => try frame.triggerMouseMove(params.x, params.y),
.mouseWheel => try frame.triggerMouseWheel(params.x, params.y, params.deltaX, params.deltaY),
}
// result already sent
}
@@ -119,3 +120,120 @@ fn insertText(cmd: *CDP.Command) !void {
try cmd.sendResult(null, .{});
}
const lp = @import("lightpanda");
const testing = @import("../testing.zig");
test "cdp.input: dispatchMouseEvent mouseMoved fires hover events" {
var ctx = try testing.context();
defer ctx.deinit();
const bc = try ctx.loadBrowserContext(.{});
const frame = try bc.session.createPage();
const url = "http://localhost:9582/src/browser/tests/mcp_actions.html";
try frame.navigate(url, .{ .reason = .address_bar, .kind = .{ .push = null } });
var runner = try bc.session.runner(.{});
try runner.wait(.{ .ms = 2000 });
var ls: lp.js.Local.Scope = undefined;
frame.js.localScope(&ls);
defer ls.deinit();
var try_catch: lp.js.TryCatch = undefined;
try_catch.init(&ls.local);
defer try_catch.deinit();
// Register listeners for the full enter sequence on #hoverTarget, then read
// its (faux-layout) position so we can target it precisely.
_ = try ls.local.compileAndRun(
\\const t = document.getElementById('hoverTarget');
\\t.addEventListener('mousemove', () => { window.moved = true; });
\\t.addEventListener('mouseenter', () => { window.entered = true; });
, null);
const rect_x = try (try ls.local.compileAndRun("document.getElementById('hoverTarget').getBoundingClientRect().x", null)).toF64();
const rect_y = try (try ls.local.compileAndRun("document.getElementById('hoverTarget').getBoundingClientRect().y", null)).toF64();
try ctx.processMessage(.{
.id = 1,
.method = "Input.dispatchMouseEvent",
.params = .{ .type = "mouseMoved", .x = rect_x, .y = rect_y },
});
const result = try ls.local.compileAndRun("window.hovered === true && window.entered === true && window.moved === true", null);
try testing.expect(result.isTrue());
}
test "cdp.input: dispatchMouseEvent mouseReleased fires mouseup" {
var ctx = try testing.context();
defer ctx.deinit();
const bc = try ctx.loadBrowserContext(.{});
const frame = try bc.session.createPage();
const url = "http://localhost:9582/src/browser/tests/mcp_actions.html";
try frame.navigate(url, .{ .reason = .address_bar, .kind = .{ .push = null } });
var runner = try bc.session.runner(.{});
try runner.wait(.{ .ms = 2000 });
var ls: lp.js.Local.Scope = undefined;
frame.js.localScope(&ls);
defer ls.deinit();
var try_catch: lp.js.TryCatch = undefined;
try_catch.init(&ls.local);
defer try_catch.deinit();
_ = try ls.local.compileAndRun(
\\document.getElementById('hoverTarget')
\\ .addEventListener('mouseup', () => { window.released = true; });
, null);
const rect_x = try (try ls.local.compileAndRun("document.getElementById('hoverTarget').getBoundingClientRect().x", null)).toF64();
const rect_y = try (try ls.local.compileAndRun("document.getElementById('hoverTarget').getBoundingClientRect().y", null)).toF64();
try ctx.processMessage(.{
.id = 1,
.method = "Input.dispatchMouseEvent",
.params = .{ .type = "mouseReleased", .x = rect_x, .y = rect_y },
});
const result = try ls.local.compileAndRun("window.released === true", null);
try testing.expect(result.isTrue());
}
test "cdp.input: dispatchMouseEvent mouseWheel fires wheel event" {
var ctx = try testing.context();
defer ctx.deinit();
const bc = try ctx.loadBrowserContext(.{});
const frame = try bc.session.createPage();
const url = "http://localhost:9582/src/browser/tests/mcp_actions.html";
try frame.navigate(url, .{ .reason = .address_bar, .kind = .{ .push = null } });
var runner = try bc.session.runner(.{});
try runner.wait(.{ .ms = 2000 });
var ls: lp.js.Local.Scope = undefined;
frame.js.localScope(&ls);
defer ls.deinit();
var try_catch: lp.js.TryCatch = undefined;
try_catch.init(&ls.local);
defer try_catch.deinit();
_ = try ls.local.compileAndRun(
\\document.getElementById('scrollbox')
\\ .addEventListener('wheel', (e) => { window.wheelDeltaY = e.deltaY; });
, null);
const rect_x = try (try ls.local.compileAndRun("document.getElementById('scrollbox').getBoundingClientRect().x", null)).toF64();
const rect_y = try (try ls.local.compileAndRun("document.getElementById('scrollbox').getBoundingClientRect().y", null)).toF64();
try ctx.processMessage(.{
.id = 1,
.method = "Input.dispatchMouseEvent",
.params = .{ .type = "mouseWheel", .x = rect_x, .y = rect_y, .deltaY = 40 },
});
const result = try ls.local.compileAndRun("window.wheelDeltaY === 40", null);
try testing.expect(result.isTrue());
}

View File

@@ -304,10 +304,24 @@ noinline fn assertionFailure(comptime ctx: []const u8, args: anytype) noreturn {
@import("crash_handler.zig").crash(ctx, args, @returnAddress());
}
// Written into every RC at construction (rc_canary) and overwritten with
// rc_poison on the final release. We only get crash reports (not logs) from
// prod, so reading _canary in the "release overflow" assert tells us which kind
// of bug it is:
// - rc_canary ("RCNT"): the struct still looks live -> a real refcount
// accounting bug, OR the memory was reused by a freshly-built RC (which
// re-stamps the canary, so this case can't be fully ruled out).
// - rc_poison ("DEADC0DE"): a stale finalizer fired again on an object we
// already released, before its memory was reused -> UAF.
// - anything else: the memory was freed and reused by non-RC data -> UAF.
const rc_canary: u32 = 0x52434E54;
const rc_poison: u32 = 0xDEADC0DE;
// Reference counting helper
pub fn RC(comptime T: type) type {
return struct {
_refs: std.atomic.Value(T) = .init(0),
_canary: u32 = rc_canary,
pub fn init(refs: T) @This() {
return .{ ._refs = .init(refs) };
@@ -319,8 +333,17 @@ pub fn RC(comptime T: type) type {
pub fn release(self: *@This(), value: anytype, page: *Page) void {
const prev = self._refs.fetchSub(1, .acq_rel);
assert(prev > 0, "release overflow", .{ .type = @typeName(@TypeOf(value)) });
assert(prev > 0, "release overflow", .{
.type = @typeName(@TypeOf(value)),
.canary = self._canary, // rc_canary=live/accounting, rc_poison=double-release, else=reuse
.refs = prev,
.ptr = @intFromPtr(value),
});
if (prev == 1) {
// Mark dead before deinit frees this memory, so a stale
// weak-callback re-fire reads rc_poison instead of a
// misleadingly-intact canary.
self._canary = rc_poison;
value.deinit(page);
}
}

View File

@@ -113,6 +113,25 @@ pub fn flushFrame(self: *DeferringLayer, frame_id: u32) void {
}
}
/// Drop orphaned deferred contexts for a frame that's going away. A `terminal`
/// context's transfer already completed while deferred, so it's been deinited
/// and unlinked from the owner — abortOwner can't reach it, yet it lingers in
/// `active` pointing at a forward target (the Fetch) whose arena page teardown
/// is about to free, and a later flushFrame would fire into it. Non-terminal
/// contexts still have a live transfer that cleans them up itself.
pub fn cancelFrame(self: *DeferringLayer, frame_id: u32) void {
var node = self.active.first;
while (node) |n| {
node = n.next;
const ctx: *DeferredContext = @fieldParentPtr("node", n);
if (ctx.frame_id != frame_id or !ctx.terminal) {
continue;
}
self.active.remove(n);
ctx.deinit();
}
}
pub fn drainAll(self: *DeferringLayer) void {
while (self.active.popFirst()) |node| {
const ctx: *DeferredContext = @fieldParentPtr("node", node);