mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
Merge 0c2586aa85
into 8990cd2f9b
This commit is contained in:
commit
f0af74e75b
@ -2,14 +2,11 @@
|
||||
* Clipboard component manages the copy/cut/paste events by capturing these events from the browser,
|
||||
* managing their state, and exposing an API to other components to get/set the data.
|
||||
*
|
||||
* Because of a lack of standardization of ClipboardEvents between browsers, the way Clipboard
|
||||
* captures the events is by creating a hidden textarea element that's always focused with some text
|
||||
* selected. Here is a good write-up of this:
|
||||
* https://www.lucidchart.com/techblog/2014/12/02/definitive-guide-copying-pasting-javascript/
|
||||
*
|
||||
* When ClipboardEvent is detected, Clipboard captures the event and calls the corresponding
|
||||
* copy/cut/paste/input command actions, which will get called on the appropriate component.
|
||||
*
|
||||
* The Clipboard also handles triggering correctly the "input" command when any key is pressed.
|
||||
*
|
||||
* Usage:
|
||||
* Components need to register copy/cut/paste actions with command.js:
|
||||
* .copy() should return @pasteObj (defined below).
|
||||
@ -45,7 +42,6 @@ var {confirmModal} = require('app/client/ui2018/modals');
|
||||
var {styled} = require('grainjs');
|
||||
|
||||
var commands = require('./commands');
|
||||
var dom = require('../lib/dom');
|
||||
var Base = require('./Base');
|
||||
var tableUtil = require('../lib/tableUtil');
|
||||
|
||||
@ -54,27 +50,10 @@ const t = makeT('Clipboard');
|
||||
function Clipboard(app) {
|
||||
Base.call(this, null);
|
||||
this._app = app;
|
||||
this.copypasteField = this.autoDispose(dom('textarea.copypaste.mousetrap', ''));
|
||||
this.timeoutId = null;
|
||||
|
||||
this.onEvent(this.copypasteField, 'input', function(elem, event) {
|
||||
var value = elem.value;
|
||||
elem.value = '';
|
||||
commands.allCommands.input.run(value);
|
||||
return false;
|
||||
});
|
||||
this.onEvent(this.copypasteField, 'copy', this._onCopy);
|
||||
this.onEvent(this.copypasteField, 'cut', this._onCut);
|
||||
this.onEvent(this.copypasteField, 'paste', this._onPaste);
|
||||
|
||||
document.body.appendChild(this.copypasteField);
|
||||
|
||||
FocusLayer.create(this, {
|
||||
defaultFocusElem: this.copypasteField,
|
||||
allowFocus: allowFocus,
|
||||
defaultFocusElem: document.body,
|
||||
onDefaultFocus: () => {
|
||||
this.copypasteField.value = ' ';
|
||||
this.copypasteField.select();
|
||||
this._app.trigger('clipboard_focus');
|
||||
},
|
||||
onDefaultBlur: () => {
|
||||
@ -94,6 +73,34 @@ function Clipboard(app) {
|
||||
}
|
||||
});
|
||||
|
||||
// Listen for copy/cut/paste events and trigger the corresponding clipboard action.
|
||||
// Note that internally, before triggering the action, we check if the currently active element
|
||||
// doesn't already handle these events itself.
|
||||
// This allows to globally handle copy/cut/paste events, without impacting
|
||||
// inputs/textareas/selects where copy/cut/paste events should be left alone.
|
||||
this.onEvent(document, 'copy', (_, event) => this._onCopy(event));
|
||||
this.onEvent(document, 'cut', (_, event) => this._onCut(event));
|
||||
this.onEvent(document, 'paste', (_, event) => this._onPaste(event));
|
||||
|
||||
// when typing a random printable character while not focusing an interactive element,
|
||||
// trigger the input command with it
|
||||
// @TODO: there is currently an issue, sometimes when typing something, that makes us focus a cell textarea,
|
||||
// and then we can mouseclick on a different cell: dom focus is still on textarea, visual table focus is on new cell.
|
||||
this.onEvent(document.body, 'keydown', (_, event) => {
|
||||
if (shouldAvoidClipboardShortcuts(document.activeElement)) {
|
||||
return;
|
||||
}
|
||||
const ev = event.originalEvent;
|
||||
const collapsesWithCommands = keypressCollapsesWithExistingCommand(ev);
|
||||
const isPrintableCharacter = keypressIsPrintableCharacter(ev);
|
||||
if (!collapsesWithCommands && isPrintableCharacter) {
|
||||
commands.allCommands.input.run(ev.key);
|
||||
event.preventDefault();
|
||||
} else {
|
||||
console.log(ev.key, ev.key.length, ev.code);
|
||||
}
|
||||
});
|
||||
|
||||
// In the event of a cut a callback is provided by the viewsection that is the target of the cut.
|
||||
// When called it returns the additional removal action needed for a cut.
|
||||
this._cutCallback = null;
|
||||
@ -116,7 +123,10 @@ Clipboard.commands = {
|
||||
* Internal helper fired on `copy` events. If a callback was registered from a component, calls the
|
||||
* callback to get selection data and puts it on the clipboard.
|
||||
*/
|
||||
Clipboard.prototype._onCopy = function(elem, event) {
|
||||
Clipboard.prototype._onCopy = function(event) {
|
||||
if (shouldAvoidClipboardShortcuts(document.activeElement)) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
|
||||
let pasteObj = commands.allCommands.copy.run();
|
||||
@ -136,7 +146,11 @@ Clipboard.prototype._doContextMenuCopyWithHeaders = function() {
|
||||
this._copyToClipboard(pasteObj, 'copy', true);
|
||||
};
|
||||
|
||||
Clipboard.prototype._onCut = function(elem, event) {
|
||||
Clipboard.prototype._onCut = function(event) {
|
||||
if (shouldAvoidClipboardShortcuts(document.activeElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
let pasteObj = commands.allCommands.cut.run();
|
||||
@ -211,7 +225,11 @@ Clipboard.prototype._setCutCallback = function(pasteObj, cutData) {
|
||||
* Internal helper fired on `paste` events. If a callback was registered from a component, calls the
|
||||
* callback with data from the clipboard.
|
||||
*/
|
||||
Clipboard.prototype._onPaste = function(elem, event) {
|
||||
Clipboard.prototype._onPaste = function(event) {
|
||||
if (shouldAvoidClipboardShortcuts(document.activeElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
const cb = event.originalEvent.clipboardData;
|
||||
const plainText = cb.getData('text/plain');
|
||||
@ -220,12 +238,6 @@ Clipboard.prototype._onPaste = function(elem, event) {
|
||||
this._doPaste(pasteData, plainText);
|
||||
};
|
||||
|
||||
var FOCUS_TARGET_TAGS = {
|
||||
'INPUT': true,
|
||||
'TEXTAREA': true,
|
||||
'SELECT': true,
|
||||
'IFRAME': true,
|
||||
};
|
||||
|
||||
Clipboard.prototype._doContextMenuPaste = async function() {
|
||||
let clipboardItem;
|
||||
@ -293,6 +305,17 @@ async function getTextFromClipboardItem(clipboardItem, type) {
|
||||
}
|
||||
}
|
||||
|
||||
const CLIPBOARD_TAGS = {
|
||||
'INPUT': true,
|
||||
'TEXTAREA': true,
|
||||
'SELECT': true,
|
||||
};
|
||||
|
||||
const FOCUS_TARGET_TAGS = {
|
||||
...CLIPBOARD_TAGS,
|
||||
'IFRAME': true,
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper to determine if the currently active element deserves to keep its own focus, and capture
|
||||
* copy-paste events. Besides inputs and textareas, any element can be marked to be a valid
|
||||
@ -304,6 +327,16 @@ function allowFocus(elem) {
|
||||
elem.classList.contains('clipboard_focus'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to determine if the given element is a valid target for copy-cut-paste actions.
|
||||
*
|
||||
* It slightly differs from allowFocus: here we exclusively check for clipboard-related actions,
|
||||
* not focus-related ones.
|
||||
*/
|
||||
function shouldAvoidClipboardShortcuts(elem) {
|
||||
return elem && CLIPBOARD_TAGS.hasOwnProperty(elem.tagName)
|
||||
}
|
||||
|
||||
Clipboard.allowFocus = allowFocus;
|
||||
|
||||
function showUnavailableMenuCommandModal(action) {
|
||||
@ -346,6 +379,68 @@ function showUnavailableMenuCommandModal(action) {
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Helper to know if a keypress from a keydown/keypress/etc event is an actually printable character.
|
||||
*
|
||||
* This is useful in the Clipboard where we listen for keypresses outside of an input field,
|
||||
* trying to know if the keypress should be handled by the application or not.
|
||||
*
|
||||
* @param {KeyboardEvent} event
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function keypressIsPrintableCharacter(event) {
|
||||
// We assume that any 'action' modifier key pressed will not result in a printable character.
|
||||
// This allows us to avoid stuff like "ctrl+r" (action), while keeping stuff like "altgr+r" (printable char).
|
||||
if (event.getModifierState('Control') || event.getModifierState('Meta')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Stop early if the key press does nothing, in order to prevent entering in a cell with no character.
|
||||
if (event.key === "") {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Count the number of characters in the key, using a spread operator trick to correctly count unicode characters.
|
||||
const keyLength = [...event.key].length;
|
||||
|
||||
// From testing in various languages, we can assume all keys represented by a single character are printable.
|
||||
// Stop early in that case.
|
||||
if (keyLength === 1) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// When here, `event.key` could be a non-printable character like `ArrowUp`, `Enter`, `F3`…
|
||||
// or a printable character with length > 1, like `لا` in Arabic.
|
||||
// We want to avoid the first case.
|
||||
|
||||
// Only special keys like `ArrowUp` etc are listed with uppercases when typed in lowercase.
|
||||
// Make tests around that depending on Shift key press.
|
||||
if (!event.getModifierState('Shift') && event.key.toLocaleLowerCase() === event.key
|
||||
|| (
|
||||
event.getModifierState('Shift')
|
||||
&& event.key.toLocaleLowerCase() === event.key
|
||||
&& event.key.toLocaleUpperCase() === event.key
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// If we are here, it means the key is like `ArrowUp` and others, those are not printable.
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to know if a given keypress matches an existing command shortcut.
|
||||
*
|
||||
* @param {KeyboardEvent} event
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function keypressCollapsesWithExistingCommand(event) {
|
||||
const shortcut = commands.getShortcutFromKeypress(event);
|
||||
return !!shortcut && shortcut.stopsPropagation;
|
||||
}
|
||||
|
||||
module.exports = Clipboard;
|
||||
|
||||
const cssModalContent = styled('div', `
|
||||
|
@ -127,6 +127,15 @@ export interface CommandDef {
|
||||
desc: string | null;
|
||||
bindKeys?: boolean;
|
||||
deprecated?: boolean;
|
||||
/**
|
||||
* Whether or not this command stops keyboard event propagation.
|
||||
*
|
||||
* Toggling this doesn't actually make the command stop propagation or not.
|
||||
* It's a representation of what the function called when the command runs does.
|
||||
*
|
||||
* When not set, defaults to true, as 99% of commands do stop propagation.
|
||||
*/
|
||||
stopsPropagation?: boolean;
|
||||
}
|
||||
|
||||
export interface MenuCommand {
|
||||
@ -314,7 +323,7 @@ export const groups: CommendGroupDef[] = [{
|
||||
name: 'cursorLeft',
|
||||
keys: ['Left'],
|
||||
desc: 'Move left to the previous field'
|
||||
}, {
|
||||
}, /**/{
|
||||
name: 'nextField',
|
||||
keys: ['Tab'],
|
||||
desc: 'Move to the next field, saving changes if editing a value'
|
||||
@ -322,7 +331,7 @@ export const groups: CommendGroupDef[] = [{
|
||||
name: 'prevField',
|
||||
keys: ['Shift+Tab'],
|
||||
desc: 'Move to the previous field, saving changes if editing a value'
|
||||
}, {
|
||||
}, /**/{
|
||||
name: 'pageDown',
|
||||
keys: ['PageDown'],
|
||||
desc: 'Move down one page of records, or to next record in a card list'
|
||||
@ -517,6 +526,7 @@ export const groups: CommendGroupDef[] = [{
|
||||
name: 'makeFormula',
|
||||
keys: ["="],
|
||||
desc: 'When typed at the start of a cell, make this a formula column',
|
||||
stopsPropagation: false,
|
||||
}, {
|
||||
name: 'unmakeFormula',
|
||||
keys: ['Backspace'],
|
||||
|
@ -53,6 +53,93 @@ export const allCommands: { [key in CommandName]: Command } = {} as any;
|
||||
*/
|
||||
const _allKeys: Record<string, CommandGroup[]> = {};
|
||||
|
||||
/**
|
||||
* Internal variable listing all shortcuts defined in the app,
|
||||
* and telling whether or not they stop keyboard event propagation.
|
||||
*
|
||||
* Useful to check if a user keypress matches a command shortcut.
|
||||
*/
|
||||
const _allShortcuts: { [key: string]: { keys: string, stopsPropagation: boolean } } = {};
|
||||
|
||||
/**
|
||||
* Gets a shortcut string (like "mod+o", or a shorcut keys list like ["mod", "o"])
|
||||
* and saves it in _allShortcuts, with a boolean indicating whether the command stops propagation.
|
||||
*/
|
||||
const saveShortcut = (key: string | string[], stopsPropagation?: boolean) => {
|
||||
let shortcut = "";
|
||||
const keyString = typeof key === 'string' ? key.toLowerCase() : key.join('+').toLowerCase();
|
||||
if (keyString === "+" || !keyString.includes('+')) {
|
||||
shortcut = keyString;
|
||||
} else {
|
||||
const splitKeys = keyString.split('+');
|
||||
shortcut = splitKeys.slice(0, -1).sort().join('+') + '+' + splitKeys[splitKeys.length - 1];
|
||||
}
|
||||
// If multiple commands have the same shortcut (but triggered in different contexts),
|
||||
// we assume all commands stop event propagation if at least of them do.
|
||||
// This works for now but I'm afraid it might to lead to issues in the future…
|
||||
_allShortcuts[shortcut] = {
|
||||
keys: shortcut,
|
||||
stopsPropagation: !!_allShortcuts[shortcut] && _allShortcuts[shortcut].stopsPropagation
|
||||
? true
|
||||
: stopsPropagation ?? true,
|
||||
};
|
||||
};
|
||||
|
||||
const _keyAliases: Record<string, string> = {
|
||||
'return': 'enter',
|
||||
'esc': 'escape',
|
||||
'+': 'plus',
|
||||
' ': 'space',
|
||||
};
|
||||
|
||||
/**
|
||||
* Given a keyboard event, get a string representing the keyboard shortcut of a registered command.
|
||||
*
|
||||
* @returns A string like "mod+o", or null if no command is found
|
||||
*/
|
||||
export const getShortcutFromKeypress = (event: KeyboardEvent) => {
|
||||
let key = event.key.toLowerCase();
|
||||
if (_keyAliases[key]) {
|
||||
key = _keyAliases[key];
|
||||
}
|
||||
const modifiers = [];
|
||||
if (event.shiftKey) {
|
||||
modifiers.push('shift');
|
||||
}
|
||||
if (event.altKey) {
|
||||
modifiers.push('alt');
|
||||
}
|
||||
if (event.ctrlKey) {
|
||||
modifiers.push('ctrl');
|
||||
}
|
||||
if (event.metaKey) {
|
||||
modifiers.push('meta');
|
||||
}
|
||||
if (
|
||||
modifiers.length
|
||||
&& ['shift', 'alt', 'ctrl', 'meta', 'mod', 'control', 'option', 'command'].includes(key)
|
||||
) {
|
||||
key = '';
|
||||
}
|
||||
const shortcut = modifiers.sort().join('+')
|
||||
+ (modifiers.length && key.length ? '+' : '')
|
||||
+ key;
|
||||
|
||||
if (isMac && event.metaKey && _allShortcuts[shortcut.replace('meta', 'mod')]) {
|
||||
return shortcut.replace('meta', 'mod');
|
||||
}
|
||||
|
||||
if (!isMac && event.ctrlKey && _allShortcuts[shortcut.replace('ctrl', 'mod')]) {
|
||||
return shortcut.replace('ctrl', 'mod');
|
||||
}
|
||||
|
||||
if (_allShortcuts[shortcut]) {
|
||||
return shortcut;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Populate allCommands from those provided, or listed in commandList.js. Also populates the
|
||||
* globally exposed `cmd` object whose properties invoke commands: e.g. typing `cmd.cursorDown` in
|
||||
@ -68,6 +155,9 @@ export function init(optCommandGroups?: CommendGroupDef[]) {
|
||||
Object.keys(_allKeys).forEach(function(k) {
|
||||
delete _allKeys[k as CommandName];
|
||||
});
|
||||
Object.keys(_allShortcuts).forEach(function(k) {
|
||||
delete _allShortcuts[k];
|
||||
});
|
||||
|
||||
commandGroups.forEach(function(commandGroup) {
|
||||
commandGroup.commands.forEach(function(c) {
|
||||
@ -78,6 +168,7 @@ export function init(optCommandGroups?: CommendGroupDef[]) {
|
||||
bindKeys: c.bindKeys,
|
||||
deprecated: c.deprecated,
|
||||
});
|
||||
c.keys.forEach(k => saveShortcut(k, c.stopsPropagation));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
@ -5,8 +5,6 @@ import * as commandList from 'app/client/components/commandList';
|
||||
import * as commands from 'app/client/components/commands';
|
||||
import {unsavedChanges} from 'app/client/components/UnsavedChanges';
|
||||
import {get as getBrowserGlobals} from 'app/client/lib/browserGlobals';
|
||||
import {isDesktop} from 'app/client/lib/browserInfo';
|
||||
import {FocusLayer} from 'app/client/lib/FocusLayer';
|
||||
import * as koUtil from 'app/client/lib/koUtil';
|
||||
import {reportError, TopAppModel, TopAppModelImpl} from 'app/client/models/AppModel';
|
||||
import {DocPageModel} from 'app/client/models/DocPageModel';
|
||||
@ -65,21 +63,7 @@ export class App extends DisposableWithEvents {
|
||||
this._settings = ko.observable({});
|
||||
this.features = ko.computed(() => this._settings().features || {});
|
||||
|
||||
if (isDesktop()) {
|
||||
this.autoDispose(Clipboard.create(this));
|
||||
} else {
|
||||
// On mobile, we do not want to keep focus on a special textarea (which would cause unwanted
|
||||
// scrolling and showing of mobile keyboard). But we still rely on 'clipboard_focus' and
|
||||
// 'clipboard_blur' events to know when the "app" has a focus (rather than a particular
|
||||
// input), by making document.body focusable and using a FocusLayer with it as the default.
|
||||
document.body.setAttribute('tabindex', '-1');
|
||||
FocusLayer.create(this, {
|
||||
defaultFocusElem: document.body,
|
||||
allowFocus: Clipboard.allowFocus,
|
||||
onDefaultFocus: () => this.trigger('clipboard_focus'),
|
||||
onDefaultBlur: () => this.trigger('clipboard_blur'),
|
||||
});
|
||||
}
|
||||
this.autoDispose(Clipboard.create(this));
|
||||
|
||||
this.topAppModel = this.autoDispose(TopAppModelImpl.create(null, G.window));
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user