Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- `RichHtmlView#setHtml(String, HtmlRenderOptions)`, which updates the HTML and the render options with a single model rebuild instead of one per property.

## [0.1.0] - 2026-07-14

### Added
Expand Down
34 changes: 30 additions & 4 deletions src/main/java/org/jabref/htmltonode/rich/RichHtmlView.java
Original file line number Diff line number Diff line change
Expand Up @@ -22,22 +22,24 @@
/// `org.jabref.htmltonode.HtmlView`, but with native text selection, caret navigation, and
/// accessibility. The area scrolls itself; do not wrap this view in a `ScrollPane`.
///
/// Re-renders whenever [#htmlProperty()] or [#optionsProperty()] changes. Must be used on the
/// JavaFX application thread. Requires the `jfx.incubator.richtext` module at runtime.
/// Re-renders whenever [#htmlProperty()] or [#optionsProperty()] changes, except while
/// [#setHtml(String,HtmlRenderOptions)] updates both — that renders once, at the end. Must be used
/// on the JavaFX application thread. Requires the `jfx.incubator.richtext` module at runtime.
public class RichHtmlView extends StackPane {

private final RichTextArea area = new HtmlRichTextArea();
private final StringProperty html = new SimpleStringProperty(this, "html", "");
private final ObjectProperty<HtmlRenderOptions> options =
new SimpleObjectProperty<>(this, "options", HtmlRenderOptions.defaults());
private boolean updatesAreBatched;

/// Creates an empty view with [HtmlRenderOptions#defaults()].
public RichHtmlView() {
setAlignment(Pos.TOP_LEFT);
getStylesheets().add(HtmlToNode.stylesheet());
getChildren().add(area);
html.addListener((observable, oldValue, newValue) -> rerender());
options.addListener((observable, oldValue, newValue) -> rerender());
html.addListener((observable, oldValue, newValue) -> rerenderIfNeeded());
options.addListener((observable, oldValue, newValue) -> rerenderIfNeeded());
rerender();
}

Expand All @@ -58,6 +60,24 @@ public final void setHtml(@Nullable String newHtml) {
html.set(newHtml == null ? "" : newHtml);
}

/// Updates the HTML content and render options in one model rebuild.
///
/// @param newHtml the HTML content to render; `null` is treated as empty
/// @param newOptions the rendering options; `null` restores [HtmlRenderOptions#defaults()]
public final void setHtml(@Nullable String newHtml, @Nullable HtmlRenderOptions newOptions) {
HtmlRenderOptions effectiveOptions = newOptions == null ? HtmlRenderOptions.defaults() : newOptions;
updatesAreBatched = true;
try {
html.set(newHtml == null ? "" : newHtml);
options.set(effectiveOptions);
} finally {
// In the finally block so a throwing property listener cannot leave the rendered
// content out of sync with the already-updated properties.
updatesAreBatched = false;
rerender();
}
}

/// The rendering options of this view. Setting a new value re-renders. Never `null`.
///
/// @return the options property
Expand Down Expand Up @@ -138,4 +158,10 @@ private void rerender() {
area.setModel(RichTextRenderer.buildModel(HtmlToNode.parse(getHtml(), renderOptions.baseUri()), renderOptions));
RichTextRenderer.configure(area, renderOptions);
}

private void rerenderIfNeeded() {
if (!updatesAreBatched) {
rerender();
}
}
}
41 changes: 41 additions & 0 deletions src/test/java/org/jabref/htmltonode/RichHtmlViewTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@

import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;

import javafx.application.Platform;
import javafx.scene.Scene;

import org.jabref.htmltonode.HtmlRenderOptions;
import org.jabref.htmltonode.rich.RichHtmlView;

import org.junit.jupiter.api.BeforeAll;
Expand Down Expand Up @@ -63,6 +65,45 @@ void selectionFacadeReturnsSelectedText() throws Exception {
assertEquals(java.util.Optional.of("First entry\nSecond one"), after.get());
}

@Test
void setHtmlWithOptionsRebuildsModelOnce() throws Exception {
AtomicReference<Throwable> error = new AtomicReference<>();
AtomicInteger batchedRebuilds = new AtomicInteger();
AtomicInteger separateRebuilds = new AtomicInteger();
AtomicReference<String> renderedText = new AtomicReference<>();
CountDownLatch done = new CountDownLatch(1);

Platform.runLater(() -> {
try {
HtmlRenderOptions newOptions = HtmlRenderOptions.defaults().withBaseFontSize(19.0);

RichHtmlView batched = new RichHtmlView();
batched.getRichTextArea().modelProperty()
.addListener((observable, oldValue, newValue) -> batchedRebuilds.incrementAndGet());
batched.setHtml("<p>Updated</p>", newOptions);
renderedText.set(batched.getRichTextArea().getModel().getPlainText(0));

RichHtmlView separate = new RichHtmlView();
separate.getRichTextArea().modelProperty()
.addListener((observable, oldValue, newValue) -> separateRebuilds.incrementAndGet());
separate.setHtml("<p>Updated</p>");
separate.setOptions(newOptions);
} 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());
}
assertEquals(1, batchedRebuilds.get(), "batched update should rebuild the model once");
assertEquals(2, separateRebuilds.get(), "separate setters rebuild the model per property");
assertEquals("Updated", renderedText.get());
}

@Test
void viewRendersHtmlIntoRichTextArea() throws Exception {
AtomicReference<Throwable> error = new AtomicReference<>();
Expand Down