What is the plugin?

Tweakpane can be extended with the plugin system. For example, Essentials plugin adds support for various useful components to the pane.

import {Pane} from 'tweakpane';
import * as EssentialsPlugin from '@tweakpane/plugin-essentials';

const pane = new Pane();

// Register plugin to the pane
pane.registerPlugin(EssentialsPlugin);

// Add a FPS graph
const fpsGraph = pane.addBlade({
  view: 'fpsgraph',
  label: 'fps',
});

// ...

How it works

Let's learn about the basics of the plugin by looking at a practical example. The goal of this section is creating a plugin that provides a custom binding control for the option view: 'counter'. This control can simply increment the bound value.

import {Pane} from 'tweakpane';

const PARAMS = {count: 0};

const pane = new Pane();
pane.registerPlugin(CounterInputPlugin);

// Add a custom binding control
// for `view: 'counter'`
pane.addBinding(PARAMS, 'count', {
  view: 'counter',
});

Plugin pool

All views of the pane will be created using the plugin. The pane has a plugin pool which comes with pre-installed plugins by default.

  Plugin pool

+------------------------------------+
|                                    |
| plugins[0]: Point2dInputPlugin     |
| plugins[1]: Point3dInputPlugin     |
| plugins[2]: Point4dInputPlugin     |
| plugins[3]: StringInputPlugin      |
| plugins[4]: NumberInputPlugin      |
| plugins[5]: StringColorInputPlugin |
| plugins[6]: ObjectColorInputPlugin |
| plugins[7]: NumberColorInputPlugin |
| plugins[8]: BooleanInputPlugin     |
| ...                                |
|                                    |
+------------------------------------+

registerPlugin() can add specified plugins to the pool.

const CounterPluginBundle: TpPluginBundle = {
  // Identifier of the plugin bundle
  id: 'counter',
  // Plugins that should be registered
  plugins: [
    CounterInputPlugin,
  ],
  // Additional CSS for this bundle
  css: `
    .tp-counter {align-items: center; display: flex;}
    .tp-counter div {color: #00ffd680; flex: 1;}
    .tp-counter button {background-color: #00ffd6c0; border-radius: 2px; color: black; height: 20px; width: 20px;}
  `,
};
pane.registerPlugin(CounterPluginBundle);
  Plugin pool

+------------------------------------+
|                                    |
| plugins[0]: CounterInputPlugin ... <-- Added
|                                    |
| plugins[1]: Point2dInputPlugin     |
| plugins[2]: Point3dInputPlugin     |
| plugins[3]: Point4dInputPlugin     |
| plugins[4]: StringInputPlugin      |
| plugins[5]: NumberInputPlugin      |
| plugins[6]: StringColorInputPlugin |
| plugins[7]: ObjectColorInputPlugin |
| plugins[8]: NumberColorInputPlugin |
| plugins[9]: BooleanInputPlugin     |
| ...                                |
|                                    |
+------------------------------------+

Plugin structure

Tweakpane plugin should be an object of the type TpPlugin.

const CounterInputPlugin: TpPlugin = createPlugin({
  id: 'counter',
  type: 'input',
  accept: () => {...},
  binding: {
    reader: () => {...},
    writer: () => {...},
  },
  controller: () => {...},
});

When user calls addBinding(), the pane passes the value and the options to each plugin to see if it can handle this binding by its accept().

              +--------------------------------------------------------------------------------+
              | Pane                                                                           |
              |                                                        Plugin pool             |
              |                                                                                |
              |                                                      +------------------+      |
              |                                                      | plugins[0]       |      |
+------+      |                       Can you handle this binding?   |                  |      |
| User | *--> | addBinding() *------+------------------------------> | accept() ....... < NO.. |
+------+      |                     |                                |                  |      |
              |                     |                                +------------------+      |
              |                     |                                                          |
              |                     |                                +------------------+      |
              |                     |                                | plugins[1]       |      |
              |                     | How about you?                 |                  |      |
              |                     +------------------------------> | accept() ....... < NO.. |
              |                     |                                |                  |      |
              |                     |                                +------------------+      |
              |                     |                                                          |
              |                     |                                +------------------+      |
              |                     |                                | plugins[2]       |      |
              |                     | How about you?                 |                  |      |
              |                     +------------------------------> | accept() ....... < YES! |
              |                                                      |                  |      |
              |                         Please create a binding      |                  |      |
              |                         for the specified value      |                  |      |
              |                       *----------------------------> | binding.reader() |      |
              |                                                      |        .writer() |      |
              |                         Please create a controller   |                  |      |
              |                         (and a view)                 |                  |      |
              |                         for the bound value          |                  |      |
              |                       *----------------------------> | controller()     |      |
              |                                                      |                  |      |
              |                                                      +------------------+      |
              |                                                                                |
              +--------------------------------------------------------------------------------+

type

There are 3 types of plugins for Tweakpane: input, monitor, and blade.

Type Description Target
input Provides a view for binding addBinding()
monitor Provides a view for readonly binding addBinding() with {readonly: true}
blade Provides a view without binding addBlade()

From here on, we will choose input to provide a custom view for the binding.

const CounterInputPlugin = {
  // ...

  type: 'input',

  // ...
};

accept()

Decides whether the plugin accepts the specified parameters. Returns a typed value and parameters if the plugin accepts the specified parameters, or null if the plugin sees them off and pass them to the next plugin. (Reference)

const CounterInputPlugin = {
  // ...

  accept(value: unknown, params: Record<string, unknown>) {
    if (typeof value !== 'number') {
      return null;
    }
    if (params.view !== 'counter') {
      return null;
    }
    return {
      initialValue: value,
      params: params,
    };
  },

  // ...
};

binding

Configurations of the binding. Tweakpane can convert the external (= input) value into an internal value for certain reasons. For example, string color value '#112233' is inconvenient when modifying color components in various color spaces. Tweakpane converts it into the internal color class using this configuration.

If you want to handle a primitive value (number, string, or boolean), you don't have to convert a value here. (Reference)

const CounterInputPlugin = {
  // ...

  binding: {
    reader: () => (value: unknown) => Number(value),
    writer: () => (target: BindingTarget, value: number) => {
      target.write(value);
    },
  },

  // ...
};

controller()

Create a controller and a view for the binding.

const CounterInputPlugin = {
  // ...

  controller(args) {
    return new CounterController(args.document, {
      value: args.value,
      viewProps: args.viewProps,
    });
  },

  // ...
};

Tweakpane adopts good old MVC structure. Controller creates a view, handle user interaction, and update models (in this case, the bound value).

import {Value, ValueController, ViewProps} from '@tweakpane/core';
import {CounterView} from './view';

interface Config {
  value: Value<number>;
  viewProps: ViewProps;
}

export class CounterController implements ValueController<number, CounterView> {
  public readonly value: Value<number>;
  public readonly view: CounterView;
  public readonly viewProps: ViewProps;

  constructor(doc: Document, config: Config) {
    // Models
    this.value = config.value;
    this.viewProps = config.viewProps;

    // Create a view
    this.view = new CounterView(doc, {
      value: config.value,
      viewProps: this.viewProps,
    });

    // Handle user interaction
    this.view.buttonElement.addEventListener('click', () => {
      // Update a model
      this.value.rawValue += 1;
    });
  }
}

View creates DOM elements and apply value changes to them.

import {Value, View, ViewProps} from '@tweakpane/core';

interface Config {
  value: Value<number>;
  viewProps: ViewProps;
}

export class CounterView implements View {
  public readonly element: HTMLElement;
  public readonly buttonElement: HTMLButtonElement;

  constructor(doc: Document, config: Config) {
    // Create view elements
    this.element = doc.createElement('div');
    this.element.classList.add('tp-counter');

    // Apply value changes to the preview element
    const previewElem = doc.createElement('div');
    const value = config.value;
    value.emitter.on('change', () => {
      previewElem.textContent = String(value.rawValue);
    });
    previewElem.textContent = String(value.rawValue);
    this.element.appendChild(previewElem);

    // Create a button element for user interaction
    const buttonElem = doc.createElement('button');
    buttonElem.textContent = '+';
    this.element.appendChild(buttonElem);
    this.buttonElement = buttonElem;
  }
}

Priority of the plugin

Newer plugins have higher priority and manually registered plugins have higher priority than pre-installed plugins.

  Plugin pool
+------------------------------------+
|                                    |
| plugins[0]: CounterInputPlugin     |  ^  Newer, higher priority
|                                    |  |
| plugins[1]: Point2dInputPlugin     |  |
| plugins[2]: Point3dInputPlugin     |  |
| plugins[3]: Point4dInputPlugin     |  |
| plugins[4]: StringInputPlugin      |  |
| plugins[5]: NumberInputPlugin      |  |
| plugins[6]: StringColorInputPlugin |  |
| plugins[7]: ObjectColorInputPlugin |  |
| plugins[8]: NumberColorInputPlugin |  |
| plugins[9]: BooleanInputPlugin     |  |
| ...                                |  *  Older, lower priority
|                                    |
+------------------------------------+

It implies that you should be cautious not to accept an excessibely wide range of parameters. view option can be useful for this purpose.

Next step

That concludes the basics of the plugin structure. There are many other practices and useful methods for the plugin development. See @tweakpane/plugin-template for the first step of your development. Additionally, @tweakpane/plugin-essentials can also serve as a practical example.