mirror of
https://github.com/lightpanda-io/browser.git
synced 2026-06-11 09:35:59 -04:00
Merge branch 'main' into agent
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"),
|
||||
|
||||
1
src/browser/tests/cdp/input_file.html
Normal file
1
src/browser/tests/cdp/input_file.html
Normal file
@@ -0,0 +1 @@
|
||||
<input id="upload" type="file">
|
||||
401
src/browser/tests/dommatrix.html
Normal file
401
src/browser/tests/dommatrix.html
Normal 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>
|
||||
@@ -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());
|
||||
|
||||
@@ -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">
|
||||
|
||||
60
src/browser/tests/element/html/input_file.html
Normal file
60
src/browser/tests/element/html/input_file.html
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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/");
|
||||
|
||||
301
src/browser/webapi/DOMMatrix.zig
Normal file
301
src/browser/webapi/DOMMatrix.zig
Normal 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;
|
||||
}
|
||||
};
|
||||
870
src/browser/webapi/DOMMatrixReadOnly.zig
Normal file
870
src/browser/webapi/DOMMatrixReadOnly.zig
Normal 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", .{});
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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, .{});
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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()),
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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, .{});
|
||||
|
||||
@@ -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, .{});
|
||||
|
||||
@@ -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" {
|
||||
|
||||
320
src/browser/webapi/element/popover.zig
Normal file
320
src/browser/webapi/element/popover.zig
Normal 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;
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
107
src/browser/webapi/event/ToggleEvent.zig
Normal file
107
src/browser/webapi/event/ToggleEvent.zig
Normal 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, .{});
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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, .{});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
62
src/browser/webapi/net/XMLHttpRequestUpload.zig
Normal file
62
src/browser/webapi/net/XMLHttpRequestUpload.zig
Normal 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;
|
||||
};
|
||||
};
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -158,6 +158,7 @@ pub const AttributeMatcher = union(enum) {
|
||||
pub const PseudoClass = union(enum) {
|
||||
// State pseudo-classes
|
||||
modal,
|
||||
popover_open,
|
||||
checked,
|
||||
disabled,
|
||||
enabled,
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user