Skip to content

Creating Widgets

nbriz edited this page Oct 1, 2021 · 16 revisions

Widgets

color widget

Widgets are multi-purpose independent windows. These are essentially netnet.studio "addons" or "plugins." They can be used during tutorials to open up other media types (images, videos, gifs, audio, texts, 3D objects, etc). Widgets can also be their own miscellaneous utilities. For example we could create a widget that explains some concept using interactive graphics. Widgets can also be GUIs that interact with the netitor, for example a widget that generates snippets of CSS code. The sky's the limit!

The docs below explain how to use netnet's widget system for creating all sorts of custom widgets. For a rundown on some of netnet's core widgets refer to the widget section of the Project-Architecture doc.

The Widget System

All the logic for how netnet's Widget system works can be found in www/js/Widget.js and so any bug fixes or modifications to the actual Widget system itself would happen there. The system creates a global WIDGETS object which you can test in your browser's dev console, copy+paste one of the following lines for example:

// Returns an array of all the files currently loaded into the system.
// These are the actual JavaScript files used to generate different
// sorts of widgets. These are essentially the various widget classes.
// (to load a custom widget class see Creating Custom Widget below)
WIDGETS.loaded

// Returns an array keys of every widget currently instantiated in netnet.
// These are the ids of the actual widgets themselves (rather than the
// JavaScript files used to generate them)
WIDGETS.instantiated

// Returns an array with references to the actual widgets objects
// themselves (ie. the instantiated widgets).
WIDGETS.list()

Creating a Simple Widget

simple widget

To create a new widget you can use the WIDGET's .create() method, which takes an object that can contain any number of optional properties, but requires a key property, which must be a unique id that isn't being used by any other widget. You can use WIDGETS.instantiated to reference a list of unique keys for all the currently instantiated widgets (your key can be anything other than those). To test this out try copy+pasting the following into the browser's developer console:

// to create the widget first run
WIDGETS.create({
  key: 'welcome-widget',
  title: 'Welcome to netnet.studio!',
  innerHTML: 'by <a href="https://netizen.org" target="_blank">netizen</a>'
})

// then to open the widget run
WIDGETS.open('welcome-widget')

// you could also do both at the same time
WIDGETS.create({ key: 'just-a-test' }).open()

// you can also close it by calling
WIDGETS.close('just-a-test')

Widget Properties and Methods

Widgets have various properties all of which can be set in the constructor by passing it any of the following options:

WIDGETS.create({
  type: 'Widget',         // type of widget
  key: 'my-new-widget',   // the widget's id
  title: 'My New Widget', // for widget title bar
  innerHTML: 'hi there',  // html string or HTMLElement
  closable: true,         // allow user to close the widget
  resizable: true,        // allow user to resize the widget
  listed: true,           // should widget be listed in search results
  left: 20,               // position from left (x axis)
  right: 20,              // position from right (x axis) instead of left
  top: 20,                // position from top (y axis)
  bottom: 20,             // position from bottom (y axis) instead of top
  zIndex: 100,            // stacking value (z axis)
  width: 500,             // window width
  height: 500             // window height
})

You can interact with any instantiated widget directly by calling WIDGETS['my-new-widget'], alternatively you could also pull it from the WIDGETS.list() array (as mentioned above). You can the call the widget's various properties like WIDGETS['my-new-widget'].resizable or WIDGETS['my-new-widget'].width, there is also a ready only WIDGETS['my-new-widget'].opened property which returns a boolean (true if the widget is currently opened, false if not)

You can also call various different methods on your instantiated widget, for example:

// to open and close the widget
// similar to WIDGETS.open(id) and WIDGETS.close(id)
WIDGETS['my-new-widget'].open()
WIDGETS['my-new-widget'].close()

// to select some html content in the widget's innerHTML
WIDGETS['my-new-widget'].$(selector)
// for example this would return all the <a> elements with a class of pizza
WIDGETS['my-new-widget'].$('a.pizza')

// you can change the widget's position (and really any CSS)
// by passing an object to the update method, for example
WIDGETS['my-new-widget'].update({
  top: 50,
  right: 50
})

// if you want to animate/transition the change in position
// pass the number of miliseconds as a second argument
WIDGETS['my-new-widget'].update({ left: 10 }, 1000)

// by default, if no position is set in the constructor
// widgets will open in the center of the page
// if you ever want to recenter a widget call
WIDGETS['my-new-widget'].recenter()

// if there are other widgets on the page obstructing your widget
// you can call it to the front of the z stacking order
WIDGETS['my-new-widget'].bring2front()

Widgets also have an event system (like pretty much everything in netnet), which works like this:

// to register a new event listener
WIDGETS['my-new-widget'].on(event, callback)

// // you can listen for the 'open' and 'close' events for example
WIDGETS['my-new-widget'].on('open', (eve) => {
  console.log('My New Widget was just opened!')
})

// you can also unsubscribe the listener after it's called
// if you don't want it to ever fire on that event again
WIDGETS['my-new-widget'].on('open', (eve) => {
  console.log('My New Widget was just opened!')
  eve.unsubscribe()
})

// you can make up your own events to...
WIDGETS['my-new-widget'].on('test', (eve) => {
  console.log(eve.data)
})
// and then to emit a custom event you can do
WIDGETS['my-new-widget'].emit('test', { data: 100 })

Creating a Custom Widget

When the options and functionality provided above aren't enough for doing whatever it is you want to do with a widget, maybe because you need a method or property that doesn't exist in the base Widget class, you can create your own custom widget by extending this base class.

custom widget

Custom widgets need to be defined in the www/js/widgets directory and should look something like this:

// the name of this file should match the class name,
// for example MyCustomWidget.js
class MyCustomWidget extends Widget {
  constructor (opts) {
    super(opts)

    this.key = 'my-custom-widget'
    this.title = 'Welcome!'
    this.innerHTML = `
      <h1>This is a custom Widget</h1>
      <button>click me!</button>
    `

    this.$('button').addEventListener('click', () => this.random())
  }

  random () {
    const r = Math.random() * 255
    const g = Math.random() * 255
    const b = Math.random() * 255
    this.$('h1').style.color = `rgb(${r}, ${g}, ${b})`
  }
}

window.MyCustomWidget = MyCustomWidget

Assuming you've saved the file above into the widgets folder, you can check and see if your widget works by opening your browser's dev console and running:

WIDGETS.open('my-custom-widget')

This is a contrived example, but you can see how this widget includes a custom method random() which wouldn't be possible using the simple widget approach explained above. You could now also do WIDGETS['my-custom-widget'].random() in the dev console to run that custom method.

NOTE:

If you had tried to run WIDGETS['my-custom-widget'].random() before WIDGETS.open('my-custom-widget') you would have gotten an error saying WIDGETS['my-custom-widget'] is undefined, that's because the .open() method is doing some extra work behind the scenes.

If you had opened your dev console and checked the WIDGETS.loaded first, you'd notice that it does not include your new MyCustomWidget.js file. This is because netnet doesn't load all the custom widgets by default (loading all the widgets on page load delays the initial load time unnecessarily). Instead the widget system provides a load method, for example: WIDGETS.load('MyCustomWidget.js'), to load the widget when you need it. This will add it to the WIDGETS.loaded array, as well as instantiating it automatically, which adds it to the WIDGETS.instantiated array, at which point you can interact with it like any other widget.

The difference between WIDGETS['my-custom-widget'].open() and WIDGETS.open('my-custom-widget'), is that the latter checks to see if the widget has been loaded first and if not loads it for you, and then instantiates it before opening it.

The reason the widget system also instantiates it automatically is because the default assumption is that there is only ever meant to be a single instance of your custom widget. That said, if you're trying to create a new type of widget, which is meant to be instantiated multiple times you can let the widget system know that you don't want it auto-instantiated by including the following getter in your custom widget:

static get skipAutoInstantiation () { return true }

Check out the ExampleWidget for other things you might want to consider when creating a custom widget. It also serves as a nice template you can duplicate to create your own custom widget from.

Creating a Code Generator Widget

The widget system provides some extra methods intended to make the creation of code generator widgets a little easier. A code generator widget is a custom widget designed for generating snippets of code to be injected into netnet's editor with the help of a GUI. A good example would be the ColorWidget.js

color widget

Like any other widget it begins with creating a new file for your custom widget in the www/js/widgets directory:

// FontSizeGenerator.js
class FontSizeGenerator extends Widget {
  constructor (opts) {
    super(opts)

    this.key = 'font-size-generator'
    this.title = 'Font Size Generator'
    this._createHTML()

    // here we listen for cursor activity in netnet's editor
    NNE.on('cursor-activity', (e) => {
      if (!e.selection || e.language !== 'css') return
      // if css code in the editor has been selected/highlighted
      const css = this.parseCSS(e.selection) // let's parse the selection
      if (css && css.property === 'font-size') {
        // if we've selected a css declaration for font-size
        // update the sliders' value to match the value selected
        this.slider.value = parseInt(css.value)
        // and update the code field to match the value selected
        this.codeField.value = `font-size: ${css.value};`
      }
    })
  }

  _createHTML () {
    const div = document.createElement('div')

    // let's create an instance of the <code-slider> custom element
    this.slider = this.createSlider({
      value: 100,
      label: 'font size',
      change: (e) => { // when the slider's value changes
        // update the value of the code field
        this.codeField.value = `font-size: ${e.target.value}px;`
      }
    })

    // now let's create an instance of the <code-field> custom element
    this.codeField = this.createCodeField({
      value: 'font-size: 100px',
      change: (e) => { // when the field's value changes
        const css = this.parseCSS(e.target.value) // let's parse it's css
        if (css.property === 'font-size') { // if it contains font-size
          // update the slider's value to match the value enterd in the filed
          this.slider.value = parseInt(css.value)
        } else { // if the user changed the css property
          // change it back to font-size
          this.codeField.value = `font-size: ${this.slider.value}px`
        }
      }
    })

    // let's append both the slider and code field to the parent div
    // and update our widget's innterHTML
    div.appendChild(this.slider)
    div.appendChild(this.codeField)
    this.innerHTML = div
  }
}

window.FontSizeGenerator = FontSizeGenerator

This widget would end up looking something like this: font generator

This widget makes use of a few special methods built into the base Widget class for creating a couple of different custom elements <code-field> and <code-slider> which are used to render the input field and slider seen in the gif above.

The options you can pass into the code field method are:

this.codeField = this.createCodeField({
  value: 'font-size: 12px', // the default value to display in the field
  readonly: false, // weather or not the user can change input field content
  update: ()_=> {/* function to run every time there's new input */},
  change: () => {/* function to run when the value in the field changes */}
})

The options you can pass into the slider method are:

this.codeField = this.createSlider({
  value: 100, // the default value to set the slider to
  change: () => {/* function to run when the slider value changes */},
  min: 0, // minimum value for the slider (default 1)
  max: 100, // maximum value for the slider (default 255),
  step: 10, // the sliders step value (default 1)
  label: 'font-size', // optional label to display below the slider
  bubble: '#ff0', // optional colored circle to display above the slider
  background: '#f00', // optional slider color
})

It also makes use of a parseCSS() method which can take a CSS declaration as a string and returns an objects with the declarations property and value parsed out. Some examples of the sorts of objects it returns are:

this.parseCSS('font-size: 24px;')
// returns: { property: 'font-size', value: ['24px'] }

this.parseCSS('padding: 5px 10px 5px 20px;')
// returns: { property: 'padding', value: ['5px', '10px', '5px', '20px'] }

this.parseCSS('transform: translate(10px, 20px) rotate(90deg)')
/*
  returns: {
    property: 'transform',
    value: [
      ['translate', '10px', '20px'],
      ['rotate', '90deg']
    ]
  }
*/