Skip to content

Commit 3d5fd4a

Browse files
committed
(kb) differenciate global view commands from focused-only commands
sometimes, we want to run "current view"-related commands via keyboard shortcuts, while we are focusing ie. the creator panel. this commit makes it possible, by distinguishing "globally active" view commands from "only user-focused" view commands. in order to have the "globally active" commands more reliably work, we make sure the active section is focused by the region switcher automatically. it's not an ideal behavior for the user, as he will then need to manually go focus back the panel he was on ; but this is more done as a safety measure to not end up in a state where `body` is focused while we'd expect otherwise.
1 parent 7bc146d commit 3d5fd4a

File tree

9 files changed

+131
-82
lines changed

9 files changed

+131
-82
lines changed

app/client/components/BaseView.js

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ const {markAsSeen} = require('app/client/models/UserPrefs');
3434
const {buildConfirmDelete, reportUndo} = require('app/client/components/modals');
3535
const {buildReassignModal} = require('app/client/ui/buildReassignModal');
3636
const {MutedError} = require('app/client/models/errors');
37+
const {viewCommands} = require('app/client/components/RegionFocusSwitcher');
3738

3839

3940
/**
@@ -128,7 +129,8 @@ function BaseView(gristDoc, viewSectionModel, options) {
128129
this.listenTo(this.viewSection.events, 'rowHeightChange', this.onResize );
129130

130131
// Create a command group for keyboard shortcuts common to all views.
131-
this.autoDispose(commands.createGroup(BaseView.commonCommands, this, this.viewSection.enableCommands));
132+
this.autoDispose(commands.createGroup(viewCommands(BaseView.commonCommands, this), this, this.viewSection.hasFocus));
133+
this.autoDispose(commands.createGroup(BaseView.commonFocusedCommands, this, this.viewSection.hasRegionFocus));
132134

133135
//--------------------------------------------------
134136
// Prepare logic for linking with other sections.
@@ -233,34 +235,43 @@ _.extend(Base.prototype, BackboneEvents);
233235

234236
/**
235237
* These commands are common to GridView and DetailView.
238+
*
239+
* They work when the view is the currently active one.
236240
*/
237241
BaseView.commonCommands = {
238242
input: function(init) {
239243
this.scrollToCursor(true).catch(reportError);
240244
this.activateEditorAtCursor({init});
241245
},
242-
editField: function(event) { closeRegisteredMenu(); this.scrollToCursor(true); this.activateEditorAtCursor({event}); },
243-
246+
copyLink: function() { this.copyLink().catch(reportError); },
247+
filterByThisCellValue: function() { this.filterByThisCellValue(); },
248+
duplicateRows: function() { this._duplicateRows().catch(reportError); },
249+
openDiscussion: function() { this.openDiscussionAtCursor(); },
244250
insertRecordBefore: function() { this.insertRow(this.cursor.rowIndex()); },
245251
insertRecordAfter: function() { this.insertRow(this.cursor.rowIndex() + 1); },
252+
};
253+
254+
/**
255+
* These commands are common to GridView and DetailView.
256+
*
257+
* They are enabled only when the user is actually focusing the view, meaning
258+
* they don't work when the view is the active one but the user is focused on the creator panel.
259+
*/
260+
BaseView.commonFocusedCommands = {
261+
editField: function(event) { closeRegisteredMenu(); this.scrollToCursor(true); this.activateEditorAtCursor({event}); },
246262

247263
insertCurrentDate: function() { this.insertCurrentDate(false); },
248264
insertCurrentDateTime: function() { this.insertCurrentDate(true); },
249265

250-
copyLink: function() { this.copyLink().catch(reportError); },
251-
252266
deleteRecords: function(source) { this.deleteRecords(source); },
253267

254-
filterByThisCellValue: function() { this.filterByThisCellValue(); },
255-
duplicateRows: function() { this._duplicateRows().catch(reportError); },
256-
openDiscussion: function() { this.openDiscussionAtCursor(); },
257268
viewAsCard: function() {
258269
/* Overridden by subclasses.
259270
*
260271
* This is still needed so that <space> doesn't trigger the `input` command
261272
* if a subclass doesn't support opening the current record as a card. */
262273
},
263-
};
274+
}
264275

265276
BaseView.prototype.selectedRows = function() {
266277
return [];
@@ -860,4 +871,5 @@ BaseView.prototype.isRecordCardDisabled = function() {
860871
return this.viewSection.isTableRecordCardDisabled();
861872
}
862873

874+
863875
module.exports = BaseView;

app/client/components/Cursor.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ export class Cursor extends Disposable {
112112

113113
this.fieldIndex = baseView.viewSection.viewFields().makeLiveIndex(optCursorPos.fieldIndex || 0);
114114

115-
this.autoDispose(commands.createGroup(Cursor.editorCommands, this, baseView.viewSection.enableCommands));
115+
this.autoDispose(commands.createGroup(Cursor.editorCommands, this, baseView.viewSection.hasRegionFocus));
116116

117117
// RowId might diverge from the one stored in _rowId when the data changes (it is filtered out). So here
118118
// we will calculate rowId based on rowIndex (so in reverse order), to have a proper value.

app/client/components/CustomView.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,7 @@ export class CustomView extends Disposable {
129129

130130
this.autoDispose(this.customDef.pluginId.subscribe(this._updatePluginInstance, this));
131131
this.autoDispose(this.customDef.sectionId.subscribe(this._updateCustomSection, this));
132-
this.autoDispose(commands.createGroup(CustomView._commands, this, this.viewSection.enableCommands));
132+
this.autoDispose(commands.createGroup(CustomView._commands, this, this.viewSection.hasRegionFocus));
133133

134134
this._unmappedColumns = this.autoDispose(ko.pureComputed(() => {
135135
const columns = this.viewSection.columnsToMap();

app/client/components/DetailView.js

Lines changed: 20 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ const selector = require('./CellSelector');
1818
const {CopySelection} = require('./CopySelection');
1919
const RecordLayout = require('./RecordLayout');
2020
const commands = require('./commands');
21+
const {viewCommands} = require('./RegionFocusSwitcher');
2122
const tableUtil = require('../lib/tableUtil');
2223
const {CardContextMenu} = require('../ui/CardContextMenu');
2324
const {FieldContextMenu} = require('../ui/FieldContextMenu');
@@ -127,8 +128,8 @@ function DetailView(gristDoc, viewSectionModel) {
127128

128129
//--------------------------------------------------
129130
// Instantiate CommandGroups for the different modes.
130-
this.autoDispose(commands.createGroup(DetailView.generalCommands, this, this.viewSection.enableCommands));
131-
this.autoDispose(commands.createGroup(DetailView.fieldCommands, this, this.viewSection.enableCommands));
131+
this.autoDispose(commands.createGroup(viewCommands(DetailView.detailCommands, this), this, this.viewSection.hasFocus));
132+
this.autoDispose(commands.createGroup(DetailView.detailFocusedCommands, this, this.viewSection.hasRegionFocus));
132133
const hasSelection = this.autoDispose(ko.pureComputed(() =>
133134
!this.cellSelector.isCurrentSelectType('') || this.copySelection()));
134135
this.autoDispose(commands.createGroup(DetailView.selectionCommands, this, hasSelection));
@@ -154,31 +155,33 @@ DetailView.prototype._updateFloatingRow = function() {
154155
};
155156

156157
/**
157-
* DetailView commands.
158+
* DetailView commands, enabled when the view is the active one.
158159
*/
159-
DetailView.generalCommands = {
160-
cursorUp: function() { this.cursor.fieldIndex(this.cursor.fieldIndex() - 1); },
161-
cursorDown: function() { this.cursor.fieldIndex(this.cursor.fieldIndex() + 1); },
162-
pageUp: function() { this.cursor.rowIndex(this.cursor.rowIndex() - 1); },
163-
pageDown: function() { this.cursor.rowIndex(this.cursor.rowIndex() + 1); },
164-
copy: function() { return this.copy(this.getSelection()); },
165-
cut: function() { return this.cut(this.getSelection()); },
166-
paste: function(pasteObj, cutCallback) {
167-
return this.gristDoc.docData.bundleActions(null, () => this.paste(pasteObj, cutCallback));
168-
},
169-
160+
DetailView.detailCommands = {
170161
editLayout: function() {
171162
if (this.scrolly()) {
172163
this.scrolly().scrollRowIntoView(this.cursor.rowIndex());
173164
}
174165
this.recordLayout.editLayout(this.cursor.rowIndex());
175166
},
167+
hideCardFields: function() { this._hideCardFields(); },
168+
copy: function() { return this.copy(this.getSelection()); },
169+
cut: function() { return this.cut(this.getSelection()); },
170+
paste: function(pasteObj, cutCallback) {
171+
return this.gristDoc.docData.bundleActions(null, () => this.paste(pasteObj, cutCallback));
172+
},
176173
};
177174

178-
DetailView.fieldCommands = {
175+
/**
176+
* DetailView commands, enabled when the view is user-focused.
177+
*/
178+
DetailView.detailFocusedCommands = {
179+
cursorUp: function() { this.cursor.fieldIndex(this.cursor.fieldIndex() - 1); },
180+
cursorDown: function() { this.cursor.fieldIndex(this.cursor.fieldIndex() + 1); },
181+
pageUp: function() { this.cursor.rowIndex(this.cursor.rowIndex() - 1); },
182+
pageDown: function() { this.cursor.rowIndex(this.cursor.rowIndex() + 1); },
179183
clearValues: function() { this._clearCardFields(); },
180-
hideCardFields: function() { this._hideCardFields(); },
181-
};
184+
}
182185

183186
DetailView.selectionCommands = {
184187
clearCopySelection: function() { this._clearCopySelection(); },

app/client/components/Forms/FormView.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -348,7 +348,7 @@ export class FormView extends Disposable {
348348
editField: keyboardActions.edit,
349349
deleteFields: keyboardActions.clearValues,
350350
hideFields: keyboardActions.hideFields,
351-
}, this, this.viewSection.enableCommands));
351+
}, this, this.viewSection.hasRegionFocus));
352352

353353
this._previewUrl = Computed.create(this, use => {
354354
const doc = use(this.gristDoc.docPageModel.currentDoc);

app/client/components/GridView.js

Lines changed: 55 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ const tableUtil = require('../lib/tableUtil');
1616
const {addToSort, sortBy} = require('../lib/sortUtil');
1717

1818
const commands = require('./commands');
19+
const {viewCommands} = require('./RegionFocusSwitcher');
1920
const viewCommon = require('./viewCommon');
2021
const Base = require('./Base');
2122
const BaseView = require('./BaseView');
@@ -266,8 +267,10 @@ function GridView(gristDoc, viewSectionModel, isPreview = false) {
266267
this.onEvent(this.scrollPane, 'scroll', this.onScroll);
267268

268269
//--------------------------------------------------
269-
// Command group implementing all grid level commands (except cancel)
270-
this.autoDispose(commands.createGroup(GridView.gridCommands, this, this.viewSection.enableCommands));
270+
// Command groups implementing all grid level commands (except cancel)
271+
this.autoDispose(commands.createGroup(viewCommands(GridView.gridCommands, this), this, this.viewSection.hasFocus));
272+
this.autoDispose(commands.createGroup(GridView.gridFocusedCommands, this, this.viewSection.hasRegionFocus));
273+
271274
// Cancel command is registered conditionally, only when there is an active
272275
// cell selection. This command is also used by Raw Data Views, to close the Grid popup.
273276
const hasSelection = this.autoDispose(ko.pureComputed(() =>
@@ -295,6 +298,56 @@ GridView.selectionCommands = {
295298
// TODO: move commands with modifications to gridEditCommands and use a single guard for
296299
// readonly state.
297300
GridView.gridCommands = {
301+
fillSelectionDown: function() { tableUtil.fillSelectionDown(this.getSelection(), this.tableModel); },
302+
selectAll: function() { this.selectAll(); },
303+
insertFieldBefore: function(event) { this._insertField(event, this.cursor.fieldIndex()); },
304+
insertFieldAfter: function(event) { this._insertField(event, this.cursor.fieldIndex() + 1); },
305+
makeHeadersFromRow: function() { this.makeHeadersFromRow(this.getSelection()); },
306+
renameField: function() { this.renameColumn(this.cursor.fieldIndex()); },
307+
hideFields: function() { this.hideFields(this.getSelection()); },
308+
deleteFields: function() { this._deleteFields(); },
309+
clearColumns: function() { this._clearColumns(this.getSelection()); },
310+
convertFormulasToData: function() { this._convertFormulasToData(this.getSelection()); },
311+
sortAsc: function() {
312+
sortBy(this.viewSection.activeSortSpec, this.currentColumn().getRowId(), Sort.ASC);
313+
},
314+
sortDesc: function() {
315+
sortBy(this.viewSection.activeSortSpec, this.currentColumn().getRowId(), Sort.DESC);
316+
},
317+
addSortAsc: function() {
318+
addToSort(this.viewSection.activeSortSpec, this.currentColumn().getRowId(), Sort.ASC);
319+
},
320+
addSortDesc: function() {
321+
addToSort(this.viewSection.activeSortSpec, this.currentColumn().getRowId(), Sort.DESC);
322+
},
323+
toggleFreeze: function() {
324+
// get column selection
325+
const selection = this.getSelection();
326+
// convert it menu option
327+
const options = this._getColumnMenuOptions(selection);
328+
// generate action that is available for freeze toggle
329+
const action = freezeAction(options);
330+
// if no action, do nothing
331+
if (!action) { return; }
332+
// if grist document is in readonly - simply change the value
333+
// without saving
334+
if (this.isReadonly) {
335+
this.viewSection.rawNumFrozen(action.numFrozen);
336+
return;
337+
}
338+
this.viewSection.rawNumFrozen.setAndSave(action.numFrozen);
339+
},
340+
copy: function() { console.log(this.getSelection()); return this.copy(this.getSelection()); },
341+
cut: function() { return this.cut(this.getSelection()); },
342+
paste: async function(pasteObj, cutCallback) {
343+
if (this.gristDoc.isReadonly.get()) { return; }
344+
await this.gristDoc.docData.bundleActions(null, () => this.paste(pasteObj, cutCallback));
345+
await this.scrollToCursor(false);
346+
},
347+
};
348+
349+
// These commands are enabled only when the grid is the user-focused region.
350+
GridView.gridFocusedCommands = {
298351
cursorDown: function() {
299352
if (this.cursor.rowIndex() === this.viewData.peekLength - 1) {
300353
// When the cursor is in the bottom row, the view may not be scrolled all the way to
@@ -335,58 +388,10 @@ GridView.gridCommands = {
335388
ctrlShiftUp: function () { this._shiftSelectUntilFirstOrLastNonEmptyCell({direction: 'up'}); },
336389
ctrlShiftRight: function () { this._shiftSelectUntilFirstOrLastNonEmptyCell({direction: 'right'}); },
337390
ctrlShiftLeft: function () { this._shiftSelectUntilFirstOrLastNonEmptyCell({direction: 'left'}); },
338-
fillSelectionDown: function() { tableUtil.fillSelectionDown(this.getSelection(), this.tableModel); },
339-
340-
selectAll: function() { this.selectAll(); },
341-
342391
fieldEditSave: function() { this.cursor.rowIndex(this.cursor.rowIndex() + 1); },
343392
// Re-define editField after fieldEditSave to make it take precedence for the Enter key.
344393
editField: function(event) { closeRegisteredMenu(); this.scrollToCursor(true); this.activateEditorAtCursor({event}); },
345-
insertFieldBefore: function(event) { this._insertField(event, this.cursor.fieldIndex()); },
346-
insertFieldAfter: function(event) { this._insertField(event, this.cursor.fieldIndex() + 1); },
347-
makeHeadersFromRow: function() { this.makeHeadersFromRow(this.getSelection()); },
348-
renameField: function() { this.renameColumn(this.cursor.fieldIndex()); },
349-
hideFields: function() { this.hideFields(this.getSelection()); },
350-
deleteFields: function() { this._deleteFields(); },
351394
clearValues: function() { this.clearValues(this.getSelection()); },
352-
clearColumns: function() { this._clearColumns(this.getSelection()); },
353-
convertFormulasToData: function() { this._convertFormulasToData(this.getSelection()); },
354-
copy: function() { return this.copy(this.getSelection()); },
355-
cut: function() { return this.cut(this.getSelection()); },
356-
paste: async function(pasteObj, cutCallback) {
357-
if (this.gristDoc.isReadonly.get()) { return; }
358-
await this.gristDoc.docData.bundleActions(null, () => this.paste(pasteObj, cutCallback));
359-
await this.scrollToCursor(false);
360-
},
361-
sortAsc: function() {
362-
sortBy(this.viewSection.activeSortSpec, this.currentColumn().getRowId(), Sort.ASC);
363-
},
364-
sortDesc: function() {
365-
sortBy(this.viewSection.activeSortSpec, this.currentColumn().getRowId(), Sort.DESC);
366-
},
367-
addSortAsc: function() {
368-
addToSort(this.viewSection.activeSortSpec, this.currentColumn().getRowId(), Sort.ASC);
369-
},
370-
addSortDesc: function() {
371-
addToSort(this.viewSection.activeSortSpec, this.currentColumn().getRowId(), Sort.DESC);
372-
},
373-
toggleFreeze: function() {
374-
// get column selection
375-
const selection = this.getSelection();
376-
// convert it menu option
377-
const options = this._getColumnMenuOptions(selection);
378-
// generate action that is available for freeze toggle
379-
const action = freezeAction(options);
380-
// if no action, do nothing
381-
if (!action) { return; }
382-
// if grist document is in readonly - simply change the value
383-
// without saving
384-
if (this.isReadonly) {
385-
this.viewSection.rawNumFrozen(action.numFrozen);
386-
return;
387-
}
388-
this.viewSection.rawNumFrozen.setAndSave(action.numFrozen);
389-
},
390395
viewAsCard() {
391396
const selectedRows = this.selectedRows();
392397
if (selectedRows.length !== 1) { return; }

app/client/components/GristDoc.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {RawDataPage, RawDataPopup} from 'app/client/components/RawDataPage';
2121
import {RecordCardPopup} from 'app/client/components/RecordCardPopup';
2222
import {ActionGroupWithCursorPos, UndoStack} from 'app/client/components/UndoStack';
2323
import {ViewLayout} from 'app/client/components/ViewLayout';
24+
import {RegionFocusSwitcher} from 'app/client/components/RegionFocusSwitcher';
2425
import {get as getBrowserGlobals} from 'app/client/lib/browserGlobals';
2526
import {DocPluginManager} from 'app/client/lib/DocPluginManager';
2627
import {ImportSourceElement} from 'app/client/lib/ImportSourceElement';
@@ -177,6 +178,7 @@ export interface GristDoc extends DisposableWithEvents {
177178
isTimingOn: Observable<boolean>;
178179
attachmentTransfer: Observable<AttachmentTransferStatus | null>;
179180
canShowRawData: Observable<boolean>;
181+
regionFocusSwitcher?: RegionFocusSwitcher;
180182

181183
docId(): string;
182184
openDocPage(viewId: IDocPage): Promise<void>;
@@ -220,6 +222,7 @@ export class GristDocImpl extends DisposableWithEvents implements GristDoc {
220222
public isReadonly = this.docPageModel.isReadonly;
221223
public isReadonlyKo = toKo(ko, this.isReadonly);
222224
public comparison: DocStateComparison | null;
225+
public regionFocusSwitcher?: RegionFocusSwitcher;
223226
// component for keeping track of latest cursor position
224227
public cursorMonitor: CursorMonitor;
225228
// component for keeping track of a cell that is being edited

app/client/components/RegionFocusSwitcher.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import * as commands from 'app/client/components/commands';
77
import {triggerFocusGrab} from 'app/client/components/Clipboard';
88
import {GristDoc} from 'app/client/components/GristDoc';
99
import {theme} from 'app/client/ui2018/cssVars';
10+
import BaseView from 'app/client/components/BaseView';
1011

1112
const t = makeT('RegionFocusSwitcher');
1213

@@ -53,6 +54,7 @@ export class RegionFocusSwitcher extends Disposable {
5354
if (this._gristDocObs && this._gristDocObs.get() === null) {
5455
this._gristDocObs.addListener((doc, prevDoc) => {
5556
if (doc && prevDoc === null) {
57+
doc.regionFocusSwitcher = this;
5658
this.init(pageContainer);
5759
}
5860
});
@@ -95,6 +97,13 @@ export class RegionFocusSwitcher extends Disposable {
9597
this.current.set(region);
9698
}
9799

100+
public focusActiveSection() {
101+
const gristDoc = this._getGristDoc();
102+
if (gristDoc) {
103+
this.focusRegion({type: 'section', id: gristDoc.viewModel.activeSectionId()});
104+
}
105+
}
106+
98107
public reset(initiator?: UpdateInitiator) {
99108
this.focusRegion(undefined, {initiator});
100109
}
@@ -339,6 +348,21 @@ export class RegionFocusSwitcher extends Disposable {
339348
}
340349
}
341350

351+
/**
352+
* Helper to declare view commands that should also focus current view.
353+
*
354+
* Used by a view when registering command groups.
355+
*/
356+
export const viewCommands = (commandsObject: Record<string, Function>, context: BaseView) => {
357+
return Object.keys(commandsObject).reduce<Record<string, Function>>((acc, key) => {
358+
const originalCommand = commandsObject[key];
359+
acc[key] = function(...args: any[]) {
360+
context.gristDoc.regionFocusSwitcher?.focusActiveSection();
361+
return originalCommand.apply(context, args);
362+
};
363+
return acc;
364+
}, {});
365+
};
342366

343367
const ATTRS = {
344368
regionId: 'data-grist-region-id',

0 commit comments

Comments
 (0)