TLDR;
I'm building a blogging widget that allows authors to further engage their audience by creating interactive and gamified experiences right within their post. This article is part of a series that looks at how this is done.
In this article I'll look at how the Widget allows extension functionality to be created by authors so that they can add their own interactive, configurable extensions and we can build a library of useful tools anyone can use! The extension functionality works without having to access the core project and can be easily developed and deployed using any framework that can output Javascript and interact with the DOM.
Motivation
I'm building the interactive widget below, vote on what you'd like to interact with or embed in your own post:
Requirements
The key principle here is to create an API that can be used to easily add an extension to the widget so that an author can create powerful new functionality to "plug in". I don't want to enforce a technology stack choice on the developer so they should be able to write in anything from vanilla Javascript to a fully fledged framework.
The developer needs to build two things, an editor component that will allow a post author to configure the extension widget and a runtime that will be rendered inside the post and perform whatever actions are required.
The key features need to be:
- Create and expose an API that allows a developer to register an extension for both editor and runtime concerns
- Expose an API that allows a plugin developer to record information relevant to the reader, the article and the widget (for example a vote in a poll)
- Provide a way of notifying the plugin developer about existing responses related to the article and changes to the data
- Provide an API to allow the plugin developer to award points and badges to the reader
- Provide a way for the plugin developer to have their extension code loaded when the plugin is to be used
Configuration Interface
I've built a configuration interface for the main Widget that allows the injection of the custom editor instances and saves all of the necessary data. To configure a widget the user works with a number of screens:
The homepage gives an author access to their profile, their articles and their comments. Each article or comment has a configuration for the widget.
The author makes an entry for each post and can use the summary view to see how many times the content has been viewed (including unique user views) and the number of times it has been interacted with.
The author can configure the main and footer widgets for their embed. They choose an available widget from a drop down list and its editor is displayed in line (here the example is for the simple HTML plugin).
If the widget is a custom built one then they can specify the files it should load on the "Advanced" tab. The entries here are for all of the Javascript files to load - while developing these could be hosted on a local development server or it could be hosted on GitHub or anywhere else, so long as the files are served as Javascript and not text. Many build systems output more than one file for inclusion in the core package (for instance a vendors file and a main source bundle) and they can all be listed here or the urls included in a .bundle
file that is then used here.
Runtime Script Loading
Ok so to start with the system needs to load the extension code specified in the "Advanced" tab. It does this by splitting the list of files on \n
and then checking if the file is one of three types (+ 1 filter):
- A .editor.* file - which will only be loaded if the widget is in the configuration system
- A .js file - in which case a
<script/>
tag is created and thesrc
set to be the file. This means the file must be served with the correct mime type (which GitHub raw files are not, unless you use a CDN extension which will cache the file, making it unwise during development). - A .jsx or a .babel.js file - in which case browser babel is loaded and then an additional
<script/>
tag with a type oftext/babel
is created with thesrc
attribute set to the file and an environment ofenv
andreact
added to it. This allows lightweight React plugins as React is used to build the outer layer. It's a big fancy, and I won't go into too much more detail here apart from to say, that if one .jsx file imports another then it also needs to be specified here. Note that GitHub raw files are fine in this case. - A .bundle file - in which case the file is downloaded and the same process is applied to the contents of the file.
It is expected that plugins will be developed as bundled projects if using a framework and the output Javascript included. I've tested it with Webpack and Rollup, you just need to be sure to include all of the files that would have been included in the index.html
.
Implementation
export async function loadPlugins(plugins) {
let hadBabel = false
for (let url of plugins) {
let type = "text/javascript"
if (url.endsWith(".bundle")) {
const response = await fetch(url)
if (!response.ok) {
console.warn("Could not load bundle", url)
continue
}
const usedBabel = await loadPlugins(
(
await response.text()
)
.split("\n")
.map((c) => c.trim())
.filter((c) => !!c)
)
hadBabel = hadBabel || usedBabel
continue
}
if (document.body.querySelector(`script[src~="${url}"]`)) continue
const script = document.createElement("script")
if (url.includes(".babel") || url.includes(".jsx")) {
hadBabel = true
type = "text/babel"
script.setAttribute("data-presets", "env,react")
script.setAttribute("data-plugins", "transform-modules-umd")
await loadBabel()
}
script.type = type
script.src = `${url}`
document.body.appendChild(script)
}
return hadBabel
}
function loadBabel() {
return new Promise((resolve) => {
const babelUrl = "https://unpkg.com/@babel/standalone/babel.min.js"
if (document.body.querySelector(`script[src='${babelUrl}']`)) {
return resolve()
}
const script = document.createElement("script")
script.src = babelUrl
script.onload = () => {
resolve()
}
document.body.appendChild(script)
})
}
I also wrote a custom hook to load the plugins and ensure the babel is transpiled:
import { useEffect } from "react"
export function usePlugins(definition, deps = []) {
useEffect(() => {
if (!definition) return
setTimeout(async () => {
const plugins = definition
.split("\n")
.map((c) => c.trim())
.filter((c) => !!c)
let hadBabel = false
for (let url of plugins) {
let type
if (url.includes(".editor")) continue
if (document.body.querySelector(`script[src~="${url}"]`))
continue
if (url.includes(".babel") || url.includes(".jsx")) {
hadBabel = true
type = "text/babel"
await loadBabel()
}
const script = document.createElement("script")
script.type = type
script.src = `${url}?${Date.now()}`
script.setAttribute("data-presets", "env,react")
document.body.appendChild(script)
}
if (hadBabel) {
window.dispatchEvent(new Event("DOMContentLoaded"))
}
})
//eslint-disable-next-line react-hooks/exhaustive-deps
}, [deps])
}
Registering New Plugins
Loading the code is one thing, but once loaded it needs to be able to interact with the outer widget. To accomplish this the outer widget exposes an API on window
in a variable called Framework4C
. This API provides all of the core functions required by a plugin.
window.Framework4C = {
Accessibility: {
reduceMotion //User prefers reduced motion
},
Material, // The whole of Material UI core
showNotification, // A function to show a toast
theme, // A material UI theme
React, // React 17
ReactDOM, // ReactDOM 17
Plugins: {
register,
PluginTypes,
}, // Function to register plugins
Interaction: {
awardPoints,
respond,
respondUnique,
addAchievement,
} // Response functions
}
To get involved in the process the only thing that the newly loaded code needs to do is to call register
passing a valid PluginTypes
value and a function that will render the editor or the runtime within a specified parent DOM element.
Registering A Plugin
Each plugin comprises an editor and a runtime.
The Editor
An editor is provided with a place to store configuration data and a function to call to say that the data has been changed. It is the job of the editor to set up any parameters that the runtime will need - these are all entirely at the discretion of the developer.
const {
Plugins: { PluginTypes, register },
} = window.Framework4C
register(PluginTypes.MAIN, "Remote", editor, null /* Ignore Runtime */)
function editor({ parent, settings, onChange }) {
/* Render the editor underneath parent */
}
If you were going to use React to render the editor you'd use ReactDOM.render passing the parent element. If you were using Vue you'd createApp
and mount it inside the parent:
import { createApp } from "vue"
import App from "./App.vue"
import Render from "./Render.vue"
const {
Plugins: { register, PluginTypes }
} = window.Framework4C || { Plugins: {} }
register(PluginTypes.MAIN, "Vue Example", editor)
function editor({ parent, settings, onChange }) {
createApp({
...App,
data() {
// Initialize props for reactivity
settings.message = settings.message || ""
return settings
},
updated() {
onChange()
}
}).mount(parent)
}
To register an editor we simply call the register
function, specifying the type of plugin and pass a callback for when it is time to render the plugin's editor.
Poll Editor UI
Here's an example of the UI for the editor that made the poll on the article.
Poll Editor Code
import {
Box,
Button,
ButtonGroup,
CardContent,
CssBaseline,
DialogActions,
DialogContent,
DialogTitle,
IconButton,
TextField,
ThemeProvider,
Typography
} from "@material-ui/core"
import { nanoid } from "nanoid"
import randomColor from "randomcolor"
import React, { useState } from "react"
import reactDom from "react-dom"
import { FaEllipsisV } from "react-icons/fa"
import { MdDelete } from "react-icons/md"
import { Bound, useBoundContext } from "../lib/Bound"
import { BoundTextField } from "../lib/bound-components"
import { BoundColorField } from "../lib/ColorField"
import { downloadObject } from "../lib/downloadObject"
import { ListItemBox } from "../lib/ListItemBox"
import { Odometer } from "../lib/odometer"
import { PluginTypes, register } from "../lib/plugins"
import { setFromEvent } from "../lib/setFromEvent"
import { Sortable, SortableItem } from "../lib/Sortable"
import { theme } from "../lib/theme"
import { UploadButton } from "../lib/uploadButton"
import { useDialog } from "../lib/useDialog"
import { useEvent } from "../lib/useEvent"
import { useRefresh } from "../lib/useRefresh"
register(PluginTypes.MAIN, "Poll", editor)
function editor({ parent, ...props }) {
reactDom.render(<Editor {...props} />, parent)
}
function Editor({ settings, onChange, response }) {
const refresh = useRefresh(onChange)
return (
<ThemeProvider theme={theme}>
<CssBaseline />
<Bound
refresh={refresh}
target={settings}
onChange={onChange}
response={response}
>
<Box mt={2}>
<PollConfig />
</Box>
</Bound>
</ThemeProvider>
)
}
function PollConfig() {
const { target, refresh } = useBoundContext()
const answers = (target.answers = target.answers || [])
const getName = useDialog(DownloadName)
return (
<>
<ListItemBox>
<Box flex={1} />
<ButtonGroup size="small">
<UploadButton
accept="*.poll.json"
variant="outlined"
color="primary"
onFile={load}
>
Load
</UploadButton>
<Button onClick={save} variant="outlined" color="secondary">
Save
</Button>
</ButtonGroup>
</ListItemBox>
<CardContent>
<BoundTextField field="question" />
</CardContent>
<CardContent>
<BoundTextField field="description" />
</CardContent>
<CardContent>
<BoundColorField field="questionColor" default="white" />
</CardContent>
<CardContent>
<Typography variant="overline" component="h3" gutterBottom>
Answers
</Typography>
<Sortable items={answers} onDragEnd={refresh}>
{answers.map((answer) => (
<Answer
answers={answers}
key={answer.id}
answer={answer}
/>
))}
</Sortable>
</CardContent>
<Button color="primary" onClick={addAnswer}>
+ Answer
</Button>
</>
)
async function save() {
const name = await getName()
if (name) {
downloadObject(target, `${name}.poll.json`)
}
}
function load(data) {
if (data) {
Object.assign(target, data)
refresh()
}
}
function addAnswer() {
answers.push({ id: nanoid(), answer: "", color: randomColor() })
refresh()
}
}
export function DownloadName({ ok, cancel }) {
const [name, setName] = useState("")
return (
<>
<DialogTitle>Name</DialogTitle>
<DialogContent>
<TextField
autoFocus
value={name}
onChange={setFromEvent(setName)}
fullWidth
/>
</DialogContent>
<DialogActions>
<Button onClick={cancel}>Cancel</Button>
<Button
onClick={() => ok(name)}
color="secondary"
variant="contained"
>
Create
</Button>
</DialogActions>
</>
)
}
export function Answer({ answers, answer }) {
const { refresh, response } = useBoundContext()
const [dragProps, setDragProps] = useState({})
useEvent("response", useRefresh())
const votes = Object.values(response?.responses?.Poll || {}).reduce(
(c, a) => (a === answer.id ? c + 1 : c),
0
)
return (
<SortableItem
borderRadius={4}
bgcolor="#fff8"
setDragProps={setDragProps}
m={1}
display="flex"
alignItems="center"
id={answer.id}
>
<Bound target={answer} refresh={refresh}>
<Box
aria-label="Drag handle"
mr={1}
color="#444"
fontSize={16}
{...dragProps}
>
<FaEllipsisV />
</Box>
<Box flex={0.6} mr={1}>
<BoundTextField
field="answer"
InputProps={{
endAdornment: (
<Box
ml={1}
textAlign="right"
color="#666"
whiteSpace="nowrap"
>
<small>
<Odometer>{votes}</Odometer> vote
<span
style={{
opacity: votes === 1 ? 0 : 1
}}
>
s
</span>
</small>
</Box>
)
}}
/>
</Box>
<Box flex={0.4} mr={1}>
<BoundTextField field="legend" />
</Box>
<Box flex={0.5} mr={1}>
<BoundColorField field="color" default="#999999" />
</Box>
<IconButton
aria-label="Delete"
onClick={remove}
color="secondary"
>
<MdDelete />
</IconButton>
</Bound>
</SortableItem>
)
function remove() {
const idx = answers.indexOf(answer)
if (idx !== -1) {
answers.splice(idx, 1)
refresh()
}
}
}
The Runtime
The runtime is used to render the component when it is viewed by a reader, presumably it takes the configuration information provided by the author and uses that to create the desired user interface.
The runtime is also supplied a parent DOM element, but it is also supplied with the settings
made in the editor, the article
that is being viewed, the current user
and a response
object that contains all of the responses. This response object may be updated after the initial render and a window
event of response
is raised passing the updated data.
Implementation
As far as the framework is concerned, the register
function just records the callback for the editor and the runtime in a data structure and raises a change event. These entries are looked up for rendering.
import { raise } from "./raise"
export const PluginTypes = {
MAIN: "main",
FOOTER: "footer",
NOTIFICATION: "notification"
}
export const Plugins = {
[PluginTypes.MAIN]: {},
[PluginTypes.FOOTER]: {},
[PluginTypes.NOTIFICATION]: {}
}
export function register(type, name, editor, runtime) {
const existing = Plugins[type][name] || {}
Plugins[type][name] = {
name,
editor: editor || existing.editor,
type,
runtime: runtime || existing.runtime
}
raise("plugins-updated")
}
Runtime Responses
The plugin system gives you the ability to capture responses from the user and store them. All of the responses for the current article are provided to you, so for instance you can show the results of a poll or a quiz. Using these methods you can record information and display it to the reader in the way you want.
The system also raises events on window when the response changes so you can show real-time updates as data changes due to any current readers.
The most common way to capture a users response is to use the API call respondUnique(articleId, type, response)
. This API call will record a response object unique to the current user. The type
parameter is an arbitrary string you use to differentiate your plugins response from others. The response
passed is an object or value that will be recorded for the user and then made available to all plugin instances for the current article.
A response
object populated due to a call passing “MyResponseType” as the type might look like this.
{
MyReponseType: {
UserId1: 1 /* something you recorded */,
UserId2: { answer: 2 } /* something you recorded for user 2 */
}
}
So to display summaries or totals for a poll or a quiz you would calculate them by iterating over the unique user responses and calculating the answer.
If you call respondUnique
multiple times, only the last value will be recorded for the current user, this is normally what you want for a poll or a quiz.
await respondUnique(article.uid, "Poll", answer.id)
You may also call respond
with the same parameters. In this case, the response
structure will contain an array of all of the responses for each user.
{
MyReponseType: {
UserId1: [{ /* something you recorded */ }, {/* another thing */}],
UserId2: [{ /* something you recorded for user 2 */ }]
}
}
Runtime Rendering
The runtime rendering of the whole widget relies on calling the registered functions. The Widget builds a container DOM structure and then calls a function called renderPlugin
passing in the settings. I'll put the whole code for this in a foldaway so you can examine it if you like, we'll concentrate on renderPlugin
.
function renderPlugin(
parent,
type,
pluginName,
settings = {},
article,
user,
response,
previewMode
) {
if (!settings || !pluginName || !type || !parent || !article || !user)
return
const plugin = Plugins[type][pluginName]
if (!plugin || !plugin.runtime) return
plugin.runtime({
parent,
article,
settings,
type,
pluginName,
user,
response,
previewMode
})
}
Rendering the plugin is simply a matter of looking up the plugin required in the registered list and then calling its runtime function. The outer holder handles monitoring Firestore for changes to the response information and raising the custom event should it happen.
renderWidget
import { addAchievement, db, view } from "../lib/firebase"
import logo from "../assets/4C_logo.jpg"
import { Plugins, PluginTypes } from "../lib/plugins"
import { raise } from "../lib/raise"
import { merge } from "../lib/merge"
let response = { notLoaded: true }
let lastMain
export async function renderWidget(
parent,
id,
user = { isAnonymous: true },
useArticle = null
) {
const definitionRef = db.collection("articles").doc(id)
const definitionDoc = (parent._definitionDoc =
parent._definitionDoc || (await definitionRef.get()))
if (!definitionDoc.exists && !useArticle) {
// Do some fallback
return null
}
if (parent._uid !== user.uid) {
if (!useArticle) {
view(id).catch(console.error)
}
}
// Get the actual data of the document
const article = useArticle || definitionDoc.data()
if (lastMain !== article[PluginTypes.MAIN]) {
article.overrideBottomBackground = null
article.overrideGradientFrom = null
article.overrideGradientTo = null
}
lastMain = article[PluginTypes.MAIN]
const removeListener = (parent._removeListener =
parent._removeListener ||
db
.collection("responses")
.doc(id)
.onSnapshot((update) => {
response.notLoaded = false
const updatedData = update.data()
Object.assign(response, updatedData)
setTimeout(() => {
response.notLoaded = false
raise(`response-${id}`, response)
raise(`response`, response)
})
}))
parent._uid = user.uid
const author = await (
await db.collection("userprofiles").doc(article.author).get()
).data()
const holder = makeContainer(parent, article, user)
holder.logoWidget.style.backgroundImage = `url(${logo})`
if (author?.photoURL) {
holder.avatarWidget.style.backgroundImage = `url(${author.photoURL})`
}
if (author.profileURL) {
holder.avatarWidget.role = "button"
holder.avatarWidget.style.cursor = "pointer"
holder.avatarWidget["aria-label"] = "Link to authors profile page"
holder.avatarWidget.onclick = () => {
if (author.displayName) {
addAchievement(
15,
`Visited profile of ${author.displayName}`
).catch(console.error)
}
window.open(author.profileURL, "_blank", "noreferrer noopener")
}
}
article.pluginSettings = article.pluginSettings || {}
renderPlugin(
holder.mainWidget,
PluginTypes.MAIN,
article[PluginTypes.MAIN],
article.pluginSettings[article[PluginTypes.MAIN]] || {},
article,
user,
response,
!!useArticle
)
renderPlugin(
holder.footerWidget,
PluginTypes.FOOTER,
article[PluginTypes.FOOTER],
article.pluginSettings[article[PluginTypes.FOOTER]] || {},
article,
user,
response,
!!useArticle
)
renderPlugin(
holder.notificationWidget,
PluginTypes.NOTIFICATION,
article[PluginTypes.NOTIFICATION] || "defaultNotification",
article.pluginSettings[article[PluginTypes.NOTIFICATION]] || {},
article,
user,
response,
!!useArticle
)
return () => {
parent._removeListener = null
removeListener()
}
}
function renderPlugin(
parent,
type,
pluginName,
settings = {},
article,
user,
response,
previewMode
) {
if (!settings || !pluginName || !type || !parent || !article || !user)
return
const plugin = Plugins[type][pluginName]
if (!plugin || !plugin.runtime) return
plugin.runtime({
parent,
article,
settings,
type,
pluginName,
user,
response,
previewMode
})
}
function makeContainer(parent, article) {
const isNarrow = window.innerWidth < 500
parent = parent || document.body
parent.style.background = `linear-gradient(45deg, ${
article?.overrideGradientFrom ?? article?.gradientFrom ?? "#fe6b8b"
} 30%, ${
article?.overrideGradientTo ?? article?.gradientTo ?? "#ff8e53"
} 90%)`
if (parent._madeContainer) {
parent._madeContainer.bottom.style.background =
article.overrideBottomBackground ||
article.bottomBackground ||
"#333"
parent._madeContainer.bottom.style.color =
article.overrideBottomColor || article.bottomColor || "#fff"
parent._madeContainer.bottom.style.display = isNarrow ? "none" : "flex"
parent._madeContainer.notificationWidget.style.display = isNarrow
? "none"
: "flex"
return parent._madeContainer
}
window.addEventListener("resize", () => makeContainer(parent, article))
const main = document.createElement("main")
Object.assign(main.style, {
display: "flex",
flexDirection: "column",
width: "100%",
height: "100%",
overflow: "hidden"
})
const top = document.createElement("div")
Object.assign(top.style, {
flex: 1,
width: "100%",
display: "flex",
justifyContent: "stretch",
overflow: "hidden"
})
main.appendChild(top)
const mainWidget = document.createElement("section")
Object.assign(mainWidget.style, {
width: "66%",
flex: 1,
overflowY: "auto",
display: "flex",
flexDirection: "column",
alignItems: "stretch",
justifyContent: "stretch",
position: "relative"
})
top.appendChild(mainWidget)
const notificationWidget = document.createElement("section")
Object.assign(notificationWidget.style, {
width: "34%",
display: isNarrow ? "none" : "block",
maxWidth: "250px",
overflowY: "hidden",
overflowX: "visible"
})
top.appendChild(notificationWidget)
const middle = document.createElement("div")
Object.assign(middle.style, {
height: "0px"
})
main.appendChild(middle)
const bottom = document.createElement("div")
Object.assign(bottom.style, {
height: "76px",
background:
article.overrideBottomBackground ||
article.bottomBackground ||
"#333",
color: article.overrideBottomColor || article.bottomColor || "#fff",
marginLeft: "-4px",
marginRight: "-4px",
marginBottom: "-4px",
boxShadow: "0 0 8px 0px #000A",
padding: "8px",
paddingTop: "4px",
display: isNarrow ? "none" : "flex",
paddingRight: window.padRightToolbar ? "142px" : undefined,
flexGrow: 0,
flexShrink: 0,
alignItems: "center",
width: "calc(100% + 8px)",
overflow: "hidden",
position: "relative"
})
main.appendChild(bottom)
const avatarWidget = document.createElement("div")
merge(avatarWidget.style, {
borderRadius: "100%",
width: "64px",
height: "64px",
backgroundRepeat: "no-repeat",
backgroundSize: "cover"
})
avatarWidget["aria-label"] = "Author avatar"
bottom.appendChild(avatarWidget)
const footerWidget = document.createElement("section")
Object.assign(footerWidget.style, {
flex: 1
})
bottom.appendChild(footerWidget)
const logoWidget = document.createElement("a")
merge(logoWidget, {
href: "https://4c.rocks",
onclick: () => addAchievement(25, "Visited 4C Rocks"),
target: "_blank",
"aria-label": "Link to 4C Rocks site"
})
merge(logoWidget.style, {
display: "block",
width: "64px",
height: "64px",
borderRadius: "8px",
backgroundSize: "contain"
})
bottom.appendChild(logoWidget)
parent.appendChild(main)
return (parent._madeContainer = {
main,
bottom,
mainWidget,
footerWidget,
logoWidget,
avatarWidget,
notificationWidget
})
}
Examples
If you've previously voted then you'll see the results, otherwise please vote to see what others think:
Conclusion
In this instalment we've seen how to load custom code into a widget, irrespective of the framework used and then how to use this code to make a pluggable UI.
Top comments (0)