Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
c15c38f
jpro-sticky: withhold the pin animation where scroll timelines are un…
Sep 7, 2026
91335ba
jpro-sticky: bind the pin to the element, not to its jpro-id
Sep 7, 2026
8666fdb
jpro-sticky: add a requestAnimationFrame pin as the universal floor
Sep 9, 2026
6f5abc2
jpro-sticky: reattach the installCompositor javadoc and tighten comments
Sep 11, 2026
d6ca0af
jpro-sticky: hold the element handle, complete the support gate, repa…
Sep 11, 2026
270148a
jpro-sticky: validate the pin mode and refuse non-finite geometry
Sep 11, 2026
e59ecf6
jpro-sticky: pin by switching positioning modes, not by animating
Sep 11, 2026
02510a4
jpro-sticky: catch the pin crossing on the frame it happens
Sep 11, 2026
4eb064a
jpro-sticky example: add a pin tier switch to the sample
Sep 11, 2026
e24c00f
jpro-sticky: take the pinned X from geometry, not from the element
Sep 14, 2026
7c83b1f
jpro-sticky: drop the per-frame pin, prefer the compositor over affix
Sep 14, 2026
eec83ad
jpro-sticky: name the ancestor that captures a forced affix pin
Sep 14, 2026
6a900bc
jpro-sticky: log the tier each pin resolved to and why
Sep 14, 2026
1cd854b
jpro-sticky: clear the will-change that captures the affix pin
Sep 14, 2026
5be273d
jpro-sticky: make affix the default tier, keep the compositor for wha…
Sep 14, 2026
09550da
jpro-sticky: record why the will-change clear is scoped rather than a…
Sep 17, 2026
8af759b
jpro-sticky: cut the pin comments back to one or two lines
Sep 17, 2026
2dca873
jpro-sticky: match the pin comments to the style around them
Sep 17, 2026
b7e853f
jpro-sticky: state what the will-change clear does, not why it is all…
Sep 17, 2026
afac6f3
jpro-sticky: one web mechanism
Sep 17, 2026
5b1c9d1
jpro-sticky: retry a blocked pin instead of latching the server pin
Sep 17, 2026
94445fd
jpro-sticky: pin with native position:sticky on the web
Sep 18, 2026
d84ddd9
jpro-sticky: keep a sticky pin pickable
Sep 18, 2026
5201fff
jpro-sticky: hold a sticky pin against the scrolling viewport
Sep 21, 2026
78eb773
jpro-sticky: pin a fixed node with the sticky span too
Sep 21, 2026
8282a03
jpro-sticky: drop the compositor-era leftovers from the web pin
Sep 21, 2026
b90fb43
jpro-sticky: warn when a scrolling ancestor owns the pin
Sep 21, 2026
7684c37
jpro-sticky: bring the README up to date with the sticky pin
Sep 21, 2026
f520515
jpro-sticky: decide the pin's scrollport on the vertical axis alone
Sep 21, 2026
bc28c94
jpro-sticky: retire the scroll-timeline framing from the Firefox test
Sep 21, 2026
3415844
jpro-sticky: say what the script reads instead of calling it baked
Sep 21, 2026
b7327c4
jpro-sticky: report the scroll container instead of rewriting it
Sep 21, 2026
34b7156
jpro-sticky: bake the span's scene y into the sticky rule
sandec-bot Sep 21, 2026
a26945f
jpro-sticky: find the span through its element handle, not a DOM id
sandec-bot Sep 21, 2026
54f0027
jpro-sticky: drop the dead no-range branch, render after a failed re-…
sandec-bot Sep 21, 2026
b8bad64
jpro-sticky: cut the dead state and fold the detach waiter into Overl…
sandec-bot Sep 21, 2026
8bb93bf
jpro-sticky: point the docs at the dispatcher's stuck reset
sandec-bot Sep 21, 2026
a614c59
jpro-sticky: name the condition the baked offset rests on
Sep 22, 2026
53e06ec
jpro-sticky: cancel a transform the host page puts above the app
Sep 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 34 additions & 8 deletions jpro-sticky/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@ content until it reaches an edge, then stays pinned) or **fixed** (always pinned
It mirrors the CSS `position` property for nodes rendered by JPro.

You write the same code for web and desktop: there are no platform-specific branches in your code, and
the library picks the right mechanism underneath. On the web the pin is a compositor effect, so
scrolling stays smooth without a JavaFX layout pass per scroll event. Two setup caveats apply; see
[Usage notes](#usage-notes).
the library picks the right mechanism underneath. On the web the pin is the browser's own
`position: sticky`, so it runs on the compositor and costs no JavaFX layout pass per scroll event.
Three setup caveats apply; see [Usage notes](#usage-notes).

## Installation

Expand Down Expand Up @@ -174,7 +174,7 @@ back to `false` and removes `:stuck`.

## Usage notes

The same `Scroll` calls work on the web and the desktop. Two things to know:
The same `Scroll` calls work on the web and the desktop. Three things to know:

**Sticky needs something to scroll.** A sticky node pins against its scrolling container: the page on
the web, or an enclosing `ScrollPane` on either target. A sticky node on the desktop with no
Expand All @@ -188,16 +188,29 @@ a `ScrollPane` doesn't need this.)

```html
<style>
html, body {
html {
max-width: 100%;
overflow-x: hidden;
overflow-x: clip;
}

body {
margin: 0;
}
</style>

<jpro-app href="/app/default" nativescrolling="true" fxHeight="true" nativeZooming="true"></jpro-app>
```

**Clip with `overflow-x: clip`, or clip `<html>` only.** Any element whose overflow is `hidden`,
`auto` or `scroll` is a scroll container, whether or not it can actually scroll, and a sticky node
pins against the nearest one. `clip` clips without creating one, so it is safe on `<html>`, `<body>`
or both. With `hidden` the placement matters: `<body>` hands its overflow to the viewport as long as
`<html>` is `visible`, so `body { overflow-x: hidden }` on its own is harmless, but clip `<html>` as
well and that hand-off stops, `<body>` becomes a scroll container that never scrolls, and every pin
holds against it instead of the page. One axis is enough, since `overflow-x: hidden` makes
`overflow-y` compute to `auto`. The library never edits your styles; it names the scroll container it
found on the browser console.

### Stacking order

When pinned nodes overlap, fixed paints above sticky. Within one mode, the node whose position you
Expand All @@ -215,8 +228,9 @@ scrim.setViewOrder(1);

**Pinned nodes are reparented.** While a node is fixed (web and desktop) or page-level sticky on the
web, it is moved into an overlay, leaving a placeholder in its original layout slot. So
`node.getParent()` and scene-graph lookups see it relocated until you clear the position. A sticky
node inside a `ScrollPane` is the exception: it stays in place.
`node.getParent()` and scene-graph lookups see it relocated until you clear the position. On the web
it lands one level deeper still, inside a pane of its own (see below). A sticky node inside a
`ScrollPane` is the exception: it stays in place.

By default the overlay sits at the scene root. A node moved there loses any CSS or context scoped to
its former ancestors, such as route styles or a popup container. To keep those, register an ancestor
Expand All @@ -226,6 +240,18 @@ pane as an overlay host, and the node reparents into the nearest one above it in
Scroll.registerOverlayHost(popupContainer);
```

**On the web, the browser owns the pin.** The node is mounted inside a pane sized to the range it
should travel, and an injected rule makes the node itself `position: sticky` at the anchor's inset.
Sticky clamps to its containing block, which is that pane, so the release point is the pane's end and
no code runs per scroll event. Fixed is the same pin over a pane as long as the document: a
viewport-anchored node fits the viewport, so that end stays out of reach and the pin never releases.

**On the web, a pinned node's own transforms are dropped.** JPro fuses a node's layout position with
its `scaleX`/`rotate`/`translateX` into one CSS `transform`, and the pin has to clear that transform
to place the box itself. So those properties have no visual effect on a web-pinned node, while the
same node still honours them on the desktop. Anchors are unaffected; only the JavaFX transform
properties are.

**On the web, the stuck flip can trail the visuals.** `:stuck` and `stuckProperty` track the
browser-viewport sync cadence, so they update up to one sync interval after the node pins. Inside a
`ScrollPane` the flip is exact.
Expand Down
13 changes: 11 additions & 2 deletions jpro-sticky/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,11 @@ dependencies {

test {
// The Playwright tests need a Chromium (installPlaywright provides it) and the example's port.
dependsOn 'installPlaywright'
dependsOn 'installPlaywright', 'installPlaywrightFirefox'
if (System.getProperty('jpro.test.port') != null) {
systemProperty 'jpro.test.port', System.getProperty('jpro.test.port')
}
// installPlaywright already provisioned the browser — don't re-download during the run.
// installPlaywright already provisioned the browser, so skip the download during the run.
environment 'PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD', '1'
}

Expand All @@ -27,6 +27,15 @@ tasks.register('installPlaywright', JavaExec) {
args = ['install', '--with-deps', 'chromium']
}

// StickyFirefoxTest drives the engine issue #127 was reported against.
tasks.register('installPlaywrightFirefox', JavaExec) {
group = 'verification'
description = 'Install the Firefox browser used by StickyFirefoxTest'
classpath = sourceSets.test.runtimeClasspath
mainClass = 'com.microsoft.playwright.CLI'
args = ['install', '--with-deps', 'firefox']
}

publishing {
publications {
mavenJava(MavenPublication) {
Expand Down
4 changes: 4 additions & 0 deletions jpro-sticky/example/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,8 @@ jpro {
// Playwright tests pass -Pjpro.test.port; fall back to 8080 for manual `jproRun`.
port = project.hasProperty('jpro.test.port') ? project.property('jpro.test.port') as int : 8080
openURLOnStartup = false
// StickyDefaultConfigTest runs the app with JPro's default (no mirrored DOM ids).
if (project.hasProperty('jpro.test.mirrorCSSToDOM')) {
JVMArgs = ["-Djpro.mirrorCSSToDOM=" + project.property('jpro.test.mirrorCSSToDOM')]
}
}
13 changes: 9 additions & 4 deletions jpro-sticky/example/src/main/resources/jpro/html/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,17 @@
<link rel="stylesheet" type="text/css" href="/jpro/css/jpro.css">
<script src="/jpro/js/jpro.js" type="text/javascript"></script>

<!-- jpro-sticky needs the page to scroll natively so the compositor scroll-timeline
override has a scroll to track. Enable it on the app tag below and let the body scroll. -->
<!-- jpro-sticky pins against the page's own scroll, so enable native scrolling on the app tag
below. overflow-x: clip clips without creating a scroll container. With hidden instead, keep
it off <body> while <html> clips too: the body would become a scroll container that never
scrolls, and every pin would hold against that instead of the page. -->
<style>
html, body {
html {
max-width: 100%;
overflow-x: hidden;
overflow-x: clip;
}

body {
margin: 0px;
}
</style>
Expand Down
24 changes: 6 additions & 18 deletions jpro-sticky/src/main/java/one/jpro/platform/sticky/Scroll.java
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@
* and {@link ScrollPosition#FIXED fixed}) to JavaFX nodes rendered by JPro.
* <p>
* The positioning mode is attached to a node and, when running inside JPro, is realised
* on the web side through a compositor override so that the node is pinned without a
* round-trip to the JavaFX layout pass on every scroll event. When running as a desktop
* on the web side through the browser's own {@code position: sticky} so that the node is pinned
* without a round-trip to the JavaFX layout pass on every scroll event. When running as a desktop
* application the mode is a no-op and the node keeps its normal flow positioning.
* <p>
* The API is layered over {@link #setScrollPosition(Node, ScrollPosition, ScrollAnchor)}, which takes
Expand Down Expand Up @@ -55,9 +55,6 @@ public final class Scroll {
/** Property key under which the {@link ScrollPosition} is stored on a node. */
private static final Object POSITION_KEY = new Object();

/** Property key under which the resolved {@link ScrollAnchor} is stored on a node. */
private static final Object ANCHOR_KEY = new Object();

/** Property key under which the active {@link ScrollImpl} is stashed on a node. */
private static final Object IMPL_KEY = new Object();

Expand Down Expand Up @@ -293,16 +290,8 @@ public static void setScrollPosition(Node node, ScrollPosition position, ScrollA
// switching sticky <-> fixed (or clearing) never leaks the previous impl/listeners.
teardown(node);

// A prior pin's stuck state is meaningless once its impl is gone: clear it up front (which
// removes the :stuck pseudo-class) so switching sticky -> fixed/static never leaves it set.
final StuckState existing = stuckState(node, false);
if (existing != null) {
existing.set(false);
}

if (position == ScrollPosition.STATIC) {
node.getProperties().remove(POSITION_KEY);
node.getProperties().remove(ANCHOR_KEY);
LOGGER.debug("Scroll position cleared for node {}", node);
return;
}
Expand All @@ -312,15 +301,14 @@ public static void setScrollPosition(Node node, ScrollPosition position, ScrollA
}

node.getProperties().put(POSITION_KEY, position);
node.getProperties().put(ANCHOR_KEY, anchor);

// STICKY publishes its pin state through the node's StuckState (stuckProperty + :stuck). The
// active sticky impl drives it via this sink. FIXED is always pinned -> never transitions ->
// no sink (and it never touches the stuck channels).
final Consumer<Boolean> stuckSink =
(position == ScrollPosition.STICKY) ? stuckState(node, true)::set : null;

// Select the implementation (desktop FX vs web compositor) once the node is in a scene, and
// Select the implementation (desktop FX vs browser) once the node is in a scene, and
// stash it so teardown(node) can reverse it. The choice is invisible to the caller.
final ScrollImpl impl = new ScrollDispatcher(node, position, anchor, within, stuckSink);
node.getProperties().put(IMPL_KEY, impl);
Expand Down Expand Up @@ -357,7 +345,7 @@ public static ScrollPosition getScrollPosition(Node node) {
* stuck state never carries information). A sticky node with nothing to scroll against (desktop,
* no scroll ancestor) also stays {@code false}, matching CSS sticky in a non-scrolling page.
* <p>
* On the web compositor path the flip tracks the {@link com.jpro.webapi.WebAPI#browserViewport()}
* On the web page path the flip tracks the {@link com.jpro.webapi.WebAPI#browserViewport()}
* sync cadence (the same fidelity picking already has), not per animation frame; see the module
* README's observability note.
*
Expand Down Expand Up @@ -456,8 +444,8 @@ private static boolean isNonEdge(Axis axis) {
}

/**
* Reverses any positioning currently installed on the node: uninstalls the compositor
* override, deregisters listeners, and drops the teardown handle. A no-op when the node
* Reverses any positioning currently installed on the node: uninstalls the active
* implementation, deregisters listeners, and drops the teardown handle. A no-op when the node
* is in normal flow.
*/
private static void teardown(Node node) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
package one.jpro.platform.sticky.impl;

import javafx.application.Platform;
import javafx.beans.InvalidationListener;
import javafx.beans.value.ChangeListener;
import javafx.geometry.Point2D;
import javafx.scene.Group;
import javafx.scene.Node;
Expand Down Expand Up @@ -40,8 +38,6 @@ public final class DesktopFixedImpl implements ScrollImpl {
private double originalX;

private final InvalidationListener relayout = obs -> sync();
/** Fires teardown when the placeholder (and thus the route subtree) leaves the scene. */
private ChangeListener<Scene> placeholderSceneWaiter;
private boolean torndown;

public DesktopFixedImpl(Node node, ScrollAnchor anchor, Runnable onDetach) {
Expand All @@ -67,7 +63,7 @@ private void attach() {

// fixed is out of flow: its slot collapses to zero (reserved at mount, before insertion, so the
// first layout honours it).
final Region ph = mount.mount(0);
final Region ph = mount.mount(0, false, onDetach);
if (ph == null) {
return; // could not mount, node stays in flow
}
Expand All @@ -79,25 +75,6 @@ private void attach() {
scene.heightProperty().addListener(relayout);
node.layoutBoundsProperty().addListener(relayout);

// placeholder rides the flow, so it leaves the scene on route unmount (the node never does).
// re-check next pulse to ignore a transient same-pulse detach/reattach.
placeholderSceneWaiter = (obs, old, s) -> {
if (s == null && !torndown) {
Platform.runLater(() -> {
if (!torndown && placeholder != null && placeholder.getScene() == null) {
// hand back to the dispatcher: uninstall this delegate but stay alive to re-pin
// if the route returns. fall back to a direct uninstall if unwired.
if (onDetach != null) {
onDetach.run();
} else {
uninstall();
}
}
});
}
};
placeholder.sceneProperty().addListener(placeholderSceneWaiter);

sync();
}

Expand Down Expand Up @@ -128,10 +105,6 @@ public void uninstall() {
scene.heightProperty().removeListener(relayout);
}
node.layoutBoundsProperty().removeListener(relayout);
if (placeholder != null && placeholderSceneWaiter != null) {
placeholder.sceneProperty().removeListener(placeholderSceneWaiter);
placeholderSceneWaiter = null;
}
mount.unmount();
}
}
Loading
Loading