Guide · Panelith 0.1.0 · plain HTML edition; the interactive edition has search and live examples
Custom widgets (plugins)
On this page 3 sections
For a body the built-in widgets and blocks cannot express, an app ships its own widget as an ES module. There is no build step: the app lists the file in plugins, the server serves it at /plugins/<name>, and the shell imports it before the first render and calls its default export with a small SDK.
The Python side#
class Sketch(Pane):
kind = "sketch"
label = "Sketch"
glyph = "pen"
family = "author"
widget = "sketch" # registered by sketch.js, listed in App.plugins
tip = "A custom widget shipped as an ES module"
def initial_state(self, ctx):
return {"strokes": 0, "points": 0, "hint": "Draw with a finger or the mouse"}
@event("stroke")
def stroke(self, ctx, payload):
st = ctx.pane.state
return a.patch_state(ctx.cid, strokes=st.get("strokes", 0) + 1, points=st.get("points", 0) + int(payload.get("points", 0)))
class Docs(App):
plugins = ["sketch.js"] # relative to the file that defines the App
A plugin widget is a kind like any other: widget names what the module registered, the state is whatever the module reads, and the events it emits reach @event handlers.
The module#
// sketch.js — the plugin the docs' "Custom widgets" page demonstrates: a canvas
// you draw on; each finished stroke is one event to Python with its point count.
export default function install(pl) {
const { h, React } = pl;
pl.css(
"sketch",
".sketch { width: 100%; height: 100%; display: flex; flex-direction: column; gap: 6px; padding: 8px; }" +
".sketch canvas { flex: 1; min-height: 120px; width: 100%; border: 1px dashed var(--pl-pane-border); border-radius: 6px; background: var(--pl-pane-bg); touch-action: none; cursor: crosshair; }" +
".sketch__bar { color: var(--pl-text-dim); font-size: 12px; display: flex; justify-content: space-between; }",
);
pl.registerWidget("sketch", function Sketch({ state, emit }) {
const ref = React.useRef(null);
const pts = React.useRef([]);
const drawing = React.useRef(false);
function pos(e) {
const r = ref.current.getBoundingClientRect();
return { x: e.clientX - r.left, y: e.clientY - r.top };
}
function down(e) {
drawing.current = true;
pts.current = [pos(e)];
ref.current.setPointerCapture(e.pointerId);
}
function move(e) {
if (!drawing.current) return;
const p = pos(e);
const prev = pts.current[pts.current.length - 1];
pts.current.push(p);
const c = ref.current.getContext("2d");
const r = ref.current.getBoundingClientRect();
if (ref.current.width !== Math.round(r.width)) {
ref.current.width = Math.round(r.width);
ref.current.height = Math.round(r.height);
}
c.strokeStyle = getComputedStyle(ref.current).getPropertyValue("--pl-accent") || "#4f8cff";
c.lineWidth = 2.5;
c.lineCap = "round";
c.beginPath();
c.moveTo(prev.x, prev.y);
c.lineTo(p.x, p.y);
c.stroke();
}
function up() {
if (!drawing.current) return;
drawing.current = false;
emit("stroke", { points: pts.current.length });
}
return h(
"div",
{ className: "sketch" },
h("canvas", { ref, onPointerDown: down, onPointerMove: move, onPointerUp: up, onPointerCancel: up }),
h("div", { className: "sketch__bar" }, h("span", null, state?.hint ?? "Draw with a finger or the mouse"), h("span", null, `${state?.strokes ?? 0} strokes · ${state?.points ?? 0} points`)),
);
});
}
The SDK#
The default export receives pl, which is also parked on window.panelith:
| Member | For |
|---|---|
React, h |
the shell's React and createElement; a plugin cannot import its own copy |
registerWidget(id, Component) |
the component receives {cid, state, options, paneActive, emit, subtab} like every built-in widget |
css(id, text) |
inject a stylesheet once; use the --pl-* tokens so both schemes work |
Glyph |
the shell's glyph component, for icons that match the tabs |
toast(text, tone, ms) |
a notice without a round trip |
api.get(path), api.post(path, body) |
the shell's API client, with the right base under any prefix |
syncClock(), now(), clockOffset |
the server clock, for anything that counts down to a server timestamp |
Plugins load before the first render, so a kind can name a plugin widget from the start, and a module may register several widgets.
Note
The Boggle board was a plugin before the blocks widget existed. Plugins remain the escape hatch; when a body keeps coming up across apps, the better move is a new block type in the framework.