From 4457f8ed8a1e04882e6cbb724f66d1a49a44d06c Mon Sep 17 00:00:00 2001 From: David Gerber Date: Sat, 18 Jan 2025 00:01:52 +0100 Subject: [PATCH] Add cut & paste support (#260) Add cut & paste for chat view --- .../chat}/ChatListCell.java | 9 +- .../chat/ChatListDragSelection.java | 369 ++++++++++++++++++ .../controller/chat/ChatListSelectRange.java | 89 +++++ .../ui/controller/chat/ChatListView.java | 48 ++- .../controller/chat/ChatViewController.java | 13 +- .../messaging/MessagingWindowController.java | 10 +- .../ui/support/clipboard/ClipboardUtils.java | 18 +- .../xeres/ui/support/contentline/Content.java | 4 +- .../ui/support/contentline/ContentEmoji.java | 5 +- .../ui/support/markdown/EmojiDetector.java | 4 +- .../xeres/ui/support/util/TextFlowUtils.java | 204 ++++++++++ 11 files changed, 750 insertions(+), 23 deletions(-) rename ui/src/main/java/io/xeres/ui/{custom => controller/chat}/ChatListCell.java (88%) create mode 100644 ui/src/main/java/io/xeres/ui/controller/chat/ChatListDragSelection.java create mode 100644 ui/src/main/java/io/xeres/ui/controller/chat/ChatListSelectRange.java create mode 100644 ui/src/main/java/io/xeres/ui/support/util/TextFlowUtils.java diff --git a/ui/src/main/java/io/xeres/ui/custom/ChatListCell.java b/ui/src/main/java/io/xeres/ui/controller/chat/ChatListCell.java similarity index 88% rename from ui/src/main/java/io/xeres/ui/custom/ChatListCell.java rename to ui/src/main/java/io/xeres/ui/controller/chat/ChatListCell.java index ed0c0e482..a72af6d9d 100644 --- a/ui/src/main/java/io/xeres/ui/custom/ChatListCell.java +++ b/ui/src/main/java/io/xeres/ui/controller/chat/ChatListCell.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2019-2023 by David Gerber - https://zapek.com + * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * @@ -17,13 +17,14 @@ * along with Xeres. If not, see . */ -package io.xeres.ui.custom; +package io.xeres.ui.controller.chat; import io.xeres.ui.support.chat.ChatLine; import io.xeres.ui.support.chat.ColorGenerator; import io.xeres.ui.support.contentline.Content; import javafx.css.PseudoClass; import javafx.scene.control.Label; +import javafx.scene.shape.Path; import javafx.scene.text.TextFlow; import org.fxmisc.flowless.Cell; @@ -31,7 +32,7 @@ import static io.xeres.ui.support.util.DateUtils.TIME_DISPLAY; -public class ChatListCell implements Cell +class ChatListCell implements Cell { private static final PseudoClass passivePseudoClass = PseudoClass.getPseudoClass("passive"); @@ -67,7 +68,7 @@ public TextFlow getNode() @Override public boolean isReusable() { - return !isRich; + return !isRich && !(content.getChildren().getLast() instanceof Path); // Do not reuse rich content AND selected content } @Override 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 new file mode 100644 index 000000000..02794e4b2 --- /dev/null +++ b/ui/src/main/java/io/xeres/ui/controller/chat/ChatListDragSelection.java @@ -0,0 +1,369 @@ +/* + * 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.controller.chat; + +import io.micrometer.common.util.StringUtils; +import io.xeres.ui.support.chat.ChatLine; +import io.xeres.ui.support.clipboard.ClipboardUtils; +import io.xeres.ui.support.util.TextFlowUtils; +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; +import org.fxmisc.flowless.VirtualFlowHit; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.LinkedList; +import java.util.List; +import java.util.stream.Collectors; + +class ChatListDragSelection +{ + private static final Logger log = LoggerFactory.getLogger(ChatListDragSelection.class); + + private final Node focusNode; + + private enum SelectionMode + { + TEXT, + ACTION_AND_TEXT, + TIME_ACTION_AND_TEXT + } + + private enum Direction + { + SAME, + DOWN, + UP + } + + private HitInfo firstHitInfo; + private int startCellIndex; + private int lastCellIndex; + + private SelectionMode selectionMode; + + private ChatListSelectRange selectRange; + + private Direction direction = Direction.SAME; + + private final List textFlows = new LinkedList<>(); + + public ChatListDragSelection(Node focusNode) + { + this.focusNode = focusNode; + } + + public void press(MouseEvent e) + { + if (e.getEventType() != MouseEvent.MOUSE_PRESSED) + { + throw new IllegalArgumentException("Event must be a MOUSE_PRESSED event"); + } + + clearSelection(); + + var virtualFlow = getVirtualFlow(e); + virtualFlow.setCursor(Cursor.TEXT); + var hitResult = virtualFlow.hit(e.getX(), e.getY()); + if (hitResult.isCellHit()) + { + var textFlow = hitResult.getCell().getNode(); + startCellIndex = hitResult.getCellIndex(); + textFlows.add(textFlow); + + var hitInfo = textFlow.hitTest(hitResult.getCellOffset()); + firstHitInfo = hitInfo; + + switch (hitInfo.getCharIndex()) + { + case 0 -> selectionMode = SelectionMode.TIME_ACTION_AND_TEXT; + case 1 -> selectionMode = SelectionMode.ACTION_AND_TEXT; + default -> selectionMode = SelectionMode.TEXT; + } + } + } + + public void drag(MouseEvent e) + { + if (e.getEventType() != MouseEvent.MOUSE_DRAGGED) + { + throw new IllegalArgumentException("Event must be a MOUSE_DRAGGED event"); + } + + var virtualFlow = getVirtualFlow(e); + var hitResult = virtualFlow.hit(e.getX(), e.getY()); + if (hitResult.isCellHit()) + { + var cellIndex = hitResult.getCellIndex(); + if (cellIndex < virtualFlow.getFirstVisibleIndex() || cellIndex > virtualFlow.getLastVisibleIndex()) + { + return; + } + + // XXX: this is not currently working (remove the above to have this be reachable) + if (cellIndex <= virtualFlow.getFirstVisibleIndex()) + { + virtualFlow.showAsFirst(cellIndex); + } + else if (cellIndex >= virtualFlow.getLastVisibleIndex()) + { + virtualFlow.showAsLast(cellIndex); + } + + if (!handleMultilineSelect(virtualFlow, hitResult)) + { + handleSingleLineSelect(hitResult); + } + } + } + + private boolean handleMultilineSelect(VirtualFlow virtualFlow, VirtualFlowHit hitResult) + { + var cellIndex = hitResult.getCellIndex(); + var textFlow = hitResult.getCell().getNode(); + + if (cellIndex != startCellIndex) + { + if (direction == Direction.SAME) + { + // We're switching to multiline mode. + var pathElements = textFlows.getFirst().rangeShape(getOffsetFromSelectionMode(), TextFlowUtils.getTextFlowCount(textFlows.getFirst())); + showVisibleSelection(textFlows.getFirst(), pathElements); + + direction = cellIndex > startCellIndex ? Direction.DOWN : Direction.UP; + markSelection(virtualFlow, startCellIndex, cellIndex); + } + else + { + markSelection(virtualFlow, lastCellIndex, cellIndex); + } + lastCellIndex = cellIndex; + return true; + } + else + { + if (direction != Direction.SAME) + { + // We're coming back to single line mode. + clearSelection(); + direction = Direction.SAME; + textFlows.add(textFlow); + } + return false; + } + } + + private void markSelection(VirtualFlow virtualFlow, int fromCell, int toCell) + { + switch (direction) + { + case UP -> + { + if (fromCell > toCell) // Going up (mark more) + { + for (int i = fromCell; i >= toCell; i--) + { + addVisibleSelection(virtualFlow.getCell(i).getNode()); + } + } + else if (fromCell < toCell) // Going down (unwind) + { + for (int i = fromCell; i < toCell; i++) + { + removeVisibleSelection(virtualFlow.getCell(i).getNode()); + } + } + } + case DOWN -> + { + if (fromCell < toCell) // Going down (mark more) + { + for (int i = fromCell; i <= toCell; i++) + { + addVisibleSelection(virtualFlow.getCell(i).getNode()); + } + } + else if (fromCell > toCell) // Going up (unwind) + { + for (int i = fromCell; i > toCell; i--) + { + removeVisibleSelection(virtualFlow.getCell(i).getNode()); + } + } + } + case null, default -> throw new IllegalArgumentException("Wrong direction: " + direction); + } + } + + private int getOffsetFromSelectionMode() + { + return switch (selectionMode) + { + case TIME_ACTION_AND_TEXT -> 0; + case ACTION_AND_TEXT -> 1; + case TEXT -> 2; + }; + } + + private void handleSingleLineSelect(VirtualFlowHit hitResult) + { + var textFlow = hitResult.getCell().getNode(); + + selectRange = new ChatListSelectRange(firstHitInfo, textFlow.hitTest(hitResult.getCellOffset())); + + if (selectRange.isSelected()) + { + var pathElements = textFlow.rangeShape(selectRange.getStart(), selectRange.getEnd() + 1); + showVisibleSelection(textFlow, pathElements); + } + else + { + hideVisibleSelection(textFlow); + } + } + + private void addVisibleSelection(TextFlow textFlow) + { + showVisibleSelection(textFlow, textFlow.rangeShape(getOffsetFromSelectionMode(), TextFlowUtils.getTextFlowCount(textFlow))); + 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); + textFlows.remove(textFlow); + } + + + public void release(MouseEvent e) + { + if (e.getEventType() != MouseEvent.MOUSE_RELEASED) + { + throw new IllegalArgumentException("Event must be a MOUSE_RELEASED event"); + } + + var virtualFlow = getVirtualFlow(e); + virtualFlow.setCursor(Cursor.DEFAULT); + + if (selectRange == null || !selectRange.isSelected()) + { + clearSelection(); + selectRange = null; + } + + if (focusNode != null) + { + focusNode.requestFocus(); + } + } + + public void copy() + { + var text = getSelectionAsText(); + if (StringUtils.isNotBlank(text)) + { + ClipboardUtils.copyTextToClipboard(text); + } + } + + public boolean isSelected() + { + return !textFlows.isEmpty(); + } + + private void clearSelection() + { + while (!textFlows.isEmpty()) + { + var textFlow = textFlows.getLast(); + removeVisibleSelection(textFlow); + } + } + + private String getSelectionAsText() + { + if (textFlows.isEmpty()) + { + return ""; + } + + if (textFlows.size() == 1) + { + // Single line selection + var textFlow = textFlows.getFirst(); + + assert textFlow.getChildren().size() >= 3; + + return TextFlowUtils.getTextFlowAsText(textFlow, selectRange.getStart(), selectRange.getEnd() + 1); + } + else + { + if (direction == Direction.UP) + { + return textFlows.reversed().stream() + .map(textFlow -> TextFlowUtils.getTextFlowAsText(textFlow, getOffsetFromSelectionMode())) + .collect(Collectors.joining("\n")); + + } + else + { + return textFlows.stream() + .map(textFlow -> TextFlowUtils.getTextFlowAsText(textFlow, getOffsetFromSelectionMode())) + .collect(Collectors.joining("\n")); + } + } + } + + private VirtualFlow getVirtualFlow(MouseEvent e) + { + //noinspection unchecked + return (VirtualFlow) e.getSource(); + } +} diff --git a/ui/src/main/java/io/xeres/ui/controller/chat/ChatListSelectRange.java b/ui/src/main/java/io/xeres/ui/controller/chat/ChatListSelectRange.java new file mode 100644 index 000000000..c9ef9316f --- /dev/null +++ b/ui/src/main/java/io/xeres/ui/controller/chat/ChatListSelectRange.java @@ -0,0 +1,89 @@ +/* + * 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.controller.chat; + +import javafx.scene.text.HitInfo; + +class ChatListSelectRange +{ + private final int start; + private final int end; + + private final boolean isSelected; + + public ChatListSelectRange(HitInfo firstHit, HitInfo secondHit) + { + var compare = compare(firstHit, secondHit); + + if (compare < 0) // left to right + { + start = firstHit.getCharIndex(); + end = secondHit.getCharIndex(); + isSelected = true; + } + else if (compare > 0) // right to left + { + start = secondHit.getCharIndex(); + end = firstHit.getCharIndex(); + isSelected = true; + } + else + { + start = 0; + end = 0; + isSelected = false; + } + } + + public int getStart() + { + return start; + } + + public int getEnd() + { + return end; + } + + public boolean isSelected() + { + return isSelected; + } + + private static int compare(HitInfo firstHit, HitInfo secondHit) + { + if (firstHit.getCharIndex() == secondHit.getCharIndex()) + { + if (firstHit.isLeading() == secondHit.isLeading()) + { + return 0; + } + else if (firstHit.isLeading()) + { + return -1; + } + else + { + return 1; + } + } + return firstHit.getCharIndex() - secondHit.getCharIndex(); + } +} diff --git a/ui/src/main/java/io/xeres/ui/controller/chat/ChatListView.java b/ui/src/main/java/io/xeres/ui/controller/chat/ChatListView.java index 154b84efa..7065e884f 100644 --- a/ui/src/main/java/io/xeres/ui/controller/chat/ChatListView.java +++ b/ui/src/main/java/io/xeres/ui/controller/chat/ChatListView.java @@ -26,7 +26,6 @@ import io.xeres.common.message.chat.ChatRoomTimeoutEvent; import io.xeres.common.message.chat.ChatRoomUserEvent; import io.xeres.ui.client.GeneralClient; -import io.xeres.ui.custom.ChatListCell; import io.xeres.ui.custom.asyncimage.ImageCache; import io.xeres.ui.support.chat.ChatAction; import io.xeres.ui.support.chat.ChatLine; @@ -45,6 +44,7 @@ import javafx.scene.control.ListView; import javafx.scene.control.MenuItem; import javafx.scene.image.Image; +import javafx.scene.input.MouseEvent; import javafx.scene.layout.AnchorPane; import javafx.scene.layout.Priority; import javafx.scene.layout.VBox; @@ -53,6 +53,8 @@ import org.jsoup.Jsoup; import org.kordamp.ikonli.javafx.FontIcon; import org.kordamp.ikonli.materialdesign2.MaterialDesignA; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import java.time.Instant; import java.time.temporal.ChronoUnit; @@ -64,6 +66,8 @@ public class ChatListView implements NicknameCompleter.UsernameFinder { + private static final Logger log = LoggerFactory.getLogger(ChatListView.class); + private static final int SCROLL_BACK_MAX_LINES = 1000; private static final int SCROLL_BACK_CLEANUP_THRESHOLD = 100; @@ -76,13 +80,16 @@ public class ChatListView implements NicknameCompleter.UsernameFinder private String nickname; private final long id; + private final AnchorPane anchorPane; private final VirtualizedScrollPane> chatView; private final ListView userListView; private final MarkdownService markdownService; private final UriAction uriAction; private final GeneralClient generalClient; private final ImageCache imageCache; - private final ResourceBundle bundle = I18nUtils.getBundle(); + private final ResourceBundle bundle; + + private final ChatListDragSelection dragSelection; public enum AddUserOrigin { @@ -90,7 +97,7 @@ public enum AddUserOrigin KEEP_ALIVE } - public ChatListView(String nickname, long id, MarkdownService markdownService, UriAction uriAction, GeneralClient generalClient, ImageCache imageCache) + public ChatListView(String nickname, long id, MarkdownService markdownService, UriAction uriAction, GeneralClient generalClient, ImageCache imageCache, Node focusNode) { this.nickname = nickname; this.id = id; @@ -98,16 +105,25 @@ public ChatListView(String nickname, long id, MarkdownService markdownService, U this.uriAction = uriAction; this.generalClient = generalClient; this.imageCache = imageCache; + bundle = I18nUtils.getBundle(); + anchorPane = new AnchorPane(); + + dragSelection = new ChatListDragSelection(focusNode); + + chatView = createChatView(dragSelection); + addToAnchorPane(chatView, anchorPane); - chatView = createChatView(); userListView = createUserListView(); } - private VirtualizedScrollPane> createChatView() + private VirtualizedScrollPane> createChatView(ChatListDragSelection selection) { final var view = VirtualFlow.createVertical(messages, ChatListCell::new, VirtualFlow.Gravity.REAR); view.setFocusTraversable(false); view.getStyleClass().add("chat-list"); + view.addEventFilter(MouseEvent.MOUSE_PRESSED, selection::press); + view.addEventFilter(MouseEvent.MOUSE_DRAGGED, selection::drag); + view.addEventFilter(MouseEvent.MOUSE_RELEASED, selection::release); return new VirtualizedScrollPane<>(view); } @@ -125,6 +141,16 @@ private ListView createUserListView() return view; } + public boolean copy() + { + if (dragSelection.isSelected()) + { + dragSelection.copy(); + return true; + } + return false; + } + public void addOwnMessage(ChatMessage chatMessage) { addOwnMessage(Instant.now(), chatMessage.getContent()); @@ -291,18 +317,22 @@ public void setNickname(String nickname) } public Node getChatView() + { + return anchorPane; + } + + private static void addToAnchorPane(Node chatView, AnchorPane anchorPane) { // We use an anchor to force the VirtualFlow to be bigger // than its default size of 100 x 100. It doesn't behave // well in a VBox only. - var anchor = new AnchorPane(chatView); - anchor.getStyleClass().add("chat-list-pane"); + anchorPane.getChildren().add(chatView); + anchorPane.getStyleClass().add("chat-list-pane"); AnchorPane.setTopAnchor(chatView, 0.0); AnchorPane.setLeftAnchor(chatView, 0.0); AnchorPane.setRightAnchor(chatView, 0.0); AnchorPane.setBottomAnchor(chatView, 0.0); - VBox.setVgrow(anchor, Priority.ALWAYS); - return anchor; + VBox.setVgrow(anchorPane, Priority.ALWAYS); } public Node getUserListView() diff --git a/ui/src/main/java/io/xeres/ui/controller/chat/ChatViewController.java b/ui/src/main/java/io/xeres/ui/controller/chat/ChatViewController.java index bb5b26b97..852a9807c 100644 --- a/ui/src/main/java/io/xeres/ui/controller/chat/ChatViewController.java +++ b/ui/src/main/java/io/xeres/ui/controller/chat/ChatViewController.java @@ -94,6 +94,7 @@ public class ChatViewController implements Controller private static final int MESSAGE_MAXIMUM_SIZE = 31000; // XXX: put that on chat service too as we shouldn't forward them. also this is only for chat rooms, not private chats private static final KeyCodeCombination TAB_KEY = new KeyCodeCombination(KeyCode.TAB); private static final KeyCodeCombination PASTE_KEY = new KeyCodeCombination(KeyCode.V, KeyCombination.SHORTCUT_DOWN); + private static final KeyCodeCombination COPY_KEY = new KeyCodeCombination(KeyCode.C, KeyCombination.SHORTCUT_DOWN); private static final KeyCodeCombination ENTER_KEY = new KeyCodeCombination(KeyCode.ENTER); private static final KeyCodeCombination BACKSPACE_KEY = new KeyCodeCombination(KeyCode.BACK_SPACE); private static final String SUBSCRIBED_MENU_ID = "subscribed"; @@ -636,7 +637,7 @@ private ChatListView getChatListViewOrCreate(TreeItem roomInfoTreeIt if (chatListView == null) { var chatRoomId = roomInfoTreeItem.getValue().getRoomInfo().getId(); - chatListView = new ChatListView(nickname, chatRoomId, markdownService, uriService, generalClient, imageCache); + chatListView = new ChatListView(nickname, chatRoomId, markdownService, uriService, generalClient, imageCache, send); var finalChatListView = chatListView; chatClient.getChatRoomBacklog(chatRoomId).collectList() .doOnSuccess(backlogs -> Platform.runLater(() -> fillBacklog(finalChatListView, backlogs))) @@ -682,6 +683,16 @@ private void handleInputKeys(KeyEvent event) event.consume(); } } + else if (COPY_KEY.match(event)) + { + if (selectedChatListView != null) + { + if (selectedChatListView.copy()) + { + event.consume(); + } + } + } else if (ENTER_KEY.match(event) && imagePreview.getImage() != null) { sendImage(); diff --git a/ui/src/main/java/io/xeres/ui/controller/messaging/MessagingWindowController.java b/ui/src/main/java/io/xeres/ui/controller/messaging/MessagingWindowController.java index 56d64f838..cebe2d39d 100644 --- a/ui/src/main/java/io/xeres/ui/controller/messaging/MessagingWindowController.java +++ b/ui/src/main/java/io/xeres/ui/controller/messaging/MessagingWindowController.java @@ -96,6 +96,7 @@ public class MessagingWindowController implements WindowController private static final int MESSAGE_MAXIMUM_SIZE = 196_000; // XXX: maximum size for normal messages? check if correct private static final KeyCodeCombination PASTE_KEY = new KeyCodeCombination(KeyCode.V, KeyCombination.SHORTCUT_DOWN); + private static final KeyCodeCombination COPY_KEY = new KeyCodeCombination(KeyCode.C, KeyCombination.SHORTCUT_DOWN); private static final KeyCodeCombination CTRL_ENTER = new KeyCodeCombination(KeyCode.ENTER, KeyCombination.CONTROL_DOWN); private static final KeyCodeCombination SHIFT_ENTER = new KeyCodeCombination(KeyCode.ENTER, KeyCombination.SHIFT_DOWN); @@ -225,7 +226,7 @@ private void sendTypingNotificationIfNeeded() private void setupChatListView(String nickname, long id) { - receive = new ChatListView(nickname, id, markdownService, this::handleUriAction, generalClient, imageCache); + receive = new ChatListView(nickname, id, markdownService, this::handleUriAction, generalClient, imageCache, send); content.getChildren().add(1, receive.getChatView()); content.setOnDragOver(event -> { if (event.getDragboard().hasFiles()) @@ -395,6 +396,13 @@ private void handleInputKeys(KeyEvent event) event.consume(); } } + else if (COPY_KEY.match(event)) + { + if (receive.copy()) + { + event.consume(); + } + } else if (CTRL_ENTER.match(event) || SHIFT_ENTER.match(event) && isNotBlank(send.getText())) { send.insertText(send.getCaretPosition(), "\n"); diff --git a/ui/src/main/java/io/xeres/ui/support/clipboard/ClipboardUtils.java b/ui/src/main/java/io/xeres/ui/support/clipboard/ClipboardUtils.java index 8dc12dce2..41cb5d690 100644 --- a/ui/src/main/java/io/xeres/ui/support/clipboard/ClipboardUtils.java +++ b/ui/src/main/java/io/xeres/ui/support/clipboard/ClipboardUtils.java @@ -98,7 +98,14 @@ public static Image getImageFromClipboard() */ public static void copyImageToClipboard(Image image) { - Toolkit.getDefaultToolkit().getSystemClipboard().setContents(new ImageSelection(SwingFXUtils.fromFXImage(image, null)), null); + try + { + Toolkit.getDefaultToolkit().getSystemClipboard().setContents(new ImageSelection(SwingFXUtils.fromFXImage(image, null)), null); + } + catch (HeadlessException | IllegalStateException e) + { + log.warn("Clipboard not available: {}", e.getMessage()); + } } /** @@ -132,7 +139,14 @@ public static String getStringFromClipboard() */ public static void copyTextToClipboard(String text) { - Toolkit.getDefaultToolkit().getSystemClipboard().setContents(new StringSelection(text), null); + try + { + Toolkit.getDefaultToolkit().getSystemClipboard().setContents(new StringSelection(text), null); + } + catch (HeadlessException | IllegalStateException e) + { + log.warn("Clipboard not available: {}", e.getMessage()); + } } private static Transferable getTransferable() diff --git a/ui/src/main/java/io/xeres/ui/support/contentline/Content.java b/ui/src/main/java/io/xeres/ui/support/contentline/Content.java index 6ae5112bc..ed3e62120 100644 --- a/ui/src/main/java/io/xeres/ui/support/contentline/Content.java +++ b/ui/src/main/java/io/xeres/ui/support/contentline/Content.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2019-2023 by David Gerber - https://zapek.com + * Copyright (c) 2019-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * @@ -32,7 +32,7 @@ default boolean isComplete() default String asText() { - return null; + return ""; } /** diff --git a/ui/src/main/java/io/xeres/ui/support/contentline/ContentEmoji.java b/ui/src/main/java/io/xeres/ui/support/contentline/ContentEmoji.java index c9425dc79..faeeda50b 100644 --- a/ui/src/main/java/io/xeres/ui/support/contentline/ContentEmoji.java +++ b/ui/src/main/java/io/xeres/ui/support/contentline/ContentEmoji.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 by David Gerber - https://zapek.com + * Copyright (c) 2023-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * @@ -30,9 +30,10 @@ public class ContentEmoji implements Content private final ImageView node; - public ContentEmoji(Image image) + public ContentEmoji(Image image, String emoji) { node = new ImageView(image); + node.setUserData(emoji); // Used for cut & paste node.setFitWidth(Font.getDefault().getSize() * SCALE_FACTOR); node.setFitHeight(Font.getDefault().getSize() * SCALE_FACTOR); } diff --git a/ui/src/main/java/io/xeres/ui/support/markdown/EmojiDetector.java b/ui/src/main/java/io/xeres/ui/support/markdown/EmojiDetector.java index 1b3e38430..49a034314 100644 --- a/ui/src/main/java/io/xeres/ui/support/markdown/EmojiDetector.java +++ b/ui/src/main/java/io/xeres/ui/support/markdown/EmojiDetector.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 by David Gerber - https://zapek.com + * Copyright (c) 2023-2025 by David Gerber - https://zapek.com * * This file is part of Xeres. * @@ -55,6 +55,6 @@ public boolean isPossibly(String line) public void process(Context context, String line) { MarkdownService.processPattern(EMOJI_PATTERN, context, line, - (s, groupName) -> context.addContent(new ContentEmoji(context.getEmojiService().getEmoji(s)))); + (s, groupName) -> context.addContent(new ContentEmoji(context.getEmojiService().getEmoji(s), s))); } } 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 new file mode 100644 index 000000000..da9d2f898 --- /dev/null +++ b/ui/src/main/java/io/xeres/ui/support/util/TextFlowUtils.java @@ -0,0 +1,204 @@ +/* + * 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 javafx.scene.Node; +import javafx.scene.control.Hyperlink; +import javafx.scene.control.Label; +import javafx.scene.image.ImageView; +import javafx.scene.shape.Path; +import javafx.scene.text.Text; +import javafx.scene.text.TextFlow; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.List; +import java.util.Objects; + +public final class TextFlowUtils +{ + private static final Logger log = LoggerFactory.getLogger(TextFlowUtils.class); + + private TextFlowUtils() + { + throw new UnsupportedOperationException("Utility class"); + } + + /** + * Returns a text flow as a string. + * + * @param textFlow the text flow, not null + * @return the string, not null + */ + public static String getTextFlowAsText(TextFlow textFlow) + { + Objects.requireNonNull(textFlow); + return getTextFlowAsText(textFlow, 0, getTextFlowCount(textFlow)); + } + + public static String getTextFlowAsText(TextFlow textFlow, int beginIndex) + { + Objects.requireNonNull(textFlow); + return getTextFlowAsText(textFlow, beginIndex, getTextFlowCount(textFlow)); + } + + /** + * Returns a text flow as a string. + * + * @param textFlow the text flow, not null + * @param beginIndex the beginning index, inclusive + * @param endIndex the ending index, exclusive + * @return the string, not null + */ + public static String getTextFlowAsText(TextFlow textFlow, int beginIndex, int endIndex) + { + var context = new Context(textFlow.getChildrenUnmodifiable(), beginIndex, endIndex); + return context.getText(); + } + + public static int getTextFlowCount(TextFlow textFlow) + { + Objects.requireNonNull(textFlow); + var children = textFlow.getChildrenUnmodifiable(); + + var total = 0; + + for (var node : children) + { + total += switch (node) + { + case Label ignored -> 1; + case Text text -> text.getText().length(); + case Hyperlink ignored -> 1; + case ImageView ignored -> 1; + default -> 0; + }; + } + return total; + } + + private static class Context + { + private final List nodes; + private final int beginIndex; + private final int endIndex; + private int currentIndex; + + private int currentNode = -1; + + public Context(List nodes, int beginIndex, int endIndex) + { + this.nodes = nodes; + this.beginIndex = beginIndex; + this.endIndex = endIndex; + } + + private boolean hasNextNode() + { + return currentNode + 1 < nodes.size() && !(nodes.get(currentNode + 1) instanceof Path); + } + + private boolean needsSpace() + { + return currentNode < 2; + } + + private String processNextNode() + { + currentNode++; + var node = nodes.get(currentNode); + + var size = switch (node) + { + case Label ignored -> 1; + case Text text -> text.getText().length(); + case Hyperlink ignored -> 1; + case ImageView ignored -> 1; + case Path ignored -> 0; + default -> throw new IllegalStateException("Unhandled node: " + node); + }; + + if (currentIndex + size <= beginIndex) + { + currentIndex += size; + return ""; + } + if (currentIndex >= endIndex) + { + currentIndex += size; + return ""; + } + + switch (node) + { + case Label label -> + { + currentIndex += size; + return label.getText(); + } + case Hyperlink hyperlink -> + { + currentIndex += size; + return hyperlink.getText(); + } + case ImageView image -> + { + currentIndex += size; + var imageUserData = image.getUserData(); + return imageUserData != null ? (String) imageUserData : ""; + } + case Path ignored -> + { + return ""; + } + case Text text -> + { + var start = 0; + var end = text.getText().length(); + if (beginIndex >= currentIndex && beginIndex < currentIndex + size) + { + start = beginIndex - currentIndex; + } + if (endIndex > currentIndex && endIndex <= currentIndex + size) + { + end = endIndex - currentIndex; + } + currentIndex += text.getText().length(); // We don't use end because that way we'll break out of the next run + return text.getText().substring(start, end); + } + default -> throw new IllegalStateException("Unhandled node: " + node); + } + } + + public String getText() + { + var sb = new StringBuilder(); + while (hasNextNode()) + { + if (needsSpace() && !sb.isEmpty()) + { + sb.append(" "); + } + sb.append(processNextNode()); + } + return sb.toString(); + } + } +}