Feat: Canvas Clipboard (#25890)

* Added constants for localstorage

* Added copy and paste to keymap.js

* Added handlers to copy seleced elements and paste elements in localstorage

* Fixed localstorage keys

* Added shortcut to cut elements

* Removed console.log

* Added toast notifications for copy/cut

* Extracted clipboard functions

* Added tests for clipboard

* Added OS specific keymaps
This commit is contained in:
Catherine Liu 2018-12-03 17:07:41 -07:00 committed by GitHub
parent 0b4ae5020b
commit 131d73d51a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 190 additions and 41 deletions

View file

@ -110,3 +110,10 @@ export const workpads = [
],
},
];
export const elements = [
{ expression: 'demodata | pointseries | getCell | repeatImage | render' },
{ expression: 'filters | demodata | markdown "hello" | render' },
{ expression: 'filters | demodata | pointseries | pie | render' },
{ expression: 'image | render' },
];

View file

@ -10,6 +10,9 @@ export const APP_ROUTE = '/app/canvas';
export const APP_ROUTE_WORKPAD = `${APP_ROUTE}#/workpad`;
export const API_ROUTE = '/api/canvas';
export const API_ROUTE_WORKPAD = `${API_ROUTE}/workpad`;
export const LOCALSTORAGE_PREFIX = `kibana.canvas`;
export const LOCALSTORAGE_CLIPBOARD = `${LOCALSTORAGE_PREFIX}.clipboard`;
export const LOCALSTORAGE_AUTOCOMPLETE_ENABLED = `${LOCALSTORAGE_PREFIX}.isAutocompleteEnabled`;
export const LOCALSTORAGE_LASTPAGE = 'canvas:lastpage';
export const FETCH_TIMEOUT = 30000; // 30 seconds
export const CANVAS_USAGE_TYPE = 'canvas';

View file

@ -20,6 +20,7 @@ import { getSelectedPage, getSelectedElement } from '../../state/selectors/workp
import { setExpression, flushContext } from '../../state/actions/elements';
import { getFunctionDefinitions } from '../../lib/function_definitions';
import { getWindow } from '../../lib/get_window';
import { LOCALSTORAGE_AUTOCOMPLETE_ENABLED } from '../../../common/lib/constants';
import { ElementNotSelected } from './element_not_selected';
import { Expression as Component } from './expression';
@ -83,12 +84,12 @@ export const Expression = compose(
dirty: false,
})),
withState('isAutocompleteEnabled', 'setIsAutocompleteEnabled', () => {
const setting = storage.get('kibana.canvas.isAutocompleteEnabled');
const setting = storage.get(LOCALSTORAGE_AUTOCOMPLETE_ENABLED);
return setting === null ? true : setting;
}),
withHandlers({
toggleAutocompleteEnabled: ({ isAutocompleteEnabled, setIsAutocompleteEnabled }) => () => {
storage.set('kibana.canvas.isAutocompleteEnabled', !isAutocompleteEnabled);
storage.set(LOCALSTORAGE_AUTOCOMPLETE_ENABLED, !isAutocompleteEnabled);
setIsAutocompleteEnabled(!isAutocompleteEnabled);
},
updateValue: ({ setFormState }) => expression => {

View file

@ -90,7 +90,7 @@ export const WorkpadHeader = ({
<WorkpadExport />
</EuiFlexItem>
<EuiFlexItem grow={false}>
{!canUserWrite && (
{canUserWrite && (
<Shortcuts name="EDITOR" handler={keyHandler} targetNodeSelector="body" global />
)}
<EuiToolTip position="bottom" content={readOnlyToolTip}>

View file

@ -7,8 +7,10 @@
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import { compose, withState, withProps } from 'recompose';
import { notify } from '../../lib/notify';
import { aeroelastic } from '../../lib/aeroelastic_kibana';
import { removeElements } from '../../state/actions/elements';
import { setClipboardData, getClipboardData } from '../../lib/clipboard';
import { removeElements, duplicateElement } from '../../state/actions/elements';
import { getFullscreen, canUserWrite } from '../../state/selectors/app';
import { getElements, isWriteable } from '../../state/selectors/workpad';
import { withEventHandlers } from './event_handlers';
@ -23,6 +25,8 @@ const mapStateToProps = (state, ownProps) => {
const mapDispatchToProps = dispatch => {
return {
duplicateElement: pageId => selectedElement =>
dispatch(duplicateElement(selectedElement, pageId)),
removeElements: pageId => elementIds => dispatch(removeElements(elementIds, pageId)),
};
};
@ -62,31 +66,63 @@ export const WorkpadPage = compose(
};
}),
withState('updateCount', 'setUpdateCount', 0), // TODO: remove this, see setUpdateCount below
withProps(({ updateCount, setUpdateCount, page, elements: pageElements, removeElements }) => {
const { shapes, selectedLeafShapes = [], cursor } = aeroelastic.getStore(page.id).currentScene;
const elementLookup = new Map(pageElements.map(element => [element.id, element]));
const elements = shapes.map(
shape =>
elementLookup.has(shape.id)
? // instead of just combining `element` with `shape`, we make property transfer explicit
{ ...shape, filter: elementLookup.get(shape.id).filter }
: shape
);
const selectedElements = selectedLeafShapes;
return {
elements,
cursor,
commit: (...args) => {
aeroelastic.commit(page.id, ...args);
// TODO: remove this, it's a hack to force react to rerender
setUpdateCount(updateCount + 1);
},
remove: () => {
// currently, handle the removal of one element, exploiting multiselect subsequently
if (selectedElements.length) removeElements(page.id)(selectedElements);
},
};
}), // Updates states; needs to have both local and global
withProps(
({
updateCount,
setUpdateCount,
page,
elements: pageElements,
removeElements,
duplicateElement,
}) => {
const { shapes, selectedLeafShapes = [], cursor } = aeroelastic.getStore(
page.id
).currentScene;
const elementLookup = new Map(pageElements.map(element => [element.id, element]));
const selectedElementIds = selectedLeafShapes;
const selectedElements = [];
const elements = shapes.map(shape => {
let element = null;
if (elementLookup.has(shape.id)) {
element = elementLookup.get(shape.id);
if (selectedElementIds.indexOf(shape.id) > -1)
selectedElements.push({ ...element, id: shape.id });
}
// instead of just combining `element` with `shape`, we make property transfer explicit
return element ? { ...shape, filter: element.filter } : shape;
});
return {
elements,
cursor,
commit: (...args) => {
aeroelastic.commit(page.id, ...args);
// TODO: remove this, it's a hack to force react to rerender
setUpdateCount(updateCount + 1);
},
remove: () => {
// currently, handle the removal of one element, exploiting multiselect subsequently
if (selectedElementIds.length) removeElements(page.id)(selectedElementIds);
},
copyElements: () => {
if (selectedElements.length) {
setClipboardData(selectedElements);
notify.success('Copied element to clipboard');
}
},
cutElements: () => {
if (selectedElements.length) {
setClipboardData(selectedElements);
removeElements(page.id)(selectedElementIds);
notify.success('Copied element to clipboard');
}
},
pasteElements: () => {
const elements = JSON.parse(getClipboardData());
if (elements) elements.map(element => duplicateElement(page.id)(element));
},
};
}
), // Updates states; needs to have both local and global
withEventHandlers // Captures user intent, needs to have reconciled state
)(Component);

View file

@ -6,6 +6,7 @@
import React, { PureComponent } from 'react';
import PropTypes from 'prop-types';
import { Shortcuts } from 'react-shortcuts';
import { ElementWrapper } from '../element_wrapper';
import { AlignmentGuide } from '../alignment_guide';
import { HoverAnnotation } from '../hover_annotation';
@ -43,6 +44,9 @@ export class WorkpadPage extends PureComponent {
onMouseUp: PropTypes.func,
onAnimationEnd: PropTypes.func,
resetHandler: PropTypes.func,
copyElements: PropTypes.func,
cutElements: PropTypes.func,
pasteElements: PropTypes.func,
};
componentWillUnmount() {
@ -66,8 +70,25 @@ export class WorkpadPage extends PureComponent {
onMouseMove,
onMouseUp,
onAnimationEnd,
copyElements,
cutElements,
pasteElements,
} = this.props;
const keyHandler = action => {
switch (action) {
case 'COPY':
copyElements();
break;
case 'CUT':
cutElements();
break;
case 'PASTE':
pasteElements();
break;
}
};
return (
<div
key={page.id}
@ -91,6 +112,14 @@ export class WorkpadPage extends PureComponent {
onAnimationEnd={onAnimationEnd}
tabIndex={0} // needed to capture keyboard events; focusing is also needed but React apparently does so implicitly
>
{isEditable && (
<Shortcuts
name="ELEMENT"
handler={keyHandler}
targetNodeSelector={`#${page.id}`}
global
/>
)}
{elements
.map(element => {
if (element.type === 'annotation') {

View file

@ -0,0 +1,16 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import expect from 'expect.js';
import { setClipboardData, getClipboardData } from '../clipboard';
import { elements } from '../../../__tests__/fixtures/workpads';
describe('clipboard', () => {
it('stores and retrieves clipboard data', () => {
setClipboardData(elements);
expect(getClipboardData()).to.eql(JSON.stringify(elements));
});
});

View file

@ -0,0 +1,13 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { Storage } from 'ui/storage';
import { LOCALSTORAGE_CLIPBOARD } from '../../common/lib/constants';
import { getWindow } from './get_window';
const storage = new Storage(getWindow().localStorage);
export const setClipboardData = data => storage.set(LOCALSTORAGE_CLIPBOARD, JSON.stringify(data));
export const getClipboardData = () => storage.get(LOCALSTORAGE_CLIPBOARD);

View file

@ -4,24 +4,49 @@
* you may not use this file except in compliance with the Elastic License.
*/
const refresh = { osx: 'option+r', windows: 'alt+r', linux: 'alt+r', other: 'alt+r' };
export const keymap = {
EDITOR: {
UNDO: 'ctrl+z',
REDO: 'ctrl+shift+y',
NEXT: 'alt+]',
PREV: 'alt+[',
FULLSCREEN: ['alt+p', 'alt+f'],
UNDO: { osx: 'command+z', windows: 'ctrl+z', linux: 'ctrl+z', other: 'ctrl+z' },
REDO: {
osx: 'command+shift+y',
windows: 'ctrl+shift+y',
linux: 'ctrl+shift+y',
other: 'ctrl+shift+y',
},
NEXT: { osx: 'option+]', windows: 'alt+]', linux: 'alt+]', other: 'alt+]' },
PREV: { osx: 'option+[', windows: 'alt+[', linux: 'alt+[', other: 'alt+[' },
FULLSCREEN: {
osx: ['option+p', 'option+f'],
windows: ['alt+p', 'alt+f'],
linux: ['alt+p', 'alt+f'],
other: ['alt+p', 'alt+f'],
},
FULLSCREEN_EXIT: ['escape'],
EDITING: ['alt+e'],
GRID: 'alt+g',
REFRESH: 'alt+r',
EDITING: { osx: 'option+e', windows: 'alt+e', linux: 'alt+e', other: 'alt+e' },
GRID: { osx: 'option+g', windows: 'alt+g', linux: 'alt+g', other: 'alt+g' },
REFRESH: refresh,
},
ELEMENT: {
DELETE: 'del',
COPY: { osx: 'command+c', windows: 'ctrl+c', linux: 'ctrl+c', other: 'ctrl+c' },
CUT: { osx: 'command+x', windows: 'ctrl+x', linux: 'ctrl+x', other: 'ctrl+x' },
PASTE: { osx: 'command+v', windows: 'ctrl+v', linux: 'ctrl+v', other: 'ctrl+v' },
DELETE: ['del', 'backspace'],
},
PRESENTATION: {
NEXT: ['space', 'right', 'alt+]'],
PREV: ['left', 'alt+['],
REFRESH: 'alt+r',
NEXT: {
osx: ['space', 'right', 'option+]'],
windows: ['space', 'right', 'alt+]'],
linux: ['space', 'right', 'alt+]'],
other: ['space', 'right', 'alt+]'],
},
PREV: {
osx: ['left', 'option+['],
windows: ['left', 'alt+['],
linux: ['left', 'alt+['],
other: ['left', 'alt+['],
},
REFRESH: refresh,
},
};

View file

@ -22,6 +22,10 @@ const options = {
pattern: 'ui/notify',
location: resolve(__dirname, '..', 'mocks', 'uiNotify'),
},
{
pattern: 'ui/storage',
location: resolve(__dirname, '..', 'mocks', 'uiStorage'),
},
{
pattern: 'ui/url/absolute_to_parsed_url',
location: resolve(__dirname, '..', 'mocks', 'absoluteToParsedUrl'),

View file

@ -0,0 +1,15 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export class Storage {
get(key) {
return this[key];
}
set(key, value) {
this[key] = value;
}
}