Skip to content

Commit

Permalink
Add selection support for forum content
Browse files Browse the repository at this point in the history
  • Loading branch information
zapek committed Jan 18, 2025
1 parent dd38060 commit ddfbf04
Show file tree
Hide file tree
Showing 5 changed files with 184 additions and 42 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;

Expand All @@ -66,7 +65,7 @@ private enum Direction

private SelectionMode selectionMode;

private ChatListSelectRange selectRange;
private TextSelectRange textSelectRange;

private Direction direction = Direction.SAME;

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -188,7 +187,7 @@ private boolean handleMultilineSelect(VirtualFlow<ChatLine, ChatListCell> 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);
Expand Down Expand Up @@ -269,51 +268,31 @@ private void handleSingleLineSelect(VirtualFlowHit<ChatListCell> 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);
}

Expand All @@ -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
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -161,6 +165,8 @@ public class ForumViewController implements Controller
private final TreeItem<ForumGroup> popularForums;
private final TreeItem<ForumGroup> 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)
Expand Down Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
@@ -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 <http://www.gnu.org/licenses/>.
*/

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);
}
}
}
49 changes: 45 additions & 4 deletions ui/src/main/java/io/xeres/ui/support/util/TextFlowUtils.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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();
}

Expand All @@ -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)
Expand All @@ -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<Node> nodes;
private final int beginIndex;
private final int endIndex;
private final int prefixNeedingSpace;
private int currentIndex;

private int currentNode = -1;

public Context(List<Node> nodes, int beginIndex, int endIndex)
public Context(List<Node> nodes, int beginIndex, int endIndex, int prefixNeedingSpace)
{
this.nodes = nodes;
this.beginIndex = beginIndex;
this.endIndex = endIndex;
this.prefixNeedingSpace = prefixNeedingSpace;
}

public String getText()
Expand All @@ -138,7 +179,7 @@ private boolean hasNextNode()

private boolean needsSpace()
{
return currentNode < 2;
return currentNode < prefixNeedingSpace;
}

private String processNextNode()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,18 +17,18 @@
* along with Xeres. If not, see <http://www.gnu.org/licenses/>.
*/

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);

Expand Down

0 comments on commit ddfbf04

Please sign in to comment.