From ddfbf04d1a71754fa35d873490e107ac48957837 Mon Sep 17 00:00:00 2001 From: David Gerber Date: Sat, 18 Jan 2025 12:08:47 +0100 Subject: [PATCH] Add selection support for forum content --- .../chat/ChatListDragSelection.java | 49 +++------ .../controller/forum/ForumViewController.java | 23 +++++ .../support/util/TextFlowDragSelection.java | 99 +++++++++++++++++++ .../xeres/ui/support/util/TextFlowUtils.java | 49 ++++++++- .../util/TextSelectRange.java} | 6 +- 5 files changed, 184 insertions(+), 42 deletions(-) create mode 100644 ui/src/main/java/io/xeres/ui/support/util/TextFlowDragSelection.java rename ui/src/main/java/io/xeres/ui/{controller/chat/ChatListSelectRange.java => support/util/TextSelectRange.java} (93%) diff --git a/ui/src/main/java/io/xeres/ui/controller/chat/ChatListDragSelection.java b/ui/src/main/java/io/xeres/ui/controller/chat/ChatListDragSelection.java index 38c4ec55..1690f579 100644 --- a/ui/src/main/java/io/xeres/ui/controller/chat/ChatListDragSelection.java +++ b/ui/src/main/java/io/xeres/ui/controller/chat/ChatListDragSelection.java @@ -23,12 +23,10 @@ import io.xeres.ui.support.chat.ChatLine; import io.xeres.ui.support.clipboard.ClipboardUtils; import io.xeres.ui.support.util.TextFlowUtils; +import io.xeres.ui.support.util.TextSelectRange; import javafx.scene.Cursor; import javafx.scene.Node; import javafx.scene.input.MouseEvent; -import javafx.scene.paint.Color; -import javafx.scene.shape.Path; -import javafx.scene.shape.PathElement; import javafx.scene.text.HitInfo; import javafx.scene.text.TextFlow; import org.fxmisc.flowless.VirtualFlow; @@ -43,6 +41,7 @@ class ChatListDragSelection { private static final Logger log = LoggerFactory.getLogger(ChatListDragSelection.class); + public static final double VIEW_MARGIN = 8.0; private final Node focusNode; @@ -66,7 +65,7 @@ private enum Direction private SelectionMode selectionMode; - private ChatListSelectRange selectRange; + private TextSelectRange textSelectRange; private Direction direction = Direction.SAME; @@ -151,10 +150,10 @@ public void release(MouseEvent e) var virtualFlow = getVirtualFlow(e); virtualFlow.setCursor(Cursor.DEFAULT); - if (selectRange == null || !selectRange.isSelected()) + if (textSelectRange == null || !textSelectRange.isSelected()) { clearSelection(); - selectRange = null; + textSelectRange = null; } if (focusNode != null) @@ -188,7 +187,7 @@ private boolean handleMultilineSelect(VirtualFlow virtua { // We're switching to multiline mode. var pathElements = textFlows.getFirst().rangeShape(getOffsetFromSelectionMode(), TextFlowUtils.getTextFlowCount(textFlows.getFirst())); - showVisibleSelection(textFlows.getFirst(), pathElements); + TextFlowUtils.showSelection(textFlows.getFirst(), pathElements, VIEW_MARGIN); direction = cellIndex > startCellIndex ? Direction.DOWN : Direction.UP; markSelection(virtualFlow, startCellIndex, cellIndex); @@ -269,51 +268,31 @@ private void handleSingleLineSelect(VirtualFlowHit hitResult) { var textFlow = hitResult.getCell().getNode(); - selectRange = new ChatListSelectRange(firstHitInfo, textFlow.hitTest(hitResult.getCellOffset())); + textSelectRange = new TextSelectRange(firstHitInfo, textFlow.hitTest(hitResult.getCellOffset())); - if (selectRange.isSelected()) + if (textSelectRange.isSelected()) { - var pathElements = textFlow.rangeShape(selectRange.getStart(), selectRange.getEnd() + 1); - showVisibleSelection(textFlow, pathElements); + var pathElements = textFlow.rangeShape(textSelectRange.getStart(), textSelectRange.getEnd() + 1); + TextFlowUtils.showSelection(textFlow, pathElements, VIEW_MARGIN); } else { - hideVisibleSelection(textFlow); + TextFlowUtils.hideSelection(textFlow); } } private void addVisibleSelection(TextFlow textFlow) { - showVisibleSelection(textFlow, textFlow.rangeShape(getOffsetFromSelectionMode(), TextFlowUtils.getTextFlowCount(textFlow))); + TextFlowUtils.showSelection(textFlow, textFlow.rangeShape(getOffsetFromSelectionMode(), TextFlowUtils.getTextFlowCount(textFlow)), VIEW_MARGIN); if (textFlows.getLast() != textFlow) { textFlows.add(textFlow); } } - private static void showVisibleSelection(TextFlow textFlow, PathElement[] pathElements) - { - var path = new Path(pathElements); - path.setStroke(Color.TRANSPARENT); - path.setFill(Color.DODGERBLUE); - path.setOpacity(0.3); - path.setManaged(false); // This is needed so they show up above - path.setTranslateX(8.0); // Margin - hideVisibleSelection(textFlow); - textFlow.getChildren().add(path); - } - - private static void hideVisibleSelection(TextFlow textFlow) - { - if (textFlow.getChildren().getLast() instanceof Path) - { - textFlow.getChildren().removeLast(); - } - } - private void removeVisibleSelection(TextFlow textFlow) { - hideVisibleSelection(textFlow); + TextFlowUtils.hideSelection(textFlow); textFlows.remove(textFlow); } @@ -340,7 +319,7 @@ private String getSelectionAsText() assert textFlow.getChildren().size() >= 3; - return TextFlowUtils.getTextFlowAsText(textFlow, selectRange.getStart(), selectRange.getEnd() + 1); + return TextFlowUtils.getTextFlowAsText(textFlow, textSelectRange.getStart(), textSelectRange.getEnd() + 1, 2); } else { diff --git a/ui/src/main/java/io/xeres/ui/controller/forum/ForumViewController.java b/ui/src/main/java/io/xeres/ui/controller/forum/ForumViewController.java index 8ce3ea03..3d2d602f 100644 --- a/ui/src/main/java/io/xeres/ui/controller/forum/ForumViewController.java +++ b/ui/src/main/java/io/xeres/ui/controller/forum/ForumViewController.java @@ -44,6 +44,7 @@ import io.xeres.ui.support.uri.ForumUri; import io.xeres.ui.support.uri.IdentityUri; import io.xeres.ui.support.uri.UriService; +import io.xeres.ui.support.util.TextFlowDragSelection; import io.xeres.ui.support.util.UiUtils; import io.xeres.ui.support.window.WindowManager; import javafx.application.Platform; @@ -52,6 +53,7 @@ import javafx.fxml.FXML; import javafx.scene.control.*; import javafx.scene.control.cell.TreeItemPropertyValueFactory; +import javafx.scene.input.*; import javafx.scene.layout.GridPane; import javafx.scene.text.TextFlow; import net.rgielen.fxweaver.core.FxmlView; @@ -81,6 +83,8 @@ public class ForumViewController implements Controller { private static final Logger log = LoggerFactory.getLogger(ForumViewController.class); + private static final KeyCodeCombination COPY_KEY = new KeyCodeCombination(KeyCode.C, KeyCombination.SHORTCUT_DOWN); + private static final String SUBSCRIBE_MENU_ID = "subscribe"; private static final String UNSUBSCRIBE_MENU_ID = "unsubscribe"; private static final String COPY_LINK_MENU_ID = "copyLink"; @@ -161,6 +165,8 @@ public class ForumViewController implements Controller private final TreeItem popularForums; private final TreeItem otherForums; + private TextFlowDragSelection dragSelection; + private MessageId messageIdToSelect; public ForumViewController(ForumClient forumClient, ResourceBundle bundle, NotificationClient notificationClient, WindowManager windowManager, ObjectMapper objectMapper, MarkdownService markdownService, UriService uriService, GeneralClient generalClient, ImageCache imageCacheService) @@ -235,6 +241,23 @@ public void initialize() getForumGroups(); setupTrees(); + + dragSelection = new TextFlowDragSelection(messageContent); + setupDragSelection(dragSelection); + } + + private void setupDragSelection(TextFlowDragSelection selection) + { + messageContent.addEventFilter(MouseEvent.MOUSE_PRESSED, selection::press); + messageContent.addEventFilter(MouseEvent.MOUSE_DRAGGED, selection::drag); + messageContent.addEventFilter(MouseEvent.MOUSE_RELEASED, selection::release); + messagePane.addEventFilter(KeyEvent.KEY_PRESSED, event -> { + if (COPY_KEY.match(event)) + { + selection.copy(); + event.consume(); + } + }); } private void setupTrees() diff --git a/ui/src/main/java/io/xeres/ui/support/util/TextFlowDragSelection.java b/ui/src/main/java/io/xeres/ui/support/util/TextFlowDragSelection.java new file mode 100644 index 00000000..42a012ba --- /dev/null +++ b/ui/src/main/java/io/xeres/ui/support/util/TextFlowDragSelection.java @@ -0,0 +1,99 @@ +/* + * Copyright (c) 2025 by David Gerber - https://zapek.com + * + * This file is part of Xeres. + * + * Xeres is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Xeres is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Xeres. If not, see . + */ + +package io.xeres.ui.support.util; + +import io.micrometer.common.util.StringUtils; +import io.xeres.ui.support.clipboard.ClipboardUtils; +import javafx.geometry.Point2D; +import javafx.scene.Cursor; +import javafx.scene.input.MouseEvent; +import javafx.scene.text.HitInfo; +import javafx.scene.text.TextFlow; + +public class TextFlowDragSelection +{ + private final TextFlow textFlow; + + private HitInfo firstHitInfo; + + private TextSelectRange textSelectRange; + + public TextFlowDragSelection(TextFlow textFlow) + { + this.textFlow = textFlow; + } + + public void press(MouseEvent e) + { + if (e.getEventType() != MouseEvent.MOUSE_PRESSED) + { + throw new IllegalArgumentException("Event must be a MOUSE_PRESSED event"); + } + + TextFlowUtils.hideSelection(textFlow); + textFlow.setCursor(Cursor.TEXT); + + firstHitInfo = textFlow.hitTest(new Point2D(e.getX(), e.getY())); + } + + public void drag(MouseEvent e) + { + if (e.getEventType() != MouseEvent.MOUSE_DRAGGED) + { + throw new IllegalArgumentException("Event must be a MOUSE_DRAGGED event"); + } + + textSelectRange = new TextSelectRange(firstHitInfo, textFlow.hitTest(new Point2D(e.getX(), e.getY()))); + if (textSelectRange.isSelected()) + { + var pathElements = textFlow.rangeShape(textSelectRange.getStart(), textSelectRange.getEnd() + 1); + TextFlowUtils.showSelection(textFlow, pathElements, 0.0); + } + else + { + TextFlowUtils.hideSelection(textFlow); + } + } + + public void release(MouseEvent e) + { + if (e.getEventType() != MouseEvent.MOUSE_RELEASED) + { + throw new IllegalArgumentException("Event must be a MOUSE_RELEASED event"); + } + + textFlow.setCursor(Cursor.DEFAULT); + + if (textSelectRange == null || !textSelectRange.isSelected()) + { + TextFlowUtils.hideSelection(textFlow); + textSelectRange = null; + } + } + + public void copy() + { + var text = TextFlowUtils.getTextFlowAsText(textFlow, textSelectRange.getStart(), textSelectRange.getEnd() + 1); + if (StringUtils.isNotBlank(text)) + { + ClipboardUtils.copyTextToClipboard(text); + } + } +} diff --git a/ui/src/main/java/io/xeres/ui/support/util/TextFlowUtils.java b/ui/src/main/java/io/xeres/ui/support/util/TextFlowUtils.java index 7be09866..16893965 100644 --- a/ui/src/main/java/io/xeres/ui/support/util/TextFlowUtils.java +++ b/ui/src/main/java/io/xeres/ui/support/util/TextFlowUtils.java @@ -23,7 +23,9 @@ import javafx.scene.control.Hyperlink; import javafx.scene.control.Label; import javafx.scene.image.ImageView; +import javafx.scene.paint.Color; import javafx.scene.shape.Path; +import javafx.scene.shape.PathElement; import javafx.scene.text.Text; import javafx.scene.text.TextFlow; @@ -60,7 +62,13 @@ public static String getTextFlowAsText(TextFlow textFlow, int beginIndex) */ public static String getTextFlowAsText(TextFlow textFlow, int beginIndex, int endIndex) { - var context = new Context(textFlow.getChildrenUnmodifiable(), beginIndex, endIndex); + var context = new Context(textFlow.getChildrenUnmodifiable(), beginIndex, endIndex, 0); + return context.getText(); + } + + public static String getTextFlowAsText(TextFlow textFlow, int beginIndex, int endIndex, int prefixNeedingSpace) + { + var context = new Context(textFlow.getChildrenUnmodifiable(), beginIndex, endIndex, prefixNeedingSpace); return context.getText(); } @@ -85,6 +93,37 @@ public static int getTextFlowCount(TextFlow textFlow) return total; } + /** + * Shows the selected text visually. + * + * @param textFlow the text flow + * @param pathElements the path elements, retrieved with {@link TextFlow#rangeShape(int, int)}. + */ + public static void showSelection(TextFlow textFlow, PathElement[] pathElements, double margin) + { + var path = new Path(pathElements); + path.setStroke(Color.TRANSPARENT); + path.setFill(Color.DODGERBLUE); + path.setOpacity(0.3); + path.setManaged(false); // This is needed so they show up above + path.setTranslateX(margin); + hideSelection(textFlow); + textFlow.getChildren().add(path); + } + + /** + * Visually hides all the selected text. + * + * @param textFlow the text flow + */ + public static void hideSelection(TextFlow textFlow) + { + if (textFlow.getChildren().getLast() instanceof Path) + { + textFlow.getChildren().removeLast(); + } + } + private static int getTotalSize(Node node) { return switch (node) @@ -99,22 +138,24 @@ private static int getTotalSize(Node node) } /** - * Little helper class to keep track of the context. + * Little helper class to keep track of the context when walking the flow. */ private static class Context { private final List nodes; private final int beginIndex; private final int endIndex; + private final int prefixNeedingSpace; private int currentIndex; private int currentNode = -1; - public Context(List nodes, int beginIndex, int endIndex) + public Context(List nodes, int beginIndex, int endIndex, int prefixNeedingSpace) { this.nodes = nodes; this.beginIndex = beginIndex; this.endIndex = endIndex; + this.prefixNeedingSpace = prefixNeedingSpace; } public String getText() @@ -138,7 +179,7 @@ private boolean hasNextNode() private boolean needsSpace() { - return currentNode < 2; + return currentNode < prefixNeedingSpace; } private String processNextNode() diff --git a/ui/src/main/java/io/xeres/ui/controller/chat/ChatListSelectRange.java b/ui/src/main/java/io/xeres/ui/support/util/TextSelectRange.java similarity index 93% rename from ui/src/main/java/io/xeres/ui/controller/chat/ChatListSelectRange.java rename to ui/src/main/java/io/xeres/ui/support/util/TextSelectRange.java index c9ef9316..f4fb0b3f 100644 --- a/ui/src/main/java/io/xeres/ui/controller/chat/ChatListSelectRange.java +++ b/ui/src/main/java/io/xeres/ui/support/util/TextSelectRange.java @@ -17,18 +17,18 @@ * along with Xeres. If not, see . */ -package io.xeres.ui.controller.chat; +package io.xeres.ui.support.util; import javafx.scene.text.HitInfo; -class ChatListSelectRange +public class TextSelectRange { private final int start; private final int end; private final boolean isSelected; - public ChatListSelectRange(HitInfo firstHit, HitInfo secondHit) + public TextSelectRange(HitInfo firstHit, HitInfo secondHit) { var compare = compare(firstHit, secondHit);