Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for copying table cells with cmd+c hotkey #542

Merged
merged 6 commits into from
Feb 3, 2017
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/core/src/compatibility/browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,11 @@ const userAgent = typeof navigator !== "undefined" ? navigator.userAgent : "";
const browser = {
isEdge: !!userAgent.match(/Edge/),
isInternetExplorer: (!!userAgent.match(/Trident/) || !!userAgent.match(/rv:11/)),
isWebkit: !!userAgent.match(/AppleWebKit/),
};

export const Browser = {
isEdge: () => browser.isEdge,
isInternetExplorer: () => browser.isInternetExplorer,
isWebkit: () => browser.isWebkit,
};
7 changes: 6 additions & 1 deletion packages/table/preview/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,11 @@ ReactDOM.render(<Nav selected="index" />, document.getElementById("nav"));

function getTableComponent(numCols: number, numRows: number, columnProps?: any, tableProps?: any) {
// combine table overrides
const tablePropsWithDefaults = { numRows, ...tableProps };
const getCellClipboardData = (row: number, col: number) => {
return Utils.toBase26Alpha(col) + (row + 1);
};

const tablePropsWithDefaults = {numRows, getCellClipboardData, ...tableProps};

// combine column overrides
const columnPropsWithDefaults = {
Expand Down Expand Up @@ -271,6 +275,7 @@ class RowSelectableTable extends React.Component<{}, {}> {
public render() {
return (<div>
<Table
renderBodyContextMenu={renderBodyContextMenu}
numRows={7}
isRowHeaderShown={false}
onSelection={this.handleSelection}
Expand Down
67 changes: 66 additions & 1 deletion packages/table/src/table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,15 @@
* and https://github.com/palantir/blueprint/blob/master/PATENTS
*/

import { AbstractComponent, IProps } from "@blueprintjs/core";
import { AbstractComponent, IProps, Utils as BlueprintUtils } from "@blueprintjs/core";
import { Hotkey, Hotkeys, HotkeysTarget } from "@blueprintjs/core";
import * as classNames from "classnames";
import * as PureRender from "pure-render-decorator";
import * as React from "react";

import { ICellProps } from "./cell/cell";
import { Column, IColumnProps } from "./column";
import { Clipboard } from "./common/clipboard";
import { Grid } from "./common/grid";
import { Rect } from "./common/rect";
import { Utils } from "./common/utils";
Expand Down Expand Up @@ -59,6 +61,14 @@ export interface ITableProps extends IProps, IRowHeights, IColumnWidths {
*/
fillBodyWithGhostCells?: boolean;

/**
* Used for hotkey copy, via (mod+c), as long as this property exists.
* If exists, a callback that returns the data for a specific cell. This need not
* match the value displayed in the `<Cell>` component. The value will be
* invisibly added as `textContent` into the DOM before copying.
*/
getCellClipboardData?: (row: number, col: number) => any;

/**
* If false, disables resizing of columns.
* @default true
Expand Down Expand Up @@ -117,6 +127,17 @@ export interface ITableProps extends IProps, IRowHeights, IColumnWidths {
*/
onSelection?: (selectedRegions: IRegion[]) => void;

/**
* If you want to do something after the copy or if you want to notify the
* user if a copy fails, you may provide this optional callback.
*
* Due to browser limitations, the copy can fail. This usually occurs if
* the selection is too large, like 20,000+ cells. The copy will also fail
* if the browser does not support the copy method (see
* `Clipboard.isCopySupported`).
*/
onCopy?: (success: boolean) => void;

/**
* Render each row's header cell
*/
Expand Down Expand Up @@ -245,6 +266,7 @@ export interface ITableState {
}

@PureRender
@HotkeysTarget
export class Table extends AbstractComponent<ITableProps, ITableState> {
public static defaultProps: ITableProps = {
allowMultipleSelection: true,
Expand Down Expand Up @@ -366,6 +388,12 @@ export class Table extends AbstractComponent<ITableProps, ITableState> {
);
}

public renderHotkeys() {
return <Hotkeys>
{this.maybeRenderCopyHotkey()}
</Hotkeys>;
}

/**
* When the component mounts, the HTML Element refs will be available, so
* we constructor the Locator, which queries the elements' bounding
Expand Down Expand Up @@ -425,6 +453,27 @@ export class Table extends AbstractComponent<ITableProps, ITableState> {
});
}

private handleCopy = (e: KeyboardEvent) => {
const { grid } = this;
const { getCellClipboardData, onCopy} = this.props;
const { selectedRegions} = this.state;

if (getCellClipboardData == null) {
return;
}

// prevent "real" copy from being called
e.preventDefault();
e.stopPropagation();

const cells = Regions.enumerateUniqueCells(selectedRegions, grid.numRows, grid.numCols);
const sparse = Regions.sparseMapCells(cells, getCellClipboardData);
if (sparse != null) {
const success = Clipboard.copyCells(sparse);
BlueprintUtils.safeInvoke(onCopy, success);
}
}

private renderMenu() {
return (
<div
Expand Down Expand Up @@ -698,6 +747,22 @@ export class Table extends AbstractComponent<ITableProps, ITableState> {
});
}

private maybeRenderCopyHotkey() {
const { getCellClipboardData } = this.props;
if (getCellClipboardData != null) {
return (
<Hotkey
label="Copy selected table cells"
group="Table"
combo="mod+c"
onKeyDown={this.handleCopy}
/>
);
} else {
return undefined;
}
}

private maybeRenderBodyRegions() {
const styler = (region: IRegion): React.CSSProperties => {
const cardinality = Regions.getRegionCardinality(region);
Expand Down
36 changes: 36 additions & 0 deletions packages/table/test/harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,42 @@

// tslint:disable max-classes-per-file

import { Browser } from "@blueprintjs/core/dist/compatibility";
import * as React from "react";
import * as ReactDOM from "react-dom";

export type MouseEventType = "click" | "mousedown" | "mouseup" | "mousemove" | "mouseenter" | "mouseleave" ;
export type KeyboardEventType = "keypress" | "keydown" | "keyup" ;

function dispatchTestKeyboardEvent(target: EventTarget, eventType: string, key: string, modKey = false) {
const event = document.createEvent("KeyboardEvent");
const keyCode = key.charCodeAt(0);

let ctrlKey = false;
let metaKey = false;

if (modKey) {
if ((typeof navigator !== "undefined") && /Mac|iPod|iPhone|iPad/.test(navigator.platform)) {
metaKey = true;
} else {
ctrlKey = true;
}
}

(event as any).initKeyboardEvent(eventType, true, true, window, key, 0, ctrlKey, false, false, metaKey);

// Hack around these readonly properties in WebKit and Chrome
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm very sorry =(

if (Browser.isWebkit()) {
(event as any).key = key;
(event as any).which = keyCode;
} else {
Object.defineProperty(event, "key", { get: () => key });
Object.defineProperty(event, "which", { get: () => keyCode });
}

target.dispatchEvent(event);
}

// TODO: Share with blueprint-components #27

export class ElementHarness {
Expand Down Expand Up @@ -75,6 +105,12 @@ export class ElementHarness {
return this;
}

public keyboard(eventType: KeyboardEventType = "keypress", key = "", modKey = false) {

dispatchTestKeyboardEvent(this.element, eventType, key, modKey);
return this;
}

public change(value?: string) {
if (value != null) {
(this.element as HTMLInputElement).value = value;
Expand Down
17 changes: 17 additions & 0 deletions packages/table/test/selectionTests.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@

import { expect } from "chai";
import "es6-shim";
import { Clipboard } from "../src/common/clipboard";
import { Utils } from "../src/common/utils";
import { RegionCardinality, Regions, SelectionModes } from "../src/index";
import { ReactHarness } from "./harness";
import { createTableOfSize } from "./mocks/table";
Expand Down Expand Up @@ -35,6 +37,21 @@ describe("Selection", () => {
expect(onSelection.lastCall.args).to.deep.equal([[Regions.column(0)]]);
});

it("Copies selected cells when keys are pressed", () => {
const onCopy = sinon.spy();
const getCellClipboardData = (row: number, col: number) => {
return Utils.toBase26Alpha(col) + (row + 1);
};
const copyCellsStub = sinon.stub(Clipboard, "copyCells").returns(true);
const table = harness.mount(createTableOfSize(3, 7, {}, {getCellClipboardData, onCopy}));

table.find(TH_SELECTOR).mouse("mousedown").mouse("mouseup");
table.find(TH_SELECTOR).focus();
table.find(TH_SELECTOR).keyboard("keydown", "C", true);
expect(copyCellsStub.lastCall.args).to.deep.equal([[["A1"], ["A2"], ["A3"], ["A4"], ["A5"], ["A6"], ["A7"]]]);
expect(onCopy.lastCall.args).to.deep.equal([true]);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

});

it("De-selects on table body click", () => {
const onSelection = sinon.spy();
const table = harness.mount(createTableOfSize(3, 7, {}, {
Expand Down