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.common.collect.Sets;
51 import com.google.inject.Inject;
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.GridSelectionModel;
61 import com.vaadin.ui.components.grid.HeaderRow;
62 import com.vaadin.ui.components.grid.ItemClickListener;
63
64
65
66
67 public class GridWithShortcuts<T> extends Panel {
68
69 private static final int ONE_ROW_DOWN = 1;
70 private static final int ONE_ROW_UP = -1;
71 private static final int[] NO_MODIFIERS = {};
72
73 private final Editor<T> editor;
74 private int currentRowIndex;
75
76 private final Grid<T> grid;
77 private Datasource<T> datasource;
78 private final ShortcutActionsExecutor actionExecutor;
79
80 private FastNavigation<T> navigation;
81 private List<Registration> shortcutsRegistrations = new LinkedList<>();
82
83 @Inject
84 public GridWithShortcuts(Grid<T> grid, ShortcutActionsExecutor actionsExecutor, Datasource<T> datasource) {
85 super(grid);
86 this.grid = grid;
87 this.actionExecutor = actionsExecutor;
88 this.datasource = datasource;
89 this.navigation = new FastNavigation<>(grid, false, true);
90 this.editor = grid.getEditor();
91 editor.setBuffered(true);
92 setup();
93 }
94
95 private void setup() {
96 navigation.addRowFocusListener(e -> currentRowIndex = e.getRow());
97 navigation.setOpenEditorOnTyping(false);
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 -> {
123 addKeyboardOnlyShortcuts();
124 saveInlineEdit(event.getBean());
125 });
126 }
127
128 void saveInlineEdit(T bean) {
129 datasource.commit(bean);
130 }
131
132 private void addShortcuts() {
133 addKeyboardOnlyShortcuts();
134 new MouseShortcutsRegistrator();
135 }
136
137 private void addKeyboardOnlyShortcuts() {
138 addLetterShortcuts();
139 addModifiedArrowShortcuts();
140 addActionShortcuts();
141 }
142
143 private void addActionShortcuts() {
144 Shortcut defaultOnEnter = new Shortcut("Default action", ENTER, NO_MODIFIERS);
145 addShortcutListener(createShortcutListener(defaultOnEnter, actionExecutor::fireDefaultAction));
146 Shortcut delete = new Shortcut("i18n Delete action", BACKSPACE, NO_MODIFIERS);
147 registerShortcutListenerIgnoredInInlineEdit(delete, actionExecutor::fireDeleteAction);
148 }
149
150 private void addLetterShortcuts() {
151 navigation.addEditorOpenShortcut(E);
152 addErgonomicUpDownNavigation("Move focus down", J, ONE_ROW_DOWN);
153 addErgonomicUpDownNavigation("Move focus up", K, ONE_ROW_UP);
154 addShiftSelection("Select downwards", J, ONE_ROW_DOWN);
155 addShiftSelection("Select upwards", K, ONE_ROW_UP);
156 }
157
158
159
160
161
162
163
164 private void addModifiedArrowShortcuts() {
165 addShiftSelection("Select upwards with arrow", ARROW_UP, ONE_ROW_UP);
166 addShiftSelection("Select downwards with arrow", ARROW_DOWN, ONE_ROW_UP);
167 }
168
169 private void addErgonomicUpDownNavigation(String shortcutCaption, int keyCode, int rowFocusDelta) {
170 final Shortcut s = new Shortcut(shortcutCaption, keyCode, NO_MODIFIERS);
171 registerShortcutListenerIgnoredInInlineEdit(s, () -> focusRowRespectingGridRange(currentRowIndex + rowFocusDelta));
172 }
173
174 private void addShiftSelection(String shortcutCaption, int keyCode, int rowFocusDelta) {
175 final Shortcut s = new Shortcut(shortcutCaption, keyCode, SHIFT);
176 registerShortcutListenerIgnoredInInlineEdit(s, () -> {
177 toggleSelectionAt(currentRowIndex);
178 focusRowRespectingGridRange(currentRowIndex + rowFocusDelta);
179 });
180 }
181
182 private void registerShortcutListenerIgnoredInInlineEdit(Shortcut shortcut, Runnable execution) {
183 final ShortcutListener listener = createShortcutListener(shortcut, execution);
184 shortcutsRegistrations.add(addShortcutListener(listener));
185 }
186
187 private ShortcutListener createShortcutListener(Shortcut shortcut, Runnable execution) {
188 return new ShortcutListener(shortcut.shortcutCaption, shortcut.keyCode, shortcut.modifierKeys) {
189 @Override
190 public void handleAction(Object sender, Object target) {
191 execution.run();
192 }
193 };
194 }
195
196 private void focusRowRespectingGridRange(int unboundedIndex) {
197 int boundedIndex = getClosestIndexInBounds(unboundedIndex);
198 navigation.setFocusedCell(boundedIndex, 0);
199 }
200
201 private int getClosestIndexInBounds(int unboundedIndex) {
202 int numOfRows = grid.getDataCommunicator().getDataProviderSize();
203 if (numOfRows <= unboundedIndex) {
204 return numOfRows - 1;
205 } else return Math.max(unboundedIndex, 0);
206 }
207
208 private boolean isSelected(T item) {
209 return grid.getSelectedItems().contains(item);
210 }
211
212 private boolean isCurrentRowSelected() {
213 return getItemAt(currentRowIndex).map(this::isSelected).orElse(false);
214 }
215
216 private void toggleSelectionAt(int rowIndex) {
217 if (rowIndex == -1) {
218 return;
219
220
221
222
223 }
224 if (!getItemAt(rowIndex).isPresent()) {
225 throw new IndexOutOfBoundsException("Row with index " + rowIndex + " does not exist in the grid.");
226 }
227 T itemToToggle = getItemAt(rowIndex).get();
228 toggleSelection(itemToToggle);
229 }
230
231 private void toggleSelection(T itemToToggle) {
232 if (isSelected(itemToToggle)) {
233 grid.deselect(itemToToggle);
234 } else {
235 grid.select(itemToToggle);
236 }
237 }
238
239 private Optional<T> getItemAt(int row) {
240 T result = null;
241 if (row >= 0 && grid.getDataCommunicator().getDataProviderSize() > 0) {
242 result = grid.getDataCommunicator().fetchItemsWithRange(row, 1).get(0);
243 }
244 return Optional.ofNullable(result);
245 }
246
247 private static class Shortcut {
248
249 private String shortcutCaption;
250 private int keyCode;
251 private int[] modifierKeys;
252
253 private Shortcut(String shortcutCaption, int keyCode, int... modifierKeys) {
254 this.shortcutCaption = shortcutCaption;
255 this.keyCode = keyCode;
256 this.modifierKeys = modifierKeys;
257 }
258 }
259
260
261
262
263 class MouseShortcutsRegistrator {
264
265 private MouseShortcutsRegistrator() {
266 addContextClickBehavior();
267 navigation.setOpenEditorWithSingleClick(false);
268 grid.addItemClickListener(new ClickListener());
269 }
270
271 private void addContextClickBehavior() {
272 grid.addContextClickListener(event -> {
273 T item = ((Grid.GridContextClickEvent<T>) event).getItem();
274 if (item != null) {
275 if (!isSelected(item)) {
276 grid.deselectAll();
277 }
278 grid.select(item);
279 } else {
280 grid.deselectAll();
281 }
282 });
283 }
284 }
285
286 class ClickListener implements ItemClickListener<T> {
287
288 @Override
289 public void itemClick(Grid.ItemClick<T> event) {
290 if (isTriggeringDefaultAction(event)) {
291 grid.deselectAll();
292 grid.select(event.getItem());
293 actionExecutor.fireDefaultAction();
294 }
295 if (isSelectingRange(event)) {
296 changeSelectionInRange(event);
297 }
298 if (isTogglingSelectionOnASingleItem(event)) {
299 toggleSelection(event.getItem());
300 }
301 if (isLeftClickingRow(event)) {
302 if (grid.getSelectedItems().size() == 1 && grid.getSelectedItems().contains(event.getItem())) {
303 grid.deselect(event.getItem());
304 } else {
305 final GridSelectionModel<T> selectionModel = grid.getSelectionModel();
306 if (selectionModel instanceof MagnoliaGridDecoration.MultiSelectionModel) {
307 ((MagnoliaGridDecoration.MultiSelectionModel<T>) selectionModel).updateSelection(Sets.newHashSet(event.getItem()), Sets.newHashSet(grid.getSelectedItems()), true);
308 } else if (selectionModel instanceof MagnoliaGridDecoration.SingleSelectionModel) {
309 final String key = grid.getDataCommunicator().getKeyMapper().key(event.getItem());
310 ((MagnoliaGridDecoration.SingleSelectionModel<T>) selectionModel).setSelectedFromClient(key);
311 }
312 }
313 }
314 if (isFocusing(event)) {
315 currentRowIndex = event.getRowIndex();
316 }
317 }
318
319 private void changeSelectionInRange(Grid.ItemClick<T> event) {
320 final boolean desiredSelectionBoolState = decideSelectionStateForRange(event);
321 final int clickedRowIndex = event.getRowIndex();
322 for (int i = Math.min(clickedRowIndex, currentRowIndex); i <= Math.max(clickedRowIndex, currentRowIndex); i++) {
323 if (FALSE.equals(desiredSelectionBoolState)) {
324 getItemAt(i).ifPresent(grid::deselect);
325 } else {
326 getItemAt(i).ifPresent(grid::select);
327 }
328 }
329 }
330
331 private boolean decideSelectionStateForRange(Grid.ItemClick<T> event) {
332 if (!isCurrentRowSelected() && !isSelected(event.getItem())) {
333 return true;
334 }
335 if (isCurrentRowSelected() && isSelected(event.getItem())) {
336 return false;
337 }
338 if (isCurrentRowSelected() && !isSelected(event.getItem())) {
339 return true;
340 }
341 if (!isCurrentRowSelected() && isSelected(event.getItem())) {
342 return false;
343 }
344 return false;
345 }
346
347 private boolean isFocusing(Grid.ItemClick<T> event) {
348 return matches(event.getMouseEventDetails(), "acms1L")
349 || isTriggeringDefaultAction(event)
350 || isTogglingSelectionOnASingleItem(event)
351 || isSelectingRange(event)
352 || isLeftClickingRow(event);
353 }
354
355 private boolean isLeftClickingRow(Grid.ItemClick<T> event) {
356 return matches(event.getMouseEventDetails(), "acms1L");
357 }
358
359 private boolean isTriggeringDefaultAction(Grid.ItemClick<T> event) {
360 return matches(event.getMouseEventDetails(), "acms2L");
361 }
362
363 private boolean isTogglingSelectionOnASingleItem(Grid.ItemClick<T> event) {
364 return (isMacOSX() && matches(event.getMouseEventDetails(), "acMs1L"))
365 || (!isMacOSX() && matches(event.getMouseEventDetails(), "aCms1L"));
366 }
367
368 private boolean isSelectingRange(Grid.ItemClick<T> event) {
369 return matches(event.getMouseEventDetails(), "acmS1L");
370 }
371
372 private boolean isMacOSX() {
373 return getUI().getPage().getWebBrowser().isMacOSX();
374 }
375 }
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393 protected static class MouseEventDetailsChecker {
394
395 private MouseEventDetailsChecker() {
396 }
397
398 public static boolean matches(MouseEventDetails details, String pattern) {
399 return createPattern(details).equals(pattern);
400 }
401
402 private static String createPattern(MouseEventDetails details) {
403 StringBuilder res = new StringBuilder();
404 res.append(details.isAltKey() ? "A" : "a");
405 res.append(details.isCtrlKey() ? "C" : "c");
406 res.append(details.isMetaKey() ? "M" : "m");
407 res.append(details.isShiftKey() ? "S" : "s");
408 res.append(details.isDoubleClick() ? "2" : "1");
409 res.append(translateMouseButtonToLetter(details));
410 return res.toString();
411 }
412
413 private static String translateMouseButtonToLetter(MouseEventDetails details) {
414 switch (details.getButton()) {
415 case LEFT:
416 return "L";
417 case MIDDLE:
418 return "M";
419 case RIGHT:
420 return "R";
421 default:
422 throw new RuntimeException("Enum MouseButton did not have any more elements!");
423 }
424 }
425 }
426 }