mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 09:19:04 -04:00
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:
parent
0b4ae5020b
commit
131d73d51a
11 changed files with 190 additions and 41 deletions
|
@ -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' },
|
||||
];
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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 => {
|
||||
|
|
|
@ -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}>
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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') {
|
||||
|
|
16
x-pack/plugins/canvas/public/lib/__tests__/clipboard.js
Normal file
16
x-pack/plugins/canvas/public/lib/__tests__/clipboard.js
Normal 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));
|
||||
});
|
||||
});
|
13
x-pack/plugins/canvas/public/lib/clipboard.js
Normal file
13
x-pack/plugins/canvas/public/lib/clipboard.js
Normal 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);
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
|
|
|
@ -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'),
|
||||
|
|
15
x-pack/plugins/canvas/tasks/mocks/uiStorage.js
Normal file
15
x-pack/plugins/canvas/tasks/mocks/uiStorage.js
Normal 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;
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue