Skip to content

Custom plugins ​

WARNING

Please install ^2.0.0 to use the latest hooks

A plugin is an object with a unique name and the setup and destroy methods. All the other methods are optional hooks, which are called by the instance while it calculates sizes, renders cells and handles input.

Minimal plugin ​

ts
import { FastSheets, type FastSheetsPlugin } from 'fast-sheets'

class HelloPlugin implements FastSheetsPlugin {
  name = 'hello'
  instance!: FastSheets

  setup(instance: FastSheets) {
    // called once, when the plugin is registered with `use`
    this.instance = instance
  }

  destroy() {
    // remove your own elements and listeners here
    this.instance.unregister(this.name)
  }
}

const elContainer = document.getElementById('fast-sheets')
if (elContainer) {
  const fastSheets = new FastSheets({
    elContainer,
    data: [
      ['cell 1 : 1', 'cell 1 : 2'],
      ['cell 2 : 1', 'cell 2 : 2'],
    ],
  })

  const helloPlugin = new HelloPlugin()
  fastSheets.use(helloPlugin)

  fastSheets.hasPlugin('hello') // true

  helloPlugin.destroy() // unregisters the plugin and repaints without it
}

Plugin names must be unique, use throws an error if a plugin with the same name is already registered. fastSheets.destroy() destroys all registered plugins as well.

The examples below show only the plugin class. Register them with fastSheets.use(new SomePlugin())

Cells ​

Hooks get a cell as { rowIndex, columnIndex }. Besides the data cells, the grid has a few special cells

  • column heading: rowIndex is -1
  • row number: columnIndex is -1
  • the corner between them: both are -1

So to change data cells only, check that both indexes are 0 or greater

WARNING

Cells, styles and contexts passed to the hooks are valid only during the call, because they are reused for other cells. Copy the values you want to keep, for example { ...cell }

Cell style ​

cellStyle changes the style of a cell before it is rendered. Here is a plugin for striped rows

ts
import type { Cell, CellStyle, FastSheetsPlugin, FastSheets } from 'fast-sheets'

class StripedRowsPlugin implements FastSheetsPlugin {
  name = 'striped-rows'
  instance!: FastSheets

  setup(instance: FastSheets) {
    this.instance = instance
  }

  destroy() {
    this.instance.unregister(this.name)
  }

  cellStyle(cell: Readonly<Cell>, style: CellStyle) {
    if (cell.rowIndex >= 0 && cell.columnIndex >= 0 && cell.rowIndex % 2) {
      style.backgroundColor = '#f6f8fa'
    }
  }
}

You can change these style properties

PropertyDefault
backgroundColor'#fff'
borderColorundefined ('#ccc' for headings)
color'#000'
font14px Verdana (12px for headings)
textAlign'left' ('center' for column names)
verticalAlign'middle', also 'top' or 'bottom'
paddingX5
paddingY5

font is a measured font object, not a string. Get it with renderer.getFont inside the hook, as fonts are measured again on every layout

ts
cellStyle(cell: Readonly<Cell>, style: CellStyle) {
  if (cell.rowIndex >= 0 && cell.columnIndex === 0) {
    style.font = this.instance.renderer.getFont('bold 14px Verdana')
    style.textAlign = 'right'
  }
}

TIP

cellStyle is called for every visible cell on every frame. Keep it fast and avoid creating objects in it

Format text ​

formatCellText returns the text to render. The value in data stays unchanged, so editing and copying still use the original value

ts
formatCellText(cell: Readonly<Cell>, text: string) {
  if (cell.rowIndex >= 0 && cell.columnIndex === 1) {
    return `$${Number(text).toFixed(2)}`
  }
  return text
}

Row heights are calculated from the original value, so a formatted text should not add new lines

Custom rendering ​

Each cell is rendered in this order

  1. background, filled with style.backgroundColor
  2. renderCellBackground hooks
  3. the text
  4. renderCellContent hooks

Borders are rendered in a separate pass after all cells, so they go on top of the content

  1. border, if style.borderColor is set
  2. renderCellBorders hooks

The canvas origin 0, 0 is the top-left corner of the cell. Here is a plugin that renders progress bars behind the percentage values

ts
import type { CellRenderContext, FastSheetsPlugin, FastSheets } from 'fast-sheets'

class ProgressBarPlugin implements FastSheetsPlugin {
  name = 'progress-bar'
  instance!: FastSheets

  setup(instance: FastSheets) {
    this.instance = instance
  }

  destroy() {
    this.instance.unregister(this.name)
  }

  renderCellBackground({ ctx, cell, text, width, height }: CellRenderContext) {
    if (cell.rowIndex < 0 || cell.columnIndex !== 2) {
      return
    }
    const percent = Math.min(Math.max(Number(text) || 0, 0), 100)
    ctx.fillStyle = '#d3e3fd'
    ctx.fillRect(0, 0, (width * percent) / 100, height)
  }
}

The context also has the resolved style of the cell. The border context has borderColor and borderWidth instead

WARNING

Don't change the canvas transform in the cell hooks. If you change other canvas settings, like globalAlpha or lineWidth, set them back when you're done

Render on top ​

afterFrameRender is called once per frame, after the whole grid is rendered. Use setOrigin(x, y) to draw in grid coordinates, the scroll position is applied for you. The frame also has the viewport and the visibleRange of cells

ts
import type { FastSheetsPlugin, FastSheets, FrameContext } from 'fast-sheets'

class ColumnMarkerPlugin implements FastSheetsPlugin {
  name = 'column-marker'
  instance!: FastSheets

  constructor(public columnIndex: number) {}

  setup(instance: FastSheets) {
    this.instance = instance
  }

  destroy() {
    this.instance.unregister(this.name)
  }

  afterFrameRender({ ctx, setOrigin }: FrameContext) {
    const { horizontalOffsets, totalHeight } = this.instance.state
    const left = horizontalOffsets[this.columnIndex]
    if (left === undefined) {
      return
    }
    setOrigin(left, 0)
    ctx.fillStyle = 'red'
    ctx.fillRect(-1, 0, 2, totalHeight)
  }
}

Sizes ​

These hooks get the calculated size and return a new one

  • getColumnWidth(columnIndex, width)
  • getRowHeight(rowIndex, height)
  • getHeaderRowHeight(height)
  • getNumberingColumnWidth(width)
ts
getRowHeight(rowIndex: number, height: number) {
  return Math.max(height, 40)
}

The layout is calculated again when a plugin with size hooks is registered or removed. If your plugin changes sizes later, recalculate it yourself

ts
this.instance.layout.init()
this.instance.renderer.render()

TIP

getRowHeight is called for every row, which can be millions of them. Keep it fast

Input events ​

Mouse, pointer and keyboard hooks get an event wrapper

  • native is the original DOM event
  • stop() stops the plugins registered before this one from getting the event
  • isStopped tells whether a plugin called stop()

Input hooks run from the last registered plugin to the first, so the top plugin gets the event first. To find the cell under the mouse, use layout.findCellAtPoint or layout.findDataCellAtPoint

Here is a plugin that opens links on double click instead of editing the cell

ts
import type { FastSheetsPlugin, FastSheets, GridEvent } from 'fast-sheets'

class LinksPlugin implements FastSheetsPlugin {
  name = 'links'
  instance!: FastSheets

  setup(instance: FastSheets) {
    this.instance = instance
  }

  destroy() {
    this.instance.unregister(this.name)
  }

  onDoubleClick(e: GridEvent<MouseEvent>) {
    const { clientX, clientY } = e.native
    const cell = this.instance.layout.findDataCellAtPoint(clientX, clientY)
    const url = cell && this.instance.state.options.data[cell.rowIndex]?.[cell.columnIndex]
    if (url?.startsWith('https://')) {
      e.stop() // the editable plugin won't open the editor
      window.open(url, '_blank')
    }
  }
}

Register it after the editable plugin, so it gets the event first

ts
fastSheets.use(editablePlugin)
fastSheets.use(new LinksPlugin())

Hooks order ​

All the other hooks run in the registration order, so a later plugin overrides the results of earlier plugins and renders over them

ts
fastSheets.use(new StripedRowsPlugin())
fastSheets.use(searchPlugin) // search results are rendered over the striped rows

Repaint ​

When your plugin state changes, request a repaint

ts
this.instance.renderer.render() // on the next animation frame
this.instance.renderer.renderImmediate() // right away

render() can be called many times in a row, the grid is rendered only once per frame

Hooks reference ​

All the hooks are optional. Return values are used only by the size and text hooks

Lifecycle ​

HookWhen it's called
afterLayoutafter sizes are calculated: on init, resize and edits
onScrollon every scroll, before the next frame is rendered

Sizes ​

Each hook gets the calculated size and returns a new one

HookWhen it's called
getColumnWidthfor every column, on layout
getRowHeightfor every row, on layout
getHeaderRowHeighton layout
getNumberingColumnWidthon layout

Text and style ​

HookWhen it's called
formatCellTextfor every visible cell, on render
cellStylefor every visible cell, on render

Rendering ​

HookWhen it's called
renderCellBackgroundfor every visible cell, after the background is filled
renderCellContentfor every visible cell, after the text is rendered
renderCellBordersfor every visible cell, after all cells are rendered
afterFrameRenderonce per frame, after the whole grid is rendered

Input ​

These hooks run from the last registered plugin to the first, see Input events

HookWhen it's called
onMouseDownmousedown on the container
onMouseMovemousemove on the window
onMouseUpmouseup on the window
onDoubleClickdblclick on the container
onPointerDownpointerdown on the container
onPointerUppointerup on the window
onPointerCancelpointercancel on the window
onKeyDownkeydown on the container
onKeyUpkeyup on the container