Merge pull request #2959 from lightpanda-io/dom-pr-18-html-misc

webapi: response content type, element scroll events, translate, SVGAElement.relList
This commit is contained in:
Karl Seguin
2026-07-16 17:41:08 +08:00
committed by GitHub
4 changed files with 190 additions and 8 deletions

View File

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

View File

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

View File

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

View File

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