zebflow 0.11.14

Reactive web automation platform — pipelines, templates, and real-time collaboration in a single binary.
Documentation
import { h, render as zebRender } from "zeb/react";
import { sanitizeAttrs, sanitizeTag } from "zeb/ui/editor-extension";

/**
 * Editor components — the machinery behind an extension's `component` and
 * `editComponent` (`zeb/ui/editor-extension` states the contract).
 *
 * - `guardComponent(C)` is `C` with its output passed through the document
 *   allowlist. `<DocumentView>` and `renderDocumentHtml` render a node's
 *   `component` only through it, so a component cannot publish a script, a
 *   style or a `javascript:` link however its attrs were stored.
 * - `componentProps(node, ext, state)` is the one props object both
 *   components get.
 * - `withNodeViews(ext, getEditor, readOnly)` gives `<Editor>` the
 *   extension with a ProseMirror node view for each component node: the
 *   component is mounted with zeb/react's `render` into the node's element
 *   and drawn again whenever the node changes.
 *
 * Plain JavaScript (no JSX), like every editor extension file.
 */

const GUARDED = new WeakMap();
const FRAGMENT = Symbol.for("zeb.fragment");
const ERROR_BOUNDARY = Symbol.for("zeb.error-boundary");
// Kept as they are: they come from code, never from a stored document, and
// renderToString never prints them.
const CODE_PROPS = /^(on[A-Z].*|ref)$/;

function noop() {}

/** A component tree with every element passed through the allowlist. */
function sanitizeTree(value) {
  if (value === null || value === undefined || typeof value === "boolean") return value;
  if (typeof value === "string" || typeof value === "number" || typeof value === "bigint") return value;
  if (Array.isArray(value)) return value.map(sanitizeTree);
  if (typeof value !== "object" || value.type == null) return null;
  const { type, props = {}, key } = value;
  if (typeof type === "function") return { type: guardComponent(type), props, key };
  if (type === FRAGMENT || type === ERROR_BOUNDARY) return { type, props: { ...props, children: sanitizeTree(props.children) }, key };
  // A portal would draw outside the document; nothing else is an element.
  if (typeof type !== "string") return null;
  const name = sanitizeTag(type);
  if (!name) return null;
  const attrs = {};
  const code = {};
  for (const [prop, v] of Object.entries(props)) {
    if (prop === "children" || prop === "dangerouslySetInnerHTML") continue;
    if (CODE_PROPS.test(prop)) { if (typeof v === "function" || (prop === "ref" && v)) code[prop] = v; continue; }
    attrs[prop] = v;
  }
  const clean = sanitizeAttrs(name, attrs, false);
  // `class` printed last, as the document renderer prints it.
  if (clean.class !== undefined) { const cls = clean.class; delete clean.class; clean.className = cls; }
  return { type: name, props: { ...clean, ...code, children: sanitizeTree(props.children) }, key };
}

/** `component` with its output sanitized; the same wrapper for the same component. */
export function guardComponent(component) {
  if (typeof component !== "function") return component;
  if (GUARDED.has(component)) return GUARDED.get(component);
  const guarded = function GuardedComponent(props) { return sanitizeTree(component(props)); };
  for (const key of ["compare", "context", "defaultProps", "displayName"]) {
    if (component[key] !== undefined) guarded[key] = component[key];
  }
  GUARDED.set(component, guarded);
  return guarded;
}

/** The props an extension's component gets. `ext` is the defineExtension object. */
export function componentProps(node, ext, state = {}) {
  const attrs = { ...(node.attrs || {}) };
  return {
    node: { type: node.type.name || node.type, attrs },
    attrs,
    options: (ext && ext.options) || {},
    edit: Boolean(state.edit),
    selected: Boolean(state.selected),
    readOnly: state.readOnly !== false,
    update: state.update || noop,
  };
}

/**
 * The published element of a component node: what DocumentView and
 * renderDocumentHtml place. A stored node missing an attr gets the spec's
 * default, as the editor's engine gives it, so both draw the same node.
 */
export function componentElement(entry, node, key) {
  const attrs = {};
  for (const [name, spec] of Object.entries((entry.spec && entry.spec.attrs) || {})) {
    if (spec && "default" in spec) attrs[name] = spec.default;
  }
  Object.assign(attrs, node.attrs);
  return h(guardComponent(entry.component), { key, ...componentProps({ type: node.type, attrs }, entry.ext) });
}

// Events the component handles itself; ProseMirror leaves them alone.
const OWN_EVENTS = "input, textarea, select, button, label, a[href], [data-node-view-events]";

function nodeView(ext, entry, getEditor, readOnly) {
  const component = entry.editComponent || guardComponent(entry.component);
  return (node, view, getPos) => {
    const dom = document.createElement(node.isInline ? "span" : "div");
    dom.setAttribute("data-node-view", node.type.name);
    dom.setAttribute("contenteditable", "false");
    let current = node;
    let selected = false;
    const update = (patch) => {
      const pos = getPos();
      if (readOnly || typeof pos !== "number" || !patch) return;
      const editor = getEditor();
      if (editor) editor.setNodeAttrs(pos, patch);
    };
    const draw = () => zebRender(h(component, componentProps(current, ext, { edit: true, selected, readOnly, update })), dom);
    draw();
    return {
      dom,
      update(next) {
        if (next.type !== current.type) return false;
        current = next;
        draw();
        return true;
      },
      selectNode() { selected = true; dom.setAttribute("data-selected", ""); draw(); },
      deselectNode() { selected = false; dom.removeAttribute("data-selected"); draw(); },
      stopEvent: (event) => Boolean(event.target && event.target !== dom && event.target.closest && event.target.closest(OWN_EVENTS)),
      // The component (and whatever it mounts, a player's shadow root…) owns this DOM.
      ignoreMutation: () => true,
      destroy() { zebRender(null, dom); },
    };
  };
}

/**
 * The extension as `<Editor>` hands it to the engine: unchanged, or with one
 * more plugin holding a node view for each node drawn by a component.
 */
export function withNodeViews(ext, getEditor, readOnly) {
  const views = {};
  for (const [type, entry] of Object.entries(ext.renderers.nodes)) {
    if (entry.component || entry.editComponent) views[type] = nodeView(ext, entry, getEditor, readOnly);
  }
  if (Object.keys(views).length === 0) return ext;
  return {
    ...ext,
    plugins: (schema, pm) => [...(ext.plugins ? ext.plugins(schema, pm) : []), new pm.Plugin({ props: { nodeViews: views } })],
  };
}

export default guardComponent;