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