) => Coord | null;
8 |
9 | constructor(
10 | isSelectionInProgress: boolean,
11 | ) {
12 | this.isSelectionInProgress = isSelectionInProgress;
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/src/selectionState/selectionTypes.ts:
--------------------------------------------------------------------------------
1 | import { Coord } from '../types';
2 |
3 | export type GridClickRegion = 'frozen-rows' | 'frozen-cols' | 'frozen-corner' | 'cells';
4 |
5 | export interface ClickMeta {
6 | region: GridClickRegion;
7 | }
8 |
9 | export interface CellCoordBounds {
10 | frozenRows: number;
11 | frozenCols: number;
12 | numRows: number;
13 | numCols: number;
14 | }
15 |
16 | export interface SelectRange {
17 | topLeft: Coord;
18 | bottomRight: Coord;
19 | }
20 |
--------------------------------------------------------------------------------
/examples/src/assets/feature-icon-gradient.svg:
--------------------------------------------------------------------------------
1 |
2 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
--------------------------------------------------------------------------------
/examples/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # production
12 | /build
13 |
14 | # misc
15 | .DS_Store
16 | .env.local
17 | .env.development.local
18 | .env.test.local
19 | .env.production.local
20 |
21 | npm-debug.log*
22 | yarn-debug.log*
23 | yarn-error.log*
24 |
25 | # Ignore the .grid.tsx files in public/examples - they're copied over at build time
26 | /public/examples/*.grid.tsx
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "module": "commonjs",
5 | "jsx": "react",
6 | "declaration": true,
7 | "outDir": "./dist",
8 | "strict": true,
9 | "types": [ // cypress includes types with global definitions which conflict with jest, so we limit to jest here
10 | "jest"
11 | ],
12 | "esModuleInterop": true
13 | },
14 | "exclude": [
15 | "node_modules",
16 | "dist",
17 | "examples",
18 | "cypress",
19 | "**/*.test.tsx",
20 | "**/*.test.ts",
21 | ]
22 | }
23 |
--------------------------------------------------------------------------------
/examples/src/components/ControlsForm.css:
--------------------------------------------------------------------------------
1 | form.controls {
2 | display: inline-block;
3 | margin-bottom: 1em;
4 | padding: 0.2em;
5 | background-color: hsl(220, 75%, 90%);
6 | border: solid 1px hsl(220, 100%, 40%);
7 | border-radius: 2px;
8 | }
9 |
10 | form.controls .inline-controls-group {
11 | margin-right: 1em;
12 | }
13 | form.controls .inline-controls-group:last-of-type {
14 | margin-right: 0;
15 | }
16 |
17 | form.controls input[type="number"] {
18 | width: 4em;
19 | }
20 |
21 | form.controls label {
22 | margin-right: 0.5em;
23 | }
--------------------------------------------------------------------------------
/examples/src/examples/CustomText.text.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 |
3 | export const CustomTextText = () => {
4 | return (
5 | <>
6 | Custom Text Renderer
7 |
8 | When specifying a cell definition, you can provide a renderText function
9 | to customise drawing the cell's text.
10 |
11 |
12 | Here, all cells use the same text renderer in order to draw the text as red.
13 |
14 | >
15 | );
16 | };
17 |
--------------------------------------------------------------------------------
/src/rafTestHelper.ts:
--------------------------------------------------------------------------------
1 | const rafCallbacks: FrameRequestCallback[] = [];
2 |
3 | let rafId = 0;
4 |
5 | export function execRaf() {
6 | const cb = rafCallbacks.shift();
7 | if (cb) {
8 | cb(performance.now() + 7);
9 | }
10 | }
11 |
12 | export function mockRaf() {
13 | jest.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => {
14 | rafCallbacks.push(cb);
15 | rafId++;
16 | return rafId;
17 | });
18 | }
19 |
20 | export function resetRaf() {
21 | (window.requestAnimationFrame as jest.Mock).mockRestore();
22 | }
23 |
--------------------------------------------------------------------------------
/examples/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "lib": [
5 | "dom",
6 | "dom.iterable",
7 | "esnext"
8 | ],
9 | "allowJs": true,
10 | "skipLibCheck": true,
11 | "esModuleInterop": true,
12 | "allowSyntheticDefaultImports": true,
13 | "strict": true,
14 | "forceConsistentCasingInFileNames": true,
15 | "module": "esnext",
16 | "moduleResolution": "node",
17 | "resolveJsonModule": true,
18 | "isolatedModules": true,
19 | "noEmit": true,
20 | "jsx": "preserve"
21 | },
22 | "include": [
23 | "src"
24 | ]
25 | }
26 |
--------------------------------------------------------------------------------
/examples/src/examples/FocusColumn.text.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 |
3 | export const FocusColumnText = () => {
4 | return (
5 | <>
6 | Focused Columns
7 |
8 | Updates to the focusedColIndex cause the grid to automatically scroll
9 | to ensure the indicated column is displayed. The scrolling behaviour is aware of
10 | frozen columns.
11 |
12 |
13 | This can be useful for building a 'search' feature.
14 |
15 | >
16 | );
17 | };
18 |
--------------------------------------------------------------------------------
/examples/src/examples/CustomTitle.text.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 |
3 | export const CustomTitleText = () => {
4 | return (
5 | <>
6 | Custom Title Text
7 |
8 | When specifying a cell definition, you can provide either a getTitle function
9 | or a title property to specify the title text shown when hovering over that cell.
10 |
11 |
12 | The title text is displayed via the browser's native title text mechanism.
13 |
14 | >
15 | );
16 | };
17 |
--------------------------------------------------------------------------------
/examples/src/examples/FrozenCells.text.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 |
3 | export const FrozenCellsText = () => {
4 | return (
5 | <>
6 | Frozen Rows & Columns
7 |
8 | By setting the frozenRows and frozenCols props, rows
9 | and columns of cells can be 'frozen' - i.e. fixed in place, even as the rest of
10 | the grid scrolls.
11 |
12 |
13 | This can be useful for creating column or row headers.
14 |
15 | >
16 | );
17 | };
18 |
--------------------------------------------------------------------------------
/examples/src/examples/CustomBackground.text.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 |
3 | export const CustomBackgroundText = () => {
4 | return (
5 | <>
6 | Custom Background Renderer
7 |
8 | When specifying a cell definition, you can provide a renderBackground function
9 | to customise drawing the cell's background.
10 |
11 |
12 | Here, all cells use the same background renderer in order to draw the background as
13 | light green.
14 |
15 | >
16 | );
17 | };
18 |
--------------------------------------------------------------------------------
/examples/src/examples/Simple.grid.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { ReactCanvasGrid } from 'react-canvas-grid';
3 | import { FixedSizeHolder } from '../components/FixedSizeHolder';
4 | import { createFakeDataAndColumns } from '../data/dataAndColumns';
5 |
6 | export const SimpleGrid = () => {
7 | const { columns, rows: data } = createFakeDataAndColumns(100, 20, () => {/* no op */});
8 |
9 | return (
10 |
11 |
12 | columns={columns}
13 | data={data}
14 | rowHeight={20}
15 | />
16 |
17 | );
18 | };
19 |
--------------------------------------------------------------------------------
/examples/src/examples/Index.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { Link } from 'react-router-dom';
3 | import exampleMeta from './exampleMeta';
4 |
5 | export const Index = () => {
6 | return (
7 |
8 | Examples
9 |
10 | {exampleMeta.map((meta) => (
11 |
12 | {meta.name}
13 | {meta.description}
14 |
15 | ))}
16 |
17 |
18 | );
19 | };
20 |
--------------------------------------------------------------------------------
/cypress/integration/grid-resizing.tsx:
--------------------------------------------------------------------------------
1 | describe('ReactCanvasGrid with resizable cssWidth / cssHeight', () => {
2 | beforeEach(() => {
3 | cy.visit('/#/examples/resize');
4 | cy.get('.react-canvas-grid').as('Root');
5 | cy.get('.react-canvas-grid canvas').eq(1).as('Canvas');
6 |
7 | cy.get('select').as('SizeSelect');
8 |
9 | cy.get('Canvas').invoke('width').should('be.greaterThan', 0);
10 | });
11 |
12 | it('redraws when cssWidth / cssHeight are increased', () => {
13 | cy.get('@SizeSelect').select('big');
14 |
15 | cy.get('@Canvas')
16 | .matchImageSnapshot('resize-grid-small-to-large');
17 | });
18 | });
19 |
--------------------------------------------------------------------------------
/examples/src/App.css:
--------------------------------------------------------------------------------
1 | .app-container {
2 | display: flex;
3 | flex: 1;
4 | flex-direction: column;
5 | }
6 |
7 | nav.top-nav {
8 | height: 3em;
9 | line-height: 3em;
10 | display: inline-flex;
11 | flex-direction: row;
12 | background-color: var(--primary-colour);
13 | }
14 |
15 | .top-nav .top-nav-item {
16 | margin-left: 1em;
17 | }
18 |
19 | .top-nav .top-nav-item a {
20 | color: white;
21 | }
22 | .top-nav .top-nav-item a.active {
23 | color: var(--secondary-colour);
24 | }
25 | .top-nav .top-nav-item a:hover {
26 | filter: drop-shadow(0 3px 3px rgba(0, 0, 0, 0.3));
27 | }
28 |
29 | .top-nav .logo-svg {
30 | height: 100%;
31 | padding: 5px;
32 | }
--------------------------------------------------------------------------------
/src/baseGridOffsetRenderer.ts:
--------------------------------------------------------------------------------
1 | import { CommonCanvasRenderer } from './commonCanvasRenderer';
2 | import { Coord } from './types';
3 |
4 | export interface CanvasRendererPosition {
5 | gridOffset: Coord;
6 | visibleRect: ClientRect;
7 | }
8 |
9 | const defaultPosProps = {
10 | gridOffset: { x: 0, y: 0 },
11 | visibleRect: { left: 0, top: 0, right: 0, bottom: 0, height: 0, width: 0 },
12 | };
13 |
14 | export class BaseGridOffsetRenderer extends CommonCanvasRenderer {
15 | protected posProps: CanvasRendererPosition = defaultPosProps;
16 |
17 | public translate() {
18 | this.context.translate(-this.posProps.gridOffset.x, -this.posProps.gridOffset.y);
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/cypress/integration/frozen-cells.tsx:
--------------------------------------------------------------------------------
1 | describe('ReactCanvasGrid with frozen rows & cells', () => {
2 | beforeEach(() => {
3 | cy.visit('/#/examples/frozen');
4 | cy.get('.fixed-size-holder').as('Holder');
5 | cy.get('.fixed-size-holder .react-canvas-grid').as('Root');
6 | cy.get('.fixed-size-holder canvas').eq(1).as('Canvas');
7 |
8 | cy.get('Canvas').invoke('width').should('be.greaterThan', 0);
9 | });
10 |
11 | it('keeps the frozen rows and columns shown on the grid (and fixes the top-left cells in place)', () => {
12 | cy.get('@Root')
13 | .trigger('wheel', { deltaX: 300, deltaY: 300 })
14 | .matchImageSnapshot('scrolled-grid-with-frozen-cells');
15 | });
16 | });
17 |
--------------------------------------------------------------------------------
/examples/src/examples/FrozenCells.grid.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { ReactCanvasGrid } from 'react-canvas-grid';
3 | import { FixedSizeHolder } from '../components/FixedSizeHolder';
4 | import { createFakeDataAndColumns } from '../data/dataAndColumns';
5 |
6 | export const FrozenCellsGrid = () => {
7 | const { columns, rows: data } = createFakeDataAndColumns(100, 20, () => {/* no op */});
8 |
9 | return (
10 |
11 |
12 | columns={columns}
13 | data={data}
14 | rowHeight={20}
15 | frozenRows={1}
16 | frozenCols={1}
17 | />
18 |
19 | );
20 | };
21 |
--------------------------------------------------------------------------------
/examples/src/examples/SelectionEvents.text.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 |
3 | export const SelectionEventsText = () => {
4 | return (
5 | <>
6 | Selection Events
7 |
8 | The three callbacks onSelectionChange[Start|Update|End] allow consumers
9 | of react-canvas-grid to take action in response to the user changing the selected area.
10 | This can be useful for keeping track of the selection, in order to act upon the data.
11 |
12 |
13 | Note that clicking / dragging on frozen headers allows the user to select entire rows / columns.
14 |
15 | >
16 | );
17 | };
18 |
--------------------------------------------------------------------------------
/cypress/support/index.js:
--------------------------------------------------------------------------------
1 | // ***********************************************************
2 | // This example support/index.js is processed and
3 | // loaded automatically before your test files.
4 | //
5 | // This is a great place to put global configuration and
6 | // behavior that modifies Cypress.
7 | //
8 | // You can change the location of this file or turn off
9 | // automatically serving support files with the
10 | // 'supportFile' configuration option.
11 | //
12 | // You can read more here:
13 | // https://on.cypress.io/configuration
14 | // ***********************************************************
15 |
16 | // Import commands.js using ES2015 syntax:
17 | import './commands'
18 |
19 | // Alternatively you can use CommonJS syntax:
20 | // require('./commands')
--------------------------------------------------------------------------------
/src/selectionState/focusOffset.ts:
--------------------------------------------------------------------------------
1 | import { GridGeometry } from '../gridGeometry';
2 | import { GridState } from '../gridState';
3 | import { Coord } from '../types';
4 |
5 | export function calculateGridOffsetForTargetCell(gridState: GridState, focusCell: Coord) {
6 | return GridGeometry.calculateGridOffsetForTargetCell(
7 | gridState.gridOffset(),
8 | gridState.canvasSize(),
9 | gridState.frozenColsWidth(),
10 | gridState.frozenRowsHeight(),
11 | focusCell,
12 | gridState.columnBoundaries(),
13 | gridState.rowHeight(),
14 | gridState.borderWidth(),
15 | gridState.data().length,
16 | gridState.verticalGutterBounds(),
17 | gridState.horizontalGutterBounds(),
18 | );
19 | }
20 |
--------------------------------------------------------------------------------
/examples/src/components/EventLog.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import './EventLog.css';
3 |
4 | interface EventLogProps {
5 | log: string;
6 | }
7 |
8 | export class EventLog extends React.PureComponent {
9 | private logRef: React.RefObject;
10 |
11 | constructor(props: EventLogProps) {
12 | super(props);
13 | this.logRef = React.createRef();
14 | }
15 |
16 | public render() {
17 | return ();
18 | }
19 |
20 | public componentDidUpdate() {
21 | if (this.logRef.current) {
22 | this.logRef.current.scrollTop = this.logRef.current.scrollHeight;
23 | }
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/examples/src/examples/SmallGrid.grid.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { ReactCanvasGrid } from 'react-canvas-grid';
3 |
4 | export const SmallGridGrid = () => {
5 | return (
6 |
7 | cssHeight={'45px'}
8 | columns={[ { fieldName: 'field-one', width: 50 }, { fieldName: 'field-two', width: 50 } ]}
9 | data={[{
10 | 'field-one': { data: undefined, text: '1A' },
11 | 'field-two': { data: undefined, text: '1B' },
12 | },
13 | {
14 | 'field-one': { data: undefined, text: '2A' },
15 | 'field-two': { data: undefined, text: '2B' },
16 | }]}
17 | rowHeight={20}
18 | />
19 | );
20 | };
21 |
--------------------------------------------------------------------------------
/cypress/integration/simple-rendering.tsx:
--------------------------------------------------------------------------------
1 | describe('ReactCanvasGrid in a fixed size parent', () => {
2 | beforeEach(() => {
3 | cy.visit('/#/examples/simple');
4 | cy.get('.fixed-size-holder').as('Holder');
5 | cy.get('.fixed-size-holder canvas').eq(1).as('Canvas');
6 |
7 | cy.get('Canvas').invoke('width').should('be.greaterThan', 0);
8 | });
9 |
10 | it('renders a grid of data', () => {
11 | cy.get('@Canvas').matchImageSnapshot('simple-grid-in-scroll');
12 | });
13 |
14 | it('can be scrolled to the middle', () => {
15 | cy.get('@Holder')
16 | .trigger('wheel', { deltaX: 300, deltaY: 300 });
17 |
18 | cy.get('@Canvas')
19 | .matchImageSnapshot('scrolled-grid-in-scroll');
20 | });
21 | });
22 |
--------------------------------------------------------------------------------
/examples/src/examples/Simple.text.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 |
3 | export const SimpleText = () => {
4 | return (
5 | <>
6 | Simple Grid
7 |
8 | This is a basic usage of react-canvas-grid: a read-only grid of static values, held
9 | within a div of fixed size.
10 |
11 |
12 | Note that because the cssHeight and cssWidth props of
13 | ReactCanvasGrid default to 100%, the grid is constrained
14 | to the size of its parent. Since the data in the grid requires a larger area than
15 | that, the grid becomes scrollable.
16 |
17 | >
18 | );
19 | };
20 |
--------------------------------------------------------------------------------
/cypress/plugins/index.js:
--------------------------------------------------------------------------------
1 | // ***********************************************************
2 | // This example plugins/index.js can be used to load plugins
3 | //
4 | // You can change the location of this file or turn off loading
5 | // the plugins file with the 'pluginsFile' configuration option.
6 | //
7 | // You can read more here:
8 | // https://on.cypress.io/plugins-guide
9 | // ***********************************************************
10 |
11 | const webpack = require('@cypress/webpack-preprocessor');
12 | const { addMatchImageSnapshotPlugin } = require('cypress-image-snapshot/plugin');
13 |
14 | // This function is called when a project is opened or re-opened (e.g. due to
15 | // the project's config changing)
16 | module.exports = (on, config) => {
17 | on('file:preprocessor', webpack({
18 | webpackOptions: require('../../webpack.config'),
19 | }));
20 |
21 | addMatchImageSnapshotPlugin(on, config);
22 | };
--------------------------------------------------------------------------------
/cypress/integration/custom-rendering.tsx:
--------------------------------------------------------------------------------
1 | describe('ReactCanvasGrid', () => {
2 | it('uses custom background renderers from cell data', () => {
3 | cy.visit('/#/examples/custom-bg');
4 | cy.get('.fixed-size-holder .react-canvas-grid').as('Root');
5 | cy.get('.fixed-size-holder canvas').eq(1).as('Canvas');
6 |
7 | cy.get('Canvas').invoke('width').should('be.greaterThan', 0);
8 |
9 | cy.get('@Root').matchImageSnapshot('custom-render-background');
10 | });
11 |
12 | it('uses custom text renderers from cell data', () => {
13 | cy.visit('/#/examples/custom-text');
14 | cy.get('.fixed-size-holder .react-canvas-grid').as('Root');
15 | cy.get('.fixed-size-holder canvas').eq(1).as('Canvas');
16 |
17 | cy.get('Canvas').invoke('width').should('be.greaterThan', 0);
18 |
19 | cy.get('@Root').matchImageSnapshot('custom-render-text');
20 | });
21 | });
22 |
--------------------------------------------------------------------------------
/src/utils.ts:
--------------------------------------------------------------------------------
1 | import { SelectRange } from './selectionState/selectionTypes';
2 | import { Bounds, Coord } from './types';
3 |
4 | export function numberBetween(num: number, min: number, max: number) {
5 | return Math.max(Math.min(num, max), min);
6 | }
7 |
8 | export function equalCoord(a: Coord, b: Coord): boolean {
9 | return a.x === b.x && a.y === b.y;
10 | }
11 |
12 | export function equalSelectRange(a: SelectRange|null, b: SelectRange|null): boolean {
13 | return a === b ||
14 | (a !== null && b !== null && equalCoord(a.topLeft, b.topLeft) && equalCoord(a.bottomRight, b.bottomRight));
15 | }
16 |
17 | export function equalBounds(a: Bounds|null, b: Bounds|null): boolean {
18 | return a === b || (
19 | a !== null &&
20 | b !== null &&
21 | a.top === b.top &&
22 | a.bottom === b.bottom &&
23 | a.left === b.left &&
24 | a.right === b.right
25 | );
26 | }
27 |
--------------------------------------------------------------------------------
/src/eventHandlers/scrolling.ts:
--------------------------------------------------------------------------------
1 | import { GridState } from '../gridState';
2 | import { numberBetween } from '../utils';
3 |
4 | export const updateOffsetByDelta = (
5 | deltaX: number,
6 | deltaY: number,
7 | gridState: GridState,
8 | ): boolean => {
9 | if (!gridState.rootSize()) {
10 | return false;
11 | }
12 | const canvasSize = gridState.canvasSize();
13 | const gridSize = gridState.gridSize();
14 | const gridOffset = gridState.gridOffset();
15 | const newX = numberBetween(gridOffset.x + deltaX, 0, gridSize.width - canvasSize.width);
16 | const newY = numberBetween(gridOffset.y + deltaY, 0, gridSize.height - canvasSize.height);
17 |
18 | if (newX === gridOffset.x && newY === gridOffset.y) {
19 | // We won't be moving, so return false
20 | return false;
21 | }
22 |
23 | gridState.gridOffsetRaw({ x: newX, y: newY });
24 |
25 | // We did move, so return true
26 | return true;
27 | };
28 |
--------------------------------------------------------------------------------
/examples/src/examples/CustomTitle.grid.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { CellDef, ReactCanvasGrid } from 'react-canvas-grid';
3 | import { FixedSizeHolder } from '../components/FixedSizeHolder';
4 | import { createFakeDataAndColumns } from '../data/dataAndColumns';
5 |
6 | interface CellData {
7 | x: number;
8 | y: number;
9 | }
10 |
11 | export const CustomTitleGrid = () => {
12 | const titleGenerator = (data: CellData) => `Title for ${data.y + 1}x${data.x + 1}`;
13 | const options: Partial> = { getTitle: titleGenerator };
14 | const dataGenerator = (x: number, y: number) => ({ x, y });
15 | const { columns, rows: data } = createFakeDataAndColumns(100, 20, dataGenerator, options);
16 |
17 | return (
18 |
19 |
20 | columns={columns}
21 | data={data}
22 | rowHeight={20}
23 | />
24 |
25 | );
26 | };
27 |
--------------------------------------------------------------------------------
/examples/src/examples/CustomBackground.grid.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { ReactCanvasGrid } from 'react-canvas-grid';
3 | import { FixedSizeHolder } from '../components/FixedSizeHolder';
4 | import { createFakeDataAndColumns } from '../data/dataAndColumns';
5 |
6 | const renderBackgroundLightGreen = (context: CanvasRenderingContext2D, cellBounds: ClientRect) => {
7 | context.fillStyle = 'lightgreen';
8 | context.fillRect(cellBounds.left, cellBounds.top, cellBounds.width, cellBounds.height);
9 | };
10 |
11 | export const CustomBackgroundGrid = () => {
12 | const options = { renderBackground: renderBackgroundLightGreen };
13 | const { columns, rows: data } = createFakeDataAndColumns(100, 20, () => {/* no op */}, options);
14 |
15 | return (
16 |
17 |
18 | columns={columns}
19 | data={data}
20 | rowHeight={20}
21 | />
22 |
23 | );
24 | };
25 |
--------------------------------------------------------------------------------
/examples/src/index.css:
--------------------------------------------------------------------------------
1 | :root {
2 | --primary-colour: hsl(123, 59%, 36%);
3 | --primary-colour-light: hsl(123, 59%, 47%);
4 | --primary-colour-dark: hsl(123, 59%, 25%);
5 | --secondary-colour: hsl(73, 87%, 82%);
6 | }
7 |
8 | body {
9 | margin: 0;
10 | padding: 0;
11 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
12 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
13 | sans-serif;
14 | -webkit-font-smoothing: antialiased;
15 | -moz-osx-font-smoothing: grayscale;
16 | }
17 |
18 | code {
19 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
20 | monospace;
21 | }
22 |
23 | button.link {
24 | background: none!important;
25 | border: none;
26 | padding: 0!important;
27 | cursor: pointer;
28 | }
29 | a, .link {
30 | color: var(--primary-colour);
31 | font-weight: bold;
32 | text-decoration: none;
33 | }
34 | a:hover, .link:hover {
35 | color: var(--primary-colour-light);
36 | }
37 |
38 | p {
39 | line-height: 1.3em;
40 | }
--------------------------------------------------------------------------------
/examples/src/App.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { HashRouter, NavLink, Route } from 'react-router-dom';
3 | import './App.css';
4 | import Examples from './Examples';
5 | import Home from './Home';
6 |
7 | const App = () => {
8 | return (
9 |
10 |
11 |
12 | Home
13 | Examples
14 |
15 | GitHub
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 | );
25 | };
26 |
27 | export default App;
28 |
--------------------------------------------------------------------------------
/cypress/support/commands.js:
--------------------------------------------------------------------------------
1 | // ***********************************************
2 | // This example commands.js shows you how to
3 | // create various custom commands and overwrite
4 | // existing commands.
5 | //
6 | // For more comprehensive examples of custom
7 | // commands please read more here:
8 | // https://on.cypress.io/custom-commands
9 | // ***********************************************
10 | //
11 | //
12 | // -- This is a parent command --
13 | // Cypress.Commands.add("login", (email, password) => { ... })
14 | //
15 | //
16 | // -- This is a child command --
17 | // Cypress.Commands.add("drag", { prevSubject: 'element'}, (subject, options) => { ... })
18 | //
19 | //
20 | // -- This is a dual command --
21 | // Cypress.Commands.add("dismiss", { prevSubject: 'optional'}, (subject, options) => { ... })
22 | //
23 | //
24 | // -- This is will overwrite an existing command --
25 | // Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... })
26 |
27 | import { addMatchImageSnapshotCommand } from 'cypress-image-snapshot/command';
28 |
29 | addMatchImageSnapshotCommand();
--------------------------------------------------------------------------------
/examples/src/examples/KeyboardEvents.text.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { Link } from 'react-router-dom';
3 |
4 | export const KeyboardEventsText = () => {
5 | return (
6 | <>
7 | Keyboard Events
8 |
9 | Keyboard events bubble up, so can be captured the parent element of the grid (when
10 | the grid has focus).
11 |
12 |
13 | Note that keyboard events will also bubble up when the user interacts with the
14 | inline editor, which you may wish to ignore. To do so, you may wish to observe
15 | the grid's edit events.
16 |
17 |
18 | Also note that the editing functionality provided in this example is incomplete;
19 | changes are not persisted. To see a more complete example, see
20 | the Editable Data example.
21 |
22 | >
23 | );
24 | };
25 |
--------------------------------------------------------------------------------
/examples/src/assets/structure.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/examples/src/examples/CustomText.grid.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { CellDef, cellHasTextFunction, ReactCanvasGrid } from 'react-canvas-grid';
3 | import { FixedSizeHolder } from '../components/FixedSizeHolder';
4 | import { createFakeDataAndColumns } from '../data/dataAndColumns';
5 |
6 | const renderTextRed = (context: CanvasRenderingContext2D, cellBounds: ClientRect, cell: CellDef) => {
7 | context.fillStyle = 'red';
8 | const text = cellHasTextFunction(cell) ? cell.getText(cell.data) : cell.text;
9 | context.fillText(text, cellBounds.left + 2, cellBounds.top + 15, cellBounds.width - 4);
10 | };
11 |
12 | export const CustomTextGrid = () => {
13 | const options = { renderText: renderTextRed };
14 | const { columns, rows: data } = createFakeDataAndColumns(100, 20, () => {/* no op */}, options);
15 |
16 | return (
17 |
18 |
19 | columns={columns}
20 | data={data}
21 | rowHeight={20}
22 | />
23 |
24 | );
25 | };
26 |
--------------------------------------------------------------------------------
/examples/src/examples/Resize.text.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 |
3 | export const ResizeText = () => {
4 | return (
5 | <>
6 | Resizable Grid
7 |
8 | The cssWidth and cssHeight props of react-canvas-grid control
9 | the grid's width and height. Both default to '100%', but accept any value that would be
10 | valid in CSS.
11 |
12 |
13 | If these props are updated, the grid will resize and redraw.
14 |
15 |
16 | Note, however, this is only true when the value of the props passed to the grid changes
17 | - so, for example, the grid will not automatically resize if cssWidth
18 | and cssHeight are set to percentages and their parent element changes size.
19 | If the grid must be reactive to the size of a parent element, the size of the grid must be
20 | set dynamically (e.g. triggered by a window resize event or via a ResizeObserver).
21 |
22 | >
23 | );
24 | };
25 |
--------------------------------------------------------------------------------
/src/selectionState/selectionStateFactory.ts:
--------------------------------------------------------------------------------
1 | import { Coord } from '../types';
2 | import { AllGridSelection } from './allGridSelection';
3 | import { CellsSelection } from './cellsSelection';
4 | import { ColsSelection } from './colsSelection';
5 | import { NoSelection } from './noSelection';
6 | import { RowsSelection } from './rowsSelection';
7 | import { ClickMeta } from './selectionTypes';
8 |
9 | export type AllSelectionStates = CellsSelection | RowsSelection | ColsSelection | AllGridSelection | NoSelection;
10 |
11 | export const createSelectionStateForMouseDown = (cell: Coord, meta: ClickMeta) => {
12 | switch (meta.region) {
13 | case 'cells':
14 | return new CellsSelection(cell, { left: cell.x, right: cell.x, top: cell.y, bottom: cell.y }, cell, true);
15 | case 'frozen-cols':
16 | return new RowsSelection(cell, cell.y, cell.y, true, cell.y);
17 | case 'frozen-rows':
18 | return new ColsSelection(cell, cell.x, cell.x, true, cell.x);
19 | case 'frozen-corner':
20 | return new AllGridSelection(true);
21 | default:
22 | throw new Error(`Unsupported click meta region: ${meta.region}`);
23 | }
24 | };
25 |
--------------------------------------------------------------------------------
/src/HighlightedGridCanvas.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { GridCanvas, GridCanvasProps } from './GridCanvas';
3 | import { HighlightCanvas, HighlightCanvasProps } from './HighlightCanvas';
4 |
5 | type LayeredCanvasesProps = GridCanvasProps & HighlightCanvasProps;
6 |
7 | export const HighlightedGridCanvas = (props: LayeredCanvasesProps) => {
8 | return (
9 | <>
10 |
20 |
30 | >
31 | );
32 | };
33 |
--------------------------------------------------------------------------------
/examples/src/examples/EditEvents.text.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { Link } from 'react-router-dom';
3 |
4 | export const EditEventsText = () => {
5 | return (
6 | <>
7 | Edit Events
8 |
9 | The callback onCellDataChanged allow consumers of react-canvas-grid to
10 | respond to the user making changes to the data. Typically, this is used to update the
11 | data passed to the grid in the data prop. Without doing so, the grid's
12 | data will not be changed. See the Editable Data example
13 | for further detail. This example does not update the data, but merely logs event.
14 |
15 |
16 | Note that the callback is fired when the user hits enter on the inline editor, or when
17 | the inline editor loses focus, regardless of whether the data has been changed. The
18 | inline editor can be dismissed with the escape key, regardless of whether the data has
19 | been changed.
20 |
21 | >
22 | );
23 | };
24 |
--------------------------------------------------------------------------------
/src/selectionState/noSelection.ts:
--------------------------------------------------------------------------------
1 | import { GridState } from '../gridState';
2 | import { Coord } from '../types';
3 | import { BaseSelectionState } from './selectionState';
4 | import { createSelectionStateForMouseDown } from './selectionStateFactory';
5 | import { CellCoordBounds, ClickMeta } from './selectionTypes';
6 |
7 | export class NoSelection extends BaseSelectionState {
8 | public arrowUp = () => this;
9 | public arrowDown = () => this;
10 | public arrowLeft = () => this;
11 | public arrowRight = () => this;
12 | public shiftArrowUp = () => this;
13 | public shiftArrowDown = () => this;
14 | public shiftArrowLeft = () => this;
15 | public shiftArrowRight = () => this;
16 |
17 | public mouseMove = () => this;
18 | public mouseUp = () => this;
19 |
20 | public mouseDown = (cell: Coord, meta: ClickMeta) => createSelectionStateForMouseDown(cell, meta);
21 |
22 | public shiftMouseDown = (cell: Coord, meta: ClickMeta) => {
23 | return this.mouseDown(cell, meta);
24 | }
25 |
26 | public getSelectionRange = () => null;
27 |
28 | public getCursorCell = () => null;
29 |
30 | public getFocusGridOffset = (_: GridState): Coord|null => null;
31 | }
32 |
--------------------------------------------------------------------------------
/src/cellRenderer.ts:
--------------------------------------------------------------------------------
1 | import { CellDef, CustomDrawCallbackMetadata, getCellText } from './types';
2 |
3 | export const drawCell = (
4 | context: CanvasRenderingContext2D,
5 | cell: CellDef,
6 | cellBounds: ClientRect,
7 | metadata: CustomDrawCallbackMetadata,
8 | ) => {
9 | const renderBackground = cell.renderBackground || drawCellBackgroundDefault;
10 | const renderText = cell.renderText || drawCellTextDefault;
11 |
12 | renderBackground(context, cellBounds, cell, metadata);
13 | renderText(context, cellBounds, cell, metadata);
14 | };
15 |
16 | const drawCellBackgroundDefault = (
17 | context: CanvasRenderingContext2D,
18 | cellBounds: ClientRect,
19 | ) => {
20 | context.fillStyle = 'white';
21 | context.fillRect(cellBounds.left, cellBounds.top, cellBounds.width, cellBounds.height);
22 | };
23 |
24 | const drawCellTextDefault = (
25 | context: CanvasRenderingContext2D,
26 | cellBounds: ClientRect,
27 | cell: CellDef,
28 | ) => {
29 | context.fillStyle = 'black';
30 | context.textBaseline = 'middle';
31 | const verticalCentre = cellBounds.top + (cellBounds.height / 2);
32 | const text = getCellText(cell);
33 | context.fillText(text, cellBounds.left + 2, verticalCentre, cellBounds.width - 4);
34 | };
35 |
--------------------------------------------------------------------------------
/examples/src/components/ControlsForm.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import './ControlsForm.css';
3 |
4 | export const ControlsForm = (props: { children?: React.ReactNode }) => {
5 | return (
6 |
9 | );
10 | };
11 |
12 | export const InlineGroup = (props: { children?: React.ReactNode }) => {
13 | return (
14 |
15 | {props.children}
16 |
17 | );
18 | };
19 |
20 | export const NumberInput = (props: Partial>) =>
21 | ;
22 |
23 | interface SelectInputProps {
24 | values: T[];
25 | selectedValue: T;
26 | onSelect: (event: React.ChangeEvent) => void;
27 | }
28 | export const RadioInputs = (
29 | { values, selectedValue, onSelect}: SelectInputProps,
30 | ) => {
31 | return
32 | {values.map((val) =>
33 |
34 |
35 | {val}
36 | )}
37 | ;
38 | };
39 |
--------------------------------------------------------------------------------
/examples/src/examples/DynamicData.text.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 |
3 | export const DyanmicDataText = () => {
4 | return (
5 | <>
6 | Dynamic Data
7 |
8 | Updates to the columns and data props cause the grid to
9 | re-render with new values.
10 |
11 |
12 | The selected cells are cleared if columns changes or the number of rows
13 | (in data) changes. The selection is not cleared, however, if columns
14 | stays the same and data is replaced with an array of the same length (even if the
15 | contents within the array is different). I.e. if the structure of the grid has changed
16 | then the selection is cleared, but if only the contents change then the selection is
17 | retained.
18 |
19 |
20 | The scroll position is maintained as long as it is valid in the newly rendered grid. If not
21 | (because there are fewer columns or rows) the scroll position is truncated.
22 |
23 | >
24 | );
25 | };
26 |
--------------------------------------------------------------------------------
/cypress/integration/keyboard-events.tsx:
--------------------------------------------------------------------------------
1 | describe('ReactCanvasGrid', () => {
2 | beforeEach(() => {
3 | cy.visit('/#/examples/keyboard-events');
4 |
5 | cy.get('.fixed-size-holder').as('Holder');
6 | cy.get('.fixed-size-holder .react-canvas-grid').as('Root');
7 | cy.get('.fixed-size-holder canvas').eq(1).as('Canvas');
8 | cy.get('textarea').as('Log');
9 |
10 | cy.get('Canvas').invoke('width').should('be.greaterThan', 0);
11 | });
12 |
13 | describe('after focusing the component', () => {
14 | beforeEach(() => {
15 | cy.get('@Root').click();
16 | });
17 |
18 | it('logs an event when pressing a key', () => {
19 | cy.get('@Root').type('a');
20 |
21 | cy.get('@Log')
22 | .invoke('text')
23 | .should('equal', 'key up: a\n');
24 | });
25 | });
26 |
27 | describe('after opening the inline editor', () => {
28 | it('logs an event when pressing a key', () => {
29 | cy.get('@Root')
30 | .trigger('dblclick', 'center', { force: true });
31 | cy.get('@Root').get('input').type('a');
32 | cy.get('@Log')
33 | .invoke('text')
34 | .should('equal', 'key up: a\n');
35 | });
36 | });
37 | });
38 |
--------------------------------------------------------------------------------
/examples/src/assets/rocket-launch-lines.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/selectionState/allGridSelection.test.ts:
--------------------------------------------------------------------------------
1 | import { AllGridSelection } from './allGridSelection';
2 | import { ColsSelection } from './colsSelection';
3 | import { RowsSelection } from './rowsSelection';
4 | import { CellCoordBounds } from './selectionTypes';
5 |
6 | describe('AllGridSelection', () => {
7 | const bounds: CellCoordBounds = { numCols: 20, numRows: 100, frozenCols: 2, frozenRows: 2 };
8 |
9 | describe('arrowRight', () => {
10 | it('selects the first unfrozen column', () => {
11 | const sel = new AllGridSelection(false);
12 |
13 | const newSel = sel.arrowRight(bounds);
14 |
15 | expect(newSel).toBeInstanceOf(ColsSelection);
16 | expect(newSel.getSelectionRange(bounds).topLeft.x).toBe(2);
17 | expect(newSel.getSelectionRange(bounds).bottomRight.x).toBe(2);
18 | });
19 | });
20 |
21 | describe('arrowDown', () => {
22 | it('selects the first unfrozen row', () => {
23 | const sel = new AllGridSelection(false);
24 |
25 | const newSel = sel.arrowDown(bounds);
26 |
27 | expect(newSel).toBeInstanceOf(RowsSelection);
28 | expect(newSel.getSelectionRange(bounds).topLeft.y).toBe(2);
29 | expect(newSel.getSelectionRange(bounds).bottomRight.y).toBe(2);
30 | });
31 | });
32 | });
33 |
--------------------------------------------------------------------------------
/examples/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-canvas-grid-examples",
3 | "version": "0.1.0",
4 | "homepage": "https://rowanhill.github.io/react-canvas-grid",
5 | "dependencies": {
6 | "@types/jest": "23.3.13",
7 | "@types/node": "10.12.21",
8 | "@types/react": "16.8.1",
9 | "@types/react-dom": "16.0.11",
10 | "@types/react-router": "^5.0.3",
11 | "@types/react-router-dom": "^4.3.4",
12 | "prism-react-renderer": "^1.1.1",
13 | "react": "^16.7.0",
14 | "react-canvas-grid": "file:../.",
15 | "react-dom": "^16.7.0",
16 | "react-router": "^5.0.1",
17 | "react-router-dom": "^5.0.1",
18 | "react-scripts": "^3.4.1",
19 | "typescript": "3.3.1"
20 | },
21 | "scripts": {
22 | "start": "react-scripts start",
23 | "build": "npm run copy-examples && react-scripts build",
24 | "test": "react-scripts test",
25 | "eject": "react-scripts eject",
26 | "copy-examples": "cd src && copy examples/*.grid.tsx ../public",
27 | "predeploy": "npm run build",
28 | "deploy": "NODE_DEBUG=gh-pages gh-pages -d build"
29 | },
30 | "eslintConfig": {
31 | "extends": "react-app"
32 | },
33 | "browserslist": [
34 | ">0.2%",
35 | "not dead",
36 | "not ie <= 11",
37 | "not op_mini all"
38 | ],
39 | "devDependencies": {
40 | "copy": "^0.3.2",
41 | "gh-pages": "^2.1.1"
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/examples/src/examples/Autofill.data.ts:
--------------------------------------------------------------------------------
1 | import { CellDef } from 'react-canvas-grid';
2 | import { createFakeColumns, createFakeData } from '../data/dataAndColumns';
3 |
4 | export type TextPair = [string, string];
5 |
6 | const numRows = 100;
7 | const numCols = 200;
8 |
9 | // Hash one int into another - just a cheap way of producing random-looking but deterministic numbers
10 | /* tslint:disable:no-bitwise */
11 | function hash(x: number): number {
12 | x = ((x >> 16) ^ x) * 0x45d9f3b;
13 | x = ((x >> 16) ^ x) * 0x45d9f3b;
14 | x = (x >> 16) ^ x;
15 | return x;
16 | }
17 | /* tslint:enable:no-bitwise */
18 |
19 | function getRandomTextA(x: number, y: number): string {
20 | return hash(x + y * numCols + 1).toString(36).substr(0, 3);
21 | }
22 | function getRandomTextB(x: number, y: number): string {
23 | return hash(x + y * numCols + numRows * numCols + 1).toString(36).substr(0, 3);
24 | }
25 |
26 | function getTextPair(x: number, y: number): TextPair {
27 | return [getRandomTextA(x, y), getRandomTextB(x, y)];
28 | }
29 |
30 | const options: Partial> = {
31 | getText: ([a, b]: TextPair) => `${a}/${b}`,
32 | };
33 | export function getData() {
34 | return createFakeData(numRows, numCols, (x, y) => getTextPair(x, y), options);
35 | }
36 |
37 | export function getColumns() {
38 | return createFakeColumns(numCols);
39 | }
40 |
--------------------------------------------------------------------------------
/examples/src/data/dataAndColumns.ts:
--------------------------------------------------------------------------------
1 | import { CellDef, ColumnDef, DataRow } from '../../../src/types';
2 |
3 | export function createFakeDataAndColumns(
4 | numRows: number,
5 | numCols: number,
6 | dataGen: (x: number, y: number) => T,
7 | options: Partial> = {},
8 | ) {
9 | return {
10 | columns: createFakeColumns(numCols),
11 | rows: createFakeData(numRows, numCols, dataGen, options),
12 | };
13 | }
14 |
15 | export function createFakeColumns(numCols: number) {
16 | const cols: ColumnDef[] = [];
17 | for (let i = 0; i < numCols; i++) {
18 | cols.push({
19 | fieldName: `col-${i}`,
20 | width: 50,
21 | });
22 | }
23 | return cols;
24 | }
25 |
26 | export function createFakeData(
27 | numRows: number,
28 | numCols: number,
29 | dataGen: (x: number, y: number) => T,
30 | options: Partial> = {},
31 | ) {
32 | const rows: Array> = [];
33 | for (let i = 0; i < numRows; i++) {
34 | const row: DataRow = {};
35 | for (let j = 0; j < numCols; j++) {
36 | row[`col-${j}`] = {
37 | getText: () => `${i + 1}x${j + 1}`,
38 | data: dataGen(j, i),
39 | ...options,
40 | };
41 | }
42 | rows.push(row);
43 | }
44 | return rows;
45 | }
46 |
--------------------------------------------------------------------------------
/examples/src/Examples.tsx:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import { NavLink, Route, Switch } from 'react-router-dom';
3 | import './Examples.css';
4 | import exampleMeta from './examples/exampleMeta';
5 | import { ExamplePage } from './examples/ExamplePage';
6 | import { Index } from './examples/Index';
7 |
8 | class Examples extends Component<{}, {}> {
9 | public render() {
10 | return (
11 |
12 |
13 |
14 |
15 | {exampleMeta.map((meta) => (
16 |
17 |
18 |
19 | ))}
20 |
21 |
22 |
23 | Examples
24 |
25 | {exampleMeta.map((meta) => (
26 |
27 | {meta.name}
28 |
29 | ))}
30 |
31 |
32 |
33 | );
34 | }
35 | }
36 |
37 | export default Examples;
38 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # [react-canvas-grid](https://rowanhill.github.io/react-canvas-grid)
2 | A canvas-powered, React-friendly datagrid component
3 |
4 | Note: This library is still considered beta, because it currently has few consumers. Feel free to give it a try and report any issues!
5 |
6 | There are already lots of excellent grid / table components that can be used with React, so why another one? Well, react-canvas-grid's
7 | particular goals are:
8 | * First-class support for complex data objects - rather than assuming every cell's value is a simple scalar
9 | * High performance handling of all major operations (data editing, scrolling, selecting, etc) even with large grids - no visible redaws, lag, or hanging the main thead
10 | * Range selection (although only a single range at a time), including the possibility to bulk update the selected area of the grid
11 | * Arbitrary numbers of frozen rows / columns
12 | * Support for column reordering / filtering (even if that's largely handled externally to the component)
13 | * Column reordering by dragging (i.e. internally to the component)
14 | * Focus/scroll to a column (for use with an external 'search' function)
15 |
16 | There was also originally a goal of making use of browser-native scrolling, although that ultimately wasn't quite tennable. :man_shrugging:
17 |
18 | ## TODO :memo:
19 | * Middle-click "auto-scrolling"
20 | * Column dragging (callback to with suggested reordering of ColumnDefs, up to parent to actually re-order & re-render)
21 |
--------------------------------------------------------------------------------
/examples/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
10 |
11 |
20 | ReactCanvasGrid
21 |
22 |
23 | You need to enable JavaScript to run this app.
24 |
25 |
35 |
36 |
37 |
--------------------------------------------------------------------------------
/src/FrozenCanvas.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { FrozenColsCanvas } from './FrozenColsCanvas';
3 | import { FrozenCornerCanvas } from './FrozenCornerCanvas';
4 | import { FrozenRowsCanvas } from './FrozenRowsCanvas';
5 | import { GridState } from './gridState';
6 |
7 | export interface FrozenCanvasCoreProps {
8 | width: number;
9 | height: number;
10 | frozenColsWidth: number;
11 | frozenRowsHeight: number;
12 | dpr: number;
13 | gridState: GridState;
14 | }
15 | export interface FrozenCanvasProps extends FrozenCanvasCoreProps {
16 | horizontalGutterBounds: ClientRect | null;
17 | verticalGutterBounds: ClientRect | null;
18 | }
19 |
20 | export class FrozenCanvas extends React.PureComponent> {
21 | public render() {
22 | const { horizontalGutterBounds, verticalGutterBounds, ...coreProps } = this.props;
23 | return (
24 |
25 | {(this.props.frozenRowsHeight > 0 && this.props.frozenColsWidth > 0) &&
26 |
27 | }
28 | {this.props.frozenRowsHeight > 0 &&
29 |
30 | }
31 | {this.props.frozenColsWidth > 0 &&
32 |
33 | }
34 |
35 | );
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/cypress/integration/focused-columns.tsx:
--------------------------------------------------------------------------------
1 | describe('ReactCanvasGrid', () => {
2 | beforeEach(() => {
3 | cy.visit('/#/examples/focused-column');
4 |
5 | cy.get('.fixed-size-holder').as('Holder');
6 | cy.get('.fixed-size-holder .react-canvas-grid').as('Root');
7 | cy.get('.fixed-size-holder canvas').eq(1).as('Canvas');
8 |
9 | cy.get('form select').as('FocusedColSelect');
10 | cy.get('form input').as('FrozenColsToggle');
11 |
12 | cy.get('Canvas').invoke('width').should('be.greaterThan', 0);
13 | });
14 |
15 | it('scrolls to the right to bring a focused column to the right into view', () => {
16 | cy.get('@FocusedColSelect').select('30');
17 |
18 | cy.get('@Root')
19 | .matchImageSnapshot('focused-col-to-right');
20 | });
21 |
22 | it('scrolls to the left to bring a focused column to the left into view', () => {
23 | cy.get('@Root')
24 | .trigger('wheel', { deltaX: 800, deltaY: 300 });
25 |
26 | cy.get('@FocusedColSelect').select('2');
27 |
28 | cy.get('@Root')
29 | .matchImageSnapshot('focused-col-to-left');
30 | });
31 |
32 | it('accounts for frozen columns when scrolling left to bring a frozen column into view', () => {
33 | cy.get('@FrozenColsToggle').click();
34 |
35 | cy.get('@Root')
36 | .trigger('wheel', { deltaX: 800, deltaY: 300 });
37 |
38 | cy.get('@FocusedColSelect').select('5');
39 |
40 | cy.get('@Root')
41 | .matchImageSnapshot('focused-col-to-left-with-frozen-cols');
42 | });
43 | });
44 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-canvas-grid",
3 | "version": "0.14.0",
4 | "description": "A simple, canvas-based, React-friendly datagrid component",
5 | "homepage": "https://rowanhill.github.io/react-canvas-grid",
6 | "main": "dist/index.js",
7 | "types": "dist/index.d.ts",
8 | "files": [
9 | "dist"
10 | ],
11 | "scripts": {
12 | "build": "tsc",
13 | "watch": "tsc -w",
14 | "test": "jest && cypress run --browser chrome",
15 | "cypress": "cypress open",
16 | "cypress:once": "cypress run --browser chrome",
17 | "jest": "jest --watch",
18 | "jest:once": "jest",
19 | "tslint": "tslint src/**/*.ts* cypress/**/*.ts* examples/**/*.ts*"
20 | },
21 | "keywords": [
22 | "react",
23 | "datagrid",
24 | "grid",
25 | "canvas"
26 | ],
27 | "author": "Rowan Hill",
28 | "license": "ISC",
29 | "peerDependencies": {
30 | "react": ">=16",
31 | "react-dom": ">=16"
32 | },
33 | "dependencies": {
34 | "instigator": "^0.6.1",
35 | "shallow-equals": "^1.0.0"
36 | },
37 | "devDependencies": {
38 | "@cypress/webpack-preprocessor": "^4.0.3",
39 | "@types/enzyme": "^3.9.0",
40 | "@types/jest": "^24.0.25",
41 | "@types/react": "^16.8.1",
42 | "@types/shallow-equals": "^1.0.0",
43 | "cypress": "^4.7.0",
44 | "cypress-image-snapshot": "^3.0.0",
45 | "enzyme": "^3.9.0",
46 | "enzyme-adapter-react-16": "^1.10.0",
47 | "jest": "^24.9.0",
48 | "react": "^16.7.0",
49 | "react-dom": "^16.8.3",
50 | "ts-jest": "^24.0.0",
51 | "ts-loader": "^5.3.3",
52 | "tslint": "^5.13.1",
53 | "typescript": "^3.3.1",
54 | "webpack": "^4.29.6"
55 | }
56 | }
57 |
--------------------------------------------------------------------------------
/src/FrozenCornerCanvas.tsx:
--------------------------------------------------------------------------------
1 | import { ReactiveFn, transformer } from 'instigator';
2 | import * as React from 'react';
3 | import { CanvasRendererPosition } from './baseGridOffsetRenderer';
4 | import { FrozenCanvasCoreProps } from './FrozenCanvas';
5 | import { HighlightedGridCanvas } from './HighlightedGridCanvas';
6 |
7 | export class FrozenCornerCanvas extends React.PureComponent> {
8 | private readonly cornerPosProps: ReactiveFn;
9 |
10 | public constructor(props: FrozenCanvasCoreProps) {
11 | super(props);
12 |
13 | const cornerVisibleRect = transformer(
14 | [props.gridState.visibleRect],
15 | (visibleRect): ClientRect => {
16 | return {
17 | ...visibleRect,
18 | top: 0,
19 | left: 0,
20 | right: visibleRect.width,
21 | bottom: visibleRect.height,
22 | };
23 | });
24 | this.cornerPosProps = transformer([cornerVisibleRect], (visibleRect): CanvasRendererPosition => ({
25 | gridOffset: { x: visibleRect.left, y: visibleRect.top },
26 | visibleRect,
27 | }));
28 | }
29 |
30 | public render() {
31 | const props = {
32 | ...this.props,
33 | top: 0,
34 | left: 0,
35 | height: this.props.frozenRowsHeight,
36 | width: this.props.frozenColsWidth,
37 | posProps: this.cornerPosProps,
38 | };
39 | return (
40 |
41 | );
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/src/scrollbars/ScrollbarCanvas.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { GridState } from '../gridState';
3 | import { CornerScrollbarCanvas } from './CornerScrollbarCanvas';
4 | import { HorizontalScrollbarCanvas } from './HorizontalScrollbarCanvas';
5 | import { VerticalScrollbarCanvas } from './VerticalScrollbarCanvas';
6 |
7 | export interface ScrollbarCanvasProps {
8 | dpr: number;
9 | gridState: GridState;
10 | }
11 |
12 | export interface ScrollbarCanvasGutterProps {
13 | horizontalGutterBounds: ClientRect | null;
14 | verticalGutterBounds: ClientRect | null;
15 | }
16 |
17 | export class ScrollbarCanvas extends React.PureComponent & ScrollbarCanvasGutterProps> {
18 | public render() {
19 | const { horizontalGutterBounds, verticalGutterBounds, ...coreProps } = this.props;
20 | return (
21 |
22 | {horizontalGutterBounds &&
23 |
24 | }
25 | {verticalGutterBounds &&
26 |
27 | }
28 | {horizontalGutterBounds && verticalGutterBounds &&
29 |
34 | }
35 |
36 | );
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/examples/src/assets/logo.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
--------------------------------------------------------------------------------
/examples/src/Examples.css:
--------------------------------------------------------------------------------
1 | .examples-container {
2 | display: flex;
3 | flex-direction: row;
4 | }
5 |
6 | main.examples-main {
7 | flex: 1;
8 | margin: 0 1.5em;
9 | }
10 |
11 | main.examples-main ul {
12 | padding: 0;
13 | list-style-type: none;
14 | }
15 |
16 | main.examples-main ul li {
17 | margin-bottom: 1.5em;
18 | }
19 |
20 | main.examples-main ul li h3 {
21 | margin: 0 0 0.5em 0;
22 | }
23 |
24 | main.examples-main ul li p {
25 | margin: 0 0 0.5em 0;
26 | }
27 |
28 | nav.examples-menu {
29 | flex: 0 0 12em;
30 | order: -1;
31 | min-height: 100vh;
32 | margin: 0.5em;
33 | }
34 |
35 | nav.examples-menu ul {
36 | border: 1px solid #eeeeee;
37 | border-radius: 3px;
38 | padding: 0;
39 | list-style-type: none;
40 | }
41 |
42 | nav.examples-menu ul li {
43 | font-size: 0.8em;
44 | font-weight: 600;
45 | border-bottom: 1px solid #eeeeee;
46 | }
47 | nav.examples-menu ul li:hover {
48 | background-color: hsl(0, 0%, 97%);
49 | }
50 |
51 | nav.examples-menu ul li:first-of-type a {
52 | border-top-right-radius: 2px;
53 | }
54 | nav.examples-menu ul li:last-of-type a {
55 | border-bottom-right-radius: 2px;
56 | }
57 | nav.examples-menu ul li:last-of-type {
58 | border-bottom: none;
59 | }
60 |
61 | nav.examples-menu ul li a {
62 | display: block;
63 | color: hsl(0, 0%, 40%);
64 | padding: 0.4em;
65 | }
66 | nav.examples-menu ul li:hover a {
67 | color: hsl(0, 0%, 50%);
68 | }
69 | nav.examples-menu ul li a.selected {
70 | border-right: 2px solid #333944;
71 | background-color: #eeeeee;
72 | }
73 |
74 | .examples-main code {
75 | font-size: 0.9em;
76 | background-color: #eee;
77 | border: 1px solid #ccc;
78 | border-radius: 2px;
79 | color: rgb(196, 78, 78);
80 | }
--------------------------------------------------------------------------------
/examples/src/examples/Resize.grid.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { ReactCanvasGrid } from 'react-canvas-grid';
3 | import { ControlsForm } from '../components/ControlsForm';
4 | import { createFakeDataAndColumns } from '../data/dataAndColumns';
5 |
6 | export class ResizeGrid extends React.Component<{}, { size: 'big'|'small'; }> {
7 | constructor(props: {}) {
8 | super(props);
9 | this.state = {
10 | size: 'small',
11 | };
12 | }
13 |
14 | public render() {
15 | const { columns, rows: data } = createFakeDataAndColumns(100, 20, () => {/* no op */});
16 |
17 | return (
18 | <>
19 |
20 |
21 | Grid size:
22 |
23 | Small
24 | Big
25 |
26 |
27 |
28 |
29 | cssHeight={this.state.size === 'big' ? '600px' : '400px'}
30 | cssWidth={this.state.size === 'big' ? '600px' : '400px'}
31 | columns={columns}
32 | data={data}
33 | rowHeight={20}
34 | />
35 | >
36 | );
37 | }
38 |
39 | private chooseSize = (e: React.ChangeEvent) => {
40 | const value = e.target.value as 'big'|'small';
41 | this.setState({
42 | size: value,
43 | });
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/cypress/integration/scrollbars.tsx:
--------------------------------------------------------------------------------
1 | describe('The scrollbars in a grid larger than the canvas', () => {
2 | beforeEach(() => {
3 | cy.visit('/#/examples/simple');
4 | cy.get('.fixed-size-holder').as('Holder');
5 | cy.get('.fixed-size-holder .react-canvas-grid').as('Root');
6 | cy.get('.fixed-size-holder canvas').eq(1).as('Canvas');
7 |
8 | cy.get('Canvas').invoke('width').should('be.greaterThan', 0);
9 | });
10 |
11 | it('get darker and larger when hovered', () => {
12 | cy.get('@Holder')
13 | .trigger('mousemove', 10, 395)
14 | .wait(10) // Small pause to ensure grid repaints before screenshot
15 | .matchImageSnapshot('scrollbar-hover');
16 | });
17 |
18 | it('stay darker and larger when dragging, even if the mouse is no longer hovering over the bar', () => {
19 | cy.get('@Holder')
20 | .trigger('mousemove', 10, 395)
21 | .trigger('mousedown', 10, 395, { buttons: 1 })
22 | .trigger('mousemove', 20, 350, { buttons: 1 })
23 | .wait(10) // Small pause to ensure grid repaints before screenshot
24 | .matchImageSnapshot('scrollbar-drag-off-bar');
25 | });
26 |
27 | it('reset when releasing a drag and no longer hovering, without further mousemove', () => {
28 | cy.get('@Holder')
29 | .trigger('mousemove', 10, 395)
30 | .trigger('mousedown', 10, 395, { buttons: 1 })
31 | .trigger('mousemove', 20, 350, { buttons: 1 })
32 | .trigger('mouseup', 20, 350)
33 | .wait(10) // Small pause to ensure grid repaints before screenshot
34 | .matchImageSnapshot('scrollbar-release-drag-off-bar');
35 | });
36 | });
37 |
--------------------------------------------------------------------------------
/src/InlineEditor.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { Coord, EditableCellDef } from './types';
3 |
4 | interface InlineTextEditorProps {
5 | left: number;
6 | top: number;
7 | width: number;
8 | height: number;
9 | gridOffset: Coord;
10 | cell: EditableCellDef;
11 |
12 | onSubmit: (newData: T) => void;
13 | onCancel: () => void;
14 | }
15 |
16 | export const InlineTextEditor = (props: InlineTextEditorProps) => {
17 | const defaultText = props.cell.editor.serialise(props.cell.data);
18 | const submit = (e: React.FocusEvent|React.KeyboardEvent) => {
19 | const inputValue = e.currentTarget.value;
20 | const newData = props.cell.editor.deserialise(inputValue, props.cell.data);
21 | props.onSubmit(newData);
22 | };
23 | const handleKeyPress = (e: React.KeyboardEvent) => {
24 | if (e.key === 'Enter') {
25 | submit(e);
26 | }
27 | };
28 | const handleKeyUp = (e: React.KeyboardEvent) => {
29 | if (e.keyCode === 27) {
30 | props.onCancel();
31 | }
32 | };
33 |
34 | return ;
51 | };
52 |
--------------------------------------------------------------------------------
/examples/src/examples/KeyboardEvents.grid.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { CellDef, ReactCanvasGrid } from 'react-canvas-grid';
3 | import { EventLog } from '../components/EventLog';
4 | import { FixedSizeHolder } from '../components/FixedSizeHolder';
5 | import { createFakeDataAndColumns } from '../data/dataAndColumns';
6 |
7 | interface KeyboardEventsState {
8 | eventLog: string;
9 | }
10 |
11 | const options: Partial> = {
12 | editor: {
13 | serialise: (value: string) => value,
14 | deserialise: (text: string, _: string) => text,
15 | },
16 | getText: (value: string) => value,
17 | };
18 | const { columns, rows: data } = createFakeDataAndColumns(100, 20, (x, y) => `${x},${y}`, options);
19 |
20 | export class KeyboardEventsGrid extends React.Component<{}, KeyboardEventsState> {
21 | constructor(props: {}) {
22 | super(props);
23 | this.state = {
24 | eventLog: '',
25 | };
26 | }
27 |
28 | public render() {
29 | return (
30 | <>
31 |
32 |
33 |
34 | columns={columns}
35 | data={data}
36 | rowHeight={20}
37 | />
38 |
39 |
40 |
41 | >
42 | );
43 | }
44 |
45 | private onKeyUp = (event: React.KeyboardEvent) => {
46 | this.setState({
47 | eventLog: this.state.eventLog + `key up: ${event.key}\n`,
48 | });
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/src/commonCanvasRenderer.test.ts:
--------------------------------------------------------------------------------
1 | import { CommonCanvasRenderer } from './commonCanvasRenderer';
2 | import { execRaf, mockRaf, resetRaf } from './rafTestHelper';
3 |
4 | class TestRenderer extends CommonCanvasRenderer {
5 | public translate(): void {
6 | this.context.translate(123, 456);
7 | }
8 | }
9 |
10 | describe('CommonCanvasRenderer', () => {
11 | beforeEach(() => {
12 | mockContext = {
13 | scale: jest.fn(),
14 | translate: jest.fn(),
15 | fillRect: jest.fn(),
16 | drawImage: jest.fn(),
17 | fillText: jest.fn(),
18 | save: jest.fn(),
19 | restore: jest.fn(),
20 | } as unknown as CanvasRenderingContext2D;
21 | mockCanvas = {
22 | getContext: () => mockContext,
23 | } as unknown as HTMLCanvasElement;
24 | renderer = new TestRenderer('test', mockCanvas, dpr, false);
25 |
26 | mockRaf();
27 | });
28 |
29 | afterEach(() => {
30 | jest.resetAllMocks(); // reset spies
31 | resetRaf();
32 | });
33 |
34 | const dpr = 2;
35 | let mockContext: CanvasRenderingContext2D;
36 | let mockCanvas: HTMLCanvasElement;
37 | let renderer: CommonCanvasRenderer;
38 |
39 | describe('drawScaled', () => {
40 | it('scales the context, translates and then restores to the previous state', () => {
41 | renderer.drawScaled(() => { /* no op */});
42 | execRaf();
43 |
44 | expect(mockContext.save).toHaveBeenCalledTimes(2);
45 | expect(mockContext.scale).toHaveBeenCalledWith(dpr, dpr);
46 | expect(mockContext.translate).toHaveBeenCalledWith(123, 456);
47 | expect(mockContext.restore).toHaveBeenCalledTimes(2);
48 | });
49 | });
50 | });
51 |
--------------------------------------------------------------------------------
/examples/src/examples/Editable.text.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 |
3 | export const EditableDataText = () => {
4 | return (
5 | <>
6 | Editable Data
7 |
8 | A cell is editable if its definition includes an editor property.
9 | If the grid also has a onCellDataChanged prop, that can persist
10 | changes made to data to be shown on the grid.
11 |
12 |
13 | The editor provides two functions, serialise and
14 | deserialise, that translate between the data stored
15 | on the cell definition and the text displayed in the editor input.
16 |
17 |
18 | Displaying the inline editor is not enough, however, as react-canvas-grid does not
19 | manage the data; instead, when data changes, it will call the function provided as
20 | the onCellDataChanged prop. This function can then update the contents
21 | of the data prop, causing the grid to render the changes.
22 |
23 |
24 | Note that the text displayed in the editor need not be the same as the text
25 | displayed in the cell itself (as defined by text or getText).
26 | In this example, the editor text is of the form A,B (i.e. comma separated),
27 | but cells are displayed as AxB (i.e. with an x).
28 |
29 |
30 | To edit a cell, double click it. Invalid values will be ignored.
31 |
32 | >
33 | );
34 | };
35 |
--------------------------------------------------------------------------------
/cypress/data/dataAndColumns.ts:
--------------------------------------------------------------------------------
1 | import { ColumnDef, DataRow } from '../../src/types';
2 |
3 | export function createFakeDataAndColumns(numRows: number, numCols: number, dataGen: (x: number, y: number) => T) {
4 | const cols: ColumnDef[] = [];
5 | for (let i = 0; i < numCols; i++) {
6 | cols.push({
7 | fieldName: `col-${i}`,
8 | width: 50,
9 | });
10 | }
11 |
12 | const rows: Array> = [];
13 | for (let i = 0; i < numRows; i++) {
14 | const row: DataRow = {};
15 | for (let j = 0; j < numCols; j++) {
16 | row[`col-${j}`] = {
17 | getText: () => `${i + 1}x${j + 1}`,
18 | data: dataGen(j, i),
19 | };
20 | }
21 | rows.push(row);
22 | }
23 |
24 | return {
25 | columns: cols,
26 | rows,
27 | };
28 | }
29 |
30 | export function createEditableFakeDataAndColumns(
31 | numRows: number, numCols: number, dataGen: (x: number, y: number) => string,
32 | ) {
33 | const cols: ColumnDef[] = [];
34 | for (let i = 0; i < numCols; i++) {
35 | cols.push({
36 | fieldName: `col-${i}`,
37 | width: 50,
38 | });
39 | }
40 |
41 | const rows: Array> = [];
42 | for (let i = 0; i < numRows; i++) {
43 | const row: DataRow = {};
44 | for (let j = 0; j < numCols; j++) {
45 | row[`col-${j}`] = {
46 | getText: (data: string) => data,
47 | data: dataGen(j, i),
48 | editor: {
49 | serialise: (data: string) => data,
50 | deserialise: (value: string) => value,
51 | },
52 | };
53 | }
54 | rows.push(row);
55 | }
56 |
57 | return {
58 | columns: cols,
59 | rows,
60 | };
61 | }
62 |
--------------------------------------------------------------------------------
/examples/src/examples/EditEvents.grid.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { CellDataChangeEvent, CellDef, ReactCanvasGrid } from 'react-canvas-grid';
3 | import { EventLog } from '../components/EventLog';
4 | import { FixedSizeHolder } from '../components/FixedSizeHolder';
5 | import { createFakeDataAndColumns } from '../data/dataAndColumns';
6 |
7 | interface EditEventsState {
8 | eventLog: string;
9 | }
10 |
11 | const options: Partial> = {
12 | editor: {
13 | serialise: (value: string) => value,
14 | deserialise: (text: string, _: string) => text,
15 | },
16 | getText: (value: string) => value,
17 | };
18 | const { columns, rows: data } = createFakeDataAndColumns(100, 20, (x, y) => `${x},${y}`, options);
19 |
20 | export class EditEventsGrid extends React.Component<{}, EditEventsState> {
21 | constructor(props: {}) {
22 | super(props);
23 | this.state = {
24 | eventLog: '',
25 | };
26 | }
27 |
28 | public render() {
29 | return (
30 | <>
31 |
32 |
33 | columns={columns}
34 | data={data}
35 | rowHeight={20}
36 | onCellDataChanged={this.onCellDataChanged}
37 | />
38 |
39 |
40 | >
41 | );
42 | }
43 |
44 | private onCellDataChanged = (event: CellDataChangeEvent) => {
45 | const { colIndex, rowIndex, fieldName, newData } = event;
46 |
47 | this.setState({
48 | eventLog: this.state.eventLog +
49 | `changed: row ${rowIndex} of column "${fieldName}" (index ${colIndex}) to ${newData}\n`,
50 | });
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/cypress/integration/edit-inline.tsx:
--------------------------------------------------------------------------------
1 | describe('ReactCanvasGrid with inline editor & data management callbacks', () => {
2 | beforeEach(() => {
3 | cy.visit('/#/examples/editable');
4 |
5 | cy.get('.fixed-size-holder').as('Holder');
6 | cy.get('.fixed-size-holder .react-canvas-grid').as('Root');
7 | cy.get('.fixed-size-holder canvas').eq(1).as('Canvas');
8 |
9 | cy.get('Canvas').invoke('width').should('be.greaterThan', 0);
10 | });
11 |
12 | it('shows the inline editor over the cell on double-click', () => {
13 | kludgeCaretInvisible();
14 | cy.get('@Canvas')
15 | .trigger('dblclick', 5, 5, { force: true });
16 | cy.get('@Root')
17 | .matchImageSnapshot('inline-editor-shown-on-dblclick');
18 | });
19 |
20 | it('re-renders the grid when data is edited', () => {
21 | cy.get('@Canvas')
22 | .trigger('dblclick', 5, 5, { force: true });
23 | cy.get('@Canvas').get('input').type('{selectall}99,99{enter}');
24 | cy.get('@Root')
25 | .matchImageSnapshot('editing-updates-grid');
26 | });
27 |
28 | it('does not update the selection when using the arrow keys whilst editing text', () => {
29 | kludgeCaretInvisible();
30 | cy.get('@Canvas')
31 | .click(5, 5, { force: true })
32 | .trigger('dblclick', 5, 5, { force: true })
33 | .get('input')
34 | .type('{downarrow}{rightarrow}');
35 | cy.get('@Root')
36 | .matchImageSnapshot('inline-editor-arrows-dont-change-selection');
37 | });
38 | });
39 |
40 | /**
41 | * Hack to hide the text insertion caret. Otherwise, the blinking caret is sometimes present
42 | * in screenshots and sometimes not, meaning the tests are flakey.
43 | */
44 | function kludgeCaretInvisible() {
45 | cy.get('@Root')
46 | .invoke('css', 'caret-color', 'transparent');
47 | }
48 |
--------------------------------------------------------------------------------
/src/scrollbars/cornerScrollbarRenderer.ts:
--------------------------------------------------------------------------------
1 | import * as ScrollGeometry from '../scrollbarGeometry';
2 | import { BaseScrollbarRenderer, ScrollbarRendererBasics, styles } from './baseScrollbarRenderer';
3 | import { drawHScrollbar } from './horizontalScrollbarRenderer';
4 | import { drawVScrollbar } from './verticalScrollbarRenderer';
5 |
6 | export class CornerScrollbarRenderer extends BaseScrollbarRenderer {
7 | constructor(canvas: HTMLCanvasElement, basicProps: ScrollbarRendererBasics) {
8 | super('scrollbar corner', canvas, basicProps);
9 | }
10 |
11 | public draw = () => {
12 | const context = this.context;
13 | // Draw scrollbar gutters
14 | const vBounds = this.basicProps.verticalGutterBounds;
15 | const hBounds = this.basicProps.horizontalGutterBounds;
16 | if (hBounds && vBounds) {
17 | this.context.fillStyle = styles.scrollGutters.fill;
18 | this.context.fillRect(vBounds.left, hBounds.top, vBounds.width, hBounds.height);
19 |
20 | this.context.strokeStyle = styles.scrollGutters.stroke;
21 | this.context.lineWidth = 1;
22 | this.context.beginPath();
23 | this.context.moveTo(vBounds.right, hBounds.top);
24 | this.context.lineTo(vBounds.right, hBounds.bottom);
25 | this.context.moveTo(vBounds.left, vBounds.bottom);
26 | this.context.lineTo(vBounds.right, vBounds.bottom);
27 | this.context.stroke();
28 | }
29 |
30 | // Draw (the ends of) the scrollbars (if needed)
31 | drawHScrollbar(context, this.basicProps.horizontalScrollbarPos, this.basicProps.hoveredScrollbar);
32 | drawVScrollbar(context, this.basicProps.verticalScrollbarPos, this.basicProps.hoveredScrollbar);
33 | }
34 |
35 | public translate = () => {
36 | const hBounds = this.basicProps.horizontalGutterBounds;
37 | const vBounds = this.basicProps.verticalGutterBounds;
38 | if (hBounds && vBounds) {
39 | this.context.translate(-vBounds.left, -hBounds.top);
40 | }
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/src/scrollbars/baseScrollbarRenderer.ts:
--------------------------------------------------------------------------------
1 | import { CommonCanvasRenderer } from '../commonCanvasRenderer';
2 | import { ScrollbarPosition } from '../scrollbarGeometry';
3 |
4 | export interface ScrollbarRendererBasics {
5 | dpr: number;
6 | horizontalGutterBounds: ClientRect|null;
7 | verticalGutterBounds: ClientRect|null;
8 | horizontalScrollbarPos: ScrollbarPosition | null;
9 | verticalScrollbarPos: ScrollbarPosition | null;
10 | hoveredScrollbar: 'x' | 'y' | null;
11 | }
12 |
13 | const colours = {
14 | grey: {
15 | veryLight: 'hsla(0, 0%, 93%, 1)',
16 | light: 'hsla(0, 0%, 83%, 1)', // == lightgrey
17 | },
18 | black: {
19 | veryTransparent: 'hsla(0, 0%, 0%, 0.4)',
20 | transparent: 'hsla(0, 0%, 0%, 0.55)',
21 | },
22 | };
23 |
24 | export const styles = {
25 | scrollbar: {
26 | defaultFill: colours.black.veryTransparent,
27 | hoverFill: colours.black.transparent,
28 | },
29 | scrollGutters: {
30 | fill: colours.grey.veryLight,
31 | stroke: colours.grey.light,
32 | },
33 | };
34 |
35 | export abstract class BaseScrollbarRenderer extends CommonCanvasRenderer {
36 | protected basicProps: ScrollbarRendererBasics;
37 |
38 | constructor(name: string, canvas: HTMLCanvasElement, basicProps: ScrollbarRendererBasics) {
39 | super(name, canvas, basicProps.dpr, false);
40 | this.basicProps = basicProps;
41 | }
42 |
43 | public updateProps = (canvas: HTMLCanvasElement, basicProps: ScrollbarRendererBasics) => {
44 | if (this.canvas !== canvas) {
45 | this.setCanvas(canvas);
46 | }
47 | this.basicProps = basicProps;
48 |
49 | this.drawScaled(this.draw);
50 | }
51 |
52 | public abstract draw(): void;
53 |
54 | protected setCanvas = (canvas: HTMLCanvasElement) => {
55 | this.canvas = canvas;
56 | const context = this.canvas.getContext('2d', { alpha: false });
57 | if (!context) {
58 | throw new Error('Could not create canvas contex');
59 | }
60 | this.context = context;
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/src/MainCanvas.tsx:
--------------------------------------------------------------------------------
1 | import { ReactiveFn, transformer } from 'instigator';
2 | import * as React from 'react';
3 | import { CanvasRendererPosition } from './baseGridOffsetRenderer';
4 | import { GridState } from './gridState';
5 | import { HighlightedGridCanvas } from './HighlightedGridCanvas';
6 |
7 | export interface MainCanvasProps {
8 | width: number;
9 | height: number;
10 | frozenColsWidth: number;
11 | frozenRowsHeight: number;
12 | dpr: number;
13 | gridState: GridState;
14 | }
15 |
16 | export class MainCanvas extends React.PureComponent> {
17 | private readonly posProps: ReactiveFn;
18 |
19 | public constructor(props: MainCanvasProps) {
20 | super(props);
21 |
22 | const mainVisibleRect = transformer(
23 | [props.gridState.visibleRect, props.gridState.frozenRowsHeight, props.gridState.frozenColsWidth],
24 | (visibleRect, frozenRowsHeight, frozenColsWidth): ClientRect => {
25 | return {
26 | ...visibleRect,
27 | top: visibleRect.top + frozenRowsHeight,
28 | left: visibleRect.left + frozenColsWidth,
29 | height: visibleRect.height - frozenRowsHeight,
30 | width: visibleRect.width - frozenColsWidth,
31 | };
32 | });
33 | this.posProps = transformer([mainVisibleRect], (visibleRect): CanvasRendererPosition => ({
34 | gridOffset: { x: visibleRect.left, y: visibleRect.top },
35 | visibleRect,
36 | }));
37 | }
38 |
39 | public render() {
40 | const props = {
41 | ...this.props,
42 | top: this.props.frozenRowsHeight,
43 | left: this.props.frozenColsWidth,
44 | height: Math.max(this.props.height - this.props.frozenRowsHeight, 0),
45 | width: Math.max(this.props.width - this.props.frozenColsWidth, 0),
46 | posProps: this.posProps,
47 | };
48 | return (
49 |
50 | );
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/src/commonCanvasRenderer.ts:
--------------------------------------------------------------------------------
1 | export const borderColour = 'lightgrey';
2 |
3 | export abstract class CommonCanvasRenderer {
4 | protected canvas: HTMLCanvasElement;
5 | protected context: CanvasRenderingContext2D;
6 | protected readonly name: string;
7 | protected readonly alpha: boolean;
8 | protected readonly dpr: number;
9 |
10 | private queuedRender: number | null = null;
11 |
12 | constructor(name: string, canvas: HTMLCanvasElement, dpr: number, alpha: boolean) {
13 | this.name = name;
14 | this.alpha = alpha;
15 | this.dpr = dpr;
16 |
17 | // Below is same as setCanvas, copied here to appease compiler
18 | this.canvas = canvas;
19 | const context = this.canvas.getContext('2d', { alpha: this.alpha });
20 | if (!context) {
21 | throw new Error('Could not create canvas contex');
22 | }
23 | this.context = context;
24 | }
25 |
26 | public drawScaled(draw: () => void, drawUntranslated?: () => void) {
27 | if (this.queuedRender !== null) {
28 | return;
29 | }
30 |
31 | this.queuedRender = window.requestAnimationFrame(() => {
32 | this.context.save();
33 | this.context.scale(this.dpr, this.dpr);
34 | try {
35 | if (drawUntranslated) {
36 | drawUntranslated();
37 | }
38 | this.context.save();
39 | this.translate();
40 | try {
41 | draw();
42 | } finally {
43 | this.context.restore();
44 | }
45 | } finally {
46 | this.context.restore();
47 |
48 | this.queuedRender = null;
49 | }
50 | });
51 | }
52 |
53 | public abstract translate(): void;
54 |
55 | protected setCanvas = (canvas: HTMLCanvasElement) => {
56 | this.canvas = canvas;
57 | const context = this.canvas.getContext('2d', { alpha: this.alpha });
58 | if (!context) {
59 | throw new Error('Could not create canvas contex');
60 | }
61 | this.context = context;
62 | }
63 | }
64 |
--------------------------------------------------------------------------------
/src/selectionState/allGridSelection.ts:
--------------------------------------------------------------------------------
1 | import { GridState } from '../gridState';
2 | import { Coord } from '../types';
3 | import { createSingleColSelection } from './colsSelection';
4 | import { createSingleRowSelection } from './rowsSelection';
5 | import { BaseSelectionState } from './selectionState';
6 | import { createSelectionStateForMouseDown } from './selectionStateFactory';
7 | import { CellCoordBounds, ClickMeta, SelectRange } from './selectionTypes';
8 |
9 | export class AllGridSelection extends BaseSelectionState {
10 | public arrowDown = (cellBounds: CellCoordBounds) => {
11 | return createSingleRowSelection({ x: cellBounds.frozenCols, y: cellBounds.frozenRows }, cellBounds);
12 | }
13 | public arrowRight = (cellBounds: CellCoordBounds) => {
14 | return createSingleColSelection({ x: cellBounds.frozenCols, y: cellBounds.frozenRows }, cellBounds);
15 | }
16 |
17 | public arrowUp = () => this;
18 | public arrowLeft = () => this;
19 | public shiftArrowUp = () => this;
20 | public shiftArrowDown = () => this;
21 | public shiftArrowLeft = () => this;
22 | public shiftArrowRight = () => this;
23 |
24 | public mouseMove = () => this;
25 | public mouseUp = () => {
26 | if (!this.isSelectionInProgress) {
27 | return this;
28 | } else {
29 | return new AllGridSelection(false);
30 | }
31 | }
32 |
33 | public mouseDown = (cell: Coord, meta: ClickMeta) => createSelectionStateForMouseDown(cell, meta);
34 |
35 | public shiftMouseDown = (cell: Coord, meta: ClickMeta) => {
36 | return this.mouseDown(cell, meta);
37 | }
38 |
39 | public getSelectionRange = (cellBounds: CellCoordBounds): SelectRange => {
40 | return {
41 | topLeft: {
42 | x: 0,
43 | y: 0,
44 | },
45 | bottomRight: {
46 | x: cellBounds.numCols - 1,
47 | y: cellBounds.numRows - 1,
48 | },
49 | };
50 | }
51 |
52 | public getCursorCell = (cellBounds: CellCoordBounds) => ({
53 | x: cellBounds.frozenCols,
54 | y: cellBounds.frozenRows,
55 | })
56 |
57 | public getFocusGridOffset = (_: GridState): Coord|null => null;
58 | }
59 |
--------------------------------------------------------------------------------
/examples/src/examples/Autofill.text.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 |
3 | export const AutofillText = () => {
4 | return (
5 | <>
6 | Autofill
7 |
8 | ReactCanvasGrid can 'autofill' values - i.e. copy the values from the current
9 | selection into a new area - when the autofill handle is dragged to define the new area.
10 | The callback shouldAllowAutofill must be supplied to control when the autofill handle
11 | is shown, along with the onAutofill callback to update the data.
12 |
13 |
14 | The shouldAllowAutofill callback is passed the currently selected range, and
15 | must return a boolean (true to show allow autofill and show the autofill
16 | handle, false to disallow and hide).
17 |
18 |
19 | The onAutofill is passed both the currently selected range and the range to be filled,
20 | and should update the data prop. ReactCanvasGrid provides
21 | the repeatSelectionIntoFill function as a convenience: it takes the selected range,
22 | range to be filled, current data, columns, and a 'factory' function; it returns a new
23 | copy of the data where the area to be filled has been overwritten with cells created by the
24 | 'factory' from the selected range.
25 |
26 |
27 | The factory method could simply clone the source cell, or it could follow more complex logic.
28 | In the below example, some cell data (the text after the slash) is treated as invariate by the
29 | factory function, and some cell data (the text before the slash) is treated as copyable.
30 |
31 |
32 | The factory method is passed a single context object, which includes information
33 | on the source (from the selection) and destination (from the area to
34 | fill): [src|dest]RowIndex, [src|dest]ColIndex,{' '}
35 | [src|dest]ColDef, and [src|dest]CellDef.
36 |
37 | >
38 | );
39 | };
40 |
--------------------------------------------------------------------------------
/src/FrozenRowsCanvas.tsx:
--------------------------------------------------------------------------------
1 | import { ReactiveFn, transformer } from 'instigator';
2 | import * as React from 'react';
3 | import { CanvasRendererPosition } from './baseGridOffsetRenderer';
4 | import { FrozenCanvasCoreProps, FrozenCanvasProps } from './FrozenCanvas';
5 | import { HighlightedGridCanvas } from './HighlightedGridCanvas';
6 |
7 | type FrozenRowsCanvasProps = FrozenCanvasCoreProps & Pick, 'verticalGutterBounds'>;
8 |
9 | export class FrozenRowsCanvas extends React.PureComponent> {
10 | private readonly rowsPosProps: ReactiveFn;
11 |
12 | public constructor(props: FrozenRowsCanvasProps) {
13 | super(props);
14 |
15 | const rowsVisibleRect = transformer(
16 | [props.gridState.visibleRect, props.gridState.frozenColsWidth, props.gridState.verticalGutterBounds],
17 | (visibleRect, frozenColsWidth, verticalGutterBounds): ClientRect => {
18 | const scrollGutterWidth = verticalGutterBounds ? verticalGutterBounds.width : 0;
19 | return {
20 | ...visibleRect,
21 | top: 0,
22 | bottom: visibleRect.height,
23 | left: visibleRect.left + frozenColsWidth,
24 | right: visibleRect.right - scrollGutterWidth,
25 | width: visibleRect.width - frozenColsWidth - scrollGutterWidth,
26 | };
27 | });
28 | this.rowsPosProps = transformer([rowsVisibleRect], (visibleRect): CanvasRendererPosition => ({
29 | gridOffset: { x: visibleRect.left, y: visibleRect.top },
30 | visibleRect,
31 | }));
32 | }
33 |
34 | public render() {
35 | const verticalGutterBounds = this.props.verticalGutterBounds;
36 | const scrollGutterWidth = verticalGutterBounds ? verticalGutterBounds.width : 0;
37 | const props = {
38 | ...this.props,
39 | top: 0,
40 | left: this.props.frozenColsWidth,
41 | height: this.props.frozenRowsHeight,
42 | width: this.props.width - this.props.frozenColsWidth - scrollGutterWidth,
43 | posProps: this.rowsPosProps,
44 | };
45 | return (
46 |
47 | );
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/cypress/integration/edit-events.tsx:
--------------------------------------------------------------------------------
1 | describe('ReactCanvasGrid', () => {
2 | beforeEach(() => {
3 | cy.visit('/#/examples/edit-events');
4 |
5 | cy.get('.fixed-size-holder').as('Holder');
6 | cy.get('.fixed-size-holder .react-canvas-grid').as('Root');
7 | cy.get('.fixed-size-holder canvas').eq(1).as('Canvas');
8 | cy.get('textarea').as('Log');
9 |
10 | cy.get('Canvas').invoke('width').should('be.greaterThan', 0);
11 | });
12 |
13 | describe('after double-clicking an editable cell', () => {
14 | beforeEach(() => {
15 | cy.get('@Root')
16 | .trigger('dblclick', 5, 5, { force: true });
17 | });
18 |
19 | it('fires onCellDataChanged event when editing and hitting enter', () => {
20 | cy.get('input')
21 | .type('a{enter}');
22 | cy.get('@Log')
23 | .invoke('text')
24 | .should('equal', 'changed: row 0 of column "col-0" (index 0) to 0,0a\n');
25 | });
26 |
27 | it('fires onCellDataChanged event when simply hitting enter', () => {
28 | cy.get('input')
29 | .type('{enter}');
30 | cy.get('@Log')
31 | .invoke('text')
32 | .should('equal', 'changed: row 0 of column "col-0" (index 0) to 0,0\n');
33 | });
34 |
35 | it('fires onCellDataChanged event when editing clicking elsewhere', () => {
36 | cy.get('input')
37 | .type('a')
38 | .then(() => cy.get('@Canvas').click({ force: true }));
39 | cy.get('@Log')
40 | .invoke('text')
41 | .should('equal', 'changed: row 0 of column "col-0" (index 0) to 0,0a\n');
42 | });
43 |
44 | it('fires onCellDataChanged event when simply clicking elsewhere', () => {
45 | cy.get('@Canvas').click({ force: true });
46 | cy.get('@Log')
47 | .invoke('text')
48 | .should('equal', 'changed: row 0 of column "col-0" (index 0) to 0,0\n');
49 | });
50 |
51 | it('does not fire onCellDataChanged when hitting escape', () => {
52 | cy.get('input')
53 | .type('{esc}');
54 | cy.get('@Log')
55 | .invoke('text')
56 | .should('equal', '');
57 | });
58 | });
59 | });
60 |
--------------------------------------------------------------------------------
/src/selectionState/colSelection.test.ts:
--------------------------------------------------------------------------------
1 | import { AllGridSelection } from './allGridSelection';
2 | import { CellsSelection } from './cellsSelection';
3 | import { ColsSelection, createSingleColSelection } from './colsSelection';
4 | import { CellCoordBounds } from './selectionTypes';
5 |
6 | describe('ColsSelection', () => {
7 | const bounds: CellCoordBounds = { numCols: 20, numRows: 100, frozenCols: 2, frozenRows: 2 };
8 |
9 | describe('arrowDown', () => {
10 | it('selects a single cell at the (unfrozen) top of the selected column', () => {
11 | const topOfCol = { x: 10, y: 2 };
12 | const sel = createSingleColSelection(topOfCol, bounds);
13 |
14 | const newSel = sel.arrowDown(bounds);
15 |
16 | expect(newSel).toBeInstanceOf(CellsSelection);
17 | expect(newSel.getCursorCell()).toEqual(topOfCol);
18 | expect(newSel.getSelectionRange()).toEqual({ topLeft: topOfCol, bottomRight: topOfCol });
19 | });
20 | });
21 |
22 | describe('arrowLeft', () => {
23 | it('selects one column to the left where available', () => {
24 | const topOfCol = { x: 10, y: 2 };
25 | const sel = createSingleColSelection(topOfCol, bounds);
26 |
27 | const newSel = sel.arrowLeft(bounds);
28 |
29 | expect(newSel).toBeInstanceOf(ColsSelection);
30 | expect(newSel.getSelectionRange(bounds).topLeft.x).toBe(9);
31 | expect(newSel.getSelectionRange(bounds).bottomRight.x).toBe(9);
32 | });
33 |
34 | it('selects all cells when moving into frozen columns', () => {
35 | const topOfCol = { x: 2, y: 2 };
36 | const sel = createSingleColSelection(topOfCol, bounds);
37 |
38 | const newSel = sel.arrowLeft(bounds);
39 |
40 | expect(newSel).toBeInstanceOf(AllGridSelection);
41 | });
42 |
43 | it('does nothing when at the left of the grid', () => {
44 | const boundsWithoutFrozenCols = { ...bounds, frozenCols: 0 };
45 | const topOfCol = { x: 0, y: 2 };
46 | const sel = createSingleColSelection(topOfCol, boundsWithoutFrozenCols);
47 |
48 | const newSel = sel.arrowLeft(boundsWithoutFrozenCols);
49 |
50 | expect(JSON.parse(JSON.stringify(newSel))).toMatchObject(JSON.parse(JSON.stringify(sel)));
51 | });
52 | });
53 | });
54 |
--------------------------------------------------------------------------------
/src/autofill.ts:
--------------------------------------------------------------------------------
1 | import { SelectRange } from './selectionState/selectionTypes';
2 | import { CellDef, ColumnDef, DataRow } from './types';
3 |
4 | export interface AutofillContext {
5 | srcRowIndex: number;
6 | srcColIndex: number;
7 | srcColDef: ColumnDef;
8 | srcCellDef: CellDef;
9 | destRowIndex: number;
10 | destColIndex: number;
11 | destColDef: ColumnDef;
12 | destCellDef: CellDef;
13 | }
14 |
15 | export function repeatSelectionIntoFill(
16 | selectRange: SelectRange,
17 | fillRange: SelectRange,
18 | data: Array>,
19 | columns: ColumnDef[],
20 | cloneCellDef: (context: AutofillContext) => CellDef,
21 | ): Array> {
22 | const selHeight = selectRange.bottomRight.y - selectRange.topLeft.y + 1;
23 | const selWidth = selectRange.bottomRight.x - selectRange.topLeft.x + 1;
24 | return data.map((row, y) => {
25 | if (y >= fillRange.topLeft.y && y <= fillRange.bottomRight.y) {
26 | return columns.reduce((acc, col, x) => {
27 | if (x >= fillRange.topLeft.x && x <= fillRange.bottomRight.x) {
28 | const srcRowIndex = ((y - fillRange.topLeft.y) % selHeight) + selectRange.topLeft.y;
29 | const srcColIndex = ((x - fillRange.topLeft.x) % selWidth) + selectRange.topLeft.x;
30 | const srcColDef = columns[srcColIndex];
31 | const srcCellDef = data[srcRowIndex][srcColDef.fieldName];
32 | const destRowIndex = y;
33 | const destColIndex = x;
34 | const destColDef = columns[destColIndex];
35 | const destCellDef = data[destRowIndex][destColDef.fieldName];
36 | acc[col.fieldName] = cloneCellDef({
37 | srcRowIndex,
38 | srcColIndex,
39 | srcColDef,
40 | srcCellDef,
41 | destRowIndex,
42 | destColIndex,
43 | destColDef,
44 | destCellDef,
45 | });
46 | } else {
47 | acc[col.fieldName] = row[col.fieldName];
48 | }
49 | return acc;
50 | }, {} as DataRow);
51 | } else {
52 | return row;
53 | }
54 | });
55 | }
56 |
--------------------------------------------------------------------------------
/src/eventHandlers/mouseCellAndRegionCalc.ts:
--------------------------------------------------------------------------------
1 | import { RefObject } from 'react';
2 | import { GridGeometry } from '../gridGeometry';
3 | import { GridState } from '../gridState';
4 | import { ReactCanvasGridProps } from '../ReactCanvasGrid';
5 | import { GridClickRegion } from '../selectionState/selectionTypes';
6 | import { Coord } from '../types';
7 |
8 | export const getMouseCellCoordAndRegion = (
9 | event: { clientX: number; clientY: number; },
10 | componentPixelCoord: Coord,
11 | rootRef: RefObject,
12 | props: ReactCanvasGridProps,
13 | gridState: GridState,
14 | ) => {
15 | // Find the cell coordinates of the mouse event. This is untruncated, and ignoring frozen cell overlays
16 | const gridCoords = GridGeometry.calculateGridCellCoordsFromGridState(event, rootRef.current, gridState);
17 |
18 | // Figure out if the mouse event is in a frozen cell to determine the region
19 | const clickInFrozenCols = componentPixelCoord.x < gridState.frozenColsWidth();
20 | const clickInFrozenRows = componentPixelCoord.y < gridState.frozenRowsHeight();
21 | const region: GridClickRegion = clickInFrozenCols ?
22 | (clickInFrozenRows ? 'frozen-corner' : 'frozen-cols') :
23 | (clickInFrozenRows ? 'frozen-rows' : 'cells');
24 |
25 | // If the mouse event was on a frozen cell, the gridCoords will be for the cell coords 'underneath' the
26 | // frozen cell, so we need to update to coordinate to zero
27 | switch (region) {
28 | case 'frozen-corner':
29 | gridCoords.x = 0;
30 | gridCoords.y = 0;
31 | break;
32 | case 'frozen-rows':
33 | gridCoords.y = 0;
34 | break;
35 | case 'frozen-cols':
36 | gridCoords.x = 0;
37 | break;
38 | }
39 |
40 | // The mouse event may be beyond the grid's actual size, so we need to truncate it (to avoid selections that
41 | // include fictional cells)
42 | return {
43 | truncatedCoord: truncateCoord(gridCoords, props),
44 | region,
45 | };
46 | };
47 |
48 | const truncateCoord = (coord: Coord, props: ReactCanvasGridProps): Coord => {
49 | return {
50 | x: Math.min(Math.max(coord.x, props.frozenCols), props.columns.length - 1),
51 | y: Math.min(Math.max(coord.y, props.frozenRows), props.data.length - 1),
52 | };
53 | };
54 |
--------------------------------------------------------------------------------
/src/FrozenColsCanvas.tsx:
--------------------------------------------------------------------------------
1 | import { ReactiveFn, transformer } from 'instigator';
2 | import * as React from 'react';
3 | import { CanvasRendererPosition } from './baseGridOffsetRenderer';
4 | import { FrozenCanvasCoreProps, FrozenCanvasProps } from './FrozenCanvas';
5 | import { GridCanvas } from './GridCanvas';
6 | import { HighlightedGridCanvas } from './HighlightedGridCanvas';
7 |
8 | type FrozenColsCanvasProps = FrozenCanvasCoreProps & Pick, 'horizontalGutterBounds'>;
9 |
10 | export class FrozenColsCanvas extends React.PureComponent> {
11 | private readonly colsPosProps: ReactiveFn;
12 |
13 | public constructor(props: FrozenColsCanvasProps) {
14 | super(props);
15 |
16 | const colsVisibleRect = transformer(
17 | [props.gridState.visibleRect, props.gridState.frozenRowsHeight, props.gridState.horizontalGutterBounds],
18 | (visibleRect, frozenRowsHeight, horizontalGutterBounds): ClientRect => {
19 | const scrollGutterHeight = horizontalGutterBounds ? horizontalGutterBounds.height : 0;
20 | return {
21 | ...visibleRect,
22 | left: 0,
23 | right: visibleRect.width,
24 | top: visibleRect.top + frozenRowsHeight,
25 | bottom: visibleRect.bottom - scrollGutterHeight,
26 | height: visibleRect.height - frozenRowsHeight - scrollGutterHeight,
27 | };
28 | });
29 | this.colsPosProps = transformer([colsVisibleRect], (visibleRect): CanvasRendererPosition => ({
30 | gridOffset: { x: visibleRect.left, y: visibleRect.top },
31 | visibleRect,
32 | }));
33 | }
34 |
35 | public render() {
36 | const horizontalGutterBounds = this.props.horizontalGutterBounds;
37 | const scrollGutterHeight = horizontalGutterBounds ? horizontalGutterBounds.height : 0;
38 | const props = {
39 | ...this.props,
40 | top: this.props.frozenRowsHeight,
41 | left: 0,
42 | height: this.props.height - this.props.frozenRowsHeight - scrollGutterHeight,
43 | width: this.props.frozenColsWidth,
44 | posProps: this.colsPosProps,
45 | };
46 | return (
47 |
48 | );
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/src/selectionState/rowsSelection.test.ts:
--------------------------------------------------------------------------------
1 | import { AllGridSelection } from './allGridSelection';
2 | import { CellsSelection } from './cellsSelection';
3 | import { ColsSelection, createSingleColSelection } from './colsSelection';
4 | import { createSingleRowSelection, RowsSelection } from './rowsSelection';
5 | import { CellCoordBounds } from './selectionTypes';
6 |
7 | describe('RowsSelection', () => {
8 | const bounds: CellCoordBounds = { numCols: 20, numRows: 100, frozenCols: 2, frozenRows: 2 };
9 |
10 | describe('arrowRight', () => {
11 | it('selects a single cell at the (unfrozen) left of the selected row', () => {
12 | const leftOfRow = { y: 10, x: 2 };
13 | const sel = createSingleRowSelection(leftOfRow, bounds);
14 |
15 | const newSel = sel.arrowRight(bounds);
16 |
17 | expect(newSel).toBeInstanceOf(CellsSelection);
18 | expect(newSel.getCursorCell()).toEqual(leftOfRow);
19 | expect(newSel.getSelectionRange()).toEqual({ topLeft: leftOfRow, bottomRight: leftOfRow });
20 | });
21 | });
22 |
23 | describe('arrowUp', () => {
24 | it('selects one row above where available', () => {
25 | const leftOfRow = { y: 10, x: 2 };
26 | const sel = createSingleRowSelection(leftOfRow, bounds);
27 |
28 | const newSel = sel.arrowUp(bounds);
29 |
30 | expect(newSel).toBeInstanceOf(RowsSelection);
31 | expect(newSel.getSelectionRange(bounds).topLeft.y).toBe(9);
32 | expect(newSel.getSelectionRange(bounds).bottomRight.y).toBe(9);
33 | });
34 |
35 | it('selects all cells when moving into frozen rows', () => {
36 | const leftOfRow = { x: 2, y: 2 };
37 | const sel = createSingleRowSelection(leftOfRow, bounds);
38 |
39 | const newSel = sel.arrowUp(bounds);
40 |
41 | expect(newSel).toBeInstanceOf(AllGridSelection);
42 | });
43 |
44 | it('does nothing when at the top of the grid', () => {
45 | const boundsWithoutFrozenRows = { ...bounds, frozenRows: 0 };
46 | const leftOfRow = { y: 0, x: 2 };
47 | const sel = createSingleRowSelection(leftOfRow, boundsWithoutFrozenRows);
48 |
49 | const newSel = sel.arrowUp(boundsWithoutFrozenRows);
50 |
51 | expect(JSON.parse(JSON.stringify(newSel))).toMatchObject(JSON.parse(JSON.stringify(sel)));
52 | });
53 | });
54 | });
55 |
--------------------------------------------------------------------------------
/examples/src/examples/Editable.grid.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { CellDataChangeEvent, CellDef, DataRow, ReactCanvasGrid } from 'react-canvas-grid';
3 | import { FixedSizeHolder } from '../components/FixedSizeHolder';
4 | import { createFakeColumns, createFakeData } from '../data/dataAndColumns';
5 |
6 | type NumPair = [number, number];
7 |
8 | interface EditableDataGridState {
9 | data: Array>;
10 | }
11 |
12 | export class EditableDataGrid extends React.Component<{}, EditableDataGridState> {
13 | constructor(props: {}) {
14 | super(props);
15 |
16 | const options: Partial> = {
17 | editor: {
18 | serialise: ([a, b]: NumPair) => `${a},${b}`,
19 | deserialise: (text: string, prev: NumPair) => {
20 | const match = text.match(/(\d+),(\d+)/);
21 | if (match) {
22 | return [parseInt(match[1], 10), parseInt(match[2], 10)];
23 | } else {
24 | return prev;
25 | }
26 | },
27 | },
28 | getText: ([a, b]: NumPair) => `${a}x${b}`,
29 | };
30 |
31 | this.state = {
32 | data: createFakeData(100, 200, (x, y) => [x, y] as NumPair, options),
33 | };
34 | }
35 |
36 | public render() {
37 | const columns = createFakeColumns(20);
38 |
39 | return (
40 |
41 |
42 | columns={columns}
43 | data={this.state.data}
44 | rowHeight={20}
45 | onCellDataChanged={this.onCellDataChanged}
46 | />
47 |
48 | );
49 | }
50 |
51 | private onCellDataChanged = (event: CellDataChangeEvent) => {
52 | this.setState({
53 | data: this.state.data.map((row, i) => {
54 | if (i === event.rowIndex) {
55 | return {
56 | ...row,
57 | [event.fieldName]: {
58 | ...row[event.fieldName],
59 | data: event.newData,
60 | },
61 | };
62 | } else {
63 | return row;
64 | }
65 | }),
66 | });
67 | }
68 | }
69 |
--------------------------------------------------------------------------------
/examples/src/examples/SelectionEvents.grid.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { ReactCanvasGrid, SelectRange } from 'react-canvas-grid';
3 | import { EventLog } from '../components/EventLog';
4 | import { FixedSizeHolder } from '../components/FixedSizeHolder';
5 | import { createFakeDataAndColumns } from '../data/dataAndColumns';
6 |
7 | interface SelectionEventsState {
8 | eventLog: string;
9 | }
10 |
11 | const { columns, rows: data } = createFakeDataAndColumns(100, 20, () => {/* no op */});
12 |
13 | export class SelectionEventsGrid extends React.Component<{}, SelectionEventsState> {
14 | constructor(props: {}) {
15 | super(props);
16 | this.state = {
17 | eventLog: '',
18 | };
19 | }
20 |
21 | public render() {
22 | return (
23 | <>
24 |
25 |
26 | columns={columns}
27 | data={data}
28 | rowHeight={20}
29 | frozenRows={1}
30 | frozenCols={1}
31 | onSelectionChangeStart={this.start}
32 | onSelectionChangeUpdate={this.update}
33 | onSelectionChangeEnd={this.end}
34 | />
35 |
36 |
37 | >
38 | );
39 | }
40 |
41 | private start = (range: SelectRange | null) => {
42 | this.appendToLog('start', range);
43 | }
44 |
45 | private update = (range: SelectRange | null) => {
46 | this.appendToLog('update', range);
47 | }
48 |
49 | private end = (range: SelectRange | null) => {
50 | this.appendToLog('end', range);
51 | }
52 |
53 | private appendToLog = (eventType: 'start' | 'update' | 'end', range: SelectRange | null) => {
54 | const paddedType = eventType === 'start' ? ' start' :
55 | eventType === 'update' ? 'update' :
56 | ' end';
57 | const newRange = range ?
58 | `(${range.topLeft.x},${range.topLeft.y}) -> (${range.bottomRight.x},${range.bottomRight.y})` :
59 | 'none';
60 | this.setState((prevState) => {
61 | return { ...prevState, eventLog: prevState.eventLog + `${paddedType}: ${newRange}\n` };
62 | });
63 | }
64 | }
65 |
--------------------------------------------------------------------------------
/src/eventHandlers/scrolling.test.ts:
--------------------------------------------------------------------------------
1 | import { GridState } from '../gridState';
2 | import { updateOffsetByDelta } from './scrolling';
3 |
4 | describe('updateOffsetByDelta', () => {
5 | const createGridState = (params: Partial> = {}) => {
6 | const defaults: GridState = {
7 | rootSize: (() => ({ width: 500, height: 500 })) as any,
8 | canvasSize: (() => ({ width: 500, height: 500 })) as any,
9 | gridSize: (() => ({ width: 1000, height: 1000 })) as any,
10 | gridOffset: (() => ({ x: 0, y: 0 })) as any,
11 | gridOffsetRaw: jest.fn() as any,
12 | } as GridState;
13 | return { ...defaults, ...params };
14 | };
15 |
16 | it('does not update the offset if the rootSize is not yet set', () => {
17 | const gridState = createGridState({ rootSize: (() => null) as any });
18 |
19 | const result = updateOffsetByDelta(10, 10, gridState);
20 |
21 | expect(result).toEqual(false);
22 | expect(gridState.gridOffsetRaw).not.toHaveBeenCalled();
23 | });
24 |
25 | it('does not update the offset if the delta is 0', () => {
26 | const gridState = createGridState();
27 |
28 | const result = updateOffsetByDelta(0, 0, gridState);
29 |
30 | expect(result).toEqual(false);
31 | expect(gridState.gridOffsetRaw).not.toHaveBeenCalled();
32 | });
33 |
34 | it('does not update the offset if the offset is at the top left and the delta is up and left', () => {
35 | const gridState = createGridState();
36 |
37 | const result = updateOffsetByDelta(-10, -10, gridState);
38 |
39 | expect(result).toEqual(false);
40 | expect(gridState.gridOffsetRaw).not.toHaveBeenCalled();
41 | });
42 |
43 | it('does not update the offset if the offset is at the bottom right and the delta is down and right', () => {
44 | const gridState = createGridState({ gridOffset: (() => ({ x: 500, y: 500 })) as any });
45 |
46 | const result = updateOffsetByDelta(10, 10, gridState);
47 |
48 | expect(result).toEqual(false);
49 | expect(gridState.gridOffsetRaw).not.toHaveBeenCalled();
50 | });
51 |
52 | it('updates the offset if the delta would change it (after truncation)', () => {
53 | const gridState = createGridState();
54 |
55 | const result = updateOffsetByDelta(10, 10, gridState);
56 |
57 | expect(result).toEqual(true);
58 | expect(gridState.gridOffsetRaw).toHaveBeenCalledWith({ x: 10, y: 10 });
59 | });
60 | });
61 |
--------------------------------------------------------------------------------
/examples/src/examples/ExamplePage.tsx:
--------------------------------------------------------------------------------
1 | import Highlight, { defaultProps } from 'prism-react-renderer';
2 | import nightOwlLight from 'prism-react-renderer/themes/nightOwlLight';
3 | import * as React from 'react';
4 | import './ExamplePage.css';
5 |
6 | interface ExamplePageProps {
7 | textComponent: React.ComponentType;
8 | gridComponent: React.ComponentType;
9 | filename: String;
10 | }
11 |
12 | export class ExamplePage extends React.Component {
13 | constructor(props: ExamplePageProps) {
14 | super(props);
15 | this.state = {
16 | source: null,
17 | showSource: false,
18 | };
19 | }
20 |
21 | public componentDidMount() {
22 | fetch(process.env.PUBLIC_URL + `/examples/${this.props.filename}.grid.tsx`)
23 | .then((res) => res.text())
24 | .then((source) => this.setState({source}));
25 | }
26 |
27 | public render() {
28 | const Text = this.props.textComponent;
29 | const Grid = this.props.gridComponent;
30 | return (
31 | <>
32 |
33 |
34 | {this.state.source &&
35 |
36 | {this.state.showSource ? 'Hide' : 'Show'} code
37 |
38 | }
39 | {this.state.source && this.state.showSource &&
40 |
41 | {({ className, style, tokens, getLineProps, getTokenProps }) => (
42 |
43 | {tokens.map((line, i) => (
44 |
45 | {line.map((token, key) => (
46 |
47 | ))}
48 |
49 | ))}
50 |
51 | )}
52 |
53 | }
54 | >
55 | );
56 | }
57 |
58 | private toggleCode = (e: React.MouseEvent) => {
59 | this.setState({
60 | showSource: !this.state.showSource,
61 | });
62 | e.preventDefault();
63 | }
64 | };
65 |
--------------------------------------------------------------------------------
/examples/src/examples/FocusColumn.grid.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { ReactCanvasGrid } from 'react-canvas-grid';
3 | import { ControlsForm } from '../components/ControlsForm';
4 | import { FixedSizeHolder } from '../components/FixedSizeHolder';
5 | import { createFakeDataAndColumns } from '../data/dataAndColumns';
6 |
7 | interface FocusColumnGridState {
8 | focusedCol: number | null;
9 | freezeCols: boolean;
10 | }
11 |
12 | export class FocusColumnGrid extends React.Component<{}, FocusColumnGridState> {
13 | constructor(props: {}) {
14 | super(props);
15 |
16 | this.state = {
17 | focusedCol: null,
18 | freezeCols: false,
19 | };
20 | }
21 |
22 | public render() {
23 | const { columns, rows: data } = createFakeDataAndColumns(20, 100, () => {/* no op */});
24 |
25 | return (
26 | <>
27 |
28 |
29 | Select a column to focus:
30 |
31 | None
32 | {columns.map((_, i) => (
33 | {i + 1}
34 | ))}
35 |
36 |
37 |
38 | Select to freeze columns 1 & 2:
39 |
40 |
41 |
42 |
43 |
44 |
45 | columns={columns}
46 | data={data}
47 | rowHeight={20}
48 | focusedColIndex={this.state.focusedCol}
49 | frozenCols={this.state.freezeCols ? 2 : 0}
50 | />
51 |
52 | >
53 | );
54 | }
55 |
56 | private focusColumn = (event: React.ChangeEvent) => {
57 | if (event.target.value === 'none') {
58 | this.setState({ focusedCol: null });
59 | } else {
60 | this.setState({ focusedCol: parseInt(event.target.value, 10) });
61 | }
62 | }
63 |
64 | private toggleFrozenCols = () => {
65 | this.setState({ freezeCols: !this.state.freezeCols });
66 | }
67 | }
68 |
--------------------------------------------------------------------------------
/src/scrollbars/verticalScrollbarRenderer.ts:
--------------------------------------------------------------------------------
1 | import * as ScrollGeometry from '../scrollbarGeometry';
2 | import { BaseScrollbarRenderer, ScrollbarRendererBasics, styles } from './baseScrollbarRenderer';
3 |
4 | export class VerticalScrollbarRenderer extends BaseScrollbarRenderer {
5 | constructor(canvas: HTMLCanvasElement, basicProps: ScrollbarRendererBasics) {
6 | super('vertical scrollbar', canvas, basicProps);
7 | }
8 |
9 | public translate() {
10 | const vBounds = this.basicProps.verticalGutterBounds;
11 | if (vBounds) {
12 | this.context.translate(-vBounds.left, -vBounds.top);
13 | }
14 | }
15 |
16 | public draw = () => {
17 | const context = this.context;
18 |
19 | // Draw scrollbar gutters
20 | const vBounds = this.basicProps.verticalGutterBounds;
21 | const hBounds = this.basicProps.horizontalGutterBounds;
22 | if (vBounds) {
23 | this.context.fillStyle = styles.scrollGutters.fill;
24 | this.context.fillRect(vBounds.left, vBounds.top, vBounds.width, vBounds.height);
25 |
26 | this.context.strokeStyle = styles.scrollGutters.stroke;
27 | this.context.lineWidth = 1;
28 | this.context.beginPath();
29 | this.context.moveTo(vBounds.left, 0);
30 | this.context.lineTo(vBounds.left, vBounds.height - (hBounds ? hBounds.height : 0));
31 | this.context.moveTo(vBounds.right, 0);
32 | this.context.lineTo(vBounds.right, vBounds.height);
33 | this.context.stroke();
34 | }
35 |
36 | // Draw vertical scrollbar (if needed)
37 | drawVScrollbar(context, this.basicProps.verticalScrollbarPos, this.basicProps.hoveredScrollbar);
38 | }
39 | }
40 |
41 | export function drawVScrollbar(
42 | context: CanvasRenderingContext2D,
43 | verticalScrollbarPos: ScrollGeometry.ScrollbarPosition | null,
44 | hoveredScrollbar: 'x' | 'y' | null,
45 | ) {
46 | // Set up for drawing scrollbars
47 | context.lineCap = 'round';
48 |
49 | if (verticalScrollbarPos) {
50 | if (hoveredScrollbar === 'y') {
51 | context.strokeStyle = styles.scrollbar.hoverFill;
52 | context.lineWidth = ScrollGeometry.barWidth + 3;
53 | } else {
54 | context.strokeStyle = styles.scrollbar.defaultFill;
55 | context.lineWidth = ScrollGeometry.barWidth;
56 | }
57 | const scrollPos = verticalScrollbarPos;
58 | context.beginPath();
59 | context.moveTo(scrollPos.transverse, scrollPos.extent.start);
60 | context.lineTo(scrollPos.transverse, scrollPos.extent.end);
61 | context.stroke();
62 | }
63 | }
64 |
--------------------------------------------------------------------------------
/src/scrollbars/horizontalScrollbarRenderer.ts:
--------------------------------------------------------------------------------
1 | import * as ScrollGeometry from '../scrollbarGeometry';
2 | import { BaseScrollbarRenderer, ScrollbarRendererBasics, styles } from './baseScrollbarRenderer';
3 |
4 | export class HorizontalScrollbarRenderer extends BaseScrollbarRenderer {
5 | constructor(canvas: HTMLCanvasElement, basicProps: ScrollbarRendererBasics) {
6 | super('horizontal scrollbar', canvas, basicProps);
7 | }
8 |
9 | public translate() {
10 | const hBounds = this.basicProps.horizontalGutterBounds;
11 | if (hBounds) {
12 | this.context.translate(-hBounds.left, -hBounds.top);
13 | }
14 | }
15 |
16 | public draw = () => {
17 | const context = this.context;
18 |
19 | // Draw scrollbar gutters
20 | const hBounds = this.basicProps.horizontalGutterBounds;
21 | const vBounds = this.basicProps.verticalGutterBounds;
22 | if (hBounds) {
23 | this.context.fillStyle = styles.scrollGutters.fill;
24 | this.context.fillRect(hBounds.left, hBounds.top, hBounds.width, hBounds.height);
25 |
26 | this.context.strokeStyle = styles.scrollGutters.stroke;
27 | this.context.lineWidth = 1;
28 | this.context.beginPath();
29 | this.context.moveTo(0, hBounds.top);
30 | this.context.lineTo(hBounds.width - (vBounds ? vBounds.width : 0), hBounds.top);
31 | this.context.moveTo(0, hBounds.bottom);
32 | this.context.lineTo(hBounds.width, hBounds.bottom);
33 | this.context.stroke();
34 | }
35 |
36 | // Draw horizontal scrollbar (if needed)
37 | drawHScrollbar(context, this.basicProps.horizontalScrollbarPos, this.basicProps.hoveredScrollbar);
38 | }
39 | }
40 |
41 | export function drawHScrollbar(
42 | context: CanvasRenderingContext2D,
43 | horizontalScrollbarPos: ScrollGeometry.ScrollbarPosition | null,
44 | hoveredScrollbar: 'x' | 'y' | null,
45 | ) {
46 | // Set up for drawing scrollbars
47 | context.lineCap = 'round';
48 |
49 | if (horizontalScrollbarPos) {
50 | if (hoveredScrollbar === 'x') {
51 | context.strokeStyle = styles.scrollbar.hoverFill;
52 | context.lineWidth = ScrollGeometry.barWidth + 3;
53 | } else {
54 | context.strokeStyle = styles.scrollbar.defaultFill;
55 | context.lineWidth = ScrollGeometry.barWidth;
56 | }
57 | const scrollPos = horizontalScrollbarPos;
58 | context.beginPath();
59 | context.moveTo(scrollPos.extent.start, scrollPos.transverse);
60 | context.lineTo(scrollPos.extent.end, scrollPos.transverse);
61 | context.stroke();
62 | }
63 | }
64 |
--------------------------------------------------------------------------------
/src/GridCanvas.test.tsx:
--------------------------------------------------------------------------------
1 | import { mount } from 'enzyme';
2 | import { transformer } from 'instigator';
3 | import * as React from 'react';
4 | import { CanvasRendererPosition } from './baseGridOffsetRenderer';
5 | import { GridCanvas, GridCanvasProps } from './GridCanvas';
6 | import { GridCanvasRenderer } from './gridCanvasRenderer';
7 | import { GridState } from './gridState';
8 |
9 | const mockFixScale = jest.fn();
10 | const mockDraw = jest.fn();
11 | const mockUpdateProps = jest.fn();
12 | jest.mock('./gridCanvasRenderer', () => {
13 | return {
14 | GridCanvasRenderer: jest.fn().mockImplementation(() => {
15 | return {
16 | fixScale: mockFixScale,
17 | draw: mockDraw,
18 | updateProps: mockUpdateProps,
19 | __dummy__: 'fake GridCanvasRenderer',
20 | };
21 | }),
22 | };
23 | });
24 | const MockedRenderer = GridCanvasRenderer as jest.Mock>;
25 |
26 | let props: GridCanvasProps;
27 |
28 | describe('GridCanvas', () => {
29 | beforeEach(() => {
30 | jest.clearAllMocks();
31 |
32 | const gridState = new GridState([{width: 40} as any], [], 20, 1, 0, 0, () => false);
33 | const posProps = transformer(
34 | [gridState.gridOffset, gridState.canvasSize],
35 | (offset, size): CanvasRendererPosition => {
36 | return {
37 | gridOffset: offset,
38 | visibleRect: {
39 | left: offset.x,
40 | top: offset.y,
41 | width: size.width,
42 | height: size.height,
43 | right: offset.x + size.width,
44 | bottom: offset.y + size.height,
45 | },
46 | };
47 | },
48 | );
49 | props = {
50 | name: 'test',
51 | top: 0,
52 | left: 0,
53 | height: 100,
54 | width: 100,
55 | gridState,
56 | dpr: 1,
57 | posProps,
58 | };
59 | });
60 |
61 | it('draws to its canvas when base props change', () => {
62 | const bc = mount( );
63 | bc.setProps(props);
64 |
65 | props.gridState.rowHeight(30);
66 |
67 | expect(MockedRenderer).toHaveBeenCalled();
68 | expect(mockUpdateProps).toHaveBeenCalled();
69 | });
70 |
71 | it('redraws to its canvas when pos props change', () => {
72 | const bc = mount( );
73 | bc.setProps(props);
74 |
75 | props.gridState.gridOffsetRaw({ x: 10, y: 10 });
76 |
77 | expect(mockUpdateProps).toHaveBeenCalled();
78 | });
79 | });
80 |
--------------------------------------------------------------------------------
/src/eventHandlers/keyboardEvents.ts:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { GridState } from '../gridState';
3 | import { ReactCanvasGridProps } from '../ReactCanvasGrid';
4 | import { AllSelectionStates } from '../selectionState/selectionStateFactory';
5 | import { CellCoordBounds } from '../selectionState/selectionTypes';
6 | import { equalSelectRange } from '../utils';
7 |
8 | interface ShiftNoShiftActions {
9 | shift: (cellBounds: CellCoordBounds) => AllSelectionStates;
10 | noShift: (cellBounds: CellCoordBounds) => AllSelectionStates;
11 | }
12 | interface StateArrowActions {
13 | [arrowKey: string]: ShiftNoShiftActions;
14 | }
15 |
16 | export const keyDownOnGrid = (
17 | event: React.KeyboardEvent,
18 | props: ReactCanvasGridProps,
19 | gridState: GridState,
20 | ) => {
21 | const selectionState = gridState.selectionState();
22 |
23 | // Find the appropriate method to call on the selection state
24 | const actions: StateArrowActions = {
25 | ArrowRight : { shift: selectionState.shiftArrowRight, noShift: selectionState.arrowRight },
26 | ArrowLeft : { shift: selectionState.shiftArrowLeft, noShift: selectionState.arrowLeft },
27 | ArrowUp : { shift: selectionState.shiftArrowUp, noShift: selectionState.arrowUp },
28 | ArrowDown : { shift: selectionState.shiftArrowDown, noShift: selectionState.arrowDown },
29 | };
30 | const selectionArrowActions = actions[event.key];
31 | if (!selectionArrowActions) {
32 | return;
33 | }
34 | const selectionArrowAction = selectionArrowActions[event.shiftKey ? 'shift' : 'noShift'];
35 |
36 | const cellBounds = gridState.cellBounds();
37 |
38 | // Create the new state
39 | const newSelState = selectionArrowAction(cellBounds);
40 |
41 | const selectionRange = newSelState.getSelectionRange(cellBounds);
42 | const newOffset = newSelState.getFocusGridOffset(gridState);
43 |
44 | const oldSelectionRange = selectionState.getSelectionRange(cellBounds);
45 |
46 | if (selectionRange !== null && !equalSelectRange(oldSelectionRange, selectionRange)) {
47 | // Start / update prop callback
48 | const onStartOrUpdate = event.shiftKey ? props.onSelectionChangeUpdate : props.onSelectionChangeStart;
49 | if (onStartOrUpdate) {
50 | onStartOrUpdate(selectionRange);
51 | }
52 |
53 | // Scroll
54 | if (newOffset !== null) {
55 | gridState.gridOffsetRaw(newOffset);
56 | }
57 |
58 | // End prop callback
59 | if (props.onSelectionChangeEnd) {
60 | props.onSelectionChangeEnd(selectionRange);
61 | }
62 |
63 | // Update selection state
64 | gridState.selectionState(newSelState);
65 |
66 | // Prevent the arrow key from scrolling the page (as we've scrolled in the grid instead)
67 | event.preventDefault();
68 | }
69 | };
70 |
--------------------------------------------------------------------------------
/src/eventHandlers/scrollingTimer.ts:
--------------------------------------------------------------------------------
1 | import { GridState } from '../gridState';
2 | import { Coord } from '../types';
3 | import { updateOffsetByDelta } from './scrolling';
4 |
5 | let scrollBySelectionDragTimerId: number | null = null;
6 |
7 | export const clearScrollByDragTimer = () => {
8 | if (scrollBySelectionDragTimerId) {
9 | clearInterval(scrollBySelectionDragTimerId);
10 | scrollBySelectionDragTimerId = null;
11 | }
12 | };
13 |
14 | export const startScrollBySelectionDragIfNeeded = (
15 | gridState: GridState,
16 | componentPixelCoord: Coord,
17 | updateSelection: () => void,
18 | options: { suppressX?: boolean; suppressY?: boolean; } = {},
19 | ) => {
20 | const rootSize = gridState.rootSize();
21 | if (!rootSize) {
22 | return;
23 | }
24 |
25 | // Clear any old scroll timer - the mouse pos has changed, so we'll set up a new timer if needed
26 | clearScrollByDragTimer();
27 |
28 | let deltaX = 0;
29 | if (options.suppressX !== true) {
30 | if (componentPixelCoord.x < 10) {
31 | deltaX = -15;
32 | } else if (componentPixelCoord.x < 20) {
33 | deltaX = -10;
34 | } else if (componentPixelCoord.x < 40) {
35 | deltaX = -5;
36 | } else if (componentPixelCoord.x < 50) {
37 | deltaX = -1;
38 | } else if (componentPixelCoord.x > rootSize.width - 10) {
39 | deltaX = 15;
40 | } else if (componentPixelCoord.x > rootSize.width - 20) {
41 | deltaX = 10;
42 | } else if (componentPixelCoord.x > rootSize.width - 40) {
43 | deltaX = 5;
44 | } else if (componentPixelCoord.x > rootSize.width - 50) {
45 | deltaX = 1;
46 | }
47 | }
48 |
49 | let deltaY = 0;
50 | if (options.suppressY !== true) {
51 | if (componentPixelCoord.y < 10) {
52 | deltaY = -15;
53 | } else if (componentPixelCoord.y < 20) {
54 | deltaY = -10;
55 | } else if (componentPixelCoord.y < 40) {
56 | deltaY = -5;
57 | } else if (componentPixelCoord.y < 50) {
58 | deltaY = -1;
59 | } else if (componentPixelCoord.y > rootSize.height - 10) {
60 | deltaY = 15;
61 | } else if (componentPixelCoord.y > rootSize.height - 20) {
62 | deltaY = 10;
63 | } else if (componentPixelCoord.y > rootSize.height - 40) {
64 | deltaY = 5;
65 | } else if (componentPixelCoord.y > rootSize.height - 50) {
66 | deltaY = 1;
67 | }
68 | }
69 |
70 | if (deltaX !== 0 || deltaY !== 0) {
71 | const updateOffsetByDeltaAndUpdateSelection = () => {
72 | updateOffsetByDelta(deltaX, deltaY, gridState);
73 | updateSelection();
74 | };
75 | scrollBySelectionDragTimerId = setInterval(updateOffsetByDeltaAndUpdateSelection, 10);
76 | updateOffsetByDelta(deltaX, deltaY, gridState);
77 | }
78 | };
79 |
--------------------------------------------------------------------------------
/src/types.ts:
--------------------------------------------------------------------------------
1 | export interface ColumnDef {
2 | fieldName: string;
3 | width: number;
4 | }
5 |
6 | export interface CustomDrawCallbackMetadata {
7 | column: ColumnDef;
8 | colIndex: number;
9 | rowIndex: number;
10 | }
11 | type CustomDrawCallback = (
12 | context: CanvasRenderingContext2D,
13 | cellBounds: ClientRect,
14 | cell: CellDef,
15 | metadata: CustomDrawCallbackMetadata,
16 | ) => void;
17 |
18 | interface CellDefCommon {
19 | data: T;
20 | renderBackground?: CustomDrawCallback;
21 | renderText?: CustomDrawCallback;
22 | }
23 |
24 | interface WithTextFunction {
25 | getText: (data: T) => string;
26 | }
27 | interface WithTextString {
28 | text: string;
29 | }
30 | type TextAccessible = WithTextFunction | WithTextString;
31 |
32 | interface WithTitleFunction {
33 | getTitle: (data: T) => string;
34 | }
35 | interface WithTitleString {
36 | title: string;
37 | }
38 | type TitleAccessible = WithTitleFunction | WithTitleString | {};
39 |
40 | interface WithSerialisers {
41 | editor: {
42 | serialise: (data: T) => string;
43 | deserialise: (value: string, oldData: T) => T;
44 | };
45 | }
46 | type Serialisable = WithSerialisers | {};
47 |
48 | export type CellDef = CellDefCommon & TextAccessible & TitleAccessible & Serialisable;
49 | export type EditableCellDef = CellDef & WithSerialisers;
50 |
51 | export const cellHasTextFunction = (cell: CellDef): cell is CellDef & WithTextFunction => {
52 | return !!(cell as any).getText;
53 | };
54 |
55 | export const getCellText = (cell: CellDef): string => {
56 | return cellHasTextFunction(cell) ? cell.getText(cell.data) : cell.text;
57 | };
58 |
59 | const cellHasTitleFunction = (cell: CellDef): cell is CellDef & WithTitleFunction => {
60 | return !!(cell as any).getTitle;
61 | };
62 | const cellHasTitleString = (cell: CellDef): cell is CellDef & WithTitleString => {
63 | return !!(cell as any).title;
64 | }
65 |
66 | export const getTitleText = (cell: CellDef): string | null => {
67 | if (cellHasTitleFunction(cell)) {
68 | return cell.getTitle(cell.data);
69 | } else if (cellHasTitleString(cell)) {
70 | return cell.title;
71 | } else {
72 | return null;
73 | }
74 | }
75 |
76 | export const cellIsEditable = (cell: CellDef): cell is EditableCellDef => {
77 | return !!(cell as EditableCellDef).editor;
78 | };
79 |
80 | export interface DataRow {
81 | [fieldName: string]: CellDef;
82 | }
83 |
84 | export interface Coord {
85 | x: number;
86 | y: number;
87 | }
88 |
89 | export interface Size {
90 | width: number;
91 | height: number;
92 | }
93 |
94 | export interface Bounds {
95 | top: number;
96 | left: number;
97 | right: number;
98 | bottom: number;
99 | }
100 |
--------------------------------------------------------------------------------
/src/eventHandlers/autofillMouseEvents.ts:
--------------------------------------------------------------------------------
1 | import { RefObject } from 'react';
2 | import { GridGeometry } from '../gridGeometry';
3 | import { GridState } from '../gridState';
4 | import { CellsSelection } from '../selectionState/cellsSelection';
5 |
6 | export const mouseDownOnAutofillHandle = (
7 | event: React.MouseEvent,
8 | gridState: GridState,
9 | rootRef: RefObject,
10 | ): boolean => {
11 | const selectionState = gridState.selectionState();
12 | if (!(selectionState instanceof CellsSelection)) {
13 | return false;
14 | }
15 | const selectionRange = selectionState.getSelectionRange();
16 |
17 | const shouldAllowAutofill = gridState.shouldAllowAutofill();
18 | if (!shouldAllowAutofill(selectionRange)) {
19 | return false;
20 | }
21 |
22 | const isHandleHit = isOverAutofillHandle(selectionState, event, gridState, rootRef);
23 |
24 | if (isHandleHit) {
25 | // Start tracking autofill drag
26 | const newSelState = selectionState.mouseDownOnAutofillHandle();
27 | gridState.selectionState(newSelState);
28 | return true;
29 | } else {
30 | return false;
31 | }
32 | };
33 |
34 | export const mouseHoverOnAutofillHandle = (
35 | event: MouseEvent,
36 | gridState: GridState,
37 | rootRef: RefObject,
38 | ): boolean => {
39 | const selectionState = gridState.selectionState();
40 | if (selectionState instanceof CellsSelection) {
41 | const selectionRange = selectionState.getSelectionRange();
42 | const shouldAllowAutofill = gridState.shouldAllowAutofill();
43 | if (shouldAllowAutofill(selectionRange)) {
44 | const isHandleHit = isOverAutofillHandle(selectionState, event, gridState, rootRef);
45 | if (isHandleHit) {
46 | gridState.autofillHandleIsHovered(true);
47 | return true;
48 | }
49 | }
50 | }
51 |
52 | gridState.autofillHandleIsHovered(false);
53 | return false;
54 | };
55 |
56 | const isOverAutofillHandle = (
57 | selectionState: CellsSelection,
58 | event: {clientX: number, clientY: number},
59 | gridState: GridState,
60 | rootRef: RefObject,
61 | ): boolean => {
62 | const selectionRange = selectionState.getSelectionRange();
63 |
64 | const bottomRightCellBounds = GridGeometry.calculateCellBounds(
65 | selectionRange.bottomRight.x,
66 | selectionRange.bottomRight.y,
67 | gridState.rowHeight(),
68 | gridState.borderWidth(),
69 | gridState.columnBoundaries(),
70 | );
71 | const gridPixelCoord = GridGeometry.calculateGridPixelCoords(
72 | event,
73 | gridState.gridOffset(),
74 | gridState.frozenColsWidth(),
75 | gridState.frozenRowsHeight(),
76 | rootRef.current,
77 | );
78 | const dx = gridPixelCoord.x - bottomRightCellBounds.right;
79 | const dy = gridPixelCoord.y - bottomRightCellBounds.bottom;
80 |
81 | return Math.abs(dx) <= 3 && Math.abs(dy) <= 3;
82 | };
83 |
--------------------------------------------------------------------------------
/src/scrollbars/VerticalScrollbarCanvas.tsx:
--------------------------------------------------------------------------------
1 | import { consumer, mergeTransformer, ReactiveConsumer } from 'instigator';
2 | import * as React from 'react';
3 | import { shallowEqualsExceptFunctions } from '../gridState';
4 | import { ScrollbarRendererBasics } from './baseScrollbarRenderer';
5 | import { ScrollbarCanvasProps } from './ScrollbarCanvas';
6 | import { VerticalScrollbarRenderer } from './verticalScrollbarRenderer';
7 |
8 | type VerticalScrollbarCanvasProps = ScrollbarCanvasProps & {
9 | verticalGutterBounds: ClientRect;
10 | };
11 |
12 | export class VerticalScrollbarCanvas extends React.PureComponent> {
13 | private readonly canvasRef: React.RefObject = React.createRef();
14 | private renderer: VerticalScrollbarRenderer|null = null;
15 | private renderCallback: ReactiveConsumer|null = null;
16 |
17 | public render() {
18 | return (
19 |
32 | );
33 | }
34 |
35 | public componentDidMount() {
36 | if (!this.canvasRef.current) {
37 | throw new Error('canvasRef is null in componentDidMount - cannot create renderer');
38 | }
39 |
40 | const gridState = this.props.gridState;
41 | const basicProps = mergeTransformer({
42 | dpr: gridState.dpr,
43 | horizontalGutterBounds: gridState.horizontalGutterBounds,
44 | verticalGutterBounds: gridState.verticalGutterBounds,
45 | horizontalScrollbarPos: gridState.horizontalScrollbarPos,
46 | verticalScrollbarPos: gridState.verticalScrollbarPos,
47 | hoveredScrollbar: gridState.hoveredScrollbar,
48 | }, shallowEqualsExceptFunctions);
49 |
50 | this.renderer = new VerticalScrollbarRenderer(
51 | this.canvasRef.current,
52 | basicProps(),
53 | );
54 | this.renderCallback = consumer(
55 | [basicProps],
56 | (newBasicProps) => {
57 | if (this.renderer) {
58 | if (!this.canvasRef.current) {
59 | throw new Error('canvasRef is null in componentDidMount - cannot create renderer');
60 | }
61 | this.renderer.updateProps(this.canvasRef.current, newBasicProps);
62 | }
63 | });
64 | this.renderCallback();
65 | }
66 |
67 | public componentWillUnmount() {
68 | if (this.renderCallback) {
69 | this.renderCallback.deregister();
70 | }
71 | }
72 | }
73 |
--------------------------------------------------------------------------------
/src/scrollbars/HorizontalScrollbarCanvas.tsx:
--------------------------------------------------------------------------------
1 | import { consumer, mergeTransformer, ReactiveConsumer } from 'instigator';
2 | import * as React from 'react';
3 | import { shallowEqualsExceptFunctions } from '../gridState';
4 | import { ScrollbarRendererBasics } from './baseScrollbarRenderer';
5 | import { HorizontalScrollbarRenderer } from './horizontalScrollbarRenderer';
6 | import { ScrollbarCanvasProps } from './ScrollbarCanvas';
7 |
8 | type HorizontalScrollbarCanvasProps = ScrollbarCanvasProps & {
9 | horizontalGutterBounds: ClientRect;
10 | };
11 |
12 | export class HorizontalScrollbarCanvas extends React.PureComponent> {
13 | private readonly canvasRef: React.RefObject = React.createRef();
14 | private renderer: HorizontalScrollbarRenderer|null = null;
15 | private renderCallback: ReactiveConsumer|null = null;
16 |
17 | public render() {
18 | return (
19 |
32 | );
33 | }
34 |
35 | public componentDidMount() {
36 | if (!this.canvasRef.current) {
37 | throw new Error('canvasRef is null in componentDidMount - cannot create renderer');
38 | }
39 |
40 | const gridState = this.props.gridState;
41 | const basicProps = mergeTransformer({
42 | dpr: gridState.dpr,
43 | horizontalGutterBounds: gridState.horizontalGutterBounds,
44 | verticalGutterBounds: gridState.verticalGutterBounds,
45 | horizontalScrollbarPos: gridState.horizontalScrollbarPos,
46 | verticalScrollbarPos: gridState.verticalScrollbarPos,
47 | hoveredScrollbar: gridState.hoveredScrollbar,
48 | }, shallowEqualsExceptFunctions);
49 |
50 | this.renderer = new HorizontalScrollbarRenderer(
51 | this.canvasRef.current,
52 | basicProps(),
53 | );
54 | this.renderCallback = consumer(
55 | [basicProps],
56 | (newBasicProps) => {
57 | if (this.renderer) {
58 | if (!this.canvasRef.current) {
59 | throw new Error('canvasRef is null in componentDidMount - cannot create renderer');
60 | }
61 | this.renderer.updateProps(this.canvasRef.current, newBasicProps);
62 | }
63 | });
64 | this.renderCallback();
65 | }
66 |
67 | public componentWillUnmount() {
68 | if (this.renderCallback) {
69 | this.renderCallback.deregister();
70 | }
71 | }
72 | }
73 |
--------------------------------------------------------------------------------
/examples/src/Home.css:
--------------------------------------------------------------------------------
1 | .hero-wrapper {
2 | background: linear-gradient(to bottom, var(--primary-colour) 0, var(--primary-colour-dark) 100%);
3 | color: white;
4 | box-sizing: border-box;
5 | padding-bottom: 15vh;
6 | }
7 |
8 | .hero-wrapper svg {
9 | fill: white;
10 | stroke: white;
11 | filter: drop-shadow(0 3px 3px rgba(0, 0, 0, 0.3));
12 | }
13 |
14 | .hero-wrapper .logo-svg {
15 | margin: 15vh 20% 3em 20%;
16 | }
17 |
18 | .hero-wrapper h1 {
19 | font-family: monospace;
20 | font-size: 5em;
21 | text-align: center;
22 | filter: drop-shadow(0 3px 3px rgba(0, 0, 0, 0.3));
23 | }
24 | .hero-wrapper h1 .warning {
25 | color: rgb(230, 0, 0);
26 | }
27 |
28 | .features {
29 | display: flex;
30 | flex-direction: row;
31 | margin: 0 10%;
32 | padding-top: 5em;
33 | }
34 | .features .feature {
35 | margin: 0 2em;
36 | flex: 1 1 0;
37 | text-align: center;
38 | }
39 | @media (min-width: 789px) {
40 | .features .feature:first-of-type {
41 | margin-left: 0;
42 | }
43 | .features .feature:last-of-type {
44 | margin-right: 0;
45 | }
46 | }
47 | .features .feature .feature-icon-sizer {
48 | width: 60%;
49 | height: 0;
50 | padding-bottom: 60%;
51 | margin: 0 auto;
52 | }
53 | .features .feature h3 {
54 | color: var(--primary-colour-dark);
55 | font-size: 1.5em;
56 | }
57 | @media (max-width: 768px) {
58 | .features {
59 | display: block;
60 | padding-top: 2em;
61 | }
62 | .features .feature {
63 | margin-bottom: 2em;
64 | }
65 | }
66 |
67 | .install-instructions-wrapper {
68 | text-align: center;
69 | margin: 0 2em;
70 | }
71 | .install-instructions {
72 | border: 2px solid white;
73 | border-radius: 5px;
74 | padding: 2em;
75 | display: inline-block;
76 | margin-top: 3em;
77 | }
78 | .install-instructions code {
79 | font-size: 1.2em;
80 | }
81 | .install-instructions .prompt {
82 | -webkit-touch-callout: none; /* iOS Safari */
83 | -webkit-user-select: none; /* Safari */
84 | -khtml-user-select: none; /* Konqueror HTML */
85 | -moz-user-select: none; /* Old versions of Firefox */
86 | -ms-user-select: none; /* Internet Explorer/Edge */
87 | user-select: none; /* Non-prefixed version, currently supported by Chrome, Edge, Opera and Firefox */
88 | }
89 |
90 | .capabilities {
91 | margin: 5em 10% 0 10%;
92 | }
93 |
94 | .capability {
95 | margin-bottom: 2em;
96 | }
97 | .capability h3 {
98 | margin: 2em 0 0.75em 0;
99 | color: var(--primary-colour-dark);
100 | }
101 | .capability p {
102 | margin: 0 0 1em 0;
103 | }
104 | .capability .capability-details {
105 | margin: 0 1.5em;
106 | display: flex;
107 | flex-direction: row;
108 | }
109 | @media (max-width: 768px) {
110 | .capability .capability-details {
111 | display: initial;
112 | }
113 | }
114 | .capability:nth-child(even) .capability-details {
115 | flex-direction: row-reverse;
116 | }
117 | .capability img {
118 | width: 204px;
119 | object-fit: contain;
120 | margin: 0 1em 0.5em 0;
121 | }
122 | .capability:nth-child(even) img {
123 | margin: 0 0 0.5em 1em;
124 | }
--------------------------------------------------------------------------------
/src/eventHandlers/scrollbarMouseEvents.ts:
--------------------------------------------------------------------------------
1 | import { GridGeometry } from '../gridGeometry';
2 | import { GridState } from '../gridState';
3 | import * as ScrollbarGeometry from '../scrollbarGeometry';
4 | import { Coord } from '../types';
5 |
6 | let draggedScrollbar: { bar: 'x' | 'y'; origScrollbarStart: number; origClick: number } | null = null;
7 |
8 | export const mouseDownOnScrollbar = (coord: Coord, gridState: GridState): boolean => {
9 | const hitScrollbar = ScrollbarGeometry.getHitScrollBar(
10 | coord,
11 | gridState.horizontalScrollbarPos(),
12 | gridState.verticalScrollbarPos(),
13 | );
14 |
15 | if (hitScrollbar) {
16 | draggedScrollbar = {
17 | bar: hitScrollbar,
18 | origScrollbarStart: hitScrollbar === 'x' ?
19 | gridState.horizontalScrollbarPos()!.extent.start :
20 | gridState.verticalScrollbarPos()!.extent.start,
21 | origClick: hitScrollbar === 'x' ? coord.x : coord.y,
22 | };
23 | return true;
24 | } else {
25 | return false;
26 | }
27 | };
28 |
29 | export const mouseDragOnScrollbar = (coord: Coord, gridState: GridState): boolean => {
30 | if (!draggedScrollbar) {
31 | return false;
32 | }
33 |
34 | const values = draggedScrollbar.bar === 'x' ?
35 | {
36 | frozenLen: gridState.frozenColsWidth(),
37 | canvasLen: gridState.canvasSize().width,
38 | gridLen: gridState.gridSize().width,
39 | barLen: gridState.horizontalScrollbarLength(),
40 | clickCoord: coord.x,
41 | } :
42 | {
43 | frozenLen: gridState.frozenRowsHeight(),
44 | canvasLen: gridState.canvasSize().height,
45 | gridLen: gridState.gridSize().height,
46 | barLen: gridState.verticalScrollbarLength(),
47 | clickCoord: coord.y,
48 | };
49 |
50 | const dragDistance = values.clickCoord - draggedScrollbar.origClick;
51 | const desiredStart = draggedScrollbar.origScrollbarStart + dragDistance;
52 | const desiredFraction = ScrollbarGeometry.calculateFractionFromStartPos(
53 | desiredStart,
54 | values.frozenLen,
55 | values.canvasLen,
56 | values.barLen,
57 | );
58 | const newOffset = GridGeometry.calculateGridOffsetFromFraction(
59 | desiredFraction,
60 | values.gridLen,
61 | values.canvasLen,
62 | );
63 | if (draggedScrollbar.bar === 'x') {
64 | gridState.gridOffsetRaw({ x: newOffset, y: gridState.gridOffset().y });
65 | } else {
66 | gridState.gridOffsetRaw({ x: gridState.gridOffset().x, y: newOffset });
67 | }
68 |
69 | return true;
70 | };
71 |
72 | export const mouseHoverOnScrollbar = (coord: Coord, gridState: GridState) => {
73 | const hoveredScrollbar = ScrollbarGeometry.getHitScrollBar(
74 | coord,
75 | gridState.horizontalScrollbarPos(),
76 | gridState.verticalScrollbarPos(),
77 | );
78 |
79 | gridState.hoveredScrollbar(hoveredScrollbar);
80 |
81 | return hoveredScrollbar !== null;
82 | };
83 |
84 | export const mouseUpOnScrollbar = (): boolean => {
85 | if (draggedScrollbar) {
86 | draggedScrollbar = null;
87 | return true;
88 | } else {
89 | return false;
90 | }
91 | };
92 |
--------------------------------------------------------------------------------
/examples/src/assets/react-js.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/scrollbars/CornerScrollbarCanvas.tsx:
--------------------------------------------------------------------------------
1 | import { consumer, mergeTransformer, ReactiveConsumer } from 'instigator';
2 | import * as React from 'react';
3 | import { shallowEqualsExceptFunctions } from '../gridState';
4 | import { ScrollbarRendererBasics } from './baseScrollbarRenderer';
5 | import { CornerScrollbarRenderer } from './cornerScrollbarRenderer';
6 | import { HorizontalScrollbarRenderer } from './horizontalScrollbarRenderer';
7 | import { ScrollbarCanvasProps } from './ScrollbarCanvas';
8 |
9 | type CornerScrollbarCanvasProps = ScrollbarCanvasProps & {
10 | horizontalGutterBounds: ClientRect;
11 | verticalGutterBounds: ClientRect;
12 | };
13 |
14 | export class CornerScrollbarCanvas extends React.PureComponent> {
15 | private readonly canvasRef: React.RefObject = React.createRef();
16 | private renderer: CornerScrollbarRenderer|null = null;
17 | private renderCallback: ReactiveConsumer|null = null;
18 |
19 | public render() {
20 | return (
21 |
34 | );
35 | }
36 |
37 | public componentDidMount() {
38 | if (!this.canvasRef.current) {
39 | throw new Error('canvasRef is null in componentDidMount - cannot create renderer');
40 | }
41 |
42 | const gridState = this.props.gridState;
43 | const basicProps = mergeTransformer({
44 | dpr: gridState.dpr,
45 | horizontalGutterBounds: gridState.horizontalGutterBounds,
46 | verticalGutterBounds: gridState.verticalGutterBounds,
47 | horizontalScrollbarPos: gridState.horizontalScrollbarPos,
48 | verticalScrollbarPos: gridState.verticalScrollbarPos,
49 | hoveredScrollbar: gridState.hoveredScrollbar,
50 | }, shallowEqualsExceptFunctions);
51 |
52 | this.renderer = new CornerScrollbarRenderer(
53 | this.canvasRef.current,
54 | basicProps(),
55 | );
56 | this.renderCallback = consumer(
57 | [basicProps],
58 | (newBasicProps) => {
59 | if (this.renderer) {
60 | if (!this.canvasRef.current) {
61 | throw new Error('canvasRef is null in componentDidMount - cannot create renderer');
62 | }
63 | this.renderer.updateProps(this.canvasRef.current, newBasicProps);
64 | }
65 | });
66 | this.renderCallback();
67 | }
68 |
69 | public componentWillUnmount() {
70 | if (this.renderCallback) {
71 | this.renderCallback.deregister();
72 | }
73 | }
74 | }
75 |
--------------------------------------------------------------------------------
/src/eventHandlers/mouseEvents.ts:
--------------------------------------------------------------------------------
1 | import { RefObject } from 'react';
2 | import { GridState } from '../gridState';
3 | import { EditingCell, ReactCanvasGridProps } from '../ReactCanvasGrid';
4 | import { Coord } from '../types';
5 | import { mouseDownOnAutofillHandle, mouseHoverOnAutofillHandle } from './autofillMouseEvents';
6 | import { mouseDownOnGrid, mouseDragOnGrid, mouseUpOnGrid } from './gridMouseEvents';
7 | import {
8 | mouseDownOnScrollbar,
9 | mouseDragOnScrollbar,
10 | mouseHoverOnScrollbar,
11 | mouseUpOnScrollbar,
12 | } from './scrollbarMouseEvents';
13 | import { updateOffsetByDelta } from './scrolling';
14 |
15 | export function isLeftButton(event: MouseEvent | React.MouseEvent): boolean {
16 | // tslint:disable-next-line: no-bitwise
17 | return (event.buttons & 1) === 1;
18 | }
19 |
20 | export function handleWheel(e: WheelEvent, gridState: GridState) {
21 | // Browsers may use a 'delta mode' when wheeling, requesting multi-pixel movement
22 | // See https://developer.mozilla.org/en-US/docs/Web/API/WheelEvent/deltaMode
23 | const scaleFactors: { [index: number]: number; } = {
24 | 0: 1, // DOM_DELTA_PIXEL: 1-to-1
25 | 1: 16, // DOM_DELTA_LINE: 16 seems a decent guess. See https://stackoverflow.com/q/20110224
26 | };
27 | const scaleFactor = scaleFactors[e.deltaMode || 0];
28 | const willUpdate = updateOffsetByDelta(e.deltaX * scaleFactor, e.deltaY * scaleFactor, gridState);
29 |
30 | if (willUpdate) {
31 | // The grid is going to move, so we want to prevent any other scrolling from happening
32 | e.preventDefault();
33 | }
34 | }
35 |
36 | export function handleMouseDown(
37 | event: React.MouseEvent,
38 | coord: Coord,
39 | rootRef: RefObject,
40 | gridState: GridState,
41 | props: ReactCanvasGridProps,
42 | editingCell: EditingCell | null,
43 | ) {
44 | if (mouseDownOnScrollbar(coord, gridState)) {
45 | return;
46 | } else if (mouseDownOnAutofillHandle(event, gridState, rootRef)) {
47 | return;
48 | } else {
49 | mouseDownOnGrid(event, coord, rootRef, props, gridState, editingCell);
50 | }
51 | }
52 |
53 | export function handleMouseMove(
54 | event: MouseEvent,
55 | coord: Coord,
56 | rootRef: RefObject,
57 | gridState: GridState,
58 | props: ReactCanvasGridProps,
59 | editingCell: EditingCell | null,
60 | ): 'grid'|'scrollbar'|'autofill-handle' {
61 | if (mouseDragOnScrollbar(coord, gridState)) {
62 | return 'scrollbar';
63 | } else if (mouseDragOnGrid(event, rootRef, props, gridState, editingCell)) {
64 | return 'grid';
65 | } else if (mouseHoverOnAutofillHandle(event, gridState, rootRef)) {
66 | return 'autofill-handle';
67 | } else if (mouseHoverOnScrollbar(coord, gridState)) {
68 | return 'scrollbar';
69 | } else {
70 | return 'grid';
71 | }
72 | }
73 |
74 | export function handleMouseUp(
75 | coord: Coord,
76 | gridState: GridState,
77 | props: ReactCanvasGridProps,
78 | editingCell: EditingCell | null,
79 | ) {
80 | if (mouseUpOnScrollbar()) {
81 | mouseHoverOnScrollbar(coord, gridState);
82 | return;
83 | } else {
84 | mouseUpOnGrid(props, gridState, editingCell);
85 | }
86 | }
87 |
--------------------------------------------------------------------------------
/src/selectionState/cellsSelectionBuilder.ts:
--------------------------------------------------------------------------------
1 | import { Bounds, Coord } from '../types';
2 | import { CellsSelection } from './cellsSelection';
3 |
4 | export class CellsSelectionBuilder {
5 | private isSelectionInProgress: boolean;
6 | private editCursorCell: Coord;
7 | private selection: Bounds;
8 | private focusCell: Coord;
9 | private autofillDragCell: Coord | null;
10 |
11 | constructor(x: number, y: number) {
12 | this.isSelectionInProgress = false;
13 | this.editCursorCell = { x, y };
14 | this.selection = { left: x, right: x, top: y, bottom: y };
15 | this.focusCell = { x, y };
16 | this.autofillDragCell = null;
17 | }
18 |
19 | public withSelectionFromCursorTo(x: number, y: number): CellsSelectionBuilder;
20 | public withSelectionFromCursorTo(coord: [number, number]): CellsSelectionBuilder;
21 | public withSelectionFromCursorTo(first: number|[number, number], second?: number) {
22 | const x = typeof first === 'number' ? first : first[0];
23 | const y = typeof first === 'number' ? second! : first[1];
24 | this.selection = {
25 | top: Math.min(this.editCursorCell.y, y),
26 | left: Math.min(this.editCursorCell.x, x),
27 | bottom: Math.max(this.editCursorCell.y, y),
28 | right: Math.max(this.editCursorCell.x, x),
29 | };
30 | this.focusCell = { x, y };
31 | return this;
32 | }
33 |
34 | public withSelection(top: number, left: number, bottom: number, right: number): CellsSelectionBuilder;
35 | public withSelection(from: [number, number], to: [number, number]): CellsSelectionBuilder;
36 | public withSelection(a: number|[number, number], b: number|[number, number], c?: number, d?: number) {
37 | if (typeof a === 'number') {
38 | this.selection = { top: a, left: b as number, bottom: c as number, right: d as number };
39 | } else {
40 | this.selection = {
41 | top: Math.min(a[1], (b as [number, number])[1]),
42 | left: Math.min(a[0], (b as [number, number])[0]),
43 | bottom: Math.max(a[1], (b as [number, number])[1]),
44 | right: Math.max(a[0], (b as [number, number])[0]),
45 | };
46 | }
47 | return this;
48 | }
49 |
50 | public withOngoingSelectionDrag() {
51 | this.isSelectionInProgress = true;
52 | return this;
53 | }
54 |
55 | public withoutOngoingSelectionDrag() {
56 | this.isSelectionInProgress = false;
57 | return this;
58 | }
59 |
60 | public withAutofillDragCell(x: number, y: number) {
61 | this.autofillDragCell = { x, y };
62 | return this;
63 | }
64 |
65 | public withoutAutofillDragCell() {
66 | this.autofillDragCell = null;
67 | return this;
68 | }
69 |
70 | public build() {
71 | return new CellsSelection(
72 | this.editCursorCell,
73 | this.selection,
74 | this.focusCell,
75 | this.isSelectionInProgress,
76 | this.autofillDragCell,
77 | );
78 | }
79 | }
80 |
81 | export function cellsSelection(x: number, y: number): CellsSelectionBuilder;
82 | export function cellsSelection(coord: [number, number]): CellsSelectionBuilder;
83 | export function cellsSelection(first: number|[number, number], second?: number) {
84 | if (typeof first === 'number') {
85 | return new CellsSelectionBuilder(first, second!);
86 | } else {
87 | return new CellsSelectionBuilder(first[0], first[1]);
88 | }
89 | }
90 |
--------------------------------------------------------------------------------
/cypress/integration/data-resizing.tsx:
--------------------------------------------------------------------------------
1 | describe('ReactCanvasGrid with some initial data', () => {
2 | beforeEach(() => {
3 | cy.visit('/#/examples/dynamic-data');
4 |
5 | cy.get('.fixed-size-holder').as('Holder');
6 | cy.get('.fixed-size-holder .react-canvas-grid').as('Root');
7 | cy.get('.fixed-size-holder canvas').eq(1).as('Canvas');
8 |
9 | cy.get('#num-rows').as('NumRowsInput');
10 | cy.get('#num-cols').as('NumColsInput');
11 | cy.get('#first-col-to-end').as('FirstColToEndButton');
12 | cy.get('#modify-top-left').as('ModifyTopLeftButton');
13 |
14 | cy.get('Canvas').invoke('width').should('be.greaterThan', 0);
15 | });
16 |
17 | it('re-renders when the number of columns shrinks', () => {
18 | cy.get('@NumColsInput')
19 | .type('{selectall}5');
20 | cy.wait(100); // Wait for 80ms debounce to trigger
21 | cy.get('@Root')
22 | .matchImageSnapshot('reduce-number-of-columns');
23 | });
24 |
25 | it('re-renders when the number of columns grows', () => {
26 | cy.get('@NumColsInput')
27 | .type('{selectall}50');
28 | cy.get('@NumRowsInput')
29 | .type('{selectall}100');
30 | cy.wait(100); // Wait for 80ms debounce to trigger
31 |
32 | cy.get('@Root')
33 | .matchImageSnapshot('increase-number-of-columns');
34 | });
35 |
36 | it('clears the selection when the number of columns changes', () => {
37 | cy.get('@Root')
38 | .click();
39 | cy.get('@NumColsInput')
40 | .type('{selectall}5');
41 | cy.wait(100); // Wait for 80ms debounce to trigger
42 | cy.get('@Root')
43 | .matchImageSnapshot('clear-selection-after-col-num-change');
44 | });
45 |
46 | it('clears the selection when the columns change order', () => {
47 | cy.get('@Root')
48 | .click();
49 | cy.get('@FirstColToEndButton')
50 | .click();
51 | cy.get('@Root')
52 | .matchImageSnapshot('clear-selection-when-cols-change');
53 | });
54 |
55 | it('clears the selection when the number of rows changes', () => {
56 | cy.get('@Root')
57 | .click();
58 | cy.get('@NumRowsInput')
59 | .type('{selectall}100');
60 | cy.wait(100); // Wait for 80ms debounce to trigger
61 | cy.get('@Root')
62 | .matchImageSnapshot('clear-selection-when-num-rows-changes');
63 | });
64 |
65 | it('does not clear the selection when the data changes but has the same number of rows', () => {
66 | cy.get('@Root')
67 | .click();
68 | cy.get('@ModifyTopLeftButton')
69 | .click();
70 |
71 | cy.get('@Root')
72 | .matchImageSnapshot('keep-selection-when-only-data-values-change');
73 | });
74 |
75 | it('constrains the scroll when the data and/or cols shrink', () => {
76 | cy.get('@NumColsInput')
77 | .type('{selectall}50');
78 | cy.get('@NumRowsInput')
79 | .type('{selectall}100');
80 | cy.wait(100); // Wait for 80ms debounce to trigger
81 |
82 | cy.get('@Root')
83 | .trigger('wheel', { deltaX: 800, deltaY: 300 });
84 |
85 | cy.get('@NumColsInput')
86 | .type('{selectall}20');
87 | cy.get('@NumRowsInput')
88 | .type('{selectall}20');
89 | cy.wait(100); // Wait for 80ms debounce to trigger
90 | cy.get('@Root')
91 | .matchImageSnapshot('truncate-scroll-when-shrinking-data');
92 | });
93 | });
94 |
--------------------------------------------------------------------------------
/cypress/integration/autofill.tsx:
--------------------------------------------------------------------------------
1 | describe('ReactCanvasGrid autofill', () => {
2 | beforeEach(() => {
3 | cy.visit('/#/examples/autofill');
4 |
5 | cy.get('.fixed-size-holder').as('Holder');
6 | cy.get('.fixed-size-holder .react-canvas-grid').as('Root');
7 | cy.get('.fixed-size-holder canvas').eq(1).as('Canvas');
8 |
9 | cy.get('Canvas').invoke('width').should('be.greaterThan', 0);
10 | });
11 |
12 | describe('multi mode', () => {
13 | it('displays an autofill handle regardless of selection size', () => {
14 | cy.get('@Root')
15 | .trigger('mousedown', 60, 30, { buttons: 1, force: true })
16 | .trigger('mousemove', 120, 75, { buttons: 1, force: true })
17 | .trigger('mouseup', 120, 75, { force: true })
18 | .matchImageSnapshot('multi-autofill-handle');
19 | });
20 | });
21 |
22 | describe('single mode', () => {
23 | beforeEach(() => {
24 | cy.get('input[value="single"]')
25 | .click();
26 | });
27 |
28 | it('displays an autofill handle on a one-cell selection', () => {
29 | cy.get('@Root')
30 | .click(60, 30, { force: true })
31 | .matchImageSnapshot('single-autofill-handle');
32 | });
33 |
34 | it('does not display an autofill handle on a multi-cell selection', () => {
35 | cy.get('@Root')
36 | .trigger('mousedown', 60, 30, { buttons: 1, force: true })
37 | .trigger('mousemove', 120, 75, { buttons: 1, force: true })
38 | .trigger('mouseup', 120, 75, { force: true })
39 | .matchImageSnapshot('single-multi-no-autofill-handle');
40 | });
41 | });
42 |
43 | describe('none mode', () => {
44 | beforeEach(() => {
45 | cy.get('input[value="none"]')
46 | .click();
47 | });
48 |
49 | it('does not display an autofill handle regardless of selection size', () => {
50 | cy.get('@Root')
51 | .click(60, 30, { force: true })
52 | .matchImageSnapshot('none-no-autofill-handle');
53 | });
54 | });
55 |
56 | it('displays an outline around the area to be filled before filling', () => {
57 | cy.get('@Root')
58 | .trigger('mousedown', 60, 30, { buttons: 1, force: true })
59 | .trigger('mousemove', 120, 75, { buttons: 1, force: true })
60 | .trigger('mouseup', 120, 75, { force: true });
61 |
62 | cy.get('@Root')
63 | .trigger('mousedown', 150, 80, { buttons: 1, force: true })
64 | .trigger('mousemove', 230, 80, { buttons: 1, force: true })
65 | .matchImageSnapshot('autofill-drag-outline');
66 |
67 | cy.get('@Root')
68 | .trigger('mouseup', 230, 80, { force: true })
69 | .matchImageSnapshot('autofill-complete');
70 | });
71 |
72 | it('highlights the autofill handle on hover and changes the cursor to crosshair', () => {
73 | cy.get('@Root')
74 | // Set up a selection
75 | .trigger('mousedown', 60, 30, { buttons: 1, force: true })
76 | .trigger('mousemove', 120, 75, { buttons: 1, force: true })
77 | .trigger('mouseup', 120, 75, { force: true })
78 | // Hover over the autofill handle
79 | .trigger('mousemove', 150, 80, { buttons: 1, force: true })
80 | .matchImageSnapshot('autofill-hover-highlight');
81 |
82 | cy.get('@Root')
83 | .should('have.css', 'cursor', 'crosshair');
84 | });
85 | });
86 |
--------------------------------------------------------------------------------
/examples/src/examples/Autofill.grid.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import {
3 | AutofillContext,
4 | CellDef,
5 | ColumnDef,
6 | DataRow,
7 | ReactCanvasGrid,
8 | repeatSelectionIntoFill,
9 | SelectRange,
10 | } from 'react-canvas-grid';
11 | import { ControlsForm, InlineGroup, RadioInputs } from '../components/ControlsForm';
12 | import { FixedSizeHolder } from '../components/FixedSizeHolder';
13 | import { getColumns, getData, TextPair } from './Autofill.data';
14 |
15 | type AutofillMode = 'none' | 'single' | 'multi';
16 |
17 | interface AutofillableGridState {
18 | data: Array>;
19 | columns: ColumnDef[];
20 | autofillMode: AutofillMode;
21 | }
22 |
23 | const shouldAllowAutofillMethods: { [mode in AutofillMode]: (selectRange: SelectRange) => boolean } = {
24 | none: (_) => {
25 | return false;
26 | },
27 | single: (selectRange: SelectRange) => {
28 | const width = selectRange.bottomRight.x - selectRange.topLeft.x + 1;
29 | const height = selectRange.bottomRight.y - selectRange.topLeft.y + 1;
30 | const numCells = width * height;
31 | return numCells === 1;
32 | },
33 | multi: (_) => {
34 | return true;
35 | },
36 | };
37 |
38 | export class AutofillGrid extends React.Component<{}, AutofillableGridState> {
39 | constructor(props: {}) {
40 | super(props);
41 |
42 | this.state = {
43 | data: getData(),
44 | columns: getColumns(),
45 | autofillMode: 'multi',
46 | };
47 | }
48 |
49 | public render() {
50 | return (
51 | <>
52 |
53 |
54 |
55 | Allowed autofill mode:
56 |
61 |
62 |
63 |
64 |
65 |
66 |
67 | columns={this.state.columns}
68 | data={this.state.data}
69 | rowHeight={20}
70 | shouldAllowAutofill={shouldAllowAutofillMethods[this.state.autofillMode]}
71 | onAutofill={this.onAutofill}
72 | frozenCols={1}
73 | frozenRows={1}
74 | />
75 |
76 | >
77 | );
78 | }
79 |
80 | private onAutofill = (selectRange: SelectRange, fillRange: SelectRange) => {
81 | const data = repeatSelectionIntoFill(
82 | selectRange,
83 | fillRange,
84 | this.state.data,
85 | this.state.columns,
86 | autofillCell,
87 | );
88 | this.setState({ data });
89 | }
90 |
91 | private onAutofillModeSelect = (event: React.ChangeEvent) => {
92 | const autofillMode = event.currentTarget.value as AutofillMode;
93 | this.setState({ autofillMode });
94 | }
95 | }
96 |
97 | function autofillCell(context: AutofillContext): CellDef {
98 | return {
99 | ...context.destCellDef,
100 | data: [context.srcCellDef.data[0], context.destCellDef.data[1]],
101 | };
102 | }
103 |
--------------------------------------------------------------------------------
/src/HighlightCanvas.tsx:
--------------------------------------------------------------------------------
1 | import { consumer, mergeTransformer, ReactiveConsumer, ReactiveFn } from 'instigator';
2 | import * as React from 'react';
3 | import { CanvasRendererPosition } from './baseGridOffsetRenderer';
4 | import { GridState, shallowEqualsExceptFunctions } from './gridState';
5 | import { HighlightCanvasRenderer, HighlightCanvasRendererBasics } from './highlightCanvasRenderer';
6 |
7 | export interface HighlightCanvasProps {
8 | name: string;
9 | top: number;
10 | left: number;
11 | width: number;
12 | height: number;
13 | dpr: number;
14 | gridState: GridState;
15 | posProps: ReactiveFn;
16 | }
17 |
18 | export class HighlightCanvas extends React.PureComponent> {
19 | private readonly canvasRef: React.RefObject = React.createRef();
20 | private renderer: HighlightCanvasRenderer|null = null;
21 | private renderCallback: ReactiveConsumer|null = null;
22 |
23 | constructor(props: HighlightCanvasProps) {
24 | super(props);
25 | }
26 |
27 | public render() {
28 | return (
29 |
42 | );
43 | }
44 |
45 | public componentDidMount() {
46 | if (!this.canvasRef.current) {
47 | throw new Error('canvasRef is null in componentDidMount - cannot create renderer');
48 | }
49 |
50 | const gridState = this.props.gridState;
51 | const basicProps = mergeTransformer({
52 | rowHeight: gridState.rowHeight,
53 | columnBoundaries: gridState.columnBoundaries,
54 | borderWidth: gridState.borderWidth,
55 | cellBounds: gridState.cellBounds,
56 | shouldAllowAutofill: gridState.shouldAllowAutofill,
57 | }, shallowEqualsExceptFunctions);
58 |
59 | const hoverProps = mergeTransformer({
60 | autofillHandleIsHovered: gridState.autofillHandleIsHovered,
61 | });
62 |
63 | const selectionProps = mergeTransformer({ selectionState: gridState.selectionState });
64 |
65 | this.renderer = new HighlightCanvasRenderer(
66 | this.props.name,
67 | this.canvasRef.current,
68 | basicProps(),
69 | gridState.dpr(),
70 | );
71 | this.renderCallback = consumer(
72 | [basicProps, this.props.posProps, hoverProps, selectionProps],
73 | (newBasicProps, newPosProps, newHoverProps, newSelectionProps) => {
74 | if (this.renderer) {
75 | if (!this.canvasRef.current) {
76 | throw new Error('canvasRef is null in componentDidMount - cannot create renderer');
77 | }
78 | this.renderer.updateProps(
79 | this.canvasRef.current,
80 | newBasicProps,
81 | newPosProps,
82 | newHoverProps,
83 | newSelectionProps,
84 | );
85 | }
86 | });
87 | }
88 |
89 | public componentWillUnmount() {
90 | if (this.renderCallback) {
91 | this.renderCallback.deregister();
92 | }
93 | }
94 | }
95 |
--------------------------------------------------------------------------------
/src/ReactCanvasGrid.test.tsx:
--------------------------------------------------------------------------------
1 | import { mount } from 'enzyme';
2 | import * as React from 'react';
3 | import { DefaultedReactCanvasGridProps, ReactCanvasGrid } from './ReactCanvasGrid';
4 |
5 | // Mock out the canvas components with no-op components, because canvas contexts aren't
6 | // supported in a Jest (i.e. node) context
7 | jest.mock('./MainCanvas', () => {
8 | return {
9 | MainCanvas: (props: any) => {
10 | return (
11 | <>{props.children}>
12 | );
13 | },
14 | };
15 | });
16 | jest.mock('./FrozenCanvas', () => {
17 | return {
18 | FrozenCanvas: (props: any) => {
19 | return (
20 | <>{props.children}>
21 | );
22 | },
23 | };
24 | });
25 | jest.mock('./scrollbars/ScrollbarCanvas', () => {
26 | return {
27 | ScrollbarCanvas: (props: any) => {
28 | return (
29 | <>{props.children}>
30 | );
31 | },
32 | };
33 | });
34 |
35 | let props: DefaultedReactCanvasGridProps;
36 |
37 | describe('ReactCanvasGrid', () => {
38 | beforeEach(() => {
39 | jest.clearAllMocks();
40 |
41 | props = {
42 | rowHeight: 15,
43 | columns: [
44 | { width: 50, fieldName: 'A' },
45 | { width: 50, fieldName: 'B' },
46 | { width: 50, fieldName: 'C' },
47 | ],
48 | data: [
49 | {
50 | 'A': { title: 'A1', text: 'A1', data: null },
51 | 'B': { title: 'B1', text: 'B1', data: null },
52 | 'C': { title: 'C1', text: 'C1', data: null },
53 | },
54 | {
55 | 'A': { title: 'A2', text: 'A', data: null },
56 | 'B': { title: 'B2', text: 'B', data: null },
57 | 'C': { title: 'C2', text: 'C', data: null },
58 | },
59 | {
60 | 'A': { title: 'A3', text: 'A3', data: null },
61 | 'B': { title: 'B3', text: 'B3', data: null },
62 | 'C': { title: 'C3', text: 'C3', data: null },
63 | },
64 | ]
65 | };
66 | });
67 |
68 | it('starts with no title text', () => {
69 | const rcg = mount( );
70 |
71 | expect(rcg.find('div').prop('title')).toBeUndefined();
72 | });
73 |
74 | it('sets the title text when mousing over a cell', () => {
75 | const rcg = mount( );
76 |
77 | window.dispatchEvent(new MouseEvent('mousemove', { clientX: 10, clientY: 10 }));
78 | rcg.update();
79 |
80 | expect(rcg.find('div').prop('title')).toBe('A1');
81 | });
82 |
83 | it('sets the title text to the hovered cell when the grid is scrolled', () => {
84 | const rcg = mount( );
85 |
86 | (rcg.find('div').instance() as unknown as EventTarget).dispatchEvent(new WheelEvent('wheel', { deltaY: 20 }));
87 | window.dispatchEvent(new MouseEvent('mousemove', { clientX: 10, clientY: 10 }));
88 | rcg.update();
89 |
90 | expect(rcg.find('div').prop('title')).toBe('A2');
91 | });
92 |
93 | it('sets the title text to the frozen cell\'s title when mousing over a frozen cell when the grid is scrolled', () => {
94 | const rcg = mount( );
95 |
96 | (rcg.find('div').instance() as unknown as EventTarget).dispatchEvent(new WheelEvent('wheel', { deltaY: 20 }));
97 | window.dispatchEvent(new MouseEvent('mousemove', { clientX: 10, clientY: 10 }));
98 | rcg.update();
99 |
100 | expect(rcg.find('div').prop('title')).toBe('A1');
101 | });
102 | });
103 |
--------------------------------------------------------------------------------
/src/GridCanvas.tsx:
--------------------------------------------------------------------------------
1 | import { ActiveSource, activeSource, consumer, mergeTransformer, ReactiveConsumer, ReactiveFn } from 'instigator';
2 | import * as React from 'react';
3 | import { CanvasRendererPosition } from './baseGridOffsetRenderer';
4 | import { GridCanvasRenderer } from './gridCanvasRenderer';
5 | import { GridState } from './gridState';
6 | import { Size } from './types';
7 |
8 | export interface GridCanvasProps {
9 | name: string;
10 | top: number;
11 | left: number;
12 | width: number;
13 | height: number;
14 | dpr: number;
15 | gridState: GridState;
16 | posProps: ReactiveFn;
17 | }
18 |
19 | export class GridCanvas extends React.PureComponent> {
20 | private readonly canvasRef: React.RefObject = React.createRef();
21 | private readonly canvasSizeSource: ActiveSource;
22 | private renderer: GridCanvasRenderer|null = null;
23 | private renderCallback: ReactiveConsumer|null = null;
24 |
25 | constructor(props: GridCanvasProps) {
26 | super(props);
27 | this.canvasSizeSource = activeSource({
28 | width: props.width,
29 | height: props.height,
30 | });
31 | }
32 |
33 | public render() {
34 | return (
35 |
48 | );
49 | }
50 |
51 | public componentDidMount() {
52 | if (!this.canvasRef.current) {
53 | throw new Error('canvasRef is null in componentDidMount - cannot create renderer');
54 | }
55 |
56 | const gridState = this.props.gridState;
57 | const basicProps = mergeTransformer({
58 | data: gridState.data,
59 | rowHeight: gridState.rowHeight,
60 | colBoundaries: gridState.columnBoundaries,
61 | columns: gridState.columns,
62 | gridInnerSize: gridState.gridInnerSize,
63 | borderWidth: gridState.borderWidth,
64 | });
65 |
66 | {
67 | const canvasSize = { width: this.props.width, height: this.props.height };
68 | this.renderer = new GridCanvasRenderer(
69 | this.canvasRef.current,
70 | canvasSize,
71 | basicProps(),
72 | gridState.dpr(),
73 | this.props.name,
74 | );
75 | }
76 |
77 | const renderCallback = consumer(
78 | [basicProps, this.props.posProps, this.canvasSizeSource],
79 | (newBasicProps, newPosProps, canvasSize) => {
80 | if (this.renderer) {
81 | if (!this.canvasRef.current) {
82 | throw new Error('canvasRef is null in componentDidMount - cannot create renderer');
83 | }
84 | this.renderer.updateProps(this.canvasRef.current, canvasSize, newBasicProps, newPosProps);
85 | }
86 | },
87 | );
88 | // Force the render - there's no guarantee the consumer's inputs will ever update, so we need to ensure
89 | // something is painted to the canvas.
90 | renderCallback();
91 | this.renderCallback = renderCallback;
92 | }
93 |
94 | public componentDidUpdate() {
95 | this.canvasSizeSource({ width: this.props.width, height: this.props.height });
96 | }
97 |
98 | public componentWillUnmount() {
99 | if (this.renderCallback) {
100 | this.renderCallback.deregister();
101 | }
102 | }
103 | }
104 |
--------------------------------------------------------------------------------
/src/autofill.test.ts:
--------------------------------------------------------------------------------
1 | import { repeatSelectionIntoFill } from './autofill';
2 | import { CellDef, DataRow, getCellText } from './types';
3 |
4 | function text(data: string): string {
5 | return data;
6 | }
7 |
8 | function cell(y: number, x: number): CellDef {
9 | return {
10 | data: `${y}x${x}`,
11 | getText: text,
12 | };
13 | }
14 |
15 | function mapDataToText(data: Array>) {
16 | return data.map((r) => Object.keys(r)
17 | .reduce(
18 | (acc, k) => {
19 | acc[k] = getCellText(r[k]);
20 | return acc;
21 | },
22 | {} as Record,
23 | ));
24 | }
25 |
26 | describe('repeatSelectionIntoFill', () => {
27 | it('copies from the selection into the autofill area, wrapping horizontally if needed', () => {
28 | const newData = repeatSelectionIntoFill(
29 | { topLeft: { x: 0, y: 0 }, bottomRight: { x: 1, y: 1 } },
30 | { topLeft: { x: 2, y: 0 }, bottomRight: { x: 4, y: 1 } },
31 | [
32 | { a: cell(0, 0), b: cell(0, 1), c: cell(0, 2), d: cell(0, 3), e: cell(0, 4) },
33 | { a: cell(1, 0), b: cell(1, 1), c: cell(1, 2), d: cell(1, 3), e: cell(1, 4) },
34 | ],
35 | [
36 | { fieldName: 'a', width: 1 },
37 | { fieldName: 'b', width: 1 },
38 | { fieldName: 'c', width: 1 },
39 | { fieldName: 'd', width: 1 },
40 | { fieldName: 'e', width: 1 },
41 | ],
42 | (context) => ({ ...context.srcCellDef }),
43 | );
44 |
45 | // the c, d, e labels are copied from the a, b, a labels
46 | const expectedText = [
47 | { a: '0x0', b: '0x1', c: '0x0', d: '0x1', e: '0x0' },
48 | { a: '1x0', b: '1x1', c: '1x0', d: '1x1', e: '1x0' },
49 | ];
50 | const newDataText = mapDataToText(newData);
51 | expect(newDataText).toEqual(expectedText);
52 | });
53 |
54 | it('copies from the selection into the autofill area, wrapping vertically if needed', () => {
55 | const newData = repeatSelectionIntoFill(
56 | { topLeft: { x: 0, y: 0 }, bottomRight: { x: 1, y: 1 } },
57 | { topLeft: { x: 0, y: 2 }, bottomRight: { x: 1, y: 4 } },
58 | [
59 | { a: cell(0, 0), b: cell(0, 1) },
60 | { a: cell(1, 0), b: cell(1, 1) },
61 | { a: cell(2, 0), b: cell(2, 1) },
62 | { a: cell(3, 0), b: cell(3, 1) },
63 | { a: cell(4, 0), b: cell(4, 1) },
64 | ],
65 | [
66 | { fieldName: 'a', width: 1 },
67 | { fieldName: 'b', width: 1 },
68 | ],
69 | (context) => ({ ...context.srcCellDef }),
70 | );
71 |
72 | // the row 2, 3, 4 labels are copied from the row 0, 1, 0 labels
73 | const expectedText = [
74 | { a: '0x0', b: '0x1' },
75 | { a: '1x0', b: '1x1' },
76 | { a: '0x0', b: '0x1' },
77 | { a: '1x0', b: '1x1' },
78 | { a: '0x0', b: '0x1' },
79 | ];
80 | const newDataText = mapDataToText(newData);
81 | expect(newDataText).toEqual(expectedText);
82 | });
83 |
84 | it('calls the celldef factory function with full context', () => {
85 | const factory = jest.fn((context) => ({ ...context.srcCellDef }));
86 |
87 | repeatSelectionIntoFill(
88 | { topLeft: { x: 0, y: 0 }, bottomRight: { x: 0, y: 0 } },
89 | { topLeft: { x: 0, y: 1 }, bottomRight: { x: 0, y: 1 } },
90 | [
91 | { a: cell(0, 0) },
92 | { a: cell(1, 0) },
93 | ],
94 | [
95 | { fieldName: 'a', width: 1 },
96 | ],
97 | factory,
98 | );
99 |
100 | expect(factory).toHaveBeenCalledWith({
101 | srcRowIndex: 0,
102 | srcColIndex: 0,
103 | srcColDef: { fieldName: 'a', width: 1 },
104 | srcCellDef: cell(0, 0),
105 | destRowIndex: 1,
106 | destColIndex: 0,
107 | destColDef: { fieldName: 'a', width: 1 },
108 | destCellDef: cell(1, 0),
109 | });
110 | });
111 | });
112 |
--------------------------------------------------------------------------------