1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
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
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
179
180
181
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
239
240
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
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;
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
384
385
386
387
388
389
390
391
392
393
394
395
396
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 }