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 @@ * * * - * + * * * * @@ -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

Selection
CaseImplementation
FIXED, browser{@link WebScrollImpl} (compositor, viewport-anchored)
FIXED, browser{@link WebScrollImpl} (browser-native pin, viewport-anchored)
FIXED, desktop{@link DesktopFixedImpl} (scene-anchored overlay)
STICKY, {@code ScrollPane} ancestor (desktop or browser){@link ScrollPaneStickyImpl}
STICKY, browser, natively scrolled document{@link WebScrollImpl}