diff --git a/CHANGELOG.md b/CHANGELOG.md index edd8aae36d7..b3803a5ec24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ Note that this project **does not** adhere to [Semantic Versioning](http://semve - We added "Attach file from URL" to right-click context menu to download and store a file with the reference library. [#9646](https://github.com/JabRef/jabref/issues/9646) - We enabled updating an existing entry with data from InspireHEP. [#9351](https://github.com/JabRef/jabref/issues/9351) - We added a fetcher for the Bibliotheksverbund Bayern (experimental). [#9641](https://github.com/JabRef/jabref/pull/9641) +- We enabled scrolling in the groups list when dragging a group on another group. [#2869](https://github.com/JabRef/jabref/pull/2869) diff --git a/src/main/java/org/jabref/gui/actions/StandardActions.java b/src/main/java/org/jabref/gui/actions/StandardActions.java index c7bf3ecb80d..90641540e48 100644 --- a/src/main/java/org/jabref/gui/actions/StandardActions.java +++ b/src/main/java/org/jabref/gui/actions/StandardActions.java @@ -177,7 +177,17 @@ public enum StandardActions implements Action { EDIT_LIST(Localization.lang("Edit"), IconTheme.JabRefIcons.EDIT), VIEW_LIST(Localization.lang("View"), IconTheme.JabRefIcons.FILE), REMOVE_LIST(Localization.lang("Remove"), IconTheme.JabRefIcons.REMOVE), - RELOAD_LIST(Localization.lang("Reload"), IconTheme.JabRefIcons.REFRESH); + RELOAD_LIST(Localization.lang("Reload"), IconTheme.JabRefIcons.REFRESH), + + GROUP_REMOVE(Localization.lang("Remove group")), + GROUP_REMOVE_KEEP_SUBGROUPS(Localization.lang("Keep subgroups")), + GROUP_REMOVE_WITH_SUBGROUPS(Localization.lang("Also remove subgroups")), + GROUP_EDIT(Localization.lang("Edit group")), + GROUP_SUBGROUP_ADD(Localization.lang("Add subgroup")), + GROUP_SUBGROUP_REMOVE(Localization.lang("Remove subgroups")), + GROUP_SUBGROUP_SORT(Localization.lang("Sort subgroups A-Z")), + GROUP_ENTRIES_ADD(Localization.lang("Add selected entries to this group")), + GROUP_ENTRIES_REMOVE(Localization.lang("Remove selected entries from this group")); private final String text; private final String description; diff --git a/src/main/java/org/jabref/gui/groups/GroupTreeView.java b/src/main/java/org/jabref/gui/groups/GroupTreeView.java index 9cc3ce11d27..9da46651442 100644 --- a/src/main/java/org/jabref/gui/groups/GroupTreeView.java +++ b/src/main/java/org/jabref/gui/groups/GroupTreeView.java @@ -15,11 +15,14 @@ import javafx.beans.binding.Bindings; import javafx.beans.property.ObjectProperty; import javafx.css.PseudoClass; +import javafx.geometry.Orientation; +import javafx.scene.Node; import javafx.scene.control.Button; import javafx.scene.control.ContextMenu; import javafx.scene.control.Control; import javafx.scene.control.Menu; import javafx.scene.control.MenuItem; +import javafx.scene.control.ScrollBar; import javafx.scene.control.SelectionMode; import javafx.scene.control.SeparatorMenuItem; import javafx.scene.control.TextField; @@ -29,6 +32,7 @@ import javafx.scene.control.TreeTableRow; import javafx.scene.control.TreeTableView; import javafx.scene.input.ClipboardContent; +import javafx.scene.input.DragEvent; import javafx.scene.input.Dragboard; import javafx.scene.input.MouseEvent; import javafx.scene.input.TransferMode; @@ -40,13 +44,18 @@ import org.jabref.gui.DialogService; import org.jabref.gui.DragAndDropDataFormats; +import org.jabref.gui.Globals; import org.jabref.gui.StateManager; +import org.jabref.gui.actions.ActionFactory; +import org.jabref.gui.actions.SimpleCommand; +import org.jabref.gui.actions.StandardActions; import org.jabref.gui.util.BindingsHelper; import org.jabref.gui.util.ControlHelper; import org.jabref.gui.util.CustomLocalDragboard; import org.jabref.gui.util.RecursiveTreeItem; import org.jabref.gui.util.TaskExecutor; import org.jabref.gui.util.ViewModelTreeTableCellFactory; +import org.jabref.gui.util.ViewModelTreeTableRowFactory; import org.jabref.logic.l10n.Localization; import org.jabref.model.entry.BibEntry; import org.jabref.model.groups.AllEntriesGroup; @@ -64,12 +73,18 @@ public class GroupTreeView extends BorderPane { private static final Logger LOGGER = LoggerFactory.getLogger(GroupTreeView.class); + private static final PseudoClass PSEUDOCLASS_ANYSELECTED = PseudoClass.getPseudoClass("any-selected"); + private static final PseudoClass PSEUDOCLASS_ALLSELECTED = PseudoClass.getPseudoClass("all-selected"); + private static final PseudoClass PSEUDOCLASS_ROOTELEMENT = PseudoClass.getPseudoClass("root"); + private static final PseudoClass PSEUDOCLASS_SUBELEMENT = PseudoClass.getPseudoClass("sub"); // > 1 deep + + private static final double SCROLL_SPEED = 50; + private TreeTableView groupTree; private TreeTableColumn mainColumn; private TreeTableColumn numberColumn; private TreeTableColumn expansionNodeColumn; private CustomTextField searchField; - private Button addNewGroup; private final StateManager stateManager; private final DialogService dialogService; @@ -81,11 +96,11 @@ public class GroupTreeView extends BorderPane { private DragExpansionHandler dragExpansionHandler; + private Timer scrollTimer; + private double scrollVelocity = 0; + /** - * The groups panel - * - * Note: This panel is deliberately not created in FXML, since parsing of this took about 500 msecs. In an attempt - * to speed up the startup time of JabRef, this has been rewritten to plain java. + * Note: This panel is deliberately not created in fxml, since parsing equivalent fxml takes about 500 msecs */ public GroupTreeView(TaskExecutor taskExecutor, StateManager stateManager, @@ -135,7 +150,7 @@ private void createNodes() { mainColumn.prefWidthProperty().bind(groupTree.widthProperty().subtract(80d).subtract(15d)); - addNewGroup = new Button(Localization.lang("Add group")); + Button addNewGroup = new Button(Localization.lang("Add group")); addNewGroup.setId("addNewGroup"); addNewGroup.setMaxWidth(Double.MAX_VALUE); HBox.setHgrow(addNewGroup, Priority.ALWAYS); @@ -164,7 +179,7 @@ private void initialize() { this::updateSelection )); - // We try to to prevent publishing changes in the search field directly to the search task that takes some time + // We try to prevent publishing changes in the search field directly to the search task that takes some time // for larger group structures. final Timer searchTask = FxTimer.create(Duration.ofMillis(400), () -> { LOGGER.debug("Run group search " + searchField.getText()); @@ -194,181 +209,179 @@ private void initialize() { .install(mainColumn); // Number of hits (only if user wants to see them) - PseudoClass anySelected = PseudoClass.getPseudoClass("any-selected"); - PseudoClass allSelected = PseudoClass.getPseudoClass("all-selected"); new ViewModelTreeTableCellFactory() - .withGraphic(group -> { - final StackPane node = new StackPane(); - node.getStyleClass().setAll("hits"); - if (!group.isRoot()) { - BindingsHelper.includePseudoClassWhen(node, anySelected, - group.anySelectedEntriesMatchedProperty()); - BindingsHelper.includePseudoClassWhen(node, allSelected, - group.allSelectedEntriesMatchedProperty()); - } - Text text = new Text(); - EasyBind.subscribe(preferencesService.getGroupsPreferences().displayGroupCountProperty(), - (newValue) -> { - if (text.textProperty().isBound()) { - text.textProperty().unbind(); - text.setText(""); - } - - if (newValue) { - text.textProperty().bind(group.getHits().map(Number::intValue).map(this::getFormattedNumber)); - } - }); - text.getStyleClass().setAll("text"); - - text.styleProperty().bind(Bindings.createStringBinding(() -> { - double reducedFontSize; - double font_size = preferencesService.getAppearancePreferences().getMainFontSize(); - // For each breaking point, the font size is reduced 0.20 em to fix issue 8797 - if (font_size > 26.0) { - reducedFontSize = 0.25; - } else if (font_size > 22.0) { - reducedFontSize = 0.35; - } else if (font_size > 18.0) { - reducedFontSize = 0.55; - } else { - reducedFontSize = 0.75; - } - return String.format("-fx-font-size: %fem;", reducedFontSize); - }, preferencesService.getAppearancePreferences().mainFontSizeProperty())); - - node.getChildren().add(text); - node.setMaxWidth(Control.USE_PREF_SIZE); - return node; - }) + .withGraphic(this::createNumberCell) .install(numberColumn); // Arrow indicating expanded status new ViewModelTreeTableCellFactory() - .withGraphic(viewModel -> { - final StackPane disclosureNode = new StackPane(); - disclosureNode.visibleProperty().bind(viewModel.hasChildrenProperty()); - disclosureNode.getStyleClass().setAll("tree-disclosure-node"); - - final StackPane disclosureNodeArrow = new StackPane(); - disclosureNodeArrow.getStyleClass().setAll("arrow"); - disclosureNode.getChildren().add(disclosureNodeArrow); - return disclosureNode; - }) + .withGraphic(this::getArrowCell) .withOnMouseClickedEvent(group -> event -> { group.toggleExpansion(); event.consume(); }) .install(expansionNodeColumn); - // Set pseudo-classes to indicate if row is root or sub-item ( > 1 deep) - PseudoClass rootPseudoClass = PseudoClass.getPseudoClass("root"); - PseudoClass subElementPseudoClass = PseudoClass.getPseudoClass("sub"); - - groupTree.setRowFactory(treeTable -> { - TreeTableRow row = new TreeTableRow<>(); - row.treeItemProperty().addListener((ov, oldTreeItem, newTreeItem) -> { - boolean isRoot = newTreeItem == treeTable.getRoot(); - row.pseudoClassStateChanged(rootPseudoClass, isRoot); - - boolean isFirstLevel = (newTreeItem != null) && (newTreeItem.getParent() == treeTable.getRoot()); - row.pseudoClassStateChanged(subElementPseudoClass, !isRoot && !isFirstLevel); - }); - // Remove disclosure node since we display custom version in separate column - // Simply setting to null is not enough since it would be replaced by the default node on every change - row.setDisclosureNode(null); - row.disclosureNodeProperty().addListener((observable, oldValue, newValue) -> row.setDisclosureNode(null)); - - // Add context menu (only for non-null items) - row.contextMenuProperty().bind( - EasyBind.wrapNullable(row.itemProperty()) - .mapOpt(this::createContextMenuForGroup) - .orElseOpt((ContextMenu) null)); - row.addEventFilter(MouseEvent.MOUSE_PRESSED, event -> { - if (event.getTarget() instanceof StackPane pane) { - if (pane.getStyleClass().contains("arrow") || pane.getStyleClass().contains("tree-disclosure-node")) { - event.consume(); - } - } - }); - - // Drag and drop support - row.setOnDragDetected(event -> { - List groupsToMove = new ArrayList<>(); - for (TreeItem selectedItem : treeTable.getSelectionModel().getSelectedItems()) { - if ((selectedItem != null) && (selectedItem.getValue() != null)) { - groupsToMove.add(selectedItem.getValue().getPath()); + new ViewModelTreeTableRowFactory() + .withContextMenu(this::createContextMenuForGroup) + .withOnMousePressedEvent((row, event) -> { + if (event.getTarget() instanceof StackPane pane) { + if (pane.getStyleClass().contains("arrow") || pane.getStyleClass().contains("tree-disclosure-node")) { + event.consume(); + } } - } + }) + .withCustomInitializer(row -> { + // Remove disclosure node since we display custom version in separate column + // Simply setting to null is not enough since it would be replaced by the default node on every change + row.setDisclosureNode(null); + row.disclosureNodeProperty().addListener((observable, oldValue, newValue) -> row.setDisclosureNode(null)); + }) + .setOnDragDetected(this::handleOnDragDetected) + .setOnDragDropped(this::handleOnDragDropped) + .setOnDragExited(this::handleOnDragExited) + .setOnDragOver(this::handleOnDragOver) + .withPseudoClass(PSEUDOCLASS_ROOTELEMENT, row -> Bindings.createBooleanBinding( + () -> (row != null) && (row.getItem() == groupTree.getRoot().getValue()), row.treeItemProperty())) + .withPseudoClass(PSEUDOCLASS_SUBELEMENT, row -> Bindings.createBooleanBinding( + () -> (row != null) && (groupTree.getTreeItemLevel(row.getTreeItem()) > 1), row.treeItemProperty())) + .install(groupTree); + + setupDragScrolling(); - if (groupsToMove.size() > 0) { - localDragboard.clearAll(); - } + // Filter text field + setupClearButtonField(searchField); + } - // Put the group nodes as content - Dragboard dragboard = treeTable.startDragAndDrop(TransferMode.MOVE); - // Display the group when dragging - dragboard.setDragView(row.snapshot(null, null)); - ClipboardContent content = new ClipboardContent(); - content.put(DragAndDropDataFormats.GROUP, groupsToMove); - dragboard.setContent(content); - event.consume(); - }); - row.setOnDragOver(event -> { - Dragboard dragboard = event.getDragboard(); - if ((event.getGestureSource() != row) && (row.getItem() != null) && row.getItem().acceptableDrop(dragboard)) { - event.acceptTransferModes(TransferMode.MOVE, TransferMode.LINK); - - // expand node and all children on drag over - dragExpansionHandler.expandGroup(row.getTreeItem()); - - if (localDragboard.hasBibEntries()) { - ControlHelper.setDroppingPseudoClasses(row); - } else { - ControlHelper.setDroppingPseudoClasses(row, event); - } - } - event.consume(); - }); - row.setOnDragExited(event -> ControlHelper.removeDroppingPseudoClasses(row)); - - row.setOnDragDropped(event -> { - Dragboard dragboard = event.getDragboard(); - boolean success = false; - - if (dragboard.hasContent(DragAndDropDataFormats.GROUP) && viewModel.canAddGroupsIn(row.getItem())) { - List pathToSources = (List) dragboard.getContent(DragAndDropDataFormats.GROUP); - List changedGroups = new LinkedList<>(); - for (String pathToSource : pathToSources) { - Optional source = viewModel - .rootGroupProperty().get() - .getChildByPath(pathToSource); - if (source.isPresent() && viewModel.canBeDragged(source.get())) { - source.get().draggedOn(row.getItem(), ControlHelper.getDroppingMouseLocation(row, event)); - changedGroups.add(source.get()); - success = true; - } + private StackPane getArrowCell(GroupNodeViewModel viewModel) { + final StackPane disclosureNode = new StackPane(); + disclosureNode.visibleProperty().bind(viewModel.hasChildrenProperty()); + disclosureNode.getStyleClass().setAll("tree-disclosure-node"); + + final StackPane disclosureNodeArrow = new StackPane(); + disclosureNodeArrow.getStyleClass().setAll("arrow"); + disclosureNode.getChildren().add(disclosureNodeArrow); + return disclosureNode; + } + + private StackPane createNumberCell(GroupNodeViewModel group) { + final StackPane node = new StackPane(); + node.getStyleClass().setAll("hits"); + if (!group.isRoot()) { + BindingsHelper.includePseudoClassWhen(node, PSEUDOCLASS_ANYSELECTED, + group.anySelectedEntriesMatchedProperty()); + BindingsHelper.includePseudoClassWhen(node, PSEUDOCLASS_ALLSELECTED, + group.allSelectedEntriesMatchedProperty()); + } + Text text = new Text(); + EasyBind.subscribe(preferencesService.getGroupsPreferences().displayGroupCountProperty(), + (newValue) -> { + if (text.textProperty().isBound()) { + text.textProperty().unbind(); + text.setText(""); } - groupTree.getSelectionModel().clearSelection(); - changedGroups.forEach(value -> selectNode(value, true)); - if (success) { - viewModel.writeGroupChangesToMetaData(); + + if (newValue) { + text.textProperty().bind(group.getHits().map(Number::intValue).map(this::getFormattedNumber)); } - } + }); + text.getStyleClass().setAll("text"); + + text.styleProperty().bind(Bindings.createStringBinding(() -> { + double reducedFontSize; + double font_size = preferencesService.getAppearancePreferences().getMainFontSize(); + // For each breaking point, the font size is reduced 0.20 em to fix issue 8797 + if (font_size > 26.0) { + reducedFontSize = 0.25; + } else if (font_size > 22.0) { + reducedFontSize = 0.35; + } else if (font_size > 18.0) { + reducedFontSize = 0.55; + } else { + reducedFontSize = 0.75; + } + return String.format("-fx-font-size: %fem;", reducedFontSize); + }, preferencesService.getAppearancePreferences().mainFontSizeProperty())); + + node.getChildren().add(text); + node.setMaxWidth(Control.USE_PREF_SIZE); + return node; + } - if (localDragboard.hasBibEntries()) { - List entries = localDragboard.getBibEntries(); - row.getItem().addEntriesToGroup(entries); + private void handleOnDragExited(TreeTableRow row, GroupNodeViewModel fieldViewModel, DragEvent dragEvent) { + ControlHelper.removeDroppingPseudoClasses(row); + } + + private void handleOnDragDetected(TreeTableRow row, GroupNodeViewModel groupViewModel, MouseEvent event) { + List groupsToMove = new ArrayList<>(); + for (TreeItem selectedItem : row.getTreeTableView().getSelectionModel().getSelectedItems()) { + if ((selectedItem != null) && (selectedItem.getValue() != null)) { + groupsToMove.add(selectedItem.getValue().getPath()); + } + } + + if (groupsToMove.size() > 0) { + localDragboard.clearAll(); + } + + // Put the group nodes as content + Dragboard dragboard = row.startDragAndDrop(TransferMode.MOVE); + // Display the group when dragging + dragboard.setDragView(row.snapshot(null, null)); + ClipboardContent content = new ClipboardContent(); + content.put(DragAndDropDataFormats.GROUP, groupsToMove); + dragboard.setContent(content); + event.consume(); + } + + private void handleOnDragDropped(TreeTableRow row, GroupNodeViewModel originalItem, DragEvent event) { + Dragboard dragboard = event.getDragboard(); + boolean success = false; + + if (dragboard.hasContent(DragAndDropDataFormats.GROUP) && viewModel.canAddGroupsIn(row.getItem())) { + List pathToSources = (List) dragboard.getContent(DragAndDropDataFormats.GROUP); + List changedGroups = new LinkedList<>(); + for (String pathToSource : pathToSources) { + Optional source = viewModel + .rootGroupProperty().get() + .getChildByPath(pathToSource); + if (source.isPresent() && viewModel.canBeDragged(source.get())) { + source.get().draggedOn(row.getItem(), ControlHelper.getDroppingMouseLocation(row, event)); + changedGroups.add(source.get()); success = true; } - event.setDropCompleted(success); - event.consume(); - }); + } + groupTree.getSelectionModel().clearSelection(); + changedGroups.forEach(value -> selectNode(value, true)); + if (success) { + viewModel.writeGroupChangesToMetaData(); + } + } - return row; - }); + if (localDragboard.hasBibEntries()) { + List entries = localDragboard.getBibEntries(); + row.getItem().addEntriesToGroup(entries); + success = true; + } + event.setDropCompleted(success); + event.consume(); + } - // Filter text field - setupClearButtonField(searchField); + private void handleOnDragOver(TreeTableRow row, GroupNodeViewModel originalItem, DragEvent event) { + Dragboard dragboard = event.getDragboard(); + if ((event.getGestureSource() != row) && (row.getItem() != null) && row.getItem().acceptableDrop(dragboard)) { + event.acceptTransferModes(TransferMode.MOVE, TransferMode.LINK); + + // expand node and all children on drag over + dragExpansionHandler.expandGroup(row.getTreeItem()); + + if (localDragboard.hasBibEntries()) { + ControlHelper.setDroppingPseudoClasses(row); + } else { + ControlHelper.setDroppingPseudoClasses(row, event); + } + } + event.consume(); } private void updateSelection(List> newSelectedGroups) { @@ -419,97 +432,76 @@ private Optional> getTreeItemByValue(TreeItem viewModel.removeGroupNoSubgroups(group)); - - MenuItem editGroup = new MenuItem(Localization.lang("Edit group")); - editGroup.setOnAction(event -> { - menu.hide(); - viewModel.editGroup(group); - groupTree.refresh(); - }); - - MenuItem removeGroupKeepSubgroups = new MenuItem(Localization.lang("Keep subgroups")); - removeGroupKeepSubgroups.setOnAction(event -> viewModel.removeGroupKeepSubgroups(group)); - - MenuItem removeGroupAndSubgroups = new MenuItem(Localization.lang("Also remove subgroups")); - removeGroupAndSubgroups.setOnAction(event -> viewModel.removeGroupAndSubgroups(group)); - - MenuItem addSubgroup = new MenuItem(Localization.lang("Add subgroup")); - addSubgroup.setOnAction(event -> { - menu.hide(); - viewModel.addNewSubgroup(group, GroupDialogHeader.SUBGROUP); + // see http://programmingtipsandtraps.blogspot.com/2015/10/drag-and-drop-in-treetableview-with.html + private void setupDragScrolling() { + scrollTimer = FxTimer.createPeriodic(Duration.ofMillis(20), () -> + getVerticalScrollbar().ifPresent(scrollBar -> { + double newValue = scrollBar.getValue() + scrollVelocity; + newValue = Math.min(newValue, 1d); + newValue = Math.max(newValue, 0d); + scrollBar.setValue(newValue); + })); + + groupTree.setOnScroll((event) -> scrollTimer.stop()); + groupTree.setOnDragDone((event) -> scrollTimer.stop()); + groupTree.setOnDragEntered((event) -> scrollTimer.stop()); + groupTree.setOnDragDropped((event) -> scrollTimer.stop()); + groupTree.setOnDragExited((event) -> { + if (event.getY() > 0) { + scrollVelocity = 1.0 / SCROLL_SPEED; + } else { + scrollVelocity = -1.0 / SCROLL_SPEED; + } + scrollTimer.restart(); }); + } - MenuItem removeSubgroups = new MenuItem(Localization.lang("Remove subgroups")); - removeSubgroups.setOnAction(event -> viewModel.removeSubgroups(group)); - - MenuItem sortSubgroups = new MenuItem(Localization.lang("Sort subgroups A-Z")); - sortSubgroups.setOnAction(event -> viewModel.sortAlphabeticallyRecursive(group.getGroupNode())); - - MenuItem addEntries = new MenuItem(Localization.lang("Add selected entries to this group")); - addEntries.setOnAction(event -> viewModel.addSelectedEntries(group)); - - MenuItem removeEntries = new MenuItem(Localization.lang("Remove selected entries from this group")); - removeEntries.setOnAction(event -> viewModel.removeSelectedEntries(group)); - - menu.setOnShown(event -> { - menu.getItems().clear(); - if (viewModel.isEditable(group)) { - menu.getItems().add(editGroup); - if ((group.getChildren().size() > 0) && viewModel.canAddGroupsIn(group)) { - menu.getItems().add(removeGroupWithSubgroups); - menu.getItems().add(new SeparatorMenuItem()); - menu.getItems().add(addSubgroup); - menu.getItems().add(removeSubgroups); - menu.getItems().add(sortSubgroups); - } else { - menu.getItems().add(removeGroupNoSubgroups); - if (viewModel.canAddGroupsIn(group)) { - menu.getItems().add(new SeparatorMenuItem()); - menu.getItems().add(addSubgroup); - } - } - } - if (group.isRoot()) { - menu.getItems().add(addSubgroup); - menu.getItems().add(removeSubgroups); - menu.getItems().add(sortSubgroups); + private Optional getVerticalScrollbar() { + for (Node node : groupTree.lookupAll(".scroll-bar")) { + if (node instanceof ScrollBar scrollbar + && scrollbar.getOrientation().equals(Orientation.VERTICAL)) { + return Optional.of(scrollbar); } + } + return Optional.empty(); + } - if (viewModel.canAddEntriesIn(group)) { - menu.getItems().add(new SeparatorMenuItem()); - menu.getItems().add(addEntries); - menu.getItems().add(removeEntries); - } - }); + private ContextMenu createContextMenuForGroup(GroupNodeViewModel group) { + if (group == null) { + return null; + } - menu.getItems().add(new Menu()); - removeGroupWithSubgroups.getItems().add(removeGroupKeepSubgroups); - removeGroupWithSubgroups.getItems().add(removeGroupAndSubgroups); - - // TODO: Disable some actions under certain conditions - // if (group.canBeEdited()) { - // editGroupPopupAction.setEnabled(false); - // addGroupPopupAction.setEnabled(false); - // removeGroupAndSubgroupsPopupAction.setEnabled(false); - // removeGroupKeepSubgroupsPopupAction.setEnabled(false); - // } else { - // editGroupPopupAction.setEnabled(true); - // addGroupPopupAction.setEnabled(true); - // addGroupPopupAction.setNode(node); - // removeGroupAndSubgroupsPopupAction.setEnabled(true); - // removeGroupKeepSubgroupsPopupAction.setEnabled(true); - // } - // sortSubmenu.setEnabled(!node.isLeaf()); - // removeSubgroupsPopupAction.setEnabled(!node.isLeaf()); - - return menu; + ContextMenu contextMenu = new ContextMenu(); + ActionFactory factory = new ActionFactory(Globals.getKeyPrefs()); + + MenuItem removeGroup; + if (viewModel.hasSubgroups(group) && viewModel.canAddGroupsIn(group) && !group.isRoot()) { + removeGroup = new Menu(Localization.lang("Remove group"), null, + factory.createMenuItem(StandardActions.GROUP_REMOVE_KEEP_SUBGROUPS, + new GroupTreeView.ContextAction(StandardActions.GROUP_REMOVE_KEEP_SUBGROUPS, group)), + factory.createMenuItem(StandardActions.GROUP_REMOVE_WITH_SUBGROUPS, + new GroupTreeView.ContextAction(StandardActions.GROUP_REMOVE_WITH_SUBGROUPS, group)) + ); + } else { + removeGroup = factory.createMenuItem(StandardActions.GROUP_REMOVE, new GroupTreeView.ContextAction(StandardActions.GROUP_REMOVE, group)); + } + + contextMenu.getItems().addAll( + factory.createMenuItem(StandardActions.GROUP_EDIT, new ContextAction(StandardActions.GROUP_EDIT, group)), + removeGroup, + factory.createMenuItem(StandardActions.GROUP_EDIT, new ContextAction(StandardActions.GROUP_EDIT, group)), + new SeparatorMenuItem(), + factory.createMenuItem(StandardActions.GROUP_SUBGROUP_ADD, new ContextAction(StandardActions.GROUP_SUBGROUP_ADD, group)), + factory.createMenuItem(StandardActions.GROUP_SUBGROUP_REMOVE, new ContextAction(StandardActions.GROUP_SUBGROUP_REMOVE, group)), + factory.createMenuItem(StandardActions.GROUP_SUBGROUP_SORT, new ContextAction(StandardActions.GROUP_SUBGROUP_SORT, group)), + new SeparatorMenuItem(), + factory.createMenuItem(StandardActions.GROUP_ENTRIES_ADD, new ContextAction(StandardActions.GROUP_ENTRIES_ADD, group)), + factory.createMenuItem(StandardActions.GROUP_ENTRIES_REMOVE, new ContextAction(StandardActions.GROUP_ENTRIES_REMOVE, group)) + ); + + contextMenu.getItems().forEach(item -> item.setGraphic(null)); + contextMenu.getStyleClass().add("context-menu"); + return contextMenu; } private void addNewGroup() { @@ -527,12 +519,10 @@ private String getFormattedNumber(int hits) { return Integer.toString(hits); } - /** - * Workaround taken from https://github.com/controlsfx/controlsfx/issues/330 - */ + // ToDo: reflective access, should be removed + // Workaround taken from https://github.com/controlsfx/controlsfx/issues/330 private void setupClearButtonField(CustomTextField customTextField) { try { - // TODO: reflective access, should be removed Method m = TextFields.class.getDeclaredMethod("setupClearButtonField", TextField.class, ObjectProperty.class); m.setAccessible(true); m.invoke(null, customTextField, customTextField.rightProperty()); @@ -564,4 +554,59 @@ public void expandGroup(TreeItem treeItem) { } } } + + private class ContextAction extends SimpleCommand { + + private final StandardActions command; + private final GroupNodeViewModel group; + + public ContextAction(StandardActions command, GroupNodeViewModel group) { + this.command = command; + this.group = group; + + this.executable.bind(BindingsHelper.constantOf( + switch (command) { + case GROUP_EDIT -> + viewModel.isEditable(group); + case GROUP_REMOVE, GROUP_REMOVE_WITH_SUBGROUPS, GROUP_REMOVE_KEEP_SUBGROUPS -> + viewModel.isEditable(group) && viewModel.canAddGroupsIn(group); + case GROUP_SUBGROUP_ADD -> + viewModel.isEditable(group) && viewModel.canAddGroupsIn(group) + || group.isRoot(); + case GROUP_SUBGROUP_REMOVE, GROUP_SUBGROUP_SORT -> + viewModel.isEditable(group) && viewModel.hasSubgroups(group) && viewModel.canAddGroupsIn(group) + || group.isRoot(); + case GROUP_ENTRIES_ADD, GROUP_ENTRIES_REMOVE -> + viewModel.canAddEntriesIn(group); + default -> + true; + })); + } + + @Override + public void execute() { + switch (command) { + case GROUP_REMOVE -> + viewModel.removeGroupNoSubgroups(group); + case GROUP_REMOVE_KEEP_SUBGROUPS -> + viewModel.removeGroupKeepSubgroups(group); + case GROUP_REMOVE_WITH_SUBGROUPS -> + viewModel.removeGroupAndSubgroups(group); + case GROUP_EDIT -> { + viewModel.editGroup(group); + groupTree.refresh(); + } + case GROUP_SUBGROUP_ADD -> + viewModel.addNewSubgroup(group, GroupDialogHeader.SUBGROUP); + case GROUP_SUBGROUP_REMOVE -> + viewModel.removeSubgroups(group); + case GROUP_SUBGROUP_SORT -> + viewModel.sortAlphabeticallyRecursive(group.getGroupNode()); + case GROUP_ENTRIES_ADD -> + viewModel.addSelectedEntries(group); + case GROUP_ENTRIES_REMOVE -> + viewModel.removeSelectedEntries(group); + } + } + } } diff --git a/src/main/java/org/jabref/gui/groups/GroupTreeViewModel.java b/src/main/java/org/jabref/gui/groups/GroupTreeViewModel.java index 9b401dc6ab9..725b6678750 100644 --- a/src/main/java/org/jabref/gui/groups/GroupTreeViewModel.java +++ b/src/main/java/org/jabref/gui/groups/GroupTreeViewModel.java @@ -635,6 +635,10 @@ public boolean canAddGroupsIn(GroupNodeViewModel groupnode) { } } + public boolean hasSubgroups(GroupNodeViewModel groupnode) { + return groupnode.getChildren().size() > 0; + } + public boolean canAddEntriesIn(GroupNodeViewModel groupnode) { AbstractGroup group = groupnode.getGroupNode().getGroup(); if (group instanceof AllEntriesGroup) { diff --git a/src/main/java/org/jabref/gui/util/ViewModelTreeTableRowFactory.java b/src/main/java/org/jabref/gui/util/ViewModelTreeTableRowFactory.java new file mode 100644 index 00000000000..cae8d1c353c --- /dev/null +++ b/src/main/java/org/jabref/gui/util/ViewModelTreeTableRowFactory.java @@ -0,0 +1,206 @@ +package org.jabref.gui.util; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.BiConsumer; +import java.util.function.Consumer; +import java.util.function.Function; + +import javafx.beans.value.ObservableValue; +import javafx.css.PseudoClass; +import javafx.geometry.Bounds; +import javafx.geometry.Point2D; +import javafx.scene.control.ContextMenu; +import javafx.scene.control.TreeTableRow; +import javafx.scene.control.TreeTableView; +import javafx.scene.input.DragEvent; +import javafx.scene.input.KeyCode; +import javafx.scene.input.KeyEvent; +import javafx.scene.input.MouseDragEvent; +import javafx.scene.input.MouseEvent; +import javafx.util.Callback; + +import com.tobiasdiez.easybind.Subscription; +import org.reactfx.util.TriConsumer; + +public class ViewModelTreeTableRowFactory implements Callback, TreeTableRow> { + private BiConsumer onMouseClickedEvent; + private BiConsumer onMousePressedEvent; + private Consumer> toCustomInitializer; + private Function contextMenuFactory; + private TriConsumer, S, ? super MouseEvent> toOnDragDetected; + private TriConsumer, S, ? super DragEvent> toOnDragDropped; + private BiConsumer toOnDragEntered; + private TriConsumer, S, ? super DragEvent> toOnDragExited; + private TriConsumer, S, ? super DragEvent> toOnDragOver; + private TriConsumer, S, ? super MouseDragEvent> toOnMouseDragEntered; + private final Map, ObservableValue>> pseudoClasses = new HashMap<>(); + + public ViewModelTreeTableRowFactory withOnMouseClickedEvent(BiConsumer event) { + this.onMouseClickedEvent = event; + return this; + } + + public ViewModelTreeTableRowFactory withOnMousePressedEvent(BiConsumer event) { + this.onMousePressedEvent = event; + return this; + } + + public ViewModelTreeTableRowFactory withCustomInitializer(Consumer> customInitializer) { + this.toCustomInitializer = customInitializer; + return this; + } + + public ViewModelTreeTableRowFactory withContextMenu(Function contextMenuFactory) { + this.contextMenuFactory = contextMenuFactory; + return this; + } + + public ViewModelTreeTableRowFactory setOnDragDetected(TriConsumer, S, ? super MouseEvent> toOnDragDetected) { + this.toOnDragDetected = toOnDragDetected; + return this; + } + + public ViewModelTreeTableRowFactory setOnDragDetected(BiConsumer toOnDragDetected) { + this.toOnDragDetected = (row, viewModel, event) -> toOnDragDetected.accept(viewModel, event); + return this; + } + + public ViewModelTreeTableRowFactory setOnDragDropped(TriConsumer, S, ? super DragEvent> toOnDragDropped) { + this.toOnDragDropped = toOnDragDropped; + return this; + } + + public ViewModelTreeTableRowFactory setOnDragDropped(BiConsumer toOnDragDropped) { + return setOnDragDropped((row, viewModel, event) -> toOnDragDropped.accept(viewModel, event)); + } + + public ViewModelTreeTableRowFactory setOnDragEntered(BiConsumer toOnDragEntered) { + this.toOnDragEntered = toOnDragEntered; + return this; + } + + public ViewModelTreeTableRowFactory setOnMouseDragEntered(TriConsumer, S, ? super MouseDragEvent> toOnDragEntered) { + this.toOnMouseDragEntered = toOnDragEntered; + return this; + } + + public ViewModelTreeTableRowFactory setOnMouseDragEntered(BiConsumer toOnDragEntered) { + return setOnMouseDragEntered((row, viewModel, event) -> toOnDragEntered.accept(viewModel, event)); + } + + public ViewModelTreeTableRowFactory setOnDragExited(TriConsumer, S, ? super DragEvent> toOnDragExited) { + this.toOnDragExited = toOnDragExited; + return this; + } + + public ViewModelTreeTableRowFactory setOnDragExited(BiConsumer toOnDragExited) { + return setOnDragExited((row, viewModel, event) -> toOnDragExited.accept(viewModel, event)); + } + + public ViewModelTreeTableRowFactory setOnDragOver(TriConsumer, S, ? super DragEvent> toOnDragOver) { + this.toOnDragOver = toOnDragOver; + return this; + } + + public ViewModelTreeTableRowFactory setOnDragOver(BiConsumer toOnDragOver) { + return setOnDragOver((row, viewModel, event) -> toOnDragOver.accept(viewModel, event)); + } + + public ViewModelTreeTableRowFactory withPseudoClass(PseudoClass pseudoClass, Callback, ObservableValue> toCondition) { + this.pseudoClasses.putIfAbsent(pseudoClass, toCondition); + return this; + } + + public void install(TreeTableView table) { + table.setRowFactory(this); + } + + @Override + public TreeTableRow call(TreeTableView treeTableView) { + return new TreeTableRow<>() { + final List subscriptions = new ArrayList<>(); + + @Override + protected void updateItem(S row, boolean empty) { + super.updateItem(row, empty); + + // Remove previous subscriptions + subscriptions.forEach(Subscription::unsubscribe); + subscriptions.clear(); + + if (contextMenuFactory != null) { + // We only create the context menu when really necessary + setOnContextMenuRequested(event -> { + if (!isEmpty()) { + setContextMenu(contextMenuFactory.apply(row)); + getContextMenu().show(this, event.getScreenX(), event.getScreenY()); + } + event.consume(); + }); + + // Activate context menu if user presses the "context menu" key + treeTableView.addEventHandler(KeyEvent.KEY_RELEASED, event -> { + boolean rowFocused = isEmpty() && treeTableView.getFocusModel().getFocusedIndex() == getIndex(); + if (event.getCode() == KeyCode.CONTEXT_MENU && rowFocused) { + // Get center of focused cell + Bounds anchorBounds = getBoundsInParent(); + double x = anchorBounds.getMinX() + anchorBounds.getWidth() / 2; + double y = anchorBounds.getMinY() + anchorBounds.getHeight() / 2; + Point2D screenPosition = getParent().localToScreen(x, y); + + if (getContextMenu() == null) { + setContextMenu(contextMenuFactory.apply(getItem())); + } + getContextMenu().show(this, screenPosition.getX(), screenPosition.getY()); + } + }); + } + + if (!empty && (row != null)) { + if (onMouseClickedEvent != null) { + setOnMouseClicked(event -> onMouseClickedEvent.accept(getItem(), event)); + } + + if (onMousePressedEvent != null) { + setOnMousePressed(event -> onMousePressedEvent.accept(getItem(), event)); + } + + if (toCustomInitializer != null) { + toCustomInitializer.accept(this); + } + + if (toOnDragDetected != null) { + setOnDragDetected(event -> toOnDragDetected.accept(this, getItem(), event)); + } + if (toOnDragDropped != null) { + setOnDragDropped(event -> toOnDragDropped.accept(this, getItem(), event)); + } + if (toOnDragEntered != null) { + setOnDragEntered(event -> toOnDragEntered.accept(getItem(), event)); + } + if (toOnDragExited != null) { + setOnDragExited(event -> toOnDragExited.accept(this, getItem(), event)); + } + if (toOnDragOver != null) { + setOnDragOver(event -> toOnDragOver.accept(this, getItem(), event)); + } + + if (toOnMouseDragEntered != null) { + setOnMouseDragEntered(event -> toOnMouseDragEntered.accept(this, getItem(), event)); + } + + for (Map.Entry, ObservableValue>> pseudoClassWithCondition : pseudoClasses.entrySet()) { + ObservableValue condition = pseudoClassWithCondition.getValue().call(this); + subscriptions.add(BindingsHelper.includePseudoClassWhen( + this, + pseudoClassWithCondition.getKey(), + condition)); + } + } + } + }; + } +}