diff --git a/src/browser/Frame.zig b/src/browser/Frame.zig index 87ab3131c..7e30b45d8 100644 --- a/src/browser/Frame.zig +++ b/src/browser/Frame.zig @@ -236,6 +236,10 @@ _factory: *Factory, _load_state: LoadState = .waiting, +// The navigation response's MIME essence, recorded when the first chunk +// arrives and applied to the new document (document.contentType) once the +// navigation commits. null means the text/html default. +_pending_content_type: ?[]const u8 = null, _parse_state: ParseState = .pre, /// `frameErrorCallback` swallows the failure into a placeholder page; @@ -1427,6 +1431,22 @@ fn frameDataCallback(transfer: *HttpClient.Transfer, data: []const u8) !void { }); } + // Record the response's MIME essence so the resulting document + // reports it (document.contentType). text/html keeps the default, so an + // HTML response (parsed from the header, or sniffed when there's no + // header) never needs an override; inside this branch the essence can + // no longer be text/html. + self._pending_content_type = null; + if (mime.content_type != .text_html) { + if (transfer.contentType()) |ct| { + const end = std.mem.indexOfScalarPos(u8, ct, 0, ';') orelse ct.len; + const essence = std.mem.trim(u8, ct[0..end], " \t"); + if (essence.len > 0) { + self._pending_content_type = try std.ascii.allocLowerString(self.arena, essence); + } + } + } + switch (mime.content_type) { .text_html => { // Normalize and store the charset using encoding_rs canonical names @@ -1501,6 +1521,11 @@ fn frameDoneCallback(ctx: *anyopaque) !void { //We need to handle different navigation types differently. try self._session.navigation.commitNavigation(self); + if (self._pending_content_type) |content_type| { + self.document._content_type = content_type; + self._pending_content_type = null; + } + defer if (comptime IS_DEBUG) { log.debug(.frame, "frame load complete", .{ .url = self.url, diff --git a/src/browser/webapi/Element.zig b/src/browser/webapi/Element.zig index 26ecce962..118c4491b 100644 --- a/src/browser/webapi/Element.zig +++ b/src/browser/webapi/Element.zig @@ -56,6 +56,9 @@ pub const NamespaceUriLookup = std.AutoHashMapUnmanaged(*Element, []const u8); pub const ScrollPosition = struct { x: u32 = 0, y: u32 = 0, + // Throttle state for the async scroll/scrollend dispatch (mirrors + // Window._scroll_pos.state). + state: enum { scroll, end, done } = .done, }; pub const ScrollPositionLookup = std.AutoHashMapUnmanaged(*Element, ScrollPosition); @@ -1334,30 +1337,47 @@ pub fn getClientRects(self: *Element, frame: *Frame) ![]*DOMRect { return rects; } +// Scroll positions live in the map of the element's own frame — not the +// caller's, which differs when a same-origin script scrolls an element in +// another frame (e.g. inside an iframe). All scroll accessors resolve the +// owner frame first so the state, the fired events and the document +// comparison stay in the element's frame. pub fn getScrollTop(self: *Element, frame: *Frame) u32 { - const pos = frame._element_scroll_positions.get(self) orelse return 0; + const owner = self.ownerFrame(frame); + const pos = owner._element_scroll_positions.get(self) orelse return 0; return pos.y; } pub fn setScrollTop(self: *Element, value: i32, frame: *Frame) !void { - const gop = try frame._element_scroll_positions.getOrPut(frame.arena, self); + const owner = self.ownerFrame(frame); + const gop = try owner._element_scroll_positions.getOrPut(owner.arena, self); if (!gop.found_existing) { gop.value_ptr.* = .{}; } - gop.value_ptr.y = @intCast(@max(0, value)); + const new_y: u32 = @intCast(@max(0, value)); + if (gop.value_ptr.y != new_y) { + gop.value_ptr.y = new_y; + try self.scheduleScrollEvents(owner); + } } pub fn getScrollLeft(self: *Element, frame: *Frame) u32 { - const pos = frame._element_scroll_positions.get(self) orelse return 0; + const owner = self.ownerFrame(frame); + const pos = owner._element_scroll_positions.get(self) orelse return 0; return pos.x; } pub fn setScrollLeft(self: *Element, value: i32, frame: *Frame) !void { - const gop = try frame._element_scroll_positions.getOrPut(frame.arena, self); + const owner = self.ownerFrame(frame); + const gop = try owner._element_scroll_positions.getOrPut(owner.arena, self); if (!gop.found_existing) { gop.value_ptr.* = .{}; } - gop.value_ptr.x = @intCast(@max(0, value)); + const new_x: u32 = @intCast(@max(0, value)); + if (gop.value_ptr.x != new_x) { + gop.value_ptr.x = new_x; + try self.scheduleScrollEvents(owner); + } } pub fn getScrollHeight(self: *Element, frame: *Frame) f64 { @@ -1643,10 +1663,13 @@ const ScrollToOpts = union(enum) { pub fn scrollTo(self: *Element, opts: ?ScrollToOpts, y: ?i32, frame: *Frame) !void { const o = opts orelse return; - const gop = try frame._element_scroll_positions.getOrPut(frame.arena, self); + const owner = self.ownerFrame(frame); + const gop = try owner._element_scroll_positions.getOrPut(owner.arena, self); if (!gop.found_existing) { gop.value_ptr.* = .{}; } + const old_x = gop.value_ptr.x; + const old_y = gop.value_ptr.y; switch (o) { .x => |x| { gop.value_ptr.x = @intCast(@max(0, x)); @@ -1657,12 +1680,16 @@ pub fn scrollTo(self: *Element, opts: ?ScrollToOpts, y: ?i32, frame: *Frame) !vo if (dict.top) |top| gop.value_ptr.y = @intCast(@max(0, top)); }, } + if (gop.value_ptr.x != old_x or gop.value_ptr.y != old_y) { + try self.scheduleScrollEvents(owner); + } } // scrollBy(): like scrollTo() but relative to the current position. pub fn scrollBy(self: *Element, opts: ?ScrollToOpts, y: ?i32, frame: *Frame) !void { const o = opts orelse return; - const gop = try frame._element_scroll_positions.getOrPut(frame.arena, self); + const owner = self.ownerFrame(frame); + const gop = try owner._element_scroll_positions.getOrPut(owner.arena, self); if (!gop.found_existing) { gop.value_ptr.* = .{}; } @@ -1672,8 +1699,108 @@ pub fn scrollBy(self: *Element, opts: ?ScrollToOpts, y: ?i32, frame: *Frame) !vo }; gop.value_ptr.x = @intCast(@max(0, @as(i32, @intCast(gop.value_ptr.x)) + dx)); gop.value_ptr.y = @intCast(@max(0, @as(i32, @intCast(gop.value_ptr.y)) + dy)); + if (dx != 0 or dy != 0) { + try self.scheduleScrollEvents(owner); + } } +// Scrolling an element fires a scroll event and then a scrollend event, +// asynchronously and throttled, mirroring Window.scrollTo. Scrolls of the +// scrolling element (the root) are fired at the document instead. +// `frame` is the element's owner frame (resolved by the public accessors). +fn scheduleScrollEvents(self: *Element, frame: *Frame) !void { + const gop = try frame._element_scroll_positions.getOrPut(frame.arena, self); + if (!gop.found_existing) { + gop.value_ptr.* = .{}; + } + gop.value_ptr.state = .scroll; + + const task = try frame._factory.create(ScrollEventTask{ .frame = frame, .element = self }); + try frame.js.scheduler.add(task, ScrollEventTask.dispatchScroll, 10, .{ .low_priority = true }); + try frame.js.scheduler.add(task, ScrollEventTask.dispatchScrollEnd, 20, .{ .low_priority = true, .finalizer = ScrollEventTask.cancelled }); +} + +const ScrollEventTask = struct { + frame: *Frame, + element: *Element, + + fn cancelled(ctx: *anyopaque) void { + const self: *ScrollEventTask = @ptrCast(@alignCast(ctx)); + self.deinit(); + } + + fn deinit(self: *ScrollEventTask) void { + self.frame._factory.destroy(self); + } + + fn dispatchScroll(ctx: *anyopaque) anyerror!?u32 { + const self: *ScrollEventTask = @ptrCast(@alignCast(ctx)); + const f = self.frame; + const pos = f._element_scroll_positions.getPtr(self.element) orelse return null; + if (pos.state != .scroll) { + return null; + } + + const Event = @import("Event.zig"); + const event = try Event.initTrusted(comptime .wrap("scroll"), .{ .bubbles = self.bubbles() }, f._page); + try f._event_manager.dispatch(self.eventTarget(), event); + + // can't use gop on _element_scroll_positions above, since the JS callback + // can mutate _element_scroll_positions and invalidate any pointers + if (f._element_scroll_positions.getPtr(self.element)) |p| { + p.state = .end; + } + return null; + } + + fn dispatchScrollEnd(ctx: *anyopaque) anyerror!?u32 { + const self: *ScrollEventTask = @ptrCast(@alignCast(ctx)); + + const f = self.frame; + const pos = f._element_scroll_positions.getPtr(self.element) orelse { + self.deinit(); + return null; + }; + + switch (pos.state) { + // The scroll event is still pending; retry in 10ms. Must not destroy + // the task here — the entry is re-scheduled and runs again. + .scroll => return 10, + .end => {}, + .done => { + self.deinit(); + return null; + }, + } + + defer self.deinit(); + const Event = @import("Event.zig"); + const event = try Event.initTrusted(comptime .wrap("scrollend"), .{ .bubbles = self.bubbles() }, f._page); + try f._event_manager.dispatch(self.eventTarget(), event); + + // can't use gop on _element_scroll_positions above, since the JS callback + // can mutate _element_scroll_positions and invalidate any pointers + if (f._element_scroll_positions.getPtr(self.element)) |p| { + p.state = .done; + } + + return null; + } + + fn eventTarget(self: *ScrollEventTask) *EventTarget { + if (self.frame.document.getDocumentElement() == self.element) { + return self.frame.document.asEventTarget(); + } + return self.element.asEventTarget(); + } + + // Scroll events fired at an element don't bubble; only document-level + // scrolls (the scrolling element, dispatched at the document) do. + fn bubbles(self: *ScrollEventTask) bool { + return self.frame.document.getDocumentElement() == self.element; + } +}; + pub fn format(self: *Element, writer: *std.Io.Writer) !void { try writer.writeByte('<'); try writer.writeAll(self.getTagNameDump()); diff --git a/src/browser/webapi/element/Html.zig b/src/browser/webapi/element/Html.zig index 4cd12b702..67924b0c5 100644 --- a/src/browser/webapi/element/Html.zig +++ b/src/browser/webapi/element/Html.zig @@ -345,6 +345,28 @@ pub fn setHidden(self: *HtmlElement, hidden: bool, frame: *Frame) !void { } } +// The translate IDL attribute reflects the element's translation mode: +// translate="yes"/"" enables it, "no" disables it, anything else (or no +// attribute) inherits from the parent, defaulting to enabled. +pub fn getTranslate(self: *HtmlElement) bool { + var node: ?*Node = self.asElement().asNode(); + while (node) |n| : (node = n.parentNode()) { + const el = n.is(Element) orelse continue; + const value = el.getAttributeSafe(comptime .wrap("translate")) orelse continue; + if (value.len == 0 or std.ascii.eqlIgnoreCase(value, "yes")) { + return true; + } + if (std.ascii.eqlIgnoreCase(value, "no")) { + return false; + } + } + return true; +} + +pub fn setTranslate(self: *HtmlElement, translate: bool, frame: *Frame) !void { + try self.asElement().setAttributeSafe(comptime .wrap("translate"), .wrap(if (translate) "yes" else "no"), frame); +} + pub fn getPopover(self: *HtmlElement) ?[]const u8 { const s = popover.getState(self.asElement()) orelse return null; return @tagName(s); @@ -1707,6 +1729,7 @@ 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 translate = bridge.accessor(HtmlElement.getTranslate, HtmlElement.setTranslate, .{ .ce_reactions = true }); pub const popover = bridge.accessor(HtmlElement.getPopover, HtmlElement.setPopover, .{ .ce_reactions = true }); pub const showPopover = bridge.function(HtmlElement.showPopover, .{}); pub const hidePopover = bridge.function(HtmlElement.hidePopover, .{}); diff --git a/src/browser/webapi/element/svg/A.zig b/src/browser/webapi/element/svg/A.zig index da5cad8dd..9cad91395 100644 --- a/src/browser/webapi/element/svg/A.zig +++ b/src/browser/webapi/element/svg/A.zig @@ -22,6 +22,7 @@ const Frame = @import("../../../Frame.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); const AnimatedString = @import("../../svg/AnimatedString.zig"); +const DOMTokenList = @import("../../collections.zig").DOMTokenList; const Graphics = @import("Graphics.zig"); @@ -35,6 +36,10 @@ pub fn asNode(self: *A) *Node { return self.asElement().asNode(); } +pub fn getRelList(self: *A, frame: *Frame) !*DOMTokenList { + return self.asElement().getRelList(frame); +} + pub const JsApi = struct { pub const bridge = js.Bridge(A); @@ -48,4 +53,6 @@ pub const JsApi = struct { fn _href(self: *A, frame: *Frame) !*AnimatedString { return AnimatedString.getOrCreate(self.asElement(), .href, frame); } + + pub const relList = bridge.accessor(A.getRelList, null, .{}); };