zebflow 0.11.14

Reactive web automation platform — pipelines, templates, and real-time collaboration in a single binary.
Documentation
import { h } from "zeb/react";
import { defineExtension, routeSearch, safeHref } from "zeb/ui/editor-extension";
import { PotoruLibraries, mergePotoruLibraries, potoruLibraryUrl } from "zeb/ui/editor-potoru-libraries";

/**
 * Potoru extension — a Potoru story (`.poto`) as a block of the document,
 * played by `zeb/potoru`'s PotoPlayer.
 *
 *   potoruExtension()                                             paste an address in the panel
 *   potoruExtension({ route: "/api/stories/search" })             or pick one from a project route
 *   potoruExtension({ libraries: ["/_files/libs/basic.potolib"] })  libraries every block gets
 *
 * The node stores `{ key, src, mode, still, time, libraries, snapshot: { title, poster } }`.
 * It is drawn by components (the extension contract's `component` and
 * `editComponent`): `PotoruBlock` renders the placeholder PotoPlayer's server
 * stub renders — `data-zeb-lib="potoru"`, `data-zeb-wrapper="PotoPlayer"`
 * and the same `data-config` — so the page hydrates into the player, and the
 * editor shows it as a live preview with the block's library list under it
 * (`PotoruBlockEditor`). The poster frame, when the snapshot has one, sits
 * inside the placeholder: it shows until the player mounts in its shadow
 * root and hides it.
 *
 * Libraries: the extension's `libraries` are defaults for every block; a
 * block adds its own in the editor (stored in its attrs). The two merge —
 * the block's entry wins over a default with the same file name — and the
 * merged list is the player's `libraries`. Each address must be `https:` or
 * a path on this site (`/…`); a bad default is refused when the extension is
 * built, a bad stored entry is left out.
 *
 * This file never imports `zeb/potoru`. The page loads the library itself,
 * and only when a placeholder is actually on it (RWE's markup-mounted
 * libraries), so a page of documents without a story downloads nothing. The
 * project must have `zeb/potoru` enabled if it restricts its libraries.
 *
 * A picker route answers `GET route?q=…` with `{ id, label, href, snapshot }`
 * where `href` is the `.poto` address and `snapshot` may carry `poster` and
 * `libraries`. The story must be same-origin, or served with CORS.
 */

export const POTORU_MODES = ["play", "slide", "interactive"];

/**
 * `data-config` exactly as PotoPlayer builds it for
 * `{ src, libraries, controls, still, time, mode }`; `defaults` are the
 * extension's libraries.
 */
export function potoruConfig(attrs, defaults) {
  const a = attrs || {};
  const config = { src: a.src ? safeHref(a.src) : "" };
  const libraries = mergePotoruLibraries(defaults, a.libraries);
  if (libraries.length) config.libraries = libraries;
  config.controls = true;
  const still = a.still === true || a.still === "true";
  if (still) config.still = true;
  const time = Number(a.time);
  if (still && a.time !== "" && a.time !== null && a.time !== undefined && Number.isFinite(time) && time >= 0) config.time = time;
  if (POTORU_MODES.includes(a.mode)) config.mode = a.mode;
  return config;
}

/** The block as the page shows it: PotoPlayer's placeholder, its poster and its title. */
export function PotoruBlock({ attrs, options, edit }) {
  const a = attrs || {};
  const s = a.snapshot || {};
  const figure = { "data-potoru-block": options.name, "data-key": a.key || undefined, className: "my-4" };
  if (!a.src) {
    if (!edit) return null;
    return h("figure", figure, h("div", { className: "rounded-lg border border-dashed border-border px-4 py-6 text-center text-sm text-muted-foreground" }, `${options.label}: set its .poto address in the panel`));
  }
  return h("figure", figure,
    h("div", {
      "data-zeb-lib": "potoru", "data-zeb-wrapper": "PotoPlayer", "data-config": JSON.stringify(potoruConfig(a, options.libraries)),
      className: "relative block w-full overflow-hidden rounded-lg bg-muted",
    }, s.poster ? h("img", { src: safeHref(s.poster), alt: s.title || "", className: "block w-full" }) : null),
    s.title ? h("figcaption", { className: "mt-2 text-sm text-muted-foreground" }, s.title) : null);
}

/** The block in the editor: the live preview, then the libraries it plays with. */
export function PotoruBlockEditor(props) {
  const { attrs, options, readOnly, selected, update } = props;
  return h("div", { "data-potoru-editor": options.name, className: selected ? "rounded-lg ring-2 ring-ring/50" : "" },
    h(PotoruBlock, props),
    h(PotoruLibraries, { defaults: options.libraries, libraries: attrs.libraries, readOnly, onChange: (libraries) => update({ libraries }) }));
}

export function potoruExtension({ name = "potoru", route, search, label = "Potoru story", hint = "Play a .poto story", libraries = [] } = {}) {
  if (!Array.isArray(libraries)) throw new Error(`potoruExtension "${name}": libraries is a list of addresses`);
  for (const url of libraries) {
    if (!potoruLibraryUrl(url)) throw new Error(`potoruExtension "${name}": library ${JSON.stringify(url)} must be an https:// address or a path on this site (/…)`);
  }
  return defineExtension({
    name,
    node: {
      group: "block", atom: true, draggable: true,
      attrs: {
        key: { default: "" }, src: { default: "" }, mode: { default: "" }, still: { default: false }, time: { default: 0 },
        libraries: { default: [] }, snapshot: { default: {} },
      },
    },
    options: { name, label, libraries: libraries.map((url) => url.trim()) },
    component: PotoruBlock,
    editComponent: PotoruBlockEditor,
    text: (node) => (node.attrs.snapshot && node.attrs.snapshot.title) || node.attrs.key || node.attrs.src,
    picker: route || search ? {
      search: search || routeSearch(route),
      toAttrs: (item) => {
        const s = item.snapshot || {};
        const own = Array.isArray(s.libraries) ? s.libraries.filter(potoruLibraryUrl) : [];
        return { key: String(item.id), src: item.href || s.src || "", mode: s.mode || "", still: false, time: 0, libraries: own, snapshot: { title: item.label || "", poster: s.poster || "" } };
      },
    } : null,
    insert: [{ id: name, label, hint, keys: `potoru story animation poto ${name}`, run: (api) => (route || search ? api.pick() : api.insert(name)) }],
    fields: [
      { name: "src", label: ".poto address", type: "url", placeholder: "/_files/stories/intro.poto" },
      { name: "mode", label: "Mode", type: "select", options: ["", ...POTORU_MODES] },
      { name: "still", label: "Still frame", type: "checkbox" },
      { name: "time", label: "Seconds (with still)", type: "number" },
      { name: "snapshot.title", label: "Title" },
      { name: "snapshot.poster", label: "Poster image", type: "url" },
      { name: "key", label: "Key" },
    ],
  });
}

export default potoruExtension;