diff --git a/CHANGELOG.md b/CHANGELOG.md
index c7c3bce47ed..33857b0aa1a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -38,6 +38,7 @@ Note that this project **does not** adhere to [Semantic Versioning](https://semv
- We made the command "Push to TexShop" more robust to allow cite commands with a character before the first slash. [forum#2699](https://discourse.jabref.org/t/push-to-texshop-mac/2699/17?u=siedlerchr)
- We only show the notification "Saving library..." if the library contains more than 2000 entries. [#9803](https://github.com/JabRef/jabref/issues/9803)
- We enhanced the dialog for adding new fields in the content selector with a selection box containing a list of standard fields. [#10912](https://github.com/JabRef/jabref/pull/10912)
+- Keywords filed are now displayed as tags. [#10910](https://github.com/JabRef/jabref/pull/10910)
### Fixed
diff --git a/src/main/java/org/jabref/gui/fieldeditors/FieldEditors.java b/src/main/java/org/jabref/gui/fieldeditors/FieldEditors.java
index e699a398451..ec921b2f523 100644
--- a/src/main/java/org/jabref/gui/fieldeditors/FieldEditors.java
+++ b/src/main/java/org/jabref/gui/fieldeditors/FieldEditors.java
@@ -96,7 +96,7 @@ public static FieldEditorFX getForField(final Field field,
} else if (fieldProperties.contains(FieldProperty.PERSON_NAMES)) {
return new PersonsEditor(field, suggestionProvider, preferences, fieldCheckers, isMultiLine, undoManager);
} else if (StandardField.KEYWORDS == field) {
- return new KeywordsEditor(field, suggestionProvider, fieldCheckers, preferences, undoManager);
+ return new KeywordsEditor(field, suggestionProvider, fieldCheckers);
} else if (field == InternalField.KEY_FIELD) {
return new CitationKeyEditor(field, suggestionProvider, fieldCheckers, databaseContext);
} else if (fieldProperties.contains(FieldProperty.MARKDOWN)) {
diff --git a/src/main/java/org/jabref/gui/fieldeditors/KeywordsEditor.fxml b/src/main/java/org/jabref/gui/fieldeditors/KeywordsEditor.fxml
new file mode 100644
index 00000000000..0f96aca52a8
--- /dev/null
+++ b/src/main/java/org/jabref/gui/fieldeditors/KeywordsEditor.fxml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
diff --git a/src/main/java/org/jabref/gui/fieldeditors/KeywordsEditor.java b/src/main/java/org/jabref/gui/fieldeditors/KeywordsEditor.java
index f11f7d1a362..2d79d22f0db 100644
--- a/src/main/java/org/jabref/gui/fieldeditors/KeywordsEditor.java
+++ b/src/main/java/org/jabref/gui/fieldeditors/KeywordsEditor.java
@@ -1,24 +1,155 @@
package org.jabref.gui.fieldeditors;
+import java.util.Comparator;
+
import javax.swing.undo.UndoManager;
+import javafx.beans.binding.Bindings;
+import javafx.fxml.FXML;
+import javafx.scene.Node;
+import javafx.scene.Parent;
+import javafx.scene.control.ContentDisplay;
+import javafx.scene.control.ContextMenu;
+import javafx.scene.control.Label;
+import javafx.scene.layout.HBox;
+
+import org.jabref.gui.ClipBoardManager;
+import org.jabref.gui.DialogService;
+import org.jabref.gui.JabRefDialogService;
+import org.jabref.gui.actions.ActionFactory;
+import org.jabref.gui.actions.SimpleCommand;
+import org.jabref.gui.actions.StandardActions;
import org.jabref.gui.autocompleter.SuggestionProvider;
+import org.jabref.gui.icon.IconTheme;
+import org.jabref.gui.keyboard.KeyBindingRepository;
+import org.jabref.gui.util.ViewModelListCellFactory;
import org.jabref.logic.integrity.FieldCheckers;
+import org.jabref.logic.l10n.Localization;
+import org.jabref.model.entry.BibEntry;
+import org.jabref.model.entry.Keyword;
import org.jabref.model.entry.field.Field;
import org.jabref.preferences.PreferencesService;
-public class KeywordsEditor extends SimpleEditor implements FieldEditorFX {
+import com.airhacks.afterburner.views.ViewLoader;
+import com.dlsc.gemsfx.TagsField;
+import jakarta.inject.Inject;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class KeywordsEditor extends HBox implements FieldEditorFX {
+ private static final Logger LOGGER = LoggerFactory.getLogger(KeywordsEditor.class);
+
+ @FXML private TagsField keywordTagsField;
+
+ @Inject private PreferencesService preferencesService;
+ @Inject private DialogService dialogService;
+ @Inject private UndoManager undoManager;
+ @Inject private ClipBoardManager clipBoardManager;
+ @Inject private KeyBindingRepository keyBindingRepository;
+
+ private final KeywordsEditorViewModel viewModel;
public KeywordsEditor(Field field,
SuggestionProvider> suggestionProvider,
- FieldCheckers fieldCheckers,
- PreferencesService preferences,
- UndoManager undoManager) {
- super(field, suggestionProvider, fieldCheckers, preferences, undoManager);
+ FieldCheckers fieldCheckers) {
+
+ ViewLoader.view(this)
+ .root(this)
+ .load();
+
+ this.viewModel = new KeywordsEditorViewModel(
+ field,
+ suggestionProvider,
+ fieldCheckers,
+ preferencesService,
+ undoManager);
+
+ keywordTagsField.setCellFactory(new ViewModelListCellFactory().withText(Keyword::get));
+ keywordTagsField.setTagViewFactory(this::createTag);
+
+ keywordTagsField.setSuggestionProvider(request -> viewModel.getSuggestions(request.getUserText()));
+ keywordTagsField.setConverter(viewModel.getStringConverter());
+ keywordTagsField.setMatcher((keyword, searchText) -> keyword.get().toLowerCase().startsWith(searchText.toLowerCase()));
+ keywordTagsField.setComparator(Comparator.comparing(Keyword::get));
+
+ keywordTagsField.setNewItemProducer(searchText -> viewModel.getStringConverter().fromString(searchText));
+
+ keywordTagsField.setShowSearchIcon(false);
+ keywordTagsField.getEditor().getStyleClass().clear();
+ keywordTagsField.getEditor().getStyleClass().add("tags-field-editor");
+
+ Bindings.bindContentBidirectional(keywordTagsField.getTags(), viewModel.keywordListProperty());
+ }
+
+ private Node createTag(Keyword keyword) {
+ Label tagLabel = new Label();
+ tagLabel.setText(keywordTagsField.getConverter().toString(keyword));
+ tagLabel.setGraphic(IconTheme.JabRefIcons.REMOVE_TAGS.getGraphicNode());
+ tagLabel.getGraphic().setOnMouseClicked(event -> keywordTagsField.removeTags(keyword));
+ tagLabel.setContentDisplay(ContentDisplay.RIGHT);
+ ContextMenu contextMenu = new ContextMenu();
+ ActionFactory factory = new ActionFactory(keyBindingRepository);
+ contextMenu.getItems().addAll(
+ factory.createMenuItem(StandardActions.COPY, new KeywordsEditor.TagContextAction(StandardActions.COPY, keyword)),
+ factory.createMenuItem(StandardActions.CUT, new KeywordsEditor.TagContextAction(StandardActions.CUT, keyword)),
+ factory.createMenuItem(StandardActions.DELETE, new KeywordsEditor.TagContextAction(StandardActions.DELETE, keyword))
+ );
+ tagLabel.setContextMenu(contextMenu);
+ return tagLabel;
+ }
+
+ public KeywordsEditorViewModel getViewModel() {
+ return viewModel;
+ }
+
+ @Override
+ public void bindToEntry(BibEntry entry) {
+ viewModel.bindToEntry(entry);
+ }
+
+ @Override
+ public Parent getNode() {
+ return this;
+ }
+
+ @Override
+ public void requestFocus() {
+ keywordTagsField.requestFocus();
}
@Override
public double getWeight() {
return 2;
}
+
+ private class TagContextAction extends SimpleCommand {
+ private final StandardActions command;
+ private final Keyword keyword;
+
+ public TagContextAction(StandardActions command, Keyword keyword) {
+ this.command = command;
+ this.keyword = keyword;
+ }
+
+ @Override
+ public void execute() {
+ switch (command) {
+ case COPY -> {
+ clipBoardManager.setContent(keyword.get());
+ dialogService.notify(Localization.lang("Copied '%0' to clipboard.",
+ JabRefDialogService.shortenDialogMessage(keyword.get())));
+ }
+ case CUT -> {
+ clipBoardManager.setContent(keyword.get());
+ dialogService.notify(Localization.lang("Copied '%0' to clipboard.",
+ JabRefDialogService.shortenDialogMessage(keyword.get())));
+ keywordTagsField.removeTags(keyword);
+ }
+ case DELETE ->
+ keywordTagsField.removeTags(keyword);
+ default ->
+ LOGGER.info("Action {} not defined", command.getText());
+ }
+ }
+ }
}
diff --git a/src/main/java/org/jabref/gui/fieldeditors/KeywordsEditorViewModel.java b/src/main/java/org/jabref/gui/fieldeditors/KeywordsEditorViewModel.java
new file mode 100644
index 00000000000..8accde67d6d
--- /dev/null
+++ b/src/main/java/org/jabref/gui/fieldeditors/KeywordsEditorViewModel.java
@@ -0,0 +1,93 @@
+package org.jabref.gui.fieldeditors;
+
+import java.util.List;
+import java.util.stream.Collectors;
+
+import javax.swing.undo.UndoManager;
+
+import javafx.beans.property.ListProperty;
+import javafx.beans.property.SimpleListProperty;
+import javafx.collections.FXCollections;
+import javafx.util.StringConverter;
+
+import org.jabref.gui.autocompleter.SuggestionProvider;
+import org.jabref.gui.util.BindingsHelper;
+import org.jabref.logic.integrity.FieldCheckers;
+import org.jabref.model.entry.Keyword;
+import org.jabref.model.entry.KeywordList;
+import org.jabref.model.entry.field.Field;
+import org.jabref.preferences.PreferencesService;
+
+import org.tinylog.Logger;
+
+public class KeywordsEditorViewModel extends AbstractEditorViewModel {
+
+ private final ListProperty keywordListProperty;
+ private final Character keywordSeparator;
+ private final SuggestionProvider> suggestionProvider;
+
+ public KeywordsEditorViewModel(Field field,
+ SuggestionProvider> suggestionProvider,
+ FieldCheckers fieldCheckers,
+ PreferencesService preferencesService,
+ UndoManager undoManager) {
+
+ super(field, suggestionProvider, fieldCheckers, undoManager);
+
+ keywordListProperty = new SimpleListProperty<>(FXCollections.observableArrayList());
+ this.keywordSeparator = preferencesService.getBibEntryPreferences().getKeywordSeparator();
+ this.suggestionProvider = suggestionProvider;
+
+ BindingsHelper.bindContentBidirectional(
+ keywordListProperty,
+ text,
+ this::serializeKeywords,
+ this::parseKeywords);
+ }
+
+ private String serializeKeywords(List keywords) {
+ return KeywordList.serialize(keywords, keywordSeparator);
+ }
+
+ private List parseKeywords(String newText) {
+ return KeywordList.parse(newText, keywordSeparator).stream().toList();
+ }
+
+ public ListProperty keywordListProperty() {
+ return keywordListProperty;
+ }
+
+ public StringConverter getStringConverter() {
+ return new StringConverter<>() {
+ @Override
+ public String toString(Keyword keyword) {
+ if (keyword == null) {
+ Logger.debug("Keyword is null");
+ return "";
+ }
+ return keyword.get();
+ }
+
+ @Override
+ public Keyword fromString(String keywordString) {
+ return new Keyword(keywordString);
+ }
+ };
+ }
+
+ public List getSuggestions(String request) {
+ List suggestions = suggestionProvider.getPossibleSuggestions().stream()
+ .map(String.class::cast)
+ .filter(keyword -> keyword.toLowerCase().contains(request.toLowerCase()))
+ .map(Keyword::new)
+ .distinct()
+ .collect(Collectors.toList());
+
+ Keyword requestedKeyword = new Keyword(request);
+ if (!suggestions.contains(requestedKeyword)) {
+ suggestions.addFirst(requestedKeyword);
+ }
+
+ return suggestions;
+ }
+}
diff --git a/src/main/java/org/jabref/model/entry/KeywordList.java b/src/main/java/org/jabref/model/entry/KeywordList.java
index d54de57f977..76adb9add8d 100644
--- a/src/main/java/org/jabref/model/entry/KeywordList.java
+++ b/src/main/java/org/jabref/model/entry/KeywordList.java
@@ -73,6 +73,10 @@ public static KeywordList parse(String keywordString, Character delimiter) {
return parse(keywordString, delimiter, Keyword.DEFAULT_HIERARCHICAL_DELIMITER);
}
+ public static String serialize(List keywords, Character delimiter) {
+ return keywords.stream().map(Keyword::get).collect(Collectors.joining(delimiter.toString()));
+ }
+
public static KeywordList merge(String keywordStringA, String keywordStringB, Character delimiter) {
KeywordList keywordListA = parse(keywordStringA, delimiter);
KeywordList keywordListB = parse(keywordStringB, delimiter);