diff --git a/CHANGELOG.md b/CHANGELOG.md index 52485dda5ce..89cf58a507b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,7 @@ Note that this project **does not** adhere to [Semantic Versioning](https://semv - We added an "Open example library" button to Welcome Tab. [#13014](https://github.com/JabRef/jabref/issues/13014) - We added automatic detection and selection of the identifier type (e.g., DOI, ISBN, arXiv) based on clipboard content when opening the "New Entry" dialog [#13111](https://github.com/JabRef/jabref/pull/13111) - We added support for import of a Refer/BibIX file format. [#13069](https://github.com/JabRef/jabref/issues/13069) +- We added markdown rendering and copy capabilities to AI chat responses. [#12234](https://github.com/JabRef/jabref/issues/12234) - We added a new `jabkit` command `pseudonymize` to pseudonymize the library. [#13109](https://github.com/JabRef/jabref/issues/13109) - We added functionality to focus running instance when trying to start a second instance. [#13129](https://github.com/JabRef/jabref/issues/13129) - We added a highlighted diff regarding changes to the Group Tree Structure of a bib file, made outside JabRef. [#11221](https://github.com/JabRef/jabref/issues/11221) diff --git a/jabgui/src/main/java/module-info.java b/jabgui/src/main/java/module-info.java index 9c7cf4505d8..41b89faf9d1 100644 --- a/jabgui/src/main/java/module-info.java +++ b/jabgui/src/main/java/module-info.java @@ -127,10 +127,10 @@ // requires org.apache.xmpbox; // requires com.ibm.icu; - // requires flexmark; + requires com.vladsch.flexmark; requires com.vladsch.flexmark.html2md.converter; - // requires flexmark.util.ast; - // requires flexmark.util.data; + requires com.vladsch.flexmark.util.ast; + requires com.vladsch.flexmark.util.data; // requires com.h2database.mvstore; diff --git a/jabgui/src/main/java/org/jabref/gui/JabRefGUI.java b/jabgui/src/main/java/org/jabref/gui/JabRefGUI.java index b1af3c9aa24..800852bafb1 100644 --- a/jabgui/src/main/java/org/jabref/gui/JabRefGUI.java +++ b/jabgui/src/main/java/org/jabref/gui/JabRefGUI.java @@ -18,6 +18,7 @@ import org.jabref.gui.help.VersionWorker; import org.jabref.gui.icon.IconTheme; import org.jabref.gui.keyboard.KeyBindingRepository; +import org.jabref.gui.keyboard.SelectableTextFlowKeyBindings; import org.jabref.gui.keyboard.TextInputKeyBindings; import org.jabref.gui.openoffice.OOBibBaseConnect; import org.jabref.gui.preferences.GuiPreferences; @@ -267,10 +268,10 @@ private void openWindow() { themeManager.installCss(scene); LOGGER.debug("Handle TextEditor key bindings"); - scene.addEventFilter(KeyEvent.KEY_PRESSED, event -> TextInputKeyBindings.call( - scene, - event, - preferences.getKeyBindingRepository())); + scene.addEventFilter(KeyEvent.KEY_PRESSED, event -> { + TextInputKeyBindings.call(scene, event, preferences.getKeyBindingRepository()); + SelectableTextFlowKeyBindings.call(scene, event, preferences.getKeyBindingRepository()); + }); mainStage.setTitle(JabRefFrame.FRAME_TITLE); mainStage.getIcons().addAll(IconTheme.getLogoSetFX()); diff --git a/jabgui/src/main/java/org/jabref/gui/ai/components/aichat/chatmessage/ChatMessageComponent.java b/jabgui/src/main/java/org/jabref/gui/ai/components/aichat/chatmessage/ChatMessageComponent.java index 511c3d06628..f620938dea3 100644 --- a/jabgui/src/main/java/org/jabref/gui/ai/components/aichat/chatmessage/ChatMessageComponent.java +++ b/jabgui/src/main/java/org/jabref/gui/ai/components/aichat/chatmessage/ChatMessageComponent.java @@ -6,15 +6,18 @@ import javafx.beans.property.SimpleObjectProperty; import javafx.fxml.FXML; import javafx.geometry.NodeOrientation; +import javafx.geometry.Pos; import javafx.scene.control.Label; import javafx.scene.layout.HBox; +import javafx.scene.layout.Pane; +import javafx.scene.layout.Priority; import javafx.scene.layout.VBox; +import org.jabref.gui.util.MarkdownTextFlow; import org.jabref.logic.ai.util.ErrorMessage; import org.jabref.logic.l10n.Localization; import com.airhacks.afterburner.views.ViewLoader; -import com.dlsc.gemsfx.ExpandingTextArea; import dev.langchain4j.data.message.AiMessage; import dev.langchain4j.data.message.ChatMessage; import dev.langchain4j.data.message.UserMessage; @@ -30,19 +33,26 @@ public class ChatMessageComponent extends HBox { @FXML private HBox wrapperHBox; @FXML private VBox vBox; @FXML private Label sourceLabel; - @FXML private ExpandingTextArea contentTextArea; + @FXML private Pane markdownContentPane; @FXML private VBox buttonsVBox; + private final MarkdownTextFlow markdownTextFlow; + public ChatMessageComponent() { ViewLoader.view(this) .root(this) .load(); - chatMessage.addListener((observable, oldValue, newValue) -> { + chatMessage.addListener((_, _, newValue) -> { if (newValue != null) { loadChatMessage(); } }); + + markdownTextFlow = new MarkdownTextFlow(markdownContentPane); + markdownContentPane.getChildren().add(markdownTextFlow); + markdownContentPane.minHeightProperty().bind(markdownTextFlow.heightProperty()); + markdownContentPane.prefHeightProperty().bind(markdownTextFlow.heightProperty()); } public ChatMessageComponent(ChatMessage chatMessage, Consumer onDeleteCallback) { @@ -68,32 +78,35 @@ private void loadChatMessage() { case UserMessage userMessage -> { setColor("-jr-ai-message-user", "-jr-ai-message-user-border"); setNodeOrientation(NodeOrientation.RIGHT_TO_LEFT); + wrapperHBox.setAlignment(Pos.TOP_RIGHT); sourceLabel.setText(Localization.lang("User")); - contentTextArea.setText(userMessage.singleText()); + markdownTextFlow.setMarkdown(userMessage.singleText()); } case AiMessage aiMessage -> { setColor("-jr-ai-message-ai", "-jr-ai-message-ai-border"); setNodeOrientation(NodeOrientation.LEFT_TO_RIGHT); + wrapperHBox.setAlignment(Pos.TOP_LEFT); sourceLabel.setText(Localization.lang("AI")); - contentTextArea.setText(aiMessage.text()); + markdownTextFlow.setMarkdown(aiMessage.text()); } case ErrorMessage errorMessage -> { setColor("-jr-ai-message-error", "-jr-ai-message-error-border"); setNodeOrientation(NodeOrientation.LEFT_TO_RIGHT); sourceLabel.setText(Localization.lang("Error")); - contentTextArea.setText(errorMessage.getText()); + markdownTextFlow.setMarkdown(errorMessage.getText()); } default -> - LOGGER.error("ChatMessageComponent supports only user, AI, or error messages, but other type was passed: {}", chatMessage.get().type().name()); + LOGGER.error("ChatMessageComponent supports only user, AI, or error messages, but other type was passed: {}", chatMessage.get().type().name()); } } @FXML private void initialize() { buttonsVBox.visibleProperty().bind(wrapperHBox.hoverProperty()); + HBox.setHgrow(this, Priority.ALWAYS); } @FXML diff --git a/jabgui/src/main/java/org/jabref/gui/keyboard/SelectableTextFlowKeyBindings.java b/jabgui/src/main/java/org/jabref/gui/keyboard/SelectableTextFlowKeyBindings.java new file mode 100644 index 00000000000..377b1f8f0c4 --- /dev/null +++ b/jabgui/src/main/java/org/jabref/gui/keyboard/SelectableTextFlowKeyBindings.java @@ -0,0 +1,25 @@ +package org.jabref.gui.keyboard; + +import javafx.scene.Scene; +import javafx.scene.input.KeyEvent; + +import org.jabref.gui.util.SelectableTextFlow; + +public class SelectableTextFlowKeyBindings { + public static void call(Scene scene, KeyEvent event, KeyBindingRepository keyBindingRepository) { + if (scene.focusOwnerProperty().get() instanceof SelectableTextFlow selectableTextFlow) { + keyBindingRepository.mapToKeyBinding(event).ifPresent(binding -> { + switch (binding) { + case COPY -> { + selectableTextFlow.copySelectedText(); + event.consume(); + } + case SELECT_ALL -> { + selectableTextFlow.selectAll(); + event.consume(); + } + } + }); + } + } +} diff --git a/jabgui/src/main/java/org/jabref/gui/util/MarkdownTextFlow.java b/jabgui/src/main/java/org/jabref/gui/util/MarkdownTextFlow.java new file mode 100644 index 00000000000..5426d76a0d1 --- /dev/null +++ b/jabgui/src/main/java/org/jabref/gui/util/MarkdownTextFlow.java @@ -0,0 +1,493 @@ +package org.jabref.gui.util; + +import java.util.ArrayDeque; +import java.util.Deque; +import java.util.StringJoiner; + +import javafx.geometry.NodeOrientation; +import javafx.scene.control.Hyperlink; +import javafx.scene.layout.Pane; +import javafx.scene.text.Text; + +import org.jabref.gui.ClipBoardManager; +import org.jabref.gui.DialogService; +import org.jabref.gui.edit.OpenBrowserAction; +import org.jabref.gui.preferences.GuiPreferences; + +import com.airhacks.afterburner.injection.Injector; +import com.vladsch.flexmark.ast.BlockQuote; +import com.vladsch.flexmark.ast.BulletList; +import com.vladsch.flexmark.ast.BulletListItem; +import com.vladsch.flexmark.ast.Code; +import com.vladsch.flexmark.ast.Emphasis; +import com.vladsch.flexmark.ast.FencedCodeBlock; +import com.vladsch.flexmark.ast.Heading; +import com.vladsch.flexmark.ast.HtmlBlock; +import com.vladsch.flexmark.ast.HtmlInline; +import com.vladsch.flexmark.ast.IndentedCodeBlock; +import com.vladsch.flexmark.ast.Link; +import com.vladsch.flexmark.ast.LinkRef; +import com.vladsch.flexmark.ast.ListBlock; +import com.vladsch.flexmark.ast.ListItem; +import com.vladsch.flexmark.ast.OrderedList; +import com.vladsch.flexmark.ast.OrderedListItem; +import com.vladsch.flexmark.ast.Paragraph; +import com.vladsch.flexmark.ast.StrongEmphasis; +import com.vladsch.flexmark.html.HtmlRenderer; +import com.vladsch.flexmark.parser.Parser; +import com.vladsch.flexmark.util.ast.DelimitedNode; +import com.vladsch.flexmark.util.ast.Document; +import com.vladsch.flexmark.util.ast.Node; +import com.vladsch.flexmark.util.ast.NodeVisitor; +import com.vladsch.flexmark.util.ast.VisitHandler; +import com.vladsch.flexmark.util.data.MutableDataSet; +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; + +public class MarkdownTextFlow extends SelectableTextFlow { + private static final String BULLET_LIST_PATTERN = "^\\s*[-*•]\\s+$"; + private static final String NUMBERED_LIST_PATTERN = "^\\s*\\d+\\.\\s+$"; + private static final String UNICODE_BULLET = "\u2022"; + private static final String BLOCKQUOTE_MARKER = "> "; + + private final Parser parser; + private final HtmlRenderer htmlRenderer; + + public MarkdownTextFlow(Pane parent) { + super(parent); + this.setNodeOrientation(NodeOrientation.LEFT_TO_RIGHT); + this.getStyleClass().add("markdown-textflow"); + MutableDataSet options = new MutableDataSet(); + this.parser = Parser.builder(options).build(); + this.htmlRenderer = HtmlRenderer.builder(options).build(); + } + + public void setMarkdown(@NonNull String markdownText) { + super.clearSelection(); + getChildren().clear(); + + if (markdownText.trim().isEmpty()) { + return; + } + + MarkdownRenderer renderer = new MarkdownRenderer(); + renderer.render(parser.parse(markdownText)); + } + + private void addTextNode(@Nullable String content, Node astNode, String... styleClasses) { + if (content == null || content.isEmpty()) { + return; + } + + MarkdownAwareText textNode = new MarkdownAwareText(content, astNode); + if (styleClasses != null) { + for (String styleClass : styleClasses) { + if (styleClass != null && !styleClass.isEmpty()) { + textNode.getStyleClass().add(styleClass); + } + } + } + getChildren().add(textNode); + } + + private void addHyperlinkNode(String text, String url, Node astNode, String... styleClasses) { + if (text == null || text.isEmpty()) { + return; + } + + MarkdownAwareHyperlink hyperlink = new MarkdownAwareHyperlink(text, astNode); + hyperlink.setOnAction(_ -> new OpenBrowserAction( + url, + Injector.instantiateModelOrService(DialogService.class), + Injector.instantiateModelOrService(GuiPreferences.class) + .getExternalApplicationsPreferences()).execute() + ); + + if (styleClasses != null) { + for (String styleClass : styleClasses) { + if (styleClass != null && !styleClass.isEmpty()) { + hyperlink.getStyleClass().add(styleClass); + } + } + } + hyperlink.getStyleClass().add("markdown-link"); + getChildren().add(hyperlink); + } + + @Override + public void copySelectedText() { + if (!isSelectionActive()) { + return; + } + + int selStart = getSelectionStartIndex(); + int selEnd = getSelectionEndIndex(); + + StringJoiner result = new StringJoiner(""); + int currentPos = 0; + + for (javafx.scene.Node fxNode : getChildren()) { + String renderedText; + String markdownText; + Node astNode; + + if (fxNode instanceof MarkdownAwareText mat) { + renderedText = mat.getText(); + astNode = mat.astNode; + markdownText = getMarkdownRepresentation(astNode, renderedText); + } else if (fxNode instanceof MarkdownAwareHyperlink mah) { + renderedText = mah.getText(); + astNode = mah.astNode; + markdownText = getMarkdownRepresentation(astNode, renderedText); + } else { + continue; + } + + int segmentStart = currentPos; + int segmentEnd = currentPos + renderedText.length(); + + if (segmentEnd <= selStart || segmentStart >= selEnd) { + currentPos = segmentEnd; + continue; + } + + int overlapStart = Math.max(segmentStart, selStart); + int overlapEnd = Math.min(segmentEnd, selEnd); + + if (overlapStart < overlapEnd) { + int startInSegment = overlapStart - segmentStart; + int endInSegment = overlapEnd - segmentStart; + + if (startInSegment == 0 && endInSegment == renderedText.length()) { + result.add(markdownText); + } else { + String partialText = renderedText.substring(startInSegment, endInSegment); + if (astNode instanceof DelimitedNode delimitedNode) { + // e.g., select "llo" inside "*hello*" would return "*llo*" + partialText = delimitedNode.getOpeningMarker() + partialText + delimitedNode.getClosingMarker(); + } + result.add(partialText); + } + } + + currentPos = segmentEnd; + } + + if (result.length() == 0) { + return; + } + + ClipBoardManager clipBoardManager = Injector.instantiateModelOrService(ClipBoardManager.class); + clipBoardManager.setHtmlContent(htmlRenderer.render(parser.parse(result.toString())), result.toString()); + } + + private String getMarkdownRepresentation(Node astNode, String renderedText) { + if ("\n".equals(renderedText) || "\n\n".equals(renderedText)) { + return renderedText; + } else if (astNode instanceof Heading heading) { + return "#".repeat(heading.getLevel()) + " " + heading.getText().toString().trim(); + } else if (astNode instanceof Code) { + return "`" + astNode.getChildChars() + "`"; + } else if (astNode instanceof Emphasis) { + return "*" + astNode.getChildChars() + "*"; + } else if (astNode instanceof StrongEmphasis) { + return "**" + astNode.getChildChars() + "**"; + } else if (astNode instanceof Link link) { + String linkText = link.getText().toString(); + String url = link.getUrl().toString(); + String title = link.getTitle().toString(); + if (title.isEmpty()) { + return "[" + linkText + "](" + url + ")"; + } else { + return "[" + linkText + "](" + url + " \"" + title + "\")"; + } + } else if (astNode instanceof LinkRef linkRef) { + String linkText = linkRef.getText().toString(); + String reference = linkRef.getReference().toString(); + return "[" + linkText + "][" + reference + "]"; + } else if (astNode instanceof FencedCodeBlock fencedCodeBlock) { + String info = fencedCodeBlock.getInfo().toString(); + String openingFence = fencedCodeBlock.getOpeningFence().toString(); + String closingFence = fencedCodeBlock.getClosingFence().toString(); + // NOTE: Hack. Flexmark always add \n at beginning, \n\n at end. + String content = fencedCodeBlock.getContentChars().toString(); + return openingFence + info + content.substring(0, content.length() - 1) + closingFence; + } else if (astNode instanceof IndentedCodeBlock) { + return astNode.getChars().toString(); + } else if (astNode instanceof BlockQuote) { + return renderedText; + } else if (renderedText.matches(BULLET_LIST_PATTERN)) { + return renderedText.replace(UNICODE_BULLET, "-"); + } else if (renderedText.matches(NUMBERED_LIST_PATTERN)) { + return renderedText; + } else { + return renderedText; + } + } + + private class MarkdownRenderer { + private final NodeVisitor visitor; + private final Deque orderedListCounters = new ArrayDeque<>(); + private int listIndentationLevel = 0; + private boolean isFirstBlockElement = true; + private Node previousBlock = null; + + MarkdownRenderer() { + visitor = new NodeVisitor( + new VisitHandler<>(Document.class, this::visit), + new VisitHandler<>(Heading.class, this::visit), + new VisitHandler<>(Paragraph.class, this::visit), + new VisitHandler<>(com.vladsch.flexmark.ast.Text.class, this::visit), + new VisitHandler<>(Emphasis.class, this::visit), + new VisitHandler<>(StrongEmphasis.class, this::visit), + new VisitHandler<>(Code.class, this::visit), + new VisitHandler<>(Link.class, this::visit), + new VisitHandler<>(FencedCodeBlock.class, this::visit), + new VisitHandler<>(IndentedCodeBlock.class, this::visit), + new VisitHandler<>(BulletList.class, this::visit), + new VisitHandler<>(OrderedList.class, this::visit), + new VisitHandler<>(BulletListItem.class, this::visit), + new VisitHandler<>(OrderedListItem.class, this::visit), + new VisitHandler<>(BlockQuote.class, this::visit), + new VisitHandler<>(HtmlInline.class, this::visit), + new VisitHandler<>(HtmlBlock.class, this::visit) + ); + } + + void render(Node node) { + visitor.visit(node); + } + + private void visit(Document document) { + for (Node child : document.getChildren()) { + visitor.visit(child); + } + } + + private void visit(Heading heading) { + addNewlinesBetweenBlocks(heading); + String text = heading.getText().toString(); + addTextNode(text, heading, "markdown-h" + heading.getLevel()); + previousBlock = heading; + } + + private void visit(Paragraph paragraph) { + boolean isInListItem = isInsideListItem(paragraph); + + if (!isInListItem) { + addNewlinesBetweenBlocks(paragraph); + } else if (paragraph.getPrevious() instanceof Paragraph) { + addTextNode("\n", paragraph); + } + + for (Node child : paragraph.getChildren()) { + visitor.visit(child); + } + + if (!isInListItem) { + previousBlock = paragraph; + } + } + + private void visit(com.vladsch.flexmark.ast.Text text) { + addTextNode(text.getChars().toString(), text); + } + + private void visit(Emphasis emphasis) { + addTextNode(emphasis.getText().toString(), emphasis, "markdown-italic"); + } + + private void visit(StrongEmphasis strong) { + addTextNode(strong.getText().toString(), strong, "markdown-bold"); + } + + private void visit(Code code) { + addTextNode(code.getText().toString(), code, "markdown-code"); + } + + private void visit(Link link) { + String text = link.getText().toString(); + String url = link.getUrl().toString(); + addHyperlinkNode(text, url, link); + } + + private void visit(FencedCodeBlock codeBlock) { + addNewlinesBetweenBlocks(codeBlock); + String content = codeBlock.getContentChars().toString(); + /* + * NOTE: Flexmark always append \n at the beginning and \n\n at the end. + * For example, ```java + * public class HelloWorld { ... } + * ``` -> contains content `\npublic class HelloWorld { ... }\n\n` + * Therefore, we need to remove the first and last characters. + */ + String processedContent = content; + if (content.length() >= 3 && content.startsWith("\n") && content.endsWith("\n\n")) { + processedContent = content.substring(1, content.length() - 2); + } + addTextNode(processedContent, codeBlock, "markdown-code-block"); + previousBlock = codeBlock; + } + + private void visit(IndentedCodeBlock codeBlock) { + addNewlinesBetweenBlocks(codeBlock); + String content = codeBlock.getContentChars().toString(); + // NOTE: Similar to FencedCodeBlock, Flexmark always appends \n at the beginning and \n\n at the end. + String processedContent = content; + if (content.length() >= 3 && content.startsWith("\n") && content.endsWith("\n\n")) { + processedContent = content.substring(1, content.length() - 2); + } + addTextNode(processedContent, codeBlock, "markdown-code-block"); + previousBlock = codeBlock; + } + + private void visit(BulletList list) { + addNewlinesBetweenBlocks(list); + listIndentationLevel++; + + for (Node child : list.getChildren()) { + visitor.visit(child); + } + + listIndentationLevel--; + if (listIndentationLevel == 0) { + previousBlock = list; + } + } + + private void visit(OrderedList list) { + addNewlinesBetweenBlocks(list); + orderedListCounters.push(list.getStartNumber() - 1); + listIndentationLevel++; + + for (Node child : list.getChildren()) { + visitor.visit(child); + } + + listIndentationLevel--; + orderedListCounters.pop(); + if (listIndentationLevel == 0) { + previousBlock = list; + } + } + + private void visit(BulletListItem item) { + if (item.getPrevious() != null) { + addTextNode("\n", item); + } + + String indent = " ".repeat(Math.max(0, listIndentationLevel - 1)); + addTextNode(indent + "• ", item, "markdown-list-bullet"); + + for (Node child : item.getChildren()) { + visitor.visit(child); + } + } + + private void visit(OrderedListItem item) { + if (item.getPrevious() != null) { + addTextNode("\n", item); + } + + int number = orderedListCounters.pop() + 1; + orderedListCounters.push(number); + + String indent = " ".repeat(Math.max(0, listIndentationLevel - 1)); + addTextNode(indent + number + ". ", item, "markdown-list-number"); + + for (Node child : item.getChildren()) { + visitor.visit(child); + } + } + + private void visit(BlockQuote quote) { + addNewlinesBetweenBlocks(quote); + + for (Node child : quote.getChildren()) { + if (child instanceof Paragraph) { + processQuoteParagraph(quote, child); + } else { + addTextNode(BLOCKQUOTE_MARKER, quote, "markdown-blockquote-marker"); + visitor.visit(child); + } + } + + previousBlock = quote; + } + + private void visit(HtmlInline html) { + addTextNode(html.getChars().toString(), html, "markdown-code"); + } + + private void visit(HtmlBlock html) { + addNewlinesBetweenBlocks(html); + addTextNode(html.getChars().toString(), html, "markdown-code-block"); + previousBlock = html; + } + + private void processQuoteParagraph(BlockQuote quote, Node child) { + String text = child.getChildChars().toString(); + String[] lines = text.split("\n", -1); + for (int i = 0; i < lines.length; i++) { + if (i > 0) { + addTextNode("\n", quote); + } + addTextNode(BLOCKQUOTE_MARKER, quote, "markdown-blockquote-marker"); + addTextNode(lines[i], child, "markdown-blockquote"); + } + } + + private boolean isInsideListItem(Node node) { + Node parent = node.getParent(); + while (parent != null) { + if (parent instanceof ListItem) { + return true; + } + parent = parent.getParent(); + } + return false; + } + + private void addNewlinesBetweenBlocks(Node currentBlock) { + if (isFirstBlockElement) { + isFirstBlockElement = false; + return; + } + + int newlineCount = 1; + + if (previousBlock instanceof Heading || currentBlock instanceof Heading) { + newlineCount = 2; + } else if (previousBlock instanceof Paragraph) { + newlineCount = 2; + } else if (currentBlock instanceof ListBlock && listIndentationLevel == 0) { + newlineCount = 2; + } else if (previousBlock instanceof FencedCodeBlock || previousBlock instanceof IndentedCodeBlock || + currentBlock instanceof FencedCodeBlock || currentBlock instanceof IndentedCodeBlock) { + newlineCount = 2; + } + + addTextNode("\n".repeat(newlineCount), currentBlock); + } + } + + private static class MarkdownAwareText extends Text { + private final Node astNode; + + public MarkdownAwareText(String text, Node astNode) { + super(text); + this.astNode = astNode; + setUserData(astNode); + } + } + + private static class MarkdownAwareHyperlink extends Hyperlink { + private final Node astNode; + + public MarkdownAwareHyperlink(String text, Node astNode) { + super(text); + this.astNode = astNode; + setUserData(astNode); + } + } +} diff --git a/jabgui/src/main/java/org/jabref/gui/util/SelectableTextFlow.java b/jabgui/src/main/java/org/jabref/gui/util/SelectableTextFlow.java new file mode 100644 index 00000000000..56554c6e1f4 --- /dev/null +++ b/jabgui/src/main/java/org/jabref/gui/util/SelectableTextFlow.java @@ -0,0 +1,176 @@ +package org.jabref.gui.util; + +import javafx.geometry.Point2D; +import javafx.scene.Cursor; +import javafx.scene.Node; +import javafx.scene.input.MouseEvent; +import javafx.scene.layout.Pane; +import javafx.scene.paint.Color; +import javafx.scene.shape.Path; +import javafx.scene.shape.PathElement; +import javafx.scene.text.HitInfo; +import javafx.scene.text.Text; +import javafx.scene.text.TextFlow; + +import org.jabref.gui.ClipBoardManager; + +import com.airhacks.afterburner.injection.Injector; +import org.jspecify.annotations.Nullable; + +public class SelectableTextFlow extends TextFlow { + @Nullable private HitInfo startHit; + @Nullable private HitInfo endHit; + @Nullable private Path selectionPath; + + private final Pane parentPane; + private boolean isDragging = false; + private boolean justFinishedDrag = false; + private final ClipBoardManager clipBoardManager; + + public SelectableTextFlow(Pane parent) { + this.parentPane = parent; + clipBoardManager = Injector.instantiateModelOrService(ClipBoardManager.class); + setCursor(Cursor.TEXT); + setFocusTraversable(true); + + addEventFilter(MouseEvent.MOUSE_PRESSED, this::onMousePressed); + addEventFilter(MouseEvent.MOUSE_DRAGGED, this::onMouseDragged); + addEventFilter(MouseEvent.MOUSE_RELEASED, this::onMouseReleased); + addEventFilter(MouseEvent.MOUSE_CLICKED, this::onMouseClicked); + + focusedProperty().addListener((_, _, newFocus) -> { + if (!newFocus) { + clearSelection(); + } + }); + } + + public void copySelectedText() { + if (!isSelectionActive()) { + return; + } + + int startIndex = getSelectionStartIndex(); + int endIndex = getSelectionEndIndex(); + + String fullText = getTextFlowContent(); + if (startIndex < 0 || endIndex > fullText.length() || startIndex >= endIndex) { + return; + } + + String selectedText = fullText.substring(startIndex, endIndex); + clipBoardManager.setContent(selectedText); + } + + public void selectAll() { + if (getChildren().isEmpty()) { + return; + } + startHit = hitTest(new Point2D(0, 0)); + endHit = hitTest(new Point2D(getLayoutBounds().getWidth(), getLayoutBounds().getHeight())); + updateSelectionHighlight(); + } + + public void clearSelection() { + startHit = null; + endHit = null; + removeHighlight(); + } + + public boolean isSelectionActive() { + return startHit != null && endHit != null && startHit.getCharIndex() != endHit.getCharIndex(); + } + + /// Returns the start index of the selection. Assumes that the selection is active. + public int getSelectionStartIndex() { + assert isSelectionActive(); + return Math.min(startHit.getCharIndex(), endHit.getCharIndex()); + } + + /// Returns the end index of the selection. Assumes that the selection is active. + public int getSelectionEndIndex() { + assert isSelectionActive(); + return Math.max(startHit.getCharIndex() + 1, endHit.getCharIndex() + 1); + } + + private String getTextFlowContent() { + StringBuilder sb = new StringBuilder(); + for (Node node : getChildren()) { + if (node instanceof Text text) { + sb.append(text.getText()); + } + } + return sb.toString(); + } + + private void updateSelectionHighlight() { + removeHighlight(); + + if (!isSelectionActive()) { + return; + } + + PathElement[] elements = rangeShape(getSelectionStartIndex(), getSelectionEndIndex()); + + Path path = new Path(); + path.getElements().addAll(elements); + path.setFill(Color.LIGHTBLUE.deriveColor(0, 1, 1, 0.5)); + path.setStroke(null); + path.setCursor(Cursor.TEXT); + path.setOnMouseClicked(_ -> removeHighlight()); + path.getTransforms().add(getLocalToParentTransform()); + path.setManaged(false); + + parentPane.getChildren().add(path); + selectionPath = path; + } + + private void onMousePressed(MouseEvent event) { + event.consume(); + requestFocus(); + + startHit = hitTest(new Point2D(event.getX(), event.getY())); + endHit = startHit; + isDragging = false; + justFinishedDrag = false; + + removeHighlight(); + } + + private void onMouseDragged(MouseEvent event) { + if (startHit == null) { + return; + } + event.consume(); + isDragging = true; + endHit = hitTest(new Point2D(event.getX(), event.getY())); + updateSelectionHighlight(); + } + + private void onMouseReleased(MouseEvent event) { + if (isDragging) { + justFinishedDrag = true; + isDragging = false; + } + } + + private void onMouseClicked(MouseEvent event) { + // NOTE: When drag event is finished, a mouse click event at the same position + // of the drag will be triggered. + if (justFinishedDrag) { + justFinishedDrag = false; + return; + } + + event.consume(); + removeHighlight(); + } + + private void removeHighlight() { + if (selectionPath == null) { + return; + } + parentPane.getChildren().remove(selectionPath); + selectionPath = null; + } +} diff --git a/jabgui/src/main/resources/org/jabref/gui/Base.css b/jabgui/src/main/resources/org/jabref/gui/Base.css index 640c743c0e8..7fda7500691 100644 --- a/jabgui/src/main/resources/org/jabref/gui/Base.css +++ b/jabgui/src/main/resources/org/jabref/gui/Base.css @@ -262,9 +262,9 @@ -jr-header-height: 3em; /* AI chat style */ - -jr-ai-message-user: -jr-accent; + -jr-ai-message-user: derive(-jr-accent, 50%); -jr-ai-message-user-border: -jr-theme; - -jr-ai-message-ai: -jr-accent; + -jr-ai-message-ai: derive(-jr-accent, 80%); -jr-ai-message-ai-border: -jr-theme; -jr-ai-message-error: -jr-error; -jr-ai-message-error-border: derive(-jr-error, -40%); @@ -2525,3 +2525,90 @@ journalInfo .grid-cell-b { -fx-font-size: 1.25em; -fx-border-color: transparent; } + +/* Markdown styles */ +.markdown-textflow { + -fx-line-spacing: 2; + -fx-text-alignment: left; +} + +.markdown-textflow > * { + -fx-fill: -fx-text-base-color; +} + +.markdown-h1 { + -fx-font-size: 2em; + -fx-font-weight: bold; +} + +.markdown-h2 { + -fx-font-size: 1.5em; + -fx-font-weight: bold; +} + +.markdown-h3 { + -fx-font-size: 1.25em; + -fx-font-weight: bold; +} + +.markdown-h4 { + -fx-font-size: 1.1em; + -fx-font-weight: bold; +} + +.markdown-h5 { + -fx-font-size: 1em; + -fx-font-weight: bold; +} + +.markdown-h6 { + -fx-font-size: 0.875em; + -fx-font-weight: bold; +} + +.markdown-bold { + -fx-font-weight: bold; +} + +.markdown-italic { + -fx-font-style: italic; +} + +.markdown-code { + -fx-font-family: monospace; + -fx-fill: -jr-theme; + -fx-effect: dropshadow(gaussian, rgba(0, 0, 0, 0.1), 1, 0, 0, 1); +} + +.markdown-code-block { + -fx-font-family: monospace; + -fx-fill: -fx-text-base-color; +} + +.markdown-list-bullet { + -fx-fill: -jr-theme; + -fx-font-weight: bold; +} + +.markdown-list-number { + -fx-fill: -jr-theme; + -fx-font-weight: bold; +} + +.markdown-blockquote-marker { + -fx-fill: transparent; +} + +.markdown-blockquote { + -fx-fill: -fx-mid-text-color; + -fx-font-style: italic; +} + +.markdown-link { + -fx-text-fill: -jr-theme; + -fx-underline: true; +} + +.markdown-link:hover { + -fx-text-fill: #004499; +} diff --git a/jabgui/src/main/resources/org/jabref/gui/Dark.css b/jabgui/src/main/resources/org/jabref/gui/Dark.css index 4db861f4280..14b8e084421 100644 --- a/jabgui/src/main/resources/org/jabref/gui/Dark.css +++ b/jabgui/src/main/resources/org/jabref/gui/Dark.css @@ -62,6 +62,9 @@ -js-summary-text-color: derive(-fx-light-text-color, 70%); -js-summary-text-color-selected: derive( -fx-dark-text-color, 70%); + -jr-ai-message-user: derive(-jr-theme, -50%); + -jr-ai-message-ai: derive(-jr-theme, -75%); + -jr-match-1-odd: -jr-row-odd-background; -jr-match-1-even: -jr-row-even-background; -jr-match-1-text-color: -fx-mid-text-color; diff --git a/jabgui/src/main/resources/org/jabref/gui/ai/components/aichat/chatmessage/ChatMessageComponent.fxml b/jabgui/src/main/resources/org/jabref/gui/ai/components/aichat/chatmessage/ChatMessageComponent.fxml index cf56ad0d56b..481f2c8d90b 100644 --- a/jabgui/src/main/resources/org/jabref/gui/ai/components/aichat/chatmessage/ChatMessageComponent.fxml +++ b/jabgui/src/main/resources/org/jabref/gui/ai/components/aichat/chatmessage/ChatMessageComponent.fxml @@ -5,12 +5,11 @@ - - - + @@ -23,7 +22,7 @@ - +