Skip to content

Commit c3d4cdf

Browse files
committed
feat: add ScrollAPI for enhanced page scrolling capabilities
1 parent 7328ae2 commit c3d4cdf

File tree

4 files changed

+226
-0
lines changed

4 files changed

+226
-0
lines changed

pydoll/browser/tab.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@
5050
TopLevelTargetRequired,
5151
WaitElementTimeout,
5252
)
53+
from pydoll.interactions.scroll import ScrollAPI
5354
from pydoll.protocol.browser.types import DownloadBehavior, DownloadProgressState
5455
from pydoll.protocol.page.events import PageEvent
5556
from pydoll.protocol.page.types import ScreenshotFormat
@@ -131,6 +132,7 @@ def __init__(
131132
self._intercept_file_chooser_dialog_enabled = False
132133
self._cloudflare_captcha_callback_id: Optional[int] = None
133134
self._request: Optional[Request] = None
135+
self._scroll: Optional[ScrollAPI] = None
134136
logger.debug(
135137
(
136138
f'Tab initialized: target_id={self._target_id}, '
@@ -176,6 +178,18 @@ def request(self) -> Request:
176178
self._request = Request(self)
177179
return self._request
178180

181+
@property
182+
def scroll(self) -> ScrollAPI:
183+
"""
184+
Get the scroll API for controlling page scroll behavior.
185+
186+
Returns:
187+
ScrollAPI: An instance of the ScrollAPI class for scroll operations.
188+
"""
189+
if self._scroll is None:
190+
self._scroll = ScrollAPI(self)
191+
return self._scroll
192+
179193
@property
180194
def intercept_file_chooser_dialog_enabled(self) -> bool:
181195
"""Whether file chooser dialog interception is active."""

pydoll/constants.py

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,13 @@ class PageLoadState(str, Enum):
1515
INTERACTIVE = 'interactive'
1616

1717

18+
class ScrollPosition(str, Enum):
19+
UP = 'up'
20+
DOWN = 'down'
21+
LEFT = 'left'
22+
RIGHT = 'right'
23+
24+
1825
class Scripts:
1926
ELEMENT_VISIBLE = """
2027
function() {
@@ -268,6 +275,87 @@ class Scripts:
268275
}})();
269276
"""
270277

278+
SCROLL_BY = """
279+
new Promise((resolve) => {{
280+
const behavior = '{behavior}';
281+
if (behavior === 'auto') {{
282+
window.scrollBy({{
283+
{axis}: {distance},
284+
behavior: 'auto'
285+
}});
286+
resolve();
287+
}} else {{
288+
const onScrollEnd = () => {{
289+
window.removeEventListener('scrollend', onScrollEnd);
290+
resolve();
291+
}};
292+
window.addEventListener('scrollend', onScrollEnd);
293+
window.scrollBy({{
294+
{axis}: {distance},
295+
behavior: 'smooth'
296+
}});
297+
setTimeout(() => {{
298+
window.removeEventListener('scrollend', onScrollEnd);
299+
resolve();
300+
}}, 2000);
301+
}}
302+
}});
303+
"""
304+
305+
SCROLL_TO_TOP = """
306+
new Promise((resolve) => {{
307+
const behavior = '{behavior}';
308+
if (behavior === 'auto') {{
309+
window.scrollTo({{
310+
top: 0,
311+
behavior: 'auto'
312+
}});
313+
resolve();
314+
}} else {{
315+
const onScrollEnd = () => {{
316+
window.removeEventListener('scrollend', onScrollEnd);
317+
resolve();
318+
}};
319+
window.addEventListener('scrollend', onScrollEnd);
320+
window.scrollTo({{
321+
top: 0,
322+
behavior: 'smooth'
323+
}});
324+
setTimeout(() => {{
325+
window.removeEventListener('scrollend', onScrollEnd);
326+
resolve();
327+
}}, 2000);
328+
}}
329+
}});
330+
"""
331+
332+
SCROLL_TO_BOTTOM = """
333+
new Promise((resolve) => {{
334+
const behavior = '{behavior}';
335+
if (behavior === 'auto') {{
336+
window.scrollTo({{
337+
top: document.body.scrollHeight,
338+
behavior: 'auto'
339+
}});
340+
resolve();
341+
}} else {{
342+
const onScrollEnd = () => {{
343+
window.removeEventListener('scrollend', onScrollEnd);
344+
resolve();
345+
}};
346+
window.addEventListener('scrollend', onScrollEnd);
347+
window.scrollTo({{
348+
top: document.body.scrollHeight,
349+
behavior: 'smooth'
350+
}});
351+
setTimeout(() => {{
352+
window.removeEventListener('scrollend', onScrollEnd);
353+
resolve();
354+
}}, 2000);
355+
}}
356+
}});
357+
"""
358+
271359

272360
class Key(tuple[str, int], Enum):
273361
BACKSPACE = ('Backspace', 8)

pydoll/interactions/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
from pydoll.interactions.scroll import ScrollAPI
2+
3+
__all__ = ['ScrollAPI']

pydoll/interactions/scroll.py

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
from __future__ import annotations
2+
3+
from typing import TYPE_CHECKING
4+
5+
from pydoll.commands import RuntimeCommands
6+
from pydoll.constants import Scripts, ScrollPosition
7+
8+
if TYPE_CHECKING:
9+
from pydoll.browser.tab import Tab
10+
11+
12+
class ScrollAPI:
13+
"""
14+
API for controlling page scroll behavior.
15+
16+
Provides methods for scrolling the page in different directions,
17+
to specific positions, or by relative distances.
18+
"""
19+
20+
def __init__(self, tab: Tab):
21+
"""
22+
Initialize the ScrollAPI with a tab instance.
23+
24+
Args:
25+
tab: Tab instance to execute scroll commands on.
26+
"""
27+
self._tab = tab
28+
29+
async def by(
30+
self,
31+
position: ScrollPosition,
32+
distance: int | float,
33+
smooth: bool = True,
34+
):
35+
"""
36+
Scroll the page by a relative distance in the specified direction.
37+
38+
Args:
39+
position: Direction to scroll (UP, DOWN, LEFT, RIGHT).
40+
distance: Number of pixels to scroll.
41+
smooth: Use smooth scrolling animation if True, instant if False.
42+
"""
43+
axis, scroll_distance = self._get_axis_and_distance(position, distance)
44+
behavior = self._get_behavior(smooth)
45+
46+
script = Scripts.SCROLL_BY.format(
47+
axis=axis,
48+
distance=scroll_distance,
49+
behavior=behavior,
50+
)
51+
52+
await self._execute_script_await_promise(script)
53+
54+
async def to_top(self, smooth: bool = True):
55+
"""
56+
Scroll to the top of the page (Y=0).
57+
58+
Args:
59+
smooth: Use smooth scrolling animation if True, instant if False.
60+
"""
61+
behavior = self._get_behavior(smooth)
62+
script = Scripts.SCROLL_TO_TOP.format(behavior=behavior)
63+
await self._execute_script_await_promise(script)
64+
65+
async def to_bottom(self, smooth: bool = True):
66+
"""
67+
Scroll to the bottom of the page (Y=document.body.scrollHeight).
68+
69+
Args:
70+
smooth: Use smooth scrolling animation if True, instant if False.
71+
"""
72+
behavior = self._get_behavior(smooth)
73+
script = Scripts.SCROLL_TO_BOTTOM.format(behavior=behavior)
74+
await self._execute_script_await_promise(script)
75+
76+
@staticmethod
77+
def _get_axis_and_distance(
78+
position: ScrollPosition, distance: int | float
79+
) -> tuple[str, int | float]:
80+
"""
81+
Convert scroll position to axis and signed distance.
82+
83+
Args:
84+
position: Direction to scroll.
85+
distance: Absolute distance to scroll.
86+
87+
Returns:
88+
Tuple of (axis, signed_distance) where axis is 'left' or 'top'
89+
and signed_distance is positive or negative based on direction.
90+
"""
91+
if position in {ScrollPosition.UP, ScrollPosition.DOWN}:
92+
axis = 'top'
93+
scroll_distance = -distance if position == ScrollPosition.UP else distance
94+
return axis, scroll_distance * 10
95+
96+
axis = 'left'
97+
scroll_distance = -distance if position == ScrollPosition.LEFT else distance
98+
return axis, scroll_distance * 10
99+
100+
@staticmethod
101+
def _get_behavior(smooth: bool) -> str:
102+
"""
103+
Convert smooth boolean to CSS scroll behavior value.
104+
105+
Args:
106+
smooth: Whether to use smooth scrolling.
107+
108+
Returns:
109+
'smooth' if smooth is True, 'auto' otherwise.
110+
"""
111+
return 'smooth' if smooth else 'auto'
112+
113+
async def _execute_script_await_promise(self, script: str):
114+
"""
115+
Execute JavaScript and await promise resolution.
116+
117+
Args:
118+
script: JavaScript code that returns a Promise.
119+
"""
120+
command = RuntimeCommands.evaluate(expression=script, await_promise=True)
121+
return await self._tab._execute_command(command)

0 commit comments

Comments
 (0)