diff --git a/jpro-sticky/README.md b/jpro-sticky/README.md
index b95f183e..18f153d9 100644
--- a/jpro-sticky/README.md
+++ b/jpro-sticky/README.md
@@ -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
@@ -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
@@ -188,9 +188,12 @@ a `ScrollPane` doesn't need this.)
```html
@@ -198,6 +201,16 @@ a `ScrollPane` doesn't need this.)
```
+**Clip with `overflow-x: clip`, or clip `` 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 ``, `
`
+or both. With `hidden` the placement matters: `` hands its overflow to the viewport as long as
+`` is `visible`, so `body { overflow-x: hidden }` on its own is harmless, but clip `` as
+well and that hand-off stops, `` 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
@@ -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
@@ -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.
diff --git a/jpro-sticky/build.gradle b/jpro-sticky/build.gradle
index 4c4d2bd2..36f481b7 100644
--- a/jpro-sticky/build.gradle
+++ b/jpro-sticky/build.gradle
@@ -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'
}
@@ -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) {
diff --git a/jpro-sticky/example/build.gradle b/jpro-sticky/example/build.gradle
index a78e12b8..2a68f910 100644
--- a/jpro-sticky/example/build.gradle
+++ b/jpro-sticky/example/build.gradle
@@ -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')]
+ }
}
diff --git a/jpro-sticky/example/src/main/resources/jpro/html/index.html b/jpro-sticky/example/src/main/resources/jpro/html/index.html
index 53b7aeb4..39e7c87b 100644
--- a/jpro-sticky/example/src/main/resources/jpro/html/index.html
+++ b/jpro-sticky/example/src/main/resources/jpro/html/index.html
@@ -9,12 +9,17 @@
-
+
diff --git a/jpro-sticky/src/main/java/one/jpro/platform/sticky/Scroll.java b/jpro-sticky/src/main/java/one/jpro/platform/sticky/Scroll.java
index 65b6f998..d71ff8d7 100644
--- a/jpro-sticky/src/main/java/one/jpro/platform/sticky/Scroll.java
+++ b/jpro-sticky/src/main/java/one/jpro/platform/sticky/Scroll.java
@@ -21,8 +21,8 @@
* and {@link ScrollPosition#FIXED fixed}) to JavaFX nodes rendered by JPro.
*
* 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.
*
* The API is layered over {@link #setScrollPosition(Node, ScrollPosition, ScrollAnchor)}, which takes
@@ -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();
@@ -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;
}
@@ -312,7 +301,6 @@ 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 ->
@@ -320,7 +308,7 @@ public static void setScrollPosition(Node node, ScrollPosition position, ScrollA
final Consumer 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);
@@ -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.
*
- * 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.
*
@@ -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) {
diff --git a/jpro-sticky/src/main/java/one/jpro/platform/sticky/impl/DesktopFixedImpl.java b/jpro-sticky/src/main/java/one/jpro/platform/sticky/impl/DesktopFixedImpl.java
index d8e5cf8a..626193d3 100644
--- a/jpro-sticky/src/main/java/one/jpro/platform/sticky/impl/DesktopFixedImpl.java
+++ b/jpro-sticky/src/main/java/one/jpro/platform/sticky/impl/DesktopFixedImpl.java
@@ -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;
@@ -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 placeholderSceneWaiter;
private boolean torndown;
public DesktopFixedImpl(Node node, ScrollAnchor anchor, Runnable onDetach) {
@@ -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
}
@@ -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();
}
@@ -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();
}
}
diff --git a/jpro-sticky/src/main/java/one/jpro/platform/sticky/impl/OverlayMount.java b/jpro-sticky/src/main/java/one/jpro/platform/sticky/impl/OverlayMount.java
index 43bd6122..b0799f7a 100644
--- a/jpro-sticky/src/main/java/one/jpro/platform/sticky/impl/OverlayMount.java
+++ b/jpro-sticky/src/main/java/one/jpro/platform/sticky/impl/OverlayMount.java
@@ -1,6 +1,9 @@
package one.jpro.platform.sticky.impl;
+import javafx.application.Platform;
+import javafx.beans.value.ChangeListener;
import javafx.scene.Group;
+import javafx.scene.Scene;
import javafx.scene.Node;
import javafx.scene.Parent;
import javafx.scene.layout.Pane;
@@ -29,9 +32,11 @@ final class OverlayMount {
private final long stackOrder;
private Pane originalParent;
- private int originalIndex = -1;
private Group overlay;
private Region placeholder;
+ private Pane range;
+ /** Fires {@code onDetach} once the placeholder, and so the flow subtree, has left the scene. */
+ private ChangeListener detachWaiter;
private boolean mounted;
OverlayMount(Node node, long stackOrder) {
@@ -47,11 +52,16 @@ final class OverlayMount {
* @param reservedHeight height the placeholder holds in the flow slot (the node's height for STICKY,
* {@code 0} for FIXED). Set before insertion so the first layout honours it; a
* {@code setPrefHeight} after insertion only requests a re-layout the pulse can drop.
+ * @param withRange when {@code true}, the node is mounted inside a {@link #range()} pane that the
+ * caller sizes to the pin's scroll span. {@code position: sticky} clamps to its
+ * containing block, so the web path needs that span as a real box in the DOM.
+ * @param onDetach called once the placeholder has left the scene (a route navigate-away), so the
+ * owner can unmount and re-pin when the subtree returns
* @return the placeholder now holding the node's flow slot, or {@code null} if the node could not
* be mounted (its parent is not a {@link Pane}, no overlay host resolved, or the node is
* not in its parent's children); on {@code null} the node is left untouched in flow
*/
- Region mount(double reservedHeight) {
+ Region mount(double reservedHeight, boolean withRange, Runnable onDetach) {
final Parent parent = node.getParent();
if (!(parent instanceof Pane)) {
LOGGER.warn("jpro-sticky: node's parent is {} (not a Pane); cannot pin {}. Node stays in flow.",
@@ -71,7 +81,6 @@ Region mount(double reservedHeight) {
}
this.originalParent = pane;
- this.originalIndex = index;
this.overlay = ov;
// mirror the node's constraints/width onto the placeholder so the flow slot does not shift, swap
@@ -85,7 +94,31 @@ Region mount(double reservedHeight) {
Placeholders.mirror(node, ph);
pane.getChildren().set(index, ph);
node.setManaged(false);
- StickyOverlay.insertSorted(overlay, node, stackOrder);
+ if (withRange) {
+ final Pane r = new Pane();
+ r.setManaged(false);
+ // the span is a positioning box, never a hit target: picking stays with the node inside it.
+ r.setPickOnBounds(false);
+ r.getStyleClass().add(StickyOverlay.RANGE_STYLE_CLASS);
+ r.getChildren().add(node);
+ StickyOverlay.insertSorted(overlay, r, stackOrder);
+ this.range = r;
+ } else {
+ StickyOverlay.insertSorted(overlay, node, stackOrder);
+ }
+
+ // the 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.
+ detachWaiter = (obs, old, scene) -> {
+ if (scene == null && mounted) {
+ Platform.runLater(() -> {
+ if (mounted && ph.getScene() == null) {
+ onDetach.run();
+ }
+ });
+ }
+ };
+ ph.sceneProperty().addListener(detachWaiter);
this.placeholder = ph;
this.mounted = true;
@@ -93,14 +126,22 @@ Region mount(double reservedHeight) {
}
/**
- * Reverses {@link #mount()}: pulls the node out of the overlay and back into its original flow slot,
+ * Reverses {@link #mount}: pulls the node out of the overlay and back into its original flow slot,
* restoring its managed state. A no-op if the node is not currently mounted.
*/
void unmount() {
if (!mounted) {
return;
}
- StickyOverlay.remove(overlay, node);
+ placeholder.sceneProperty().removeListener(detachWaiter);
+ detachWaiter = null;
+ if (range != null) {
+ range.getChildren().remove(node);
+ StickyOverlay.remove(overlay, range);
+ range = null;
+ } else {
+ StickyOverlay.remove(overlay, node);
+ }
if (originalParent != null && placeholder != null) {
final int idx = originalParent.getChildren().indexOf(placeholder);
if (idx >= 0) {
@@ -111,17 +152,22 @@ void unmount() {
mounted = false;
}
- /** The overlay the node is mounted into; {@code null} until a successful {@link #mount()}. */
+ /** The overlay the node is mounted into; {@code null} until a successful {@link #mount}. */
Group overlay() {
return overlay;
}
- /** The placeholder holding the node's flow slot; {@code null} until a successful {@link #mount()}. */
+ /** The placeholder holding the node's flow slot; {@code null} until a successful {@link #mount}. */
Region placeholder() {
return placeholder;
}
- /** The node's flow parent captured at {@link #mount()}; {@code null} until a successful mount. */
+ /** The per-pin span the node sits in; {@code null} unless mounted with {@code withRange}. */
+ Pane range() {
+ return range;
+ }
+
+ /** The node's flow parent captured at {@link #mount}; {@code null} until a successful mount. */
Pane originalParent() {
return originalParent;
}
diff --git a/jpro-sticky/src/main/java/one/jpro/platform/sticky/impl/ScrollDispatcher.java b/jpro-sticky/src/main/java/one/jpro/platform/sticky/impl/ScrollDispatcher.java
index af527d9e..8f9891c5 100644
--- a/jpro-sticky/src/main/java/one/jpro/platform/sticky/impl/ScrollDispatcher.java
+++ b/jpro-sticky/src/main/java/one/jpro/platform/sticky/impl/ScrollDispatcher.java
@@ -20,7 +20,7 @@
*
STICKY, {@code ScrollPane} ancestor (desktop or browser)
{@link ScrollPaneStickyImpl}
*
STICKY, browser, natively scrolled document
{@link WebScrollImpl}
@@ -55,20 +55,19 @@ public ScrollDispatcher(Node node, ScrollPosition position, ScrollAnchor anchor,
@Override
public void install() {
- if (node.getScene() != null) {
- choose();
- } else {
- // parent chain is only realised once the node is in a scene. wait for it so the
- // ScrollPane-ancestor check (which decides FX vs web) sees the final tree.
- waitForScene();
- }
+ pinWhenInScene();
}
/**
- * Arms the one-shot scene listener that (re-)selects and installs the delegate the moment the
- * node enters a scene. Used both for the initial pre-scene wait and to re-pin after a detach.
+ * Selects and installs the delegate now if the node is in a scene, else the moment it enters one:
+ * the parent chain, and so the ScrollPane-ancestor check that decides FX vs web, is only realised
+ * then. Used for the initial install and to re-pin after a detach.
*/
- private void waitForScene() {
+ private void pinWhenInScene() {
+ if (node.getScene() != null) {
+ choose();
+ return;
+ }
sceneWaiter = (obs, old, scene) -> {
if (scene != null) {
node.sceneProperty().removeListener(sceneWaiter);
@@ -94,17 +93,11 @@ private void onDelegateDetached() {
delegate.uninstall();
delegate = null;
}
- // node left the scene, so it's no longer pinned: clear the stuck channel. the detach path skips
- // Scroll.setScrollPosition's central reset, so a node stuck at navigate-away would otherwise stay stuck.
+ // node left the scene, so it's no longer pinned: clear the stuck channel.
if (stuckSink != null) {
stuckSink.accept(false);
}
- if (node.getScene() != null) {
- // already back in a scene (a same-pulse return): re-pin now.
- choose();
- } else if (sceneWaiter == null) {
- waitForScene();
- }
+ pinWhenInScene();
}
private void choose() {
@@ -135,7 +128,7 @@ private ScrollImpl select() {
return new ScrollPaneStickyImpl(node, anchor, within, scrollPane, stuckSink);
}
if (WebAPI.isBrowser()) {
- // natively scrolled browser document: the compositor override.
+ // natively scrolled browser document: the browser-native pin.
return new WebScrollImpl(node, position, anchor, within, stuckSink, this::onDelegateDetached);
}
// desktop with no scroll ancestor: nothing scrolls, so a sticky element never moves, the same
@@ -154,6 +147,11 @@ public void uninstall() {
delegate.uninstall();
delegate = null;
}
+ // a prior pin's stuck state is meaningless once its impl is gone; clearing it here also drops
+ // the :stuck pseudo-class when switching sticky -> fixed/static.
+ if (stuckSink != null) {
+ stuckSink.accept(false);
+ }
}
/** Walks the node's parent chain and returns the nearest {@link ScrollPane} ancestor, or null. */
diff --git a/jpro-sticky/src/main/java/one/jpro/platform/sticky/impl/ScrollImpl.java b/jpro-sticky/src/main/java/one/jpro/platform/sticky/impl/ScrollImpl.java
index 019be068..ff3ba298 100644
--- a/jpro-sticky/src/main/java/one/jpro/platform/sticky/impl/ScrollImpl.java
+++ b/jpro-sticky/src/main/java/one/jpro/platform/sticky/impl/ScrollImpl.java
@@ -8,7 +8,7 @@
* {@link Scroll} selects one implementation per node at install time and stashes it on the node so
* a later {@code setScrollPosition} (or {@code clearScrollPosition}) can tear it down cleanly:
*
- *
{@link WebScrollImpl}, the web path: a compositor scroll-timeline override, used for
+ *
{@link WebScrollImpl}, the web path: a native {@code position: sticky} pin, used for
* natively scrolled browser documents;
*
{@link ScrollPaneStickyImpl}, the sticky path for a node inside an FX {@code ScrollPane}
* (desktop or browser): pure JavaFX, pinning by {@code translate} within the scrolled content;
diff --git a/jpro-sticky/src/main/java/one/jpro/platform/sticky/impl/StickyOverlay.java b/jpro-sticky/src/main/java/one/jpro/platform/sticky/impl/StickyOverlay.java
index fe9acce7..a0c2573e 100644
--- a/jpro-sticky/src/main/java/one/jpro/platform/sticky/impl/StickyOverlay.java
+++ b/jpro-sticky/src/main/java/one/jpro/platform/sticky/impl/StickyOverlay.java
@@ -46,9 +46,12 @@ public final class StickyOverlay {
/** {@link Node} property key stashing a mounted node's stack order, read by sibling mounts. */
private static final Object STACK_ORDER_KEY = new Object();
- /** {@code id} stamped on every overlay {@link Group}, lets {@link #isOverlay} spot a mounted node. */
+ /** {@code id} stamped on every overlay {@link Group}. */
private static final String OVERLAY_ID = "jpro-sticky-overlay";
+ /** Style class of the per-pin span the web path mounts a node into (see {@link OverlayMount#mount}). */
+ static final String RANGE_STYLE_CLASS = "jpro-sticky-range";
+
/**
* {@code viewOrder} for the overlay {@link Group} itself (negative = in front), so it paints above the
* host's other children regardless of child-list order. Needed when the host is a routing container
@@ -205,18 +208,6 @@ static void remove(Group overlay, Node node) {
node.getProperties().remove(STACK_ORDER_KEY);
}
- /**
- * Whether {@code parent} is one of jpro-sticky's overlay {@link Group}s. Used by the async web
- * attach to tell a still-mounted node (a superseded application racing this one) apart from a real
- * flow parent, so it never mistakes the overlay for the flow slot.
- *
- * @param parent the node's current parent, or {@code null}
- * @return {@code true} if {@code parent} is a sticky overlay
- */
- static boolean isOverlay(Node parent) {
- return parent instanceof Group && OVERLAY_ID.equals(parent.getId());
- }
-
/** The stack order stashed on a mounted node, or {@link Long#MIN_VALUE} if absent. */
private static long stackOrderOf(Node n) {
final Object v = n.getProperties().get(STACK_ORDER_KEY);
diff --git a/jpro-sticky/src/main/java/one/jpro/platform/sticky/impl/StuckState.java b/jpro-sticky/src/main/java/one/jpro/platform/sticky/impl/StuckState.java
index ef0a5739..d0de5b3a 100644
--- a/jpro-sticky/src/main/java/one/jpro/platform/sticky/impl/StuckState.java
+++ b/jpro-sticky/src/main/java/one/jpro/platform/sticky/impl/StuckState.java
@@ -16,8 +16,8 @@
*
* One holder lives for the node's life (stashed on {@code node.getProperties()} by {@link Scroll}), so
* listener identity is stable across clear / re-apply. The active STICKY implementation feeds it through
- * {@link #set(boolean)} at each pin/unpin transition; {@link Scroll} resets it to {@code false} on
- * teardown (which removes the pseudo-class).
+ * {@link #set(boolean)} at each pin/unpin transition; {@link ScrollDispatcher} resets it to
+ * {@code false} on uninstall and detach (which removes the pseudo-class).
*
* @author Tobias Horak
*/
diff --git a/jpro-sticky/src/main/java/one/jpro/platform/sticky/impl/WebScrollImpl.java b/jpro-sticky/src/main/java/one/jpro/platform/sticky/impl/WebScrollImpl.java
index 2d9afbfb..db51dce9 100644
--- a/jpro-sticky/src/main/java/one/jpro/platform/sticky/impl/WebScrollImpl.java
+++ b/jpro-sticky/src/main/java/one/jpro/platform/sticky/impl/WebScrollImpl.java
@@ -1,15 +1,14 @@
package one.jpro.platform.sticky.impl;
+import com.jpro.webapi.JSVariable;
import com.jpro.webapi.WebAPI;
-import javafx.application.Platform;
import javafx.beans.InvalidationListener;
-import javafx.beans.value.ChangeListener;
import javafx.geometry.Point2D;
import javafx.geometry.Rectangle2D;
import javafx.scene.Group;
import javafx.scene.Node;
import javafx.scene.Parent;
-import javafx.scene.Scene;
+import javafx.scene.layout.Pane;
import javafx.scene.layout.Region;
import one.jpro.jmemorybuddy.CleanupDetector;
import one.jpro.platform.sticky.ScrollAnchor;
@@ -22,25 +21,44 @@
import java.util.function.Consumer;
/**
- * The scroll-aware pinning for a single {@link Node}, realised on the web side through a
- * compositor scroll-timeline override. One instance owns one node's override lifecycle: it
- * reparents the node into a per-scene overlay, leaves a layout-mirroring placeholder in the
- * node's flow slot, server-pins the node (for picking and as a no-compositor fallback), and
- * overrides the DOM visual with an {@code animation-timeline: scroll()} animation so scrolling
- * stays smooth without a JavaFX layout pass per scroll event.
+ * The scroll-aware pinning for a single {@link Node} on the web. One instance owns one node's
+ * override lifecycle: it reparents the node into a per-scene overlay, leaves a layout-mirroring
+ * placeholder in the node's flow slot, server-pins the node (which is what picking sees), and
+ * positions the node's DOM peer so scrolling costs nothing on either side.
*
* It builds only on the JPro Viewport API ({@link WebAPI#browserViewport()} /
* {@link WebAPI#documentBounds()}) and needs no core change.
*
+ * How the pin works. The browser does it. The node is mounted inside a span sized
+ * to the pin's scroll range, and the injected sheet makes the node's element
+ * {@code position: sticky} at the anchor's inset. Sticky clamps to its containing block, so the
+ * release is the span's own end and needs no code, and the crossing is the compositor's, so nothing
+ * is measured or written per scroll event. {@link ScrollPosition#FIXED} is the same pin over a span
+ * the length of the document: a viewport-anchored node always fits the viewport, so that span's end
+ * is out of reach and the pin never releases.
+ *
+ * The correction the sheet carries. The renderer writes every node's layout as a
+ * CSS {@code transform}, and sticky resolves in layout space, before transforms, so the ancestors'
+ * translates displace it. Their sum is the span's scene y, which the server subtracts from the inset
+ * when it writes the rule.
+ *
+ * Sticky also holds against the nearest scroll container, which an ancestor becomes merely by having
+ * a non-visible {@code overflow}. That is the host page's business, not this class's, so a pin only
+ * reports the one it found on the console. {@code body} is the case worth knowing: it hands its
+ * overflow to the viewport while {@code html} is {@code visible}, and becomes a scroll container in
+ * its own right only once {@code html} clips too.
+ *
+ * The server keeps the node at the position it appears at, because picking is a scene pick and a
+ * node parked at the span's origin is not where the click lands. The renderer writes that offset out
+ * as a transform below the pin, which the sheet drops: the rule places the box.
+ *
* When running as a desktop application the {@link WebAPI} consumer never fires, so installation
* is a no-op and the node keeps its normal flow positioning.
*
* Anchoring. The {@link ScrollAnchor} resolves the horizontal and vertical axes
- * independently. The vertical axis drives the scroll-timeline keyframe (pin line, ride, and, for
- * bounded sticky, release); the horizontal axis is a constant baked into the keyframe (the page does
- * not scroll horizontally). {@link ScrollAnchor.Mode#STRETCH} resizes the node to span the axis;
- * {@link ScrollPosition#FIXED} is the degenerate pin (from scroll 0, no ride and, being
- * viewport-anchored, no containment release).
+ * independently. The vertical axis drives the pin line, the ride and, for bounded sticky, the
+ * release; the horizontal axis is a constant the pin carries throughout.
+ * {@link ScrollAnchor.Mode#STRETCH} resizes the node to span the axis.
*
* @author Tobias Horak
*/
@@ -63,8 +81,6 @@ public final class WebScrollImpl implements ScrollImpl {
private final Consumer stuckSink;
/** Called when the flow slot leaves the scene, so the dispatcher can re-pin on re-entry. */
private final Runnable onDetach;
- /** Last stuck value pushed to {@link #stuckSink}, so we only fire on change. */
- private boolean lastStuck;
private final String jsKey = "n" + KEY_SEQ.incrementAndGet();
/** Stack key ordering this node in the overlay; see {@link StickyOverlay#nextStackOrder}. */
private final long stackOrder;
@@ -74,6 +90,11 @@ public final class WebScrollImpl implements ScrollImpl {
// resolved at install time
private WebAPI webapi;
+ /** The current {@code jpro.var_N} handles for the node's and the span's elements. Held for as long
+ * as the emitted script may use them: JPro's JSVariable cleanup fires {@code jpro.var_N = undefined}
+ * once one is unreachable, which would leave the resolver (and so the rebind heartbeat) dead. */
+ private JSVariable elementVar;
+ private JSVariable rangeVar;
private Group overlay;
private Region placeholder;
private Parent root;
@@ -82,13 +103,8 @@ public final class WebScrollImpl implements ScrollImpl {
// reactive plumbing: one listener re-syncs geometry on any relevant change
private final InvalidationListener relayout = obs -> sync();
- private ChangeListener sceneWaiter;
- /** Fires teardown when the placeholder (and thus the route subtree) leaves the scene. */
- private ChangeListener placeholderSceneWaiter;
- /** Defers attach while a superseded application still has the node mounted in an overlay. */
- private ChangeListener settleWaiter;
- private boolean installedCompositor = false;
- private String lastSig = "";
+ /** Signature of the last emitted rule; {@code null} until the first install. */
+ private String lastSig;
private boolean torndown = false;
public WebScrollImpl(Node node, ScrollPosition position, ScrollAnchor anchor, Node within,
@@ -104,8 +120,9 @@ public WebScrollImpl(Node node, ScrollPosition position, ScrollAnchor anchor, No
}
/**
- * Installs the override. Only meaningful under JPro: on desktop the {@link WebAPI} consumer
- * never fires and the node keeps its normal flow positioning.
+ * Installs the override. Only meaningful under JPro: the {@link WebAPI} consumer fires once JPro
+ * has rendered the node in a scene with a window, and on desktop it never fires, so the node keeps
+ * its normal flow positioning.
*/
@Override
public void install() {
@@ -117,48 +134,16 @@ private void onWebAPI(WebAPI webapi) {
return;
}
this.webapi = webapi;
- if (node.getScene() != null) {
- attach();
- } else {
- // node not in a scene yet, attach when it enters.
- sceneWaiter = (obs, old, scene) -> {
- if (scene != null) {
- node.sceneProperty().removeListener(sceneWaiter);
- sceneWaiter = null;
- attach();
- }
- };
- node.sceneProperty().addListener(sceneWaiter);
- }
+ attach();
}
private void attach() {
- if (torndown) {
- return;
- }
- final Parent parent = node.getParent();
- // a superseded app (rapid re-apply / scene churn) may still hold the node in an overlay when this
- // async attach fires, which we'd mistake for the flow slot. wait (one-shot) until it settles back.
- if (StickyOverlay.isOverlay(parent)) {
- if (settleWaiter == null) {
- settleWaiter = (obs, old, p) -> {
- if (!torndown && p != null && !StickyOverlay.isOverlay(p)) {
- node.parentProperty().removeListener(settleWaiter);
- settleWaiter = null;
- attach();
- }
- };
- node.parentProperty().addListener(settleWaiter);
- LOGGER.debug("jpro-sticky[{}]: node still in an overlay; deferring attach until it settles", jsKey);
- }
- return;
- }
// lift the node into the overlay, leaving a placeholder that reserves the node's height in its
// flow slot (FIXED reserves nothing). naturalHeight, not current bounds: pinning often happens
// during scene construction, before layout, when bounds are still zero. sync() refines it later.
final double reservedHeight = (position == ScrollPosition.FIXED) ? 0
: AnchorGeometry.naturalHeight(node, AnchorGeometry.naturalWidth(node));
- final Region ph = mount.mount(reservedHeight);
+ final Region ph = mount.mount(reservedHeight, true, onDetach);
if (ph == null) {
return; // could not mount (no Pane parent / overlay host), node stays in flow
}
@@ -183,25 +168,6 @@ private void attach() {
container.localToSceneTransformProperty().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, scene) -> {
- if (scene == 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);
-
registerCleanup();
LOGGER.debug("jpro-sticky[{}]: attached (overlay={}, parent={})", jsKey,
overlay.getId(), mount.originalParent().getClass().getSimpleName());
@@ -209,8 +175,8 @@ private void attach() {
}
/**
- * Recomputes the node's pinned position and (re-)emits the compositor keyframes when the
- * geometry signature changes, never per scroll event (that is the compositor's job).
+ * Recomputes the node's pinned position and (re-)emits the sticky rule when the geometry
+ * signature changes, never per scroll event (that is the browser's job).
*/
private void sync() {
if (torndown || placeholder == null) {
@@ -253,56 +219,78 @@ private void sync() {
// FIXED is out of flow: placeholder reserves no height. STICKY keeps its slot.
placeholder.setPrefHeight(fixed ? 0 : nodeH);
- // natTop = keyframe 'from'. STICKY rides the flow from its natural top, FIXED pins from the very
- // top (natTop == y0 makes sPin 0, so no ride).
+ // natTop = the span's top. STICKY rides the flow from its natural top, FIXED from the page top.
final double natTop = fixed ? y0 : flowTop;
// STICKY release limit = containerBottom - nodeH. -1 = unbounded (FIXED or page-spanning container).
final double relLimitServer = releaseLimit(nodeH);
- // server-side pin (also the no-compositor fallback + what picking sees): clamp to pin line while
+ // server-side pin (also the no-script fallback + what picking sees): clamp to pin line while
// pinned, ride flow before, honour the release limit.
double serverY = fixed ? (viewportTop + y0) : Math.max(flowTop, viewportTop + y0);
if (!fixed && relLimitServer >= 0) {
serverY = Math.min(serverY, relLimitServer);
}
final Point2D local = overlay.sceneToLocal(x, serverY);
- node.setLayoutX(local.getX());
- node.setLayoutY(local.getY());
-
- // the overlay may sit offset down the document (under a registered host, e.g. a popup nested in
- // the route). the compositor transform is relative to the overlay's own DOM box, so endpoints bake
- // in host-local space (scene-y minus this offset) while the scroll-range math stays document-space.
- final double hostOffsetY = overlay.localToScene(0, 0).getY();
+ final Pane range = mount.range();
+ // FIXED spans the document from its top, STICKY from the node's flow top.
+ final double spanTop = fixed ? 0 : natTop;
+ // the span runs from the node's flow top to its release point. unbounded pins, FIXED
+ // included, run to the end of the document.
+ final double docH = root.getLayoutBounds().getHeight();
+ final double relLimit = fixed ? (docH - nodeH)
+ : (relLimitServer >= 0 ? relLimitServer : Math.max(natTop, docH - nodeH));
+ // a fixed node taller than the viewport can reach its span's end, so it drifts there.
+ if (fixed && viewportH > 0 && y0 + nodeH > viewportH + STUCK_EPS) {
+ LOGGER.warn("jpro-sticky[{}]: fixed node is taller than the viewport ({} + {} > {});"
+ + " the pin will drift near the end of the document.", jsKey, y0, nodeH, viewportH);
+ }
+ final Point2D span = overlay.sceneToLocal(x, spanTop);
+ range.setLayoutX(span.getX());
+ range.setLayoutY(span.getY());
+ range.resize(nodeW, Math.max(nodeH, (relLimit - spanTop) + nodeH));
+ // picking is a scene pick, so the node sits where it appears. the sheet drops the
+ // transform that produces.
+ node.setLayoutX(local.getX() - span.getX());
+ node.setLayoutY(local.getY() - span.getY());
// publish pin state (STICKY only): stuck iff the server pin differs from the natural flow top (same
// rule as ScrollPaneStickyImpl, appear != natural). fidelity = browserViewport() cadence, not per-frame.
if (!fixed && stuckSink != null) {
- final boolean nowStuck = Math.abs(serverY - flowTop) > STUCK_EPS;
- if (nowStuck != lastStuck) {
- lastStuck = nowStuck;
- stuckSink.accept(nowStuck);
- }
+ stuckSink.accept(Math.abs(serverY - flowTop) > STUCK_EPS);
}
- final double docH = root.getLayoutBounds().getHeight();
- final String sig = natTop + "|" + y0 + "|" + local.getX() + "|" + relLimitServer + "|"
- + nodeW + "|" + nodeH + "|" + docH + "|" + hostOffsetY;
-
- if (!installedCompositor) {
- // install inline on the first sync with a real width. the node's DOM peer may still be unregistered,
- // but the injected script resolves it via its own retry loop, so no server-side deferral needed.
- installCompositor(local.getX(), natTop, y0, relLimitServer, hostOffsetY);
- installedCompositor = true;
- lastSig = sig;
- LOGGER.debug("jpro-sticky[{}]: compositor installed (w={}, natTop={}, y0={}, hostOffsetY={})",
- jsKey, nodeW, natTop, y0, hostOffsetY);
- } else if (!sig.equals(lastSig)) {
- installCompositor(local.getX(), natTop, y0, relLimitServer, hostOffsetY);
+ // what the sheet contains, plus the span's top: moving the span moves the very ancestor
+ // transform the script measures, so a stale offset outlives the change without it.
+ final String sig = y0 + "|" + nodeW + "|" + nodeH + "|" + spanTop;
+
+ // NaN/Infinity are valid JS literals, so a non-finite value installs cleanly and then dies in
+ // the CSS parser, leaving a dead pin and nothing in any log. refuse the install instead.
+ if (!allFinite(y0, nodeW, nodeH, spanTop)) {
+ LOGGER.warn("jpro-sticky[{}]: skipping install, non-finite geometry (y0={}, w={}, h={}, spanTop={})",
+ jsKey, y0, nodeW, nodeH, spanTop);
+ return;
+ }
+
+ if (!sig.equals(lastSig)) {
+ // the node's DOM peer may still be unregistered on the first install; the injected script
+ // resolves it via its own retry, so no server-side deferral is needed.
+ installSticky(y0, spanTop, nodeW, nodeH);
+ LOGGER.debug("jpro-sticky[{}]: sticky rule {} (w={}, h={}, y0={}, spanTop={})",
+ jsKey, lastSig == null ? "installed" : "updated", nodeW, nodeH, y0, spanTop);
lastSig = sig;
}
}
+ private static boolean allFinite(double... values) {
+ for (double v : values) {
+ if (!Double.isFinite(v)) {
+ return false;
+ }
+ }
+ return true;
+ }
+
/**
* The scene-y at which a bounded STICKY node releases (rides up out of its containing block):
* {@code containerBottom - nodeH}. Returns {@code -1} (unbounded, document-long) for FIXED, for
@@ -318,33 +306,38 @@ private double releaseLimit(double nodeH) {
}
/**
- * (Re-)installs the scroll-timeline animation that pins the node. The animation is realised
- * entirely inside an injected {@code