From a9bc1b7fa9ef555dad48e7f46395c44d2d092920 Mon Sep 17 00:00:00 2001 From: martinRenou Date: Sat, 9 Sep 2023 04:09:53 +0200 Subject: [PATCH] Datagrid: Introduce AsyncCellRenderer and ImageRenderer (#630) * Introducing async renderers * Iterate + add an example * Working solution * Add sizing modes * Improve example * Introduce AsyncCellRenderer class * Fix column/row repaint after asynchronous work * Linter * Generate API file * More sizing modes * Add more options for the placeholder * Repaint logic for merged cells * Review * Add tests * Linter --- examples/example-datagrid/src/index.ts | 82 ++++- packages/datagrid/src/asynccellrenderer.ts | 53 ++++ packages/datagrid/src/datagrid.ts | 41 ++- packages/datagrid/src/imagerenderer.ts | 297 ++++++++++++++++++ packages/datagrid/src/index.ts | 2 + .../datagrid/tests/src/imagerenderer.spec.ts | 157 +++++++++ packages/datagrid/tests/src/index.spec.ts | 1 + review/api/datagrid.api.md | 35 +++ 8 files changed, 664 insertions(+), 4 deletions(-) create mode 100644 packages/datagrid/src/asynccellrenderer.ts create mode 100644 packages/datagrid/src/imagerenderer.ts create mode 100644 packages/datagrid/tests/src/imagerenderer.spec.ts diff --git a/examples/example-datagrid/src/index.ts b/examples/example-datagrid/src/index.ts index 2b32fcb57..295c375ce 100644 --- a/examples/example-datagrid/src/index.ts +++ b/examples/example-datagrid/src/index.ts @@ -20,7 +20,8 @@ import { ICellEditor, JSONModel, MutableDataModel, - TextRenderer + TextRenderer, + ImageRenderer } from '@lumino/datagrid'; import { DockPanel, StackedPanel, Widget } from '@lumino/widgets'; @@ -576,7 +577,7 @@ function main(): void { let grid5 = new DataGrid({ style: greenStripeStyle, defaultSizes: { - rowHeight: 32, + rowHeight: 75, columnWidth: 128, rowHeaderWidth: 64, columnHeaderHeight: 32 @@ -590,6 +591,29 @@ function main(): void { selectionMode: 'row' }); + const imageRenderer = new ImageRenderer({ + width: '100%', + height: '' // Will be computed automatically + // Other options: + // width: '50px', + // height: '50px', + // + // width: '100%', + // height: '50px', + // + // For keeping the original size: + // width: '', + // height: '', + }); + grid5.cellRenderers.update({ + body: (config: CellRenderer.CellConfig) => { + if (config.metadata.name === 'Image') { + return imageRenderer; + } + return undefined; + } + }); + let grid6 = new DataGrid({ defaultSizes: { rowHeight: 32, @@ -962,6 +986,8 @@ namespace Data { Origin: 'USA', Miles_per_Gallon: 18.0, Name: 'chevrolet chevelle malibu', + Image: + 'https://upload.wikimedia.org/wikipedia/commons/thumb/c/c2/1966_Chevrolet_Chevelle_SS_%2832985111206%29.jpg/420px-1966_Chevrolet_Chevelle_SS_%2832985111206%29.jpg', index: 0, Acceleration: 12.0, Year: '1970-01-01', @@ -974,6 +1000,8 @@ namespace Data { Origin: 'USA', Miles_per_Gallon: 15.0, Name: 'buick skylark 320', + Image: + 'https://upload.wikimedia.org/wikipedia/commons/thumb/1/1d/1972_Buick_Skylark_Front.jpg/420px-1972_Buick_Skylark_Front.jpg', index: 1, Acceleration: 11.5, Year: '1970-01-01', @@ -986,6 +1014,8 @@ namespace Data { Origin: 'USA', Miles_per_Gallon: 18.0, Name: 'plymouth satellite', + Image: + 'https://upload.wikimedia.org/wikipedia/commons/thumb/f/f0/66Sat.jpg/420px-66Sat.jpg', index: 2, Acceleration: 11.0, Year: '1970-01-01', @@ -998,6 +1028,8 @@ namespace Data { Origin: 'USA', Miles_per_Gallon: 16.0, Name: 'amc rebel sst', + Image: + 'https://upload.wikimedia.org/wikipedia/commons/thumb/7/71/1968_AMC_Rebel_Station_Wagon-GoldWhite.jpg/420px-1968_AMC_Rebel_Station_Wagon-GoldWhite.jpg', index: 3, Acceleration: 12.0, Year: '1970-01-01', @@ -1010,6 +1042,8 @@ namespace Data { Origin: 'USA', Miles_per_Gallon: 17.0, Name: 'ford torino', + Image: + 'https://upload.wikimedia.org/wikipedia/commons/thumb/0/03/1970_ford_torino_cobra_sportsroof_chiolero.jpg/420px-1970_ford_torino_cobra_sportsroof_chiolero.jpg', index: 4, Acceleration: 10.5, Year: '1970-01-01', @@ -1022,6 +1056,8 @@ namespace Data { Origin: 'USA', Miles_per_Gallon: 15.0, Name: 'ford galaxie 500', + Image: + 'https://upload.wikimedia.org/wikipedia/commons/thumb/9/92/1963_Ford_Galaxie_sedan_2_--_06-05-2010.jpg/420px-1963_Ford_Galaxie_sedan_2_--_06-05-2010.jpg', index: 5, Acceleration: 10.0, Year: '1970-01-01', @@ -1034,6 +1070,8 @@ namespace Data { Origin: 'USA', Miles_per_Gallon: 14.0, Name: 'chevrolet impala', + Image: + 'https://upload.wikimedia.org/wikipedia/commons/thumb/2/28/1965_Chevrolet_Impala_300_hp_V8_big_Block_Engine.JPG/420px-1965_Chevrolet_Impala_300_hp_V8_big_Block_Engine.JPG', index: 6, Acceleration: 9.0, Year: '1970-01-01', @@ -1046,6 +1084,8 @@ namespace Data { Origin: 'USA', Miles_per_Gallon: 14.0, Name: 'plymouth fury iii', + Image: + 'https://upload.wikimedia.org/wikipedia/commons/thumb/0/0e/1959_Plymouth_Sport_Fury_photo-13.JPG/420px-1959_Plymouth_Sport_Fury_photo-13.JPG', index: 7, Acceleration: 8.5, Year: '1970-01-01', @@ -1058,6 +1098,8 @@ namespace Data { Origin: 'USA', Miles_per_Gallon: 14.0, Name: 'pontiac catalina', + Image: + 'https://upload.wikimedia.org/wikipedia/commons/thumb/4/4d/Pontiac_Catalina_front.jpg/420px-Pontiac_Catalina_front.jpg', index: 8, Acceleration: 10.0, Year: '1970-01-01', @@ -1070,6 +1112,8 @@ namespace Data { Origin: 'USA', Miles_per_Gallon: 15.0, Name: 'amc ambassador dpl', + Image: + 'https://upload.wikimedia.org/wikipedia/commons/thumb/4/4c/1964_Rambler_Ambassador_990_H_in_black_and_white_with_red_interior_at_2017_AMO_meet_01of16.jpg/420px-1964_Rambler_Ambassador_990_H_in_black_and_white_with_red_interior_at_2017_AMO_meet_01of16.jpg', index: 9, Acceleration: 8.5, Year: '1970-01-01', @@ -1082,6 +1126,8 @@ namespace Data { Origin: 'Europe', Miles_per_Gallon: null, Name: 'citroen ds-21 pallas', + Image: + 'https://upload.wikimedia.org/wikipedia/commons/thumb/5/58/Festival_automobile_international_2018_-_Citro%C3%ABn_DS_21_-_1965_-_002.jpg/300px-Festival_automobile_international_2018_-_Citro%C3%ABn_DS_21_-_1965_-_002.jpg', index: 10, Acceleration: 17.5, Year: '1970-01-01', @@ -1094,6 +1140,8 @@ namespace Data { Origin: 'USA', Miles_per_Gallon: null, Name: 'chevrolet chevelle concours (sw)', + Image: + 'https://upload.wikimedia.org/wikipedia/commons/thumb/8/8f/1969_Chevrolet_Chevelle_SS396_2_door_Hardtop_%2825260012813%29.jpg/270px-1969_Chevrolet_Chevelle_SS396_2_door_Hardtop_%2825260012813%29.jpg', index: 11, Acceleration: 11.5, Year: '1970-01-01', @@ -1106,6 +1154,8 @@ namespace Data { Origin: 'USA', Miles_per_Gallon: null, Name: 'ford torino (sw)', + Image: + 'https://upload.wikimedia.org/wikipedia/commons/thumb/0/03/1970_ford_torino_cobra_sportsroof_chiolero.jpg/420px-1970_ford_torino_cobra_sportsroof_chiolero.jpg', index: 12, Acceleration: 11.0, Year: '1970-01-01', @@ -1118,6 +1168,8 @@ namespace Data { Origin: 'USA', Miles_per_Gallon: null, Name: 'plymouth satellite (sw)', + Image: + 'https://upload.wikimedia.org/wikipedia/commons/thumb/f/f0/66Sat.jpg/420px-66Sat.jpg', index: 13, Acceleration: 10.5, Year: '1970-01-01', @@ -1130,6 +1182,8 @@ namespace Data { Origin: 'USA', Miles_per_Gallon: null, Name: 'amc rebel sst (sw)', + Image: + 'https://upload.wikimedia.org/wikipedia/commons/thumb/7/71/1968_AMC_Rebel_Station_Wagon-GoldWhite.jpg/420px-1968_AMC_Rebel_Station_Wagon-GoldWhite.jpg', index: 14, Acceleration: 11.0, Year: '1970-01-01', @@ -1142,6 +1196,8 @@ namespace Data { Origin: 'USA', Miles_per_Gallon: 15.0, Name: 'dodge challenger se', + Image: + 'https://upload.wikimedia.org/wikipedia/commons/thumb/e/eb/1970_Dodge_Challenger_RT_440_Magnum_%2813440447413%29.jpg/420px-1970_Dodge_Challenger_RT_440_Magnum_%2813440447413%29.jpg', index: 15, Acceleration: 10.0, Year: '1970-01-01', @@ -1154,6 +1210,8 @@ namespace Data { Origin: 'USA', Miles_per_Gallon: 14.0, Name: "plymouth 'cuda 340", + Image: + 'https://upload.wikimedia.org/wikipedia/commons/thumb/4/49/%2770_Plymouth_Barracuda_%28%2711_Auto_classique_VAQ_Mont_St-Hilaire%29.JPG/420px-%2770_Plymouth_Barracuda_%28%2711_Auto_classique_VAQ_Mont_St-Hilaire%29.JPG', index: 16, Acceleration: 8.0, Year: '1970-01-01', @@ -1166,6 +1224,8 @@ namespace Data { Origin: 'USA', Miles_per_Gallon: null, Name: 'ford mustang boss 302', + Image: + 'https://upload.wikimedia.org/wikipedia/commons/thumb/5/5d/1970_Ford_Mustang_Boss_302_%2815863840731%29.jpg/420px-1970_Ford_Mustang_Boss_302_%2815863840731%29.jpg', index: 17, Acceleration: 8.0, Year: '1970-01-01', @@ -1178,6 +1238,8 @@ namespace Data { Origin: 'USA', Miles_per_Gallon: 15.0, Name: 'chevrolet monte carlo', + Image: + 'https://upload.wikimedia.org/wikipedia/commons/thumb/3/36/Chevrolet_Monte_Carlo_1970_P6170033.jpg/420px-Chevrolet_Monte_Carlo_1970_P6170033.jpg', index: 18, Acceleration: 9.5, Year: '1970-01-01', @@ -1190,6 +1252,8 @@ namespace Data { Origin: 'USA', Miles_per_Gallon: 14.0, Name: 'buick estate wagon (sw)', + Image: + 'https://upload.wikimedia.org/wikipedia/commons/thumb/2/27/1987_Buick_Electra_Estate_a_station_wagon_at_2019_AACA_Hershey_meet_1of3.jpg/420px-1987_Buick_Electra_Estate_a_station_wagon_at_2019_AACA_Hershey_meet_1of3.jpg', index: 19, Acceleration: 10.0, Year: '1970-01-01', @@ -1202,6 +1266,7 @@ namespace Data { Origin: 'Japan', Miles_per_Gallon: 24.0, Name: 'toyota corona mark ii', + Image: '', index: 20, Acceleration: 15.0, Year: '1970-01-01', @@ -1214,6 +1279,7 @@ namespace Data { Origin: 'USA', Miles_per_Gallon: 22.0, Name: 'plymouth duster', + Image: '', index: 21, Acceleration: 15.5, Year: '1970-01-01', @@ -1226,6 +1292,7 @@ namespace Data { Origin: 'USA', Miles_per_Gallon: 18.0, Name: 'amc hornet', + Image: '', index: 22, Acceleration: 15.5, Year: '1970-01-01', @@ -1238,6 +1305,7 @@ namespace Data { Origin: 'USA', Miles_per_Gallon: 21.0, Name: 'ford maverick', + Image: '', index: 23, Acceleration: 16.0, Year: '1970-01-01', @@ -1250,6 +1318,7 @@ namespace Data { Origin: 'Japan', Miles_per_Gallon: 27.0, Name: 'datsun pl510', + Image: '', index: 24, Acceleration: 14.5, Year: '1970-01-01', @@ -1262,6 +1331,7 @@ namespace Data { Origin: 'Europe', Miles_per_Gallon: 26.0, Name: 'volkswagen 1131 deluxe sedan', + Image: '', index: 25, Acceleration: 20.5, Year: '1970-01-01', @@ -1274,6 +1344,7 @@ namespace Data { Origin: 'Europe', Miles_per_Gallon: 25.0, Name: 'peugeot 504', + Image: '', index: 26, Acceleration: 17.5, Year: '1970-01-01', @@ -1286,6 +1357,7 @@ namespace Data { Origin: 'Europe', Miles_per_Gallon: 24.0, Name: 'audi 100 ls', + Image: '', index: 27, Acceleration: 14.5, Year: '1970-01-01', @@ -1298,6 +1370,7 @@ namespace Data { Origin: 'Europe', Miles_per_Gallon: 25.0, Name: 'saab 99e', + Image: '', index: 28, Acceleration: 17.5, Year: '1970-01-01', @@ -1310,6 +1383,7 @@ namespace Data { Origin: 'Europe', Miles_per_Gallon: 26.0, Name: 'bmw 2002', + Image: '', index: 29, Acceleration: 12.5, Year: '1970-01-01', @@ -1349,6 +1423,10 @@ namespace Data { name: 'Name', type: 'string' }, + { + name: 'Image', + type: 'string' + }, { name: 'Origin', type: 'string' diff --git a/packages/datagrid/src/asynccellrenderer.ts b/packages/datagrid/src/asynccellrenderer.ts new file mode 100644 index 000000000..2a9e1c0b3 --- /dev/null +++ b/packages/datagrid/src/asynccellrenderer.ts @@ -0,0 +1,53 @@ +// Copyright (c) Jupyter Development Team. +// Distributed under the terms of the Modified BSD License. +/*----------------------------------------------------------------------------- +| Copyright (c) 2014-2023, Lumino Contributors +| +| Distributed under the terms of the BSD 3-Clause License. +| +| The full license is in the file LICENSE, distributed with this software. +|----------------------------------------------------------------------------*/ + +import { CellRenderer } from './cellrenderer'; +import { GraphicsContext } from './graphicscontext'; + +/** + * An object which renders the cells of a data grid asynchronously. + * + * #### Notes + * For performance reason, the datagrid only paints cells synchronously, + * though if your cell renderer inherits from AsyncCellRenderer, you will + * be able to do some asynchronous work prior to painting the cell. + * See `ImageRenderer` for an example of an asynchronous renderer. + */ +export abstract class AsyncCellRenderer extends CellRenderer { + /** + * Whether the renderer is ready or not for that specific config. + * If it's not ready, the datagrid will paint the placeholder using `paintPlaceholder`. + * If it's ready, the datagrid will paint the cell synchronously using `paint`. + * + * @param config - The configuration data for the cell. + * + * @returns Whether the renderer is ready for this config or not. + */ + abstract isReady(config: CellRenderer.CellConfig): boolean; + + /** + * Do any asynchronous work prior to painting this cell config. + * + * @param config - The configuration data for the cell. + */ + abstract load(config: CellRenderer.CellConfig): Promise; + + /** + * Paint the placeholder for a cell, waiting for the renderer to be ready. + * + * @param gc - The graphics context to use for drawing. + * + * @param config - The configuration data for the cell. + */ + abstract paintPlaceholder( + gc: GraphicsContext, + config: CellRenderer.CellConfig + ): void; +} diff --git a/packages/datagrid/src/datagrid.ts b/packages/datagrid/src/datagrid.ts index e9389866e..ac3568df0 100644 --- a/packages/datagrid/src/datagrid.ts +++ b/packages/datagrid/src/datagrid.ts @@ -20,6 +20,8 @@ import { import { GridLayout, ScrollBar, Widget } from '@lumino/widgets'; +import { AsyncCellRenderer } from './asynccellrenderer'; + import { CellRenderer } from './cellrenderer'; import { DataModel, MutableDataModel } from './datamodel'; @@ -5061,7 +5063,24 @@ export class DataGrid extends Widget { // Paint the cell into the off-screen buffer. try { - renderer.paint(gc, config); + if (renderer instanceof AsyncCellRenderer) { + if (renderer.isReady(config)) { + renderer.paint(gc, config); + } else { + renderer.paintPlaceholder(gc, config); + renderer.load(config).then(() => { + const r1 = row; + const r2 = row + 1; + + const c1 = column; + const c2 = column + 1; + + this.repaintRegion(rgn.region, r1, c1, r2, c2); + }); + } + } else { + renderer.paint(gc, config); + } } catch (err) { console.error(err); } @@ -5285,7 +5304,25 @@ export class DataGrid extends Widget { // Paint the cell into the off-screen buffer. try { - renderer.paint(gc, config); + if (renderer instanceof AsyncCellRenderer) { + if (renderer.isReady(config)) { + renderer.paint(gc, config); + } else { + renderer.paintPlaceholder(gc, config); + + const r1 = group.r1; + const r2 = group.r2; + + const c1 = group.c1; + const c2 = group.c2; + + renderer.load(config).then(() => { + this.repaintRegion(rgn.region, r1, c1, r2, c2); + }); + } + } else { + renderer.paint(gc, config); + } } catch (err) { console.error(err); } diff --git a/packages/datagrid/src/imagerenderer.ts b/packages/datagrid/src/imagerenderer.ts new file mode 100644 index 000000000..d4e7634bb --- /dev/null +++ b/packages/datagrid/src/imagerenderer.ts @@ -0,0 +1,297 @@ +// Copyright (c) Jupyter Development Team. +// Distributed under the terms of the Modified BSD License. +/*----------------------------------------------------------------------------- +| Copyright (c) 2014-2023, Lumino Contributors +| +| Distributed under the terms of the BSD 3-Clause License. +| +| The full license is in the file LICENSE, distributed with this software. +|----------------------------------------------------------------------------*/ +import { PromiseDelegate } from '@lumino/coreutils'; +import { AsyncCellRenderer } from './asynccellrenderer'; +import { CellRenderer } from './cellrenderer'; + +import { GraphicsContext } from './graphicscontext'; + +const PERCENTAGE_REGEX = /^(\d+(\.\d+)?)%$/; +const PIXEL_REGEX = /^(\d+(\.\d+)?)px$/; + +/** + * A cell renderer which renders data values as images. + */ +export class ImageRenderer extends AsyncCellRenderer { + /** + * Construct a new text renderer. + * + * @param options - The options for initializing the renderer. + */ + constructor(options: ImageRenderer.IOptions = {}) { + super(); + + this.backgroundColor = options.backgroundColor || ''; + this.textColor = options.textColor || '#000000'; + this.placeholder = options.placeholder || '...'; + + this.width = options.width || ''; + // Not using the || operator, because the empty string '' is a valid value + this.height = options.height === undefined ? '100%' : options.height; + } + + /** + * The CSS color for drawing the placeholder text. + */ + readonly textColor: CellRenderer.ConfigOption; + + /** + * The CSS color for the cell background. + */ + readonly backgroundColor: CellRenderer.ConfigOption; + + /** + * The placeholder text. + */ + readonly placeholder: CellRenderer.ConfigOption; + + /** + * The width of the image. + */ + readonly width: CellRenderer.ConfigOption; + + /** + * The height of the image. + */ + readonly height: CellRenderer.ConfigOption; + + /** + * Whether the renderer is ready or not for that specific config. + * If it's not ready, the datagrid will paint the placeholder. + * If it's ready, the datagrid will paint the image synchronously. + * + * @param config - The configuration data for the cell. + * + * @returns Whether the renderer is ready for this config or not. + */ + isReady(config: CellRenderer.CellConfig): boolean { + return ( + !config.value || ImageRenderer.dataCache.get(config.value) !== undefined + ); + } + + /** + * Load the image asynchronously for a specific config. + * + * @param config - The configuration data for the cell. + */ + async load(config: CellRenderer.CellConfig): Promise { + // Bail early if there is nothing to do + if (!config.value) { + return; + } + + const value = config.value; + const loadedPromise = new PromiseDelegate(); + + ImageRenderer.dataCache.set(value, undefined); + + const img = new Image(); + img.onload = () => { + ImageRenderer.dataCache.set(value, img); + + loadedPromise.resolve(); + }; + img.src = value; + + return loadedPromise.promise; + } + + /** + * Paint the placeholder for a cell, waiting for the renderer to be ready. + * + * @param gc - The graphics context to use for drawing. + * + * @param config - The configuration data for the cell. + */ + paintPlaceholder(gc: GraphicsContext, config: CellRenderer.CellConfig): void { + this.drawBackground(gc, config); + this.drawPlaceholder(gc, config); + } + + /** + * Paint the content for a cell. + * + * @param gc - The graphics context to use for drawing. + * + * @param config - The configuration data for the cell. + */ + paint(gc: GraphicsContext, config: CellRenderer.CellConfig): void { + this.drawBackground(gc, config); + this.drawImage(gc, config); + } + + /** + * Draw the background for the cell. + * + * @param gc - The graphics context to use for drawing. + * + * @param config - The configuration data for the cell. + */ + drawBackground(gc: GraphicsContext, config: CellRenderer.CellConfig): void { + // Resolve the background color for the cell. + const color = CellRenderer.resolveOption(this.backgroundColor, config); + + // Bail if there is no background color to draw. + if (!color) { + return; + } + + // Fill the cell with the background color. + gc.fillStyle = color; + gc.fillRect(config.x, config.y, config.width, config.height); + } + + /** + * Draw the placeholder for the cell. + * + * @param gc - The graphics context to use for drawing. + * + * @param config - The configuration data for the cell. + */ + drawPlaceholder(gc: GraphicsContext, config: CellRenderer.CellConfig): void { + const placeholder = CellRenderer.resolveOption(this.placeholder, config); + const color = CellRenderer.resolveOption(this.textColor, config); + + const textX = config.x + config.width / 2; + const textY = config.y + config.height / 2; + + // Draw the placeholder. + gc.fillStyle = color; + gc.fillText(placeholder, textX, textY); + } + + /** + * Draw the image for the cell. + * + * @param gc - The graphics context to use for drawing. + * + * @param config - The configuration data for the cell. + */ + drawImage(gc: GraphicsContext, config: CellRenderer.CellConfig): void { + // Bail early if there is nothing to draw + if (!config.value) { + return; + } + + const img = ImageRenderer.dataCache.get(config.value); + + // If it's not loaded yet, show the placeholder + if (!img) { + return this.drawPlaceholder(gc, config); + } + + const width = CellRenderer.resolveOption(this.width, config); + const height = CellRenderer.resolveOption(this.height, config); + + // width and height are unset, we display the image with its original size + if (!width && !height) { + gc.drawImage(img, config.x, config.y); + return; + } + + let requestedWidth = img.width; + let requestedHeight = img.height; + + let widthPercentageMatch: RegExpMatchArray | null; + let widthPixelMatch: RegExpMatchArray | null; + let heightPercentageMatch: RegExpMatchArray | null; + let heightPixelMatch: RegExpMatchArray | null; + + if ((widthPercentageMatch = width.match(PERCENTAGE_REGEX))) { + requestedWidth = + (parseFloat(widthPercentageMatch[1]) / 100) * config.width; + } else if ((widthPixelMatch = width.match(PIXEL_REGEX))) { + requestedWidth = parseFloat(widthPixelMatch[1]); + } + + if ((heightPercentageMatch = height.match(PERCENTAGE_REGEX))) { + requestedHeight = + (parseFloat(heightPercentageMatch[1]) / 100) * config.height; + } else if ((heightPixelMatch = height.match(PIXEL_REGEX))) { + requestedHeight = parseFloat(heightPixelMatch[1]); + } + + // If width is not set, we compute it respecting the image size ratio + if (!width) { + requestedWidth = (img.width / img.height) * requestedHeight; + } + + // If height is not set, we compute it respecting the image size ratio + if (!height) { + requestedHeight = (img.height / img.width) * requestedWidth; + } + + gc.drawImage(img, config.x, config.y, requestedWidth, requestedHeight); + } + + private static dataCache = new Map(); +} + +/** + * The namespace for the `ImageRenderer` class statics. + */ +export namespace ImageRenderer { + /** + * An options object for initializing an image renderer. + */ + export interface IOptions { + /** + * The background color for the cells. + * + * The default is `''`. + */ + backgroundColor?: CellRenderer.ConfigOption; + + /** + * The placeholder text while the cell is loading. + * + * The default is `'...'`. + */ + placeholder?: CellRenderer.ConfigOption; + + /** + * The color for the drawing the placeholder text. + * + * The default is `'#000000'`. + */ + textColor?: CellRenderer.ConfigOption; + + /** + * The width of the image. Can be a percentage of the available space (e.g. '50%'), a + * number of pixels (e.g. '123px') or an empty string. + * If it's an empty string, it will respect the image size ratio depending on the height value + * Examples: + * - if height='100%' and width='', it will take the available height in the cell and compute the width so + * that the image is not malformed. + * - if height='' and width='50%', it will take half of the available width in the cell and compute the height so + * that the image is not malformed. + * - if height='' and width='', the image will keep its original size. + * + * The default is `''`. + */ + width?: CellRenderer.ConfigOption; + + /** + * The height of the image. Can be a percentage of the available space (e.g. '50%'), a + * number of pixels (e.g. '123px') or an empty string. + * If it's an empty string, it will respect the image size ratio depending on the width value + * Examples: + * - if height='100%' and width='', it will take the available height in the cell and compute the width so + * that the image is not malformed. + * - if height='' and width='50%', it will take half of the available width in the cell and compute the height so + * that the image is not malformed. + * - if height='' and width='', the image will keep its original size. + * + * The default is `'100%'`. + */ + height?: CellRenderer.ConfigOption; + } +} diff --git a/packages/datagrid/src/index.ts b/packages/datagrid/src/index.ts index dd655299c..2c8bf9a2d 100644 --- a/packages/datagrid/src/index.ts +++ b/packages/datagrid/src/index.ts @@ -14,6 +14,7 @@ export * from './basickeyhandler'; export * from './basicmousehandler'; export * from './basicselectionmodel'; +export * from './asynccellrenderer'; export * from './cellrenderer'; export * from './celleditor'; export * from './celleditorcontroller'; @@ -26,4 +27,5 @@ export * from './selectionmodel'; export * from './sectionlist'; export * from './textrenderer'; export * from './hyperlinkrenderer'; +export * from './imagerenderer'; export * from './cellgroup'; diff --git a/packages/datagrid/tests/src/imagerenderer.spec.ts b/packages/datagrid/tests/src/imagerenderer.spec.ts new file mode 100644 index 000000000..14dd89d86 --- /dev/null +++ b/packages/datagrid/tests/src/imagerenderer.spec.ts @@ -0,0 +1,157 @@ +// Copyright (c) Jupyter Development Team. +// Distributed under the terms of the Modified BSD License. +import { expect } from 'chai'; + +import { CellRenderer, GraphicsContext, ImageRenderer } from '@lumino/datagrid'; + +class LoggingGraphicsContext extends GraphicsContext { + drawImage( + img: HTMLImageElement, + x: number, + y: number, + width?: number, + height?: number + ): void { + if (width && height) { + super.drawImage(img, x, y, width, height); + } else { + super.drawImage(img, x, y); + } + this.images.push({ img, x, y, width, height }); + } + + images: any[] = []; +} + +describe('@lumino/datagrid', () => { + let gc: LoggingGraphicsContext; + let img: HTMLImageElement; + const defaultCellConfig: CellRenderer.CellConfig = { + x: 5, + y: 6, + width: 240, + height: 20, + row: 0, + column: 0, + region: 'body', + value: '', + metadata: {} + }; + beforeEach(() => { + const canvas = document.createElement('canvas'); + const context = canvas.getContext('2d')!; + gc = new LoggingGraphicsContext(context); + img = document.createElement('img'); + img.width = 12; + img.height = 2; + ImageRenderer['dataCache'].set('test-image', img); + }); + describe('ImageRenderer', () => { + describe('drawImage()', () => { + it('should take full available height by default', () => { + const renderer = new ImageRenderer(); + renderer.drawImage(gc, { + ...defaultCellConfig, + value: 'test-image' + }); + expect(gc.images.pop()).to.deep.eq({ + img, + x: 5, + y: 6, + width: 120, + height: 20 + }); + }); + + it('should take full available width when requested', () => { + const renderer = new ImageRenderer({ + width: '100%', + height: '' + }); + renderer.drawImage(gc, { + ...defaultCellConfig, + value: 'test-image' + }); + expect(gc.images.pop()).to.deep.eq({ + img, + x: 5, + y: 6, + width: 240, + height: 40 + }); + }); + + it('should take full available width and height when requested', () => { + const renderer = new ImageRenderer({ + width: '100%', + height: '100%' + }); + renderer.drawImage(gc, { + ...defaultCellConfig, + value: 'test-image' + }); + expect(gc.images.pop()).to.deep.eq({ + img, + x: 5, + y: 6, + width: 240, + height: 20 + }); + }); + + it('should take width in pixels', () => { + const renderer = new ImageRenderer({ + width: '24px', + height: '' + }); + renderer.drawImage(gc, { + ...defaultCellConfig, + value: 'test-image' + }); + expect(gc.images.pop()).to.deep.eq({ + img, + x: 5, + y: 6, + width: 24, + height: 4 + }); + }); + + it('should take width and height pixels', () => { + const renderer = new ImageRenderer({ + width: '24px', + height: '13px' + }); + renderer.drawImage(gc, { + ...defaultCellConfig, + value: 'test-image' + }); + expect(gc.images.pop()).to.deep.eq({ + img, + x: 5, + y: 6, + width: 24, + height: 13 + }); + }); + + it('should take width in pixels and height in percentage', () => { + const renderer = new ImageRenderer({ + width: '24px', + height: '50%' + }); + renderer.drawImage(gc, { + ...defaultCellConfig, + value: 'test-image' + }); + expect(gc.images.pop()).to.deep.eq({ + img, + x: 5, + y: 6, + width: 24, + height: 10 + }); + }); + }); + }); +}); diff --git a/packages/datagrid/tests/src/index.spec.ts b/packages/datagrid/tests/src/index.spec.ts index c7ff3ba4d..414507a3e 100644 --- a/packages/datagrid/tests/src/index.spec.ts +++ b/packages/datagrid/tests/src/index.spec.ts @@ -2,3 +2,4 @@ // Distributed under the terms of the Modified BSD License. import './textrenderer.spec'; +import './imagerenderer.spec'; diff --git a/review/api/datagrid.api.md b/review/api/datagrid.api.md index 51824f599..113e7f0a5 100644 --- a/review/api/datagrid.api.md +++ b/review/api/datagrid.api.md @@ -12,6 +12,13 @@ import { ReadonlyJSONObject } from '@lumino/coreutils'; import { Signal } from '@lumino/signaling'; import { Widget } from '@lumino/widgets'; +// @public +export abstract class AsyncCellRenderer extends CellRenderer { + abstract isReady(config: CellRenderer.CellConfig): boolean; + abstract load(config: CellRenderer.CellConfig): Promise; + abstract paintPlaceholder(gc: GraphicsContext, config: CellRenderer.CellConfig): void; +} + // @public export class BasicKeyHandler implements DataGrid.IKeyHandler { dispose(): void; @@ -668,6 +675,34 @@ export interface ICellInputValidatorResponse { valid: boolean; } +// @public +export class ImageRenderer extends AsyncCellRenderer { + constructor(options?: ImageRenderer.IOptions); + readonly backgroundColor: CellRenderer.ConfigOption; + drawBackground(gc: GraphicsContext, config: CellRenderer.CellConfig): void; + drawImage(gc: GraphicsContext, config: CellRenderer.CellConfig): void; + drawPlaceholder(gc: GraphicsContext, config: CellRenderer.CellConfig): void; + readonly height: CellRenderer.ConfigOption; + isReady(config: CellRenderer.CellConfig): boolean; + load(config: CellRenderer.CellConfig): Promise; + paint(gc: GraphicsContext, config: CellRenderer.CellConfig): void; + paintPlaceholder(gc: GraphicsContext, config: CellRenderer.CellConfig): void; + readonly placeholder: CellRenderer.ConfigOption; + readonly textColor: CellRenderer.ConfigOption; + readonly width: CellRenderer.ConfigOption; +} + +// @public +export namespace ImageRenderer { + export interface IOptions { + backgroundColor?: CellRenderer.ConfigOption; + height?: CellRenderer.ConfigOption; + placeholder?: CellRenderer.ConfigOption; + textColor?: CellRenderer.ConfigOption; + width?: CellRenderer.ConfigOption; + } +} + // @public export abstract class InputCellEditor extends CellEditor { // (undocumented)