View Javadoc
1   /**
2    * This file Copyright (c) 2019 Magnolia International
3    * Ltd.  (http://www.magnolia-cms.com). All rights reserved.
4    *
5    *
6    * This file is dual-licensed under both the Magnolia
7    * Network Agreement and the GNU General Public License.
8    * You may elect to use one or the other of these licenses.
9    *
10   * This file is distributed in the hope that it will be
11   * useful, but AS-IS and WITHOUT ANY WARRANTY; without even the
12   * implied warranty of MERCHANTABILITY or FITNESS FOR A
13   * PARTICULAR PURPOSE, TITLE, or NONINFRINGEMENT.
14   * Redistribution, except as permitted by whichever of the GPL
15   * or MNA you select, is prohibited.
16   *
17   * 1. For the GPL license (GPL), you can redistribute and/or
18   * modify this file under the terms of the GNU General
19   * Public License, Version 3, as published by the Free Software
20   * Foundation.  You should have received a copy of the GNU
21   * General Public License, Version 3 along with this program;
22   * if not, write to the Free Software Foundation, Inc., 51
23   * Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
24   *
25   * 2. For the Magnolia Network Agreement (MNA), this file
26   * and the accompanying materials are made available under the
27   * terms of the MNA which accompanies this distribution, and
28   * is available at http://www.magnolia-cms.com/mna.html
29   *
30   * Any modifications to this file must keep this entire header
31   * intact.
32   *
33   */
34  package info.magnolia.ui.contentapp.browser;
35  
36  import static com.vaadin.event.ShortcutAction.KeyCode.*;
37  import static com.vaadin.event.ShortcutAction.ModifierKey.SHIFT;
38  import static info.magnolia.ui.contentapp.browser.GridWithShortcuts.MouseEventDetailsChecker.matches;
39  import static java.lang.Boolean.FALSE;
40  
41  import info.magnolia.ui.contentapp.Datasource;
42  import info.magnolia.ui.contentapp.browser.actions.ShortcutActionsExecutor;
43  
44  import java.util.LinkedList;
45  import java.util.List;
46  import java.util.Optional;
47  
48  import org.vaadin.patrik.FastNavigation;
49  
50  import com.google.inject.Inject;
51  import com.vaadin.data.Binder;
52  import com.vaadin.event.FieldEvents.BlurNotifier;
53  import com.vaadin.event.FieldEvents.FocusNotifier;
54  import com.vaadin.event.ShortcutListener;
55  import com.vaadin.shared.MouseEventDetails;
56  import com.vaadin.shared.Registration;
57  import com.vaadin.ui.Grid;
58  import com.vaadin.ui.Panel;
59  import com.vaadin.ui.components.grid.Editor;
60  import com.vaadin.ui.components.grid.HeaderRow;
61  import com.vaadin.ui.components.grid.ItemClickListener;
62  
63  /**
64   * Manages events for Grids by adding listeners to the grid {@link ItemClickListener}s, {@link ShortcutListener}s etc.
65   */
66  public class GridWithShortcuts<T> extends Panel {
67  
68      private static final int ONE_ROW_DOWN = 1;
69      private static final int ONE_ROW_UP = -1;
70      private static final int[] NO_MODIFIERS = {};
71  
72      private final Editor<T> editor;
73      private int currentRowIndex;
74  
75      private final Grid<T> grid;
76      private Datasource<T> datasource;
77      private final ShortcutActionsExecutor actionExecutor;
78  
79      private FastNavigation<T> navigation;
80      private List<Registration> shortcutsRegistrations = new LinkedList<>();
81  
82      @Inject
83      public GridWithShortcuts(Grid<T> grid, ShortcutActionsExecutor actionsExecutor, Datasource<T> datasource) {
84          super(grid);
85          this.grid = grid;
86          this.actionExecutor = actionsExecutor;
87          this.datasource = datasource;
88          editor = grid.getEditor();
89  
90          navigation = new FastNavigation<>(grid, false, true);
91          setup();
92      }
93  
94      private void setup() {
95          navigation.addRowFocusListener(e -> currentRowIndex = e.getRow());
96          navigation.setOpenEditorOnTyping(false);
97          handleSavingInlineEdits();
98          narrowShortcutsScope();
99          addShortcuts();
100     }
101 
102     private void narrowShortcutsScope() {
103         disableShortcutsInHeaderRows();
104         disableShortcutsInInlineEditor();
105     }
106 
107     private void disableShortcutsInHeaderRows() {
108         for (int i = 0; i < grid.getHeaderRowCount(); i++) {
109             final HeaderRow headerRow = grid.getHeaderRow(i);
110             headerRow.getComponents().forEach(component -> {
111                 if (component instanceof FocusNotifier && component instanceof BlurNotifier) {
112                     ((FocusNotifier) component).addFocusListener(e -> shortcutsRegistrations.forEach(Registration::remove));
113                     ((BlurNotifier) component).addBlurListener(e -> addKeyboardOnlyShortcuts());
114                 }
115             });
116         }
117     }
118 
119     private void disableShortcutsInInlineEditor() {
120         editor.addOpenListener(event -> shortcutsRegistrations.forEach(Registration::remove));
121         editor.addCancelListener(event -> addKeyboardOnlyShortcuts());
122         editor.addSaveListener(event -> addKeyboardOnlyShortcuts());
123     }
124 
125     private void handleSavingInlineEdits() {
126         navigation.addEditorCloseListener(e -> {
127             if (!e.wasCancelled()) {
128                 saveInlineEdit();
129             } else {
130                 refreshEditedItem();
131             }
132         });
133     }
134 
135     private boolean saveInlineEdit() {
136         final Binder<T> binder = editor.getBinder();
137         final T edited = binder.getBean();
138         binder.validate();
139         if (binder.writeBeanIfValid(edited)) {
140             refreshEditedItem();
141             datasource.commit(edited);
142             return true;
143         }
144         return false;
145     }
146 
147     private void refreshEditedItem() {
148         grid.getDataProvider().refreshItem(editor.getBinder().getBean());
149     }
150 
151     private void addShortcuts() {
152         addKeyboardOnlyShortcuts();
153         new MouseShortcutsRegistrator();
154     }
155 
156     private void addKeyboardOnlyShortcuts() {
157         addLetterShortcuts();
158         addModifiedArrowShortcuts();
159         addActionShortcuts();
160     }
161 
162     private void addActionShortcuts() {
163         Shortcut defaultOnEnter = new Shortcut("Default action", ENTER, NO_MODIFIERS);
164         addShortcutListener(createShortcutListener(defaultOnEnter, actionExecutor::fireDefaultAction));
165         Shortcut delete = new Shortcut("i18n Delete action", BACKSPACE, NO_MODIFIERS);
166         registerShortcutListenerIgnoredInInlineEdit(delete, actionExecutor::fireDeleteAction);
167     }
168 
169     private void addLetterShortcuts() {
170         navigation.addEditorOpenShortcut(E);
171         addErgonomicUpDownNavigation("Move focus down", J, ONE_ROW_DOWN);
172         addErgonomicUpDownNavigation("Move focus up", K, ONE_ROW_UP);
173         addShiftSelection("Select downwards", J, ONE_ROW_DOWN);
174         addShiftSelection("Select upwards", K, ONE_ROW_UP);
175     }
176 
177     /**
178      * TODO MGNLUI-5565 These are not working.
179      *  Client Grid swallows arrows before they are bubbled up to the Panel that would handle them.
180      *  See https://github.com/vaadin/framework/issues/9507
181      *  See event.stopPropagation(); at com.vaadin.client.widgets.Grid:2899
182      */
183     private void addModifiedArrowShortcuts() {
184         addShiftSelection("Select upwards with arrow", ARROW_UP, ONE_ROW_UP);
185         addShiftSelection("Select downwards with arrow", ARROW_DOWN, ONE_ROW_UP);
186     }
187 
188     private void addErgonomicUpDownNavigation(String shortcutCaption, int keyCode, int rowFocusDelta) {
189         final Shortcut s = new Shortcut(shortcutCaption, keyCode, NO_MODIFIERS);
190         registerShortcutListenerIgnoredInInlineEdit(s, () -> focusRowRespectingGridRange(currentRowIndex + rowFocusDelta));
191     }
192 
193     private void addShiftSelection(String shortcutCaption, int keyCode, int rowFocusDelta) {
194         final Shortcut s = new Shortcut(shortcutCaption, keyCode, SHIFT);
195         registerShortcutListenerIgnoredInInlineEdit(s, () -> {
196             toggleSelectionAt(currentRowIndex);
197             focusRowRespectingGridRange(currentRowIndex + rowFocusDelta);
198         });
199     }
200 
201     private void registerShortcutListenerIgnoredInInlineEdit(Shortcut shortcut, Runnable execution) {
202         final ShortcutListener listener = createShortcutListener(shortcut, execution);
203         shortcutsRegistrations.add(addShortcutListener(listener));
204     }
205 
206     private ShortcutListener createShortcutListener(Shortcut shortcut, Runnable execution) {
207         return new ShortcutListener(shortcut.shortcutCaption, shortcut.keyCode, shortcut.modifierKeys) {
208             @Override
209             public void handleAction(Object sender, Object target) {
210                 execution.run();
211             }
212         };
213     }
214 
215     private void focusRowRespectingGridRange(int unboundedIndex) {
216         int boundedIndex = getClosestIndexInBounds(unboundedIndex);
217         navigation.setFocusedCell(boundedIndex, 0);
218     }
219 
220     private int getClosestIndexInBounds(int unboundedIndex) {
221         int numOfRows = grid.getDataCommunicator().getDataProviderSize();
222         if (numOfRows <= unboundedIndex) {
223             return numOfRows - 1;
224         } else return Math.max(unboundedIndex, 0);
225     }
226 
227     private boolean isSelected(T item) {
228         return grid.getSelectedItems().contains(item);
229     }
230 
231     private boolean isCurrentRowSelected() {
232         return getItemAt(currentRowIndex).map(this::isSelected).orElse(false);
233     }
234 
235     private void toggleSelectionAt(int rowIndex) {
236         if (rowIndex == -1) {
237             return;
238             /* Client-side Grid allows to focus -1st row (that is to lose focus from any row) using arrow up from
239              *   the top row. j/k will not let you go there at the moment, but since currentFocusedRow can be -1,
240              *   selection must not be toggled there.
241              */
242         }
243         if (!getItemAt(rowIndex).isPresent()) {
244             throw new IndexOutOfBoundsException("Row with index " + rowIndex + " does not exist in the grid.");
245         }
246         T itemToToggle = getItemAt(rowIndex).get();
247         toggleSelection(itemToToggle);
248     }
249 
250     private void toggleSelection(T itemToToggle) {
251         if (isSelected(itemToToggle)) {
252             grid.deselect(itemToToggle);
253         } else {
254             grid.select(itemToToggle);
255         }
256     }
257 
258     private Optional<T> getItemAt(int row) {
259         T result = null;
260         if (row >= 0 && grid.getDataCommunicator().getDataProviderSize() > 0) {
261             result = grid.getDataCommunicator().fetchItemsWithRange(row, 1).get(0);
262         }
263         return Optional.ofNullable(result);
264     }
265 
266     private static class Shortcut {
267 
268         private String shortcutCaption;
269         private int keyCode;
270         private int[] modifierKeys;
271 
272         private Shortcut(String shortcutCaption, int keyCode, int... modifierKeys) {
273             this.shortcutCaption = shortcutCaption;
274             this.keyCode = keyCode;
275             this.modifierKeys = modifierKeys;
276         }
277     }
278 
279     /*
280      * TODO can be refactored out to MagnoliaGrid(s) if more isolated setup from shortcuts necessary.
281      */
282     private class MouseShortcutsRegistrator {
283 
284         private MouseShortcutsRegistrator() {
285             addContextClickBehavior();
286             navigation.setOpenEditorWithSingleClick(false);
287 
288             grid.addItemClickListener(event -> {
289                 if (isTriggeringDefaultAction(event)) {
290                     grid.deselectAll();
291                     grid.select(event.getItem());
292                     actionExecutor.fireDefaultAction();
293                 }
294                 if (isSelectingRange(event)) {
295                     changeSelectionInRange(event);
296                 }
297                 if (isTogglingSelectionOnASingleItem(event)) {
298                     toggleSelection(event.getItem());
299                 }
300                 if (isSelectingWithRowClick(event)) {
301                     grid.deselectAll();
302                     grid.select(event.getItem());
303                 }
304                 if (isFocusing(event)) {
305                     currentRowIndex = event.getRowIndex();
306                 }
307             });
308         }
309 
310         private void addContextClickBehavior() {
311             grid.addContextClickListener(event -> {
312                 T item = ((Grid.GridContextClickEvent<T>) event).getItem();
313                 if (item != null) {
314                     if (!isSelected(item)) {
315                         grid.deselectAll();
316                     }
317                     grid.select(item);
318                 } else {
319                     grid.deselectAll();
320                 }
321             });
322         }
323 
324         private void changeSelectionInRange(Grid.ItemClick<T> event) {
325             final boolean desiredSelectionBoolState = decideSelectionStateForRange(event);
326             final int clickedRowIndex = event.getRowIndex();
327             for (int i = Math.min(clickedRowIndex, currentRowIndex); i <= Math.max(clickedRowIndex, currentRowIndex); i++) {
328                 if (FALSE.equals(desiredSelectionBoolState)) {
329                     getItemAt(i).ifPresent(grid::deselect);
330                 } else {
331                     getItemAt(i).ifPresent(grid::select);
332                 }
333             }
334         }
335 
336         private boolean decideSelectionStateForRange(Grid.ItemClick<T> event) {
337             if (!isCurrentRowSelected() && !isSelected(event.getItem())) {
338                 return true;
339             }
340             if (isCurrentRowSelected() && isSelected(event.getItem())) {
341                 return false;
342             }
343             if (isCurrentRowSelected() && !isSelected(event.getItem())) {
344                 return true;
345             }
346             if (!isCurrentRowSelected() && isSelected(event.getItem())) {
347                 return false;
348             }
349             return false; //unreachable
350         }
351 
352         private boolean isFocusing(Grid.ItemClick<T> event) {
353             return matches(event.getMouseEventDetails(), "acms1L")
354                     || isTriggeringDefaultAction(event)
355                     || isTogglingSelectionOnASingleItem(event)
356                     || isSelectingRange(event)
357                     || isSelectingWithRowClick(event);
358         }
359 
360         private boolean isSelectingWithRowClick(Grid.ItemClick<T> event) {
361             return matches(event.getMouseEventDetails(), "acms1L");
362         }
363 
364         private boolean isTriggeringDefaultAction(Grid.ItemClick<T> event) {
365             return matches(event.getMouseEventDetails(), "acms2L");
366         }
367 
368         private boolean isTogglingSelectionOnASingleItem(Grid.ItemClick<T> event) {
369             return (isMacOSX() && matches(event.getMouseEventDetails(), "acMs1L"))
370                     || (!isMacOSX() && matches(event.getMouseEventDetails(), "aCms1L"));
371         }
372 
373         private boolean isSelectingRange(Grid.ItemClick<T> event) {
374             return matches(event.getMouseEventDetails(), "acmS1L");
375         }
376 
377         private boolean isMacOSX() {
378             return getUI().getPage().getWebBrowser().isMacOSX();
379         }
380     }
381 
382     /**
383      * To make checking for mouse event details easier a short pattern can be used. Letters representing keys are
384      * capitalized when pressed. Check legend below. E.g. ALT + SHIFT + double click with middle mouse button would
385      * be: "AcmS2middle"
386      *
387      * a/A = alt (unpressed / pressed)
388      * c/C = control
389      * m/M = meta (command in Mac OSX)
390      * s/S = shift
391      * 1/2 = single click / double click
392      * L/M/R = clicked button on mouse (left/middle/right)
393      *
394      * Each of the above is present in the pattern irrespective of state.
395      *
396      * @see MouseEventDetailsChecker#createPattern(MouseEventDetails) for exact construction.
397      */
398     protected static class MouseEventDetailsChecker {
399 
400         private MouseEventDetailsChecker() {
401         }
402 
403         public static boolean matches(MouseEventDetails details, String pattern) {
404             return createPattern(details).equals(pattern);
405         }
406 
407         private static String createPattern(MouseEventDetails details) {
408             StringBuilder res = new StringBuilder();
409             res.append(details.isAltKey() ? "A" : "a");
410             res.append(details.isCtrlKey() ? "C" : "c");
411             res.append(details.isMetaKey() ? "M" : "m");
412             res.append(details.isShiftKey() ? "S" : "s");
413             res.append(details.isDoubleClick() ? "2" : "1");
414             res.append(translateMouseButtonToLetter(details));
415             return res.toString();
416         }
417 
418         private static String translateMouseButtonToLetter(MouseEventDetails details) {
419             switch (details.getButton()) {
420             case LEFT: return "L";
421             case MIDDLE: return "M";
422             case RIGHT: return "R";
423             default: throw new RuntimeException("Enum MouseButton did not have any more elements!");
424             }
425         }
426     }
427 }