Merge pull request #2659 from rohitsux/feat/element-scroll-into-view

feat(webapi): implement Element.scrollIntoView
This commit is contained in:
Karl Seguin
2026-06-07 13:55:56 +08:00
committed by GitHub
4 changed files with 90 additions and 12 deletions

View File

@@ -114,3 +114,33 @@
testing.expectEqual(rect.height, test1.offsetHeight);
}
</script>
<script id="scrollIntoView">
{
// Add an element deep in the document so its position is non-zero.
const deep = document.createElement('div');
deep.id = 'deep';
document.body.appendChild(deep);
const targetY = deep.getBoundingClientRect().y;
testing.expectTrue(targetY > 0);
// scrollIntoView() always brings the element's top to the scroll offset.
window.scrollTo(0, 999999);
deep.scrollIntoView();
testing.expectEqual(targetY, window.scrollY);
// scrollIntoViewIfNeeded() is a no-op when the element is already within the
// viewport band [scrollY, scrollY + innerHeight]. Sit just below the
// element's top so a stray scroll would visibly move scrollY.
const inView = Math.max(0, targetY - 1);
window.scrollTo(0, inView);
deep.scrollIntoViewIfNeeded();
testing.expectEqual(inView, window.scrollY);
// ...but it does scroll when the element is outside the band.
window.scrollTo(0, targetY + window.innerHeight + 100);
deep.scrollIntoViewIfNeeded();
testing.expectEqual(targetY, window.scrollY);
}
</script>

View File

@@ -0,0 +1,28 @@
<!DOCTYPE html>
<script src="../testing.js"></script>
<!-- Chrome don't scroll if the body isn't big enough. -->
<body style=height:4000px;width:4000px></body>
<script id=scroll_dedup type=module>
{
const state = await testing.async();
let scrollevt = 0;
document.addEventListener("scroll", () => {
scrollevt++;
});
// First move to a fresh position dispatches a scroll event (async).
window.scrollTo(100, 200);
// Scrolling to the same position must NOT dispatch a second scroll event.
window.scrollTo(100, 200);
window.setTimeout(() => {
state.resolve();
}, 100);
await state.done(() => {
testing.expectEqual(1, scrollevt);
});
}
</script>

View File

@@ -1478,16 +1478,28 @@ pub fn clone(self: *Element, deep: bool, frame: *Frame) !*Node {
return node;
}
pub fn scrollIntoViewIfNeeded(_: *const Element, center_if_needed: ?bool) void {
pub fn scrollIntoViewIfNeeded(self: *Element, center_if_needed: ?bool, frame: *Frame) void {
_ = center_if_needed;
const y = calculateDocumentPosition(self.asNode());
const scroll_y: f64 = @floatFromInt(frame.window.getScrollY());
const viewport_height: f64 = @floatFromInt(frame.window.getInnerHeight());
if (y >= scroll_y and y <= scroll_y + viewport_height) {
return;
}
self.scrollIntoView(null, frame);
}
const ScrollIntoViewOpts = union {
align_to_top: bool,
obj: js.Object,
};
pub fn scrollIntoView(_: *const Element, opts: ?ScrollIntoViewOpts) void {
pub fn scrollIntoView(self: *Element, opts: ?ScrollIntoViewOpts, frame: *Frame) void {
_ = opts;
// Scroll the window so the element's top is brought into the viewport.
// Positions come from the faux-layout document position (top = preorder
// depth-scaled y), the same source getBoundingClientRect uses.
const y = calculateDocumentPosition(self.asNode());
frame.window.scrollTo(.{ .x = 0 }, @intFromFloat(@max(0, y)), frame) catch {};
}
pub fn format(self: *Element, writer: *std.Io.Writer) !void {

View File

@@ -54,6 +54,10 @@ const Notification = @import("../../Notification.zig");
const log = lp.log;
const IS_DEBUG = builtin.mode == .Debug;
// Faux-layout viewport height. Exposed as window.innerHeight and used to decide
// whether an element is already within view (e.g. scrollIntoViewIfNeeded).
const DEFAULT_INNER_HEIGHT: u32 = 1080;
const Allocator = std.mem.Allocator;
const Execution = js.Execution;
@@ -701,6 +705,10 @@ pub fn getScrollY(self: *const Window) u32 {
return self._scroll_pos.y;
}
pub fn getInnerHeight(_: *const Window) u32 {
return DEFAULT_INNER_HEIGHT;
}
const ScrollToOpts = union(enum) {
x: i32,
opts: Opts,
@@ -712,17 +720,17 @@ const ScrollToOpts = union(enum) {
};
};
pub fn scrollTo(self: *Window, opts: ScrollToOpts, y: ?i32, frame: *Frame) !void {
switch (opts) {
.x => |x| {
self._scroll_pos.x = @intCast(@max(x, 0));
self._scroll_pos.y = @intCast(@max(0, y orelse 0));
},
.opts => |o| {
self._scroll_pos.x = @intCast(@max(0, o.left));
self._scroll_pos.y = @intCast(@max(0, o.top));
},
const new_x: u32, const new_y: u32 = switch (opts) {
.x => |x| .{ @intCast(@max(x, 0)), @intCast(@max(0, y orelse 0)) },
.opts => |o| .{ @intCast(@max(0, o.left)), @intCast(@max(0, o.top)) },
};
if (new_x == self._scroll_pos.x and new_y == self._scroll_pos.y) {
return;
}
self._scroll_pos.x = new_x;
self._scroll_pos.y = new_y;
self._scroll_pos.state = .scroll;
// We dispatch scroll event asynchronously after 10ms. So we can throttle
@@ -1003,7 +1011,7 @@ pub const JsApi = struct {
// [Replaceable] (CSSOM-View): writable so assignment overwrites rather than throws.
pub const innerWidth = bridge.property(1920, .{ .template = false, .readonly = false });
pub const innerHeight = bridge.property(1080, .{ .template = false, .readonly = false });
pub const innerHeight = bridge.property(DEFAULT_INNER_HEIGHT, .{ .template = false, .readonly = false });
pub const devicePixelRatio = bridge.property(1, .{ .template = false, .readonly = false });
pub const opener = bridge.accessor(Window.getOpener, null, .{});