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
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:
rowIndexis-1 - row number:
columnIndexis-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
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
| Property | Default |
|---|---|
backgroundColor | '#fff' |
borderColor | undefined ('#ccc' for headings) |
color | '#000' |
font | 14px Verdana (12px for headings) |
textAlign | 'left' ('center' for column names) |
verticalAlign | 'middle', also 'top' or 'bottom' |
paddingX | 5 |
paddingY | 5 |
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
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
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
- background, filled with
style.backgroundColor renderCellBackgroundhooks- the text
renderCellContenthooks
Borders are rendered in a separate pass after all cells, so they go on top of the content
- border, if
style.borderColoris set renderCellBordershooks
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
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
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)
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
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
nativeis the original DOM eventstop()stops the plugins registered before this one from getting the eventisStoppedtells whether a plugin calledstop()
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
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
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
fastSheets.use(new StripedRowsPlugin())
fastSheets.use(searchPlugin) // search results are rendered over the striped rowsRepaint
When your plugin state changes, request a repaint
this.instance.renderer.render() // on the next animation frame
this.instance.renderer.renderImmediate() // right awayrender() 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
| Hook | When it's called |
|---|---|
afterLayout | after sizes are calculated: on init, resize and edits |
onScroll | on every scroll, before the next frame is rendered |
Sizes
Each hook gets the calculated size and returns a new one
| Hook | When it's called |
|---|---|
getColumnWidth | for every column, on layout |
getRowHeight | for every row, on layout |
getHeaderRowHeight | on layout |
getNumberingColumnWidth | on layout |
Text and style
| Hook | When it's called |
|---|---|
formatCellText | for every visible cell, on render |
cellStyle | for every visible cell, on render |
Rendering
| Hook | When it's called |
|---|---|
renderCellBackground | for every visible cell, after the background is filled |
renderCellContent | for every visible cell, after the text is rendered |
renderCellBorders | for every visible cell, after all cells are rendered |
afterFrameRender | once per frame, after the whole grid is rendered |
Input
These hooks run from the last registered plugin to the first, see Input events
| Hook | When it's called |
|---|---|
onMouseDown | mousedown on the container |
onMouseMove | mousemove on the window |
onMouseUp | mouseup on the window |
onDoubleClick | dblclick on the container |
onPointerDown | pointerdown on the container |
onPointerUp | pointerup on the window |
onPointerCancel | pointercancel on the window |
onKeyDown | keydown on the container |
onKeyUp | keyup on the container |