diff --git a/src/main/java/org/jabref/htmltonode/rich/HtmlRichTextArea.java b/src/main/java/org/jabref/htmltonode/rich/HtmlRichTextArea.java
new file mode 100644
index 0000000..2fbed1f
--- /dev/null
+++ b/src/main/java/org/jabref/htmltonode/rich/HtmlRichTextArea.java
@@ -0,0 +1,48 @@
+package org.jabref.htmltonode.rich;
+
+import jfx.incubator.scene.control.richtext.RichTextArea;
+import jfx.incubator.scene.control.richtext.StyleHandlerRegistry;
+import jfx.incubator.scene.control.richtext.model.StyledTextModel;
+import jfx.incubator.scene.control.richtext.skin.CellContext;
+
+/// A [RichTextArea] that renders links with the hand cursor.
+///
+/// The incubator control gives every segment's `Text` node the default text (I-beam) cursor, so a
+/// link would otherwise be indistinguishable from ordinary selectable text on hover — unlike
+/// [org.jabref.htmltonode.FxRenderer], whose link runs carry [javafx.scene.Cursor#HAND]. The
+/// control has no built-in notion of links; the recommended way to react to a custom character
+/// attribute is to extend its [StyleHandlerRegistry] (see the incubator docs). This subclass adds a
+/// segment handler for [RichTextRenderer#HREF] that appends an `-fx-cursor: hand` inline style via
+/// the [CellContext], exactly as the control's own attribute handlers do. Routing it through the
+/// cell context (rather than setting the cursor on the node directly) means the skin re-applies it
+/// on every layout and, because it replaces a segment node's whole inline style, a recycled node
+/// reused for a non-link run does not keep a stale hand cursor.
+public final class HtmlRichTextArea extends RichTextArea {
+
+ /// Mirrors the `-fx-...:value;` shape the control's built-in handlers append to the cell style.
+ private static final String HAND_CURSOR_STYLE = "-fx-cursor:hand;";
+
+ private static final StyleHandlerRegistry REGISTRY = buildRegistry();
+
+ public HtmlRichTextArea() {
+ super();
+ }
+
+ public HtmlRichTextArea(StyledTextModel model) {
+ super(model);
+ }
+
+ @Override
+ public StyleHandlerRegistry getStyleHandlerRegistry() {
+ return REGISTRY;
+ }
+
+ private static StyleHandlerRegistry buildRegistry() {
+ // builder(parent) seeds the new registry with all of the control's default handlers, so
+ // bold/italic/color/… keep working; we only add link-cursor handling on top.
+ StyleHandlerRegistry.Builder builder = StyleHandlerRegistry.builder(RichTextArea.styleHandlerRegistry);
+ builder.setSegHandler(RichTextRenderer.HREF,
+ (RichTextArea control, CellContext context, String href) -> context.addStyle(HAND_CURSOR_STYLE));
+ return builder.build();
+ }
+}
diff --git a/src/main/java/org/jabref/htmltonode/rich/RichHtmlView.java b/src/main/java/org/jabref/htmltonode/rich/RichHtmlView.java
index 241b4e9..aad5fbe 100644
--- a/src/main/java/org/jabref/htmltonode/rich/RichHtmlView.java
+++ b/src/main/java/org/jabref/htmltonode/rich/RichHtmlView.java
@@ -26,7 +26,7 @@
/// JavaFX application thread. Requires the `jfx.incubator.richtext` module at runtime.
public class RichHtmlView extends StackPane {
- private final RichTextArea area = new RichTextArea();
+ private final RichTextArea area = new HtmlRichTextArea();
private final StringProperty html = new SimpleStringProperty(this, "html", "");
private final ObjectProperty options =
new SimpleObjectProperty<>(this, "options", HtmlRenderOptions.defaults());
diff --git a/src/main/java/org/jabref/htmltonode/rich/RichTextRenderer.java b/src/main/java/org/jabref/htmltonode/rich/RichTextRenderer.java
index 9cac404..03328fc 100644
--- a/src/main/java/org/jabref/htmltonode/rich/RichTextRenderer.java
+++ b/src/main/java/org/jabref/htmltonode/rich/RichTextRenderer.java
@@ -79,7 +79,7 @@ public static StyledTextModel buildModel(List blocks, HtmlRenderOptions o
/// @param options rendering options
/// @return the configured control
public static RichTextArea render(List blocks, HtmlRenderOptions options) {
- RichTextArea area = new RichTextArea(buildModel(blocks, options));
+ RichTextArea area = new HtmlRichTextArea(buildModel(blocks, options));
configure(area, options);
return area;
}
diff --git a/src/test/java/org/jabref/htmltonode/HtmlRichTextAreaTest.java b/src/test/java/org/jabref/htmltonode/HtmlRichTextAreaTest.java
new file mode 100644
index 0000000..b992bc1
--- /dev/null
+++ b/src/test/java/org/jabref/htmltonode/HtmlRichTextAreaTest.java
@@ -0,0 +1,107 @@
+package org.jabref.htmltonode;
+
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicReference;
+
+import javafx.application.Platform;
+import javafx.scene.Node;
+import javafx.scene.text.Text;
+
+import org.jabref.htmltonode.rich.HtmlRichTextArea;
+import org.jabref.htmltonode.rich.RichTextRenderer;
+
+import jfx.incubator.scene.control.richtext.model.StyleAttributeMap;
+import jfx.incubator.scene.control.richtext.skin.CellContext;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/// Verifies that [HtmlRichTextArea] gives link runs the hand cursor. Constructing the incubator
+/// control needs the JavaFX toolkit, so this is a `gui` test — run via `./gradlew guiTest` with a
+/// display or under `xvfb-run`.
+@Tag("gui")
+class HtmlRichTextAreaTest {
+
+ @BeforeAll
+ static void startToolkit() throws InterruptedException {
+ CountDownLatch started = new CountDownLatch(1);
+ try {
+ Platform.startup(started::countDown);
+ } catch (IllegalStateException alreadyRunning) {
+ started.countDown();
+ }
+ assertTrue(started.await(15, TimeUnit.SECONDS), "JavaFX toolkit failed to start");
+ }
+
+ /// Captures the inline style the skin's cell context would apply to a segment node — the same
+ /// path [jfx.incubator.scene.control.richtext.StyleHandlerRegistry#process] drives per attribute.
+ private static final class CapturingCellContext implements CellContext {
+ private final Node node;
+ private final StringBuilder style = new StringBuilder();
+
+ CapturingCellContext(Node node) {
+ this.node = node;
+ }
+
+ @Override
+ public void addStyle(String s) {
+ style.append(s);
+ }
+
+ @Override
+ public Node getNode() {
+ return node;
+ }
+
+ @Override
+ public StyleAttributeMap getAttributes() {
+ return StyleAttributeMap.builder().build();
+ }
+
+ String style() {
+ return style.toString();
+ }
+ }
+
+ @Test
+ void linkRunGetsHandCursorWhileInheritedHandlersStillApply() throws Exception {
+ AtomicReference error = new AtomicReference<>();
+ AtomicReference linkStyle = new AtomicReference<>();
+ AtomicReference boldStyle = new AtomicReference<>();
+ CountDownLatch done = new CountDownLatch(1);
+
+ Platform.runLater(() -> {
+ try {
+ HtmlRichTextArea area = new HtmlRichTextArea();
+
+ CapturingCellContext link = new CapturingCellContext(new Text("10.1/x"));
+ area.getStyleHandlerRegistry()
+ .process(area, false, link, RichTextRenderer.HREF, "https://doi.org/10.1/x");
+ linkStyle.set(link.style());
+
+ // The default handlers (here: bold) must survive the registry rebuild.
+ CapturingCellContext bold = new CapturingCellContext(new Text("bold"));
+ area.getStyleHandlerRegistry()
+ .process(area, false, bold, StyleAttributeMap.BOLD, Boolean.TRUE);
+ boldStyle.set(bold.style());
+ } catch (Throwable t) {
+ error.set(t);
+ } finally {
+ done.countDown();
+ }
+ });
+
+ assertTrue(done.await(15, TimeUnit.SECONDS), "FX task timed out");
+ if (error.get() != null) {
+ throw new AssertionError("FX task failed", error.get());
+ }
+ assertTrue(linkStyle.get().contains("-fx-cursor:hand"),
+ "link run should request the hand cursor, was: " + linkStyle.get());
+ assertFalse(boldStyle.get().isEmpty(), "inherited bold handler should still apply a style");
+ assertFalse(boldStyle.get().contains("-fx-cursor"), "non-link run must not get the hand cursor");
+ }
+}