zebflow 0.11.14

Reactive web automation platform — pipelines, templates, and real-time collaboration in a single binary.
Documentation
import { cx, useEffect, useRef, useState, createPortal } from "zeb/react";
import { createEditor } from "zeb/prosemirror";
import { DocumentView, EDITOR_CLASSES } from "zeb/ui/editor-render";
import { composeExtensions } from "zeb/ui/editor-extension";
import { withNodeViews } from "zeb/ui/editor-component";
import { EditorPicker, editorMenuPlacement } from "zeb/ui/editor-picker";
import { EditorNodePanel } from "zeb/ui/editor-panel";
import { Toggle } from "zeb/ui/toggle";

/**
 * Editor — a Notion-style block editor: type `/` for blocks, select text for
 * the bubble toolbar, Markdown shortcuts (`# `, `- `, `[] `, `> `, ```` ``` ````,
 * `---`), drag the ⋮⋮ handle to move a block, paste or drop an image.
 *
 * The value is a ProseMirror document (JSON). Store that; render it with
 * `<DocumentView doc>` or `renderDocumentHtml(doc)` from `zeb/ui/editor-render`.
 *
 * This is the one zeb/ui component with a runtime dependency: `zeb/prosemirror`
 * is the engine (schema, commands, plugins) and draws nothing; every visible
 * piece here is zeb/ui on the theme. Uploads go through `uploadImage(file)`,
 * which the page supplies — usually a webhook running `fs.file.put` — and
 * which returns a URL or `{ src, ref, alt }`. Without it, images are refused.
 *
 *   <Editor value={doc} onChange={setDoc} placeholder="Write…" uploadImage={upload} />
 *
 * `extensions` adds blocks, inline nodes and marks (`zeb/ui/editor-extension`):
 * their slash items, `@` pickers and node panels appear here, and the same
 * list renders the document — `<DocumentView doc extensions>`. Build the list
 * once, outside the component; the engine reads it when it mounts. A stored
 * node no extension knows is kept as it is, never dropped. A node an extension
 * draws with a component (`component` / `editComponent`) is that component,
 * mounted in the document, live.
 *
 *   const EXTENSIONS = [calloutExtension(), tableExtension(), figureExtension()];
 *   <Editor value={doc} onChange={setDoc} extensions={EXTENSIONS} />
 */

const SLASH_ITEMS = [
  { id: "paragraph", label: "Text", hint: "Plain paragraph", keys: "text p", run: (ed) => ed.exec("paragraph") },
  { id: "h1", label: "Heading 1", hint: "Section title", keys: "h1 title", run: (ed) => ed.exec("heading", 1) },
  { id: "h2", label: "Heading 2", hint: "Subsection", keys: "h2", run: (ed) => ed.exec("heading", 2) },
  { id: "h3", label: "Heading 3", hint: "Small heading", keys: "h3", run: (ed) => ed.exec("heading", 3) },
  { id: "bullet", label: "Bulleted list", hint: "• item", keys: "ul bullet list", run: (ed) => ed.exec("list", "bullet") },
  { id: "ordered", label: "Numbered list", hint: "1. item", keys: "ol number", run: (ed) => ed.exec("list", "ordered") },
  { id: "todo", label: "To-do list", hint: "☐ task", keys: "todo task check", run: (ed) => ed.exec("list", "todo") },
  { id: "quote", label: "Quote", hint: "A pulled line", keys: "quote blockquote", run: (ed) => ed.exec("blockquote") },
  { id: "code", label: "Code", hint: "A code block", keys: "code pre", run: (ed) => ed.exec("codeBlock") },
  { id: "divider", label: "Divider", hint: "A horizontal rule", keys: "hr divider rule", run: (ed) => ed.exec("horizontalRule") },
  { id: "image", label: "Image", hint: "Upload a picture", keys: "image photo picture", needsUpload: true, run: null },
];

const MARK_BUTTONS = [
  ["bold", "B", "font-bold"],
  ["italic", "I", "italic"],
  ["underline", "U", "underline"],
  ["strike", "S", "line-through"],
  ["code", "</>", "font-mono text-xs"],
];

// The menus are fixed-position portals placed at caret coordinates. Near
// the top the bubble goes below the selection; near the bottom the slash
// menu opens upward. Neither may leave the viewport sideways.
function viewportWidth() {
  return typeof window === "undefined" ? 1280 : window.innerWidth;
}

/** Normalize what `uploadImage` answered: a URL, or `{ src | url, ref, alt }`. */
function uploadedImage(result, file) {
  if (!result) return null;
  return typeof result === "string" ? { src: result, alt: file.name, ref: null } : { src: result.src || result.url, ref: result.ref || null, alt: result.alt || file.name };
}

function isEmptyDoc(doc) {
  return !doc || !doc.content || (doc.content.length === 1 && doc.content[0].type === "paragraph" && !(doc.content[0].content || []).length);
}

export function Editor({ value, defaultValue, onChange, placeholder = "Type '/' for blocks, or just write…", uploadImage, readOnly = false, className, minHeight = "12rem", extensions }) {
  const mountRef = useRef(null);
  const editorRef = useRef(null);
  const fileRef = useRef(null);
  // A pending `api.upload()` from an extension's slash item, resolved by the file input.
  const uploadWaiter = useRef(null);
  const registry = composeExtensions(extensions);
  // Every document the engine has emitted (or been given), by identity. The
  // controlled effect below sees each render's `value` in turn, and under
  // fast typing that is a stale one — A, after AB was already emitted.
  // Anything we produced ourselves is never written back into the engine.
  const emitted = useRef(new WeakSet());
  const lastEmitted = useRef(null);
  const [empty, setEmpty] = useState(isEmptyDoc(value ?? defaultValue));
  const [slash, setSlash] = useState(null);
  const [slashIndex, setSlashIndex] = useState(0);
  const [selection, setSelection] = useState(null);
  const [linkDraft, setLinkDraft] = useState(null);
  const [picker, setPicker] = useState(null);
  const [nodePanel, setNodePanel] = useState(null);
  const [mounted, setMounted] = useState(false);

  // Mount the engine once; it owns the DOM under mountRef from here on.
  useEffect(() => {
    const mount = mountRef.current;
    if (!mount || typeof createEditor !== "function") return;
    const initial = value ?? defaultValue;
    if (initial && typeof initial === "object") emitted.current.add(initial);
    const editor = createEditor(mount, {
      doc: initial,
      classes: EDITOR_CLASSES,
      // A node drawn by a component gets a node view: the component, mounted in place.
      extensions: registry.list.map((ext) => withNodeViews(ext, () => editorRef.current, readOnly)),
      placeholder,
      editable: !readOnly,
      uploadImage,
      onChange: (json) => {
        emitted.current.add(json);
        lastEmitted.current = JSON.stringify(json);
        setEmpty(isEmptyDoc(json));
        onChange?.(json);
      },
      onSlash: (state) => { setSlash(state); setSlashIndex(0); },
      onTrigger: (state) => setPicker((open) => (state ? { ...state, searchable: false } : open && open.searchable ? open : null)),
      onNode: setNodePanel,
      onSelection: setSelection,
      onError: (err) => console.error("zeb/ui/editor: the document could not be loaded; the editor starts empty", err),
    });
    editorRef.current = editor;
    setMounted(true);
    return () => { editor.destroy(); editorRef.current = null; };
  }, []);

  // Controlled: a value the page changed (not one we just emitted) replaces the document.
  useEffect(() => {
    const editor = editorRef.current;
    if (!editor || value === undefined || value === null) return;
    if (typeof value === "object" && emitted.current.has(value)) return;
    const next = JSON.stringify(value);
    if (next === lastEmitted.current || next === JSON.stringify(editor.getJSON())) return;
    editor.setJSON(value);
    setEmpty(isEmptyDoc(value));
  }, [value]);

  const allItems = [...SLASH_ITEMS, ...registry.list.flatMap((ext) => ext.insert.map((item) => ({ ...item, id: item.id || ext.name, ext })))];
  const slashItems = slash
    ? allItems.filter((item) => (!item.needsUpload || uploadImage) && (item.label + " " + (item.keys || "")).toLowerCase().includes(slash.query.toLowerCase()))
    : [];

  // What an extension's slash item can do with the editor.
  function extensionApi(ext) {
    const editor = editorRef.current;
    return {
      editor,
      canUpload: Boolean(uploadImage),
      insert: (type, attrs) => editor.insertNode(type, attrs),
      wrap: (type, attrs) => editor.exec(editor.pm.wrapIn(editor.schema.nodes[type], attrs || null)),
      exec: (name, ...args) => editor.exec(name, ...args),
      pick: () => {
        const coords = editor.view.coordsAtPos(editor.view.state.selection.from);
        setPicker({ name: ext.name, query: "", searchable: true, left: coords.left, top: coords.top, bottom: coords.bottom });
      },
      upload: () => new Promise((resolve) => { uploadWaiter.current = resolve; fileRef.current?.click(); }),
    };
  }

  function runSlash(item) {
    const editor = editorRef.current;
    if (!editor || !slash) return;
    editor.deleteSlash(slash);
    setSlash(null);
    if (item.ext) item.run(extensionApi(item.ext));
    else if (item.id === "image") fileRef.current?.click();
    else item.run(editor);
  }

  const pickerExt = picker ? registry.byName[picker.name] : null;
  function pick(item) {
    const editor = editorRef.current;
    if (!editor || !picker || !pickerExt) return;
    const range = picker.searchable ? undefined : { from: picker.from, to: picker.to };
    setPicker(null);
    editor.insertNode(pickerExt.picker.type || pickerExt.name, pickerExt.picker.toAttrs(item), range);
  }

  // The slash menu takes the arrow keys and Enter while it is open.
  useEffect(() => {
    const mount = mountRef.current;
    if (!mount || !slash) return;
    const onKey = (event) => {
      if (event.key === "ArrowDown") { event.preventDefault(); setSlashIndex((i) => (i + 1) % Math.max(slashItems.length, 1)); }
      else if (event.key === "ArrowUp") { event.preventDefault(); setSlashIndex((i) => (i - 1 + Math.max(slashItems.length, 1)) % Math.max(slashItems.length, 1)); }
      else if (event.key === "Enter") { event.preventDefault(); if (slashItems[slashIndex]) runSlash(slashItems[slashIndex]); }
      else if (event.key === "Escape") { event.preventDefault(); setSlash(null); }
    };
    mount.addEventListener("keydown", onKey, true);
    return () => mount.removeEventListener("keydown", onKey, true);
  }, [slash, slashItems.length, slashIndex]);

  async function onFilePicked(event) {
    const file = event.target.files && event.target.files[0];
    event.target.value = "";
    const editor = editorRef.current;
    const waiter = uploadWaiter.current;
    uploadWaiter.current = null;
    if (!file || !editor || !uploadImage) return;
    const attrs = uploadedImage(await uploadImage(file), file);
    if (waiter) waiter(attrs);
    else if (attrs) editor.exec("image", attrs);
  }

  const overlays = mounted && typeof document !== "undefined" ? createPortal(
    <>
      {slash && slashItems.length > 0 ? (
        <div role="listbox" data-slot="editor-slash" className={cx("fixed z-50 max-h-96 w-64 overflow-y-auto rounded-lg border border-border bg-popover p-1 text-popover-foreground shadow-md", editorMenuPlacement(slash, slashItems.length * 44 + 8).up ? "-translate-y-full" : "")} style={editorMenuPlacement(slash, slashItems.length * 44 + 8).style}>
          {slashItems.map((item, i) => (
            <button
              key={item.id}
              type="button"
              role="option"
              aria-selected={i === slashIndex ? "true" : "false"}
              onMouseDown={(e) => { e.preventDefault(); runSlash(item); }}
              onMouseEnter={() => setSlashIndex(i)}
              className={cx("flex w-full items-center gap-3 rounded-md px-2 py-1.5 text-left text-sm", i === slashIndex ? "bg-accent text-accent-foreground" : "")}
            >
              <span className="w-8 shrink-0 text-center font-mono text-[0.65rem] text-muted-foreground">{item.hint.slice(0, 2)}</span>
              <span className="min-w-0 flex-1"><span className="block font-medium">{item.label}</span><span className="block truncate text-xs text-muted-foreground">{item.hint}</span></span>
            </button>
          ))}
        </div>
      ) : null}
      {selection && !readOnly ? (
        <div data-slot="editor-bubble" className={cx("fixed z-50 flex -translate-x-1/2 items-center gap-0.5 rounded-lg border border-border bg-popover p-1 text-popover-foreground shadow-md", selection.top < 56 ? "" : "-translate-y-full")} style={{ left: `${Math.max(170, Math.min(selection.left, viewportWidth() - 170))}px`, top: `${selection.top < 56 ? selection.bottom + 8 : selection.top - 8}px` }} onMouseDown={(e) => e.preventDefault()}>
          {MARK_BUTTONS.map(([mark, label, cls]) => (
            <Toggle key={mark} size="sm" pressed={!!selection.marks[mark]} onPressedChange={() => editorRef.current?.exec(`toggle${mark[0].toUpperCase()}${mark.slice(1)}`)} aria-label={mark} className={cls}>{label}</Toggle>
          ))}
          <span className="mx-1 h-5 w-px bg-border" />
          <Toggle size="sm" pressed={selection.block === "heading" && selection.attrs?.level === 1} onPressedChange={() => editorRef.current?.exec(selection.block === "heading" && selection.attrs?.level === 1 ? "paragraph" : "heading", 1)} aria-label="Heading 1" className="font-mono text-xs">H1</Toggle>
          <Toggle size="sm" pressed={selection.block === "heading" && selection.attrs?.level === 2} onPressedChange={() => editorRef.current?.exec(selection.block === "heading" && selection.attrs?.level === 2 ? "paragraph" : "heading", 2)} aria-label="Heading 2" className="font-mono text-xs">H2</Toggle>
          <span className="mx-1 h-5 w-px bg-border" />
          {linkDraft === null ? (
            <Toggle size="sm" pressed={!!selection.marks.link} onPressedChange={() => (selection.marks.link ? editorRef.current?.exec("setLink", "") : setLinkDraft(""))} aria-label="Link" className="font-mono text-xs">link</Toggle>
          ) : (
            <form className="flex items-center gap-1" onSubmit={(e) => { e.preventDefault(); editorRef.current?.exec("setLink", linkDraft.trim()); setLinkDraft(null); }}>
              <input autoFocus value={linkDraft} onInput={(e) => setLinkDraft(e.target.value)} onKeyDown={(e) => { if (e.key === "Escape") setLinkDraft(null); }} placeholder="https://" className="h-7 w-44 rounded-md border border-input bg-background px-2 text-xs outline-none focus-visible:ring-2 focus-visible:ring-ring/50" />
              <button type="submit" className="h-7 rounded-md bg-primary px-2 text-xs text-primary-foreground">Set</button>
            </form>
          )}
        </div>
      ) : null}
      {picker && pickerExt && pickerExt.picker && !readOnly ? (
        <EditorPicker key={`${picker.name}:${picker.searchable}`} picker={pickerExt.picker} query={picker.query} anchor={picker} searchable={picker.searchable} onPick={pick} onClose={() => { setPicker(null); editorRef.current?.focus(); }} />
      ) : null}
      {nodePanel && registry.byName[nodePanel.extension] && !readOnly && !picker ? (
        <EditorNodePanel ext={registry.byName[nodePanel.extension]} node={nodePanel} editor={editorRef.current} onClose={() => setNodePanel(null)} />
      ) : null}
    </>,
    document.body,
  ) : null;

  return (
    <div data-slot="editor" className={cx("relative rounded-lg border border-input bg-background px-4 py-3 focus-within:border-ring focus-within:ring-2 focus-within:ring-ring/30", className)} style={{ minHeight }}>
      {empty && !readOnly ? <div aria-hidden="true" className="pointer-events-none absolute left-11 top-3 select-none text-base leading-7 text-muted-foreground">{placeholder}</div> : null}
      {/* The engine owns everything under mountRef; it is never given React
          children, so a re-render cannot patch ProseMirror's DOM. The static
          view is a sibling for the server (and the moment before mount). */}
      <div ref={mountRef} />
      {!mounted ? <DocumentView doc={value ?? defaultValue} extensions={registry.list} className={EDITOR_CLASSES.root} /> : null}
      {uploadImage ? <input ref={fileRef} type="file" accept="image/*" className="hidden" onChange={onFilePicked} /> : null}
      {overlays}
    </div>
  );
}

export default Editor;