Skip to content

Framework adapters ​

The editor DOM is always contenteditable from createEditor / mountVanillaEditor. Do not reimplement the reconciler in React/Vue VDOM. Adapters are host samples in examples/: they depend on the framework + this library. The published package has zero React/Vue/etc. dependencies.

Contract (every host):

ts
type EditorHandle = {
  getDocument(): PortableDocument;
  setDocument(doc: PortableDocument): void;
  setTheme(theme: ThemeName): void;
  commands: Editor["commands"];
  destroy(): void;
};
ts
import { mountVanillaEditor } from "velo-text";
// or: velo-text/vanilla

const editor = mountVanillaEditor(el, {
  document,
  theme: "light-neutral",
  editable: true,
  onChange: (doc) => { /* debounce persist */ },
  resolveAssetUrl: (id) => urls[id],
});
editor.commands.insertVariable("name");
editor.destroy(); // removes .pde-editor-wrapper

CSS from the package: import "velo-text/themes/base.css" and import "velo-text/themes/components.css". Theme via data-pde-theme (light-neutral | light-warm | dark-slate | dark-contrast).

The editor is client-only. Astro/SSR should emit renderDocumentToHtml at build time and hydrate the editor in an island (client:load).

Interactive demo: Playground. Full copy-paste pages: Examples.

HostAdapterApp sample
Vanillaexamples/vanilla/index.htmlsame file
Reactexamples/react/PortableEditor.jsxexamples/react/App.jsx
Vueexamples/vue/PortableEditor.vueexamples/vue/App.vue
Svelteexamples/svelte/portableEditor.jsexamples/svelte/App.svelte
Angularexamples/angular/portable-editor.tsexamples/angular/app.component.ts
Astroexamples/astro/PortableEditor.astroexamples/astro/index.astro

Each sample: mount → insertVariable → exportDocument (pdf / odt / docx).

Vanilla ​

Official API. Own the host element; call destroy() on unmount.

html
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8" />
  <title>vanilla mountVanillaEditor</title>
  <link rel="stylesheet" href="../../themes/base.css" />
  <link rel="stylesheet" href="../../themes/components.css" />
</head>
<body data-pde-theme="light-neutral">
  <h1>Vanilla</h1>
  <div id="host"></div>
  <button id="var">Insert variable</button>
  <button id="pdf">PDF</button>
  <--+<button id="odt">ODT</button>
  <button id="docx">DOCX</button>
  <script type="module">
    import {
      createDocument, createIdGenerator, mountVanillaEditor, exportDocument,
    } from "../../dist/public-api/index.js";
    const MIME = {
      pdf: "application/pdf",
      odt: "application/vnd.oasis.opendocument.text",
      docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
    };
    const g = createIdGenerator("v");
    const doc = createDocument({ idGenerator: g, clock: { nowIso: () => new Date().toISOString() } });
    doc.root.children.push({ type: "paragraph", id: g.next(), children: [{ type: "text", id: g.next(), text: "Hello" }] });
    const el = document.getElementById("host");
    const editor = mountVanillaEditor(el, { document: doc });
    document.getElementById("var").onclick = () => editor.commands.insertVariable("name");
    async function doExport(format) {
      const chunks = [];
      await exportDocument({ document: editor.getDocument(), data: { name: "Ada" }, format, sink: { write(c){chunks.push(c)}, close(){} }, options: { strict: false } });
      const a = document.createElement("a");
      a.href = URL.createObjectURL(new Blob(chunks, { type: MIME[format] }));
      a.download = "vanilla." + format;
      a.click();
    }
    document.getElementById("pdf").onclick = () => doExport("pdf");
    document.getElementById("odt").onclick = () => doExport("odt");
    document.getElementById("docx").onclick = () => doExport("docx");
  </script>
</body>
</html>

React ​

useRef + useEffect cleanup. React must not reconcile children of the host.

jsx
import { useEffect, useImperativeHandle, useRef, forwardRef } from "react";
import { mountVanillaEditor } from "velo-text";

/**
 * Peer: react. Mounts createEditor on a host element.
 * Do not write innerHTML / children into the host — the reconciler owns the DOM.
 */
export const PortableEditor = forwardRef(function PortableEditor(
  { document, theme = "light-neutral", editable = true, onChange, resolveAssetUrl, getVariableCatalog, getTemplateData, onImageFile },
  ref,
) {
  const host = useRef(null);
  const handle = useRef(null);

  useEffect(() => {
    if (!host.current) return;
    handle.current = mountVanillaEditor(host.current, {
      document, theme, editable, onChange, resolveAssetUrl, getVariableCatalog, getTemplateData, onImageFile,
    });
    return () => {
      handle.current?.destroy();
      handle.current = null;
    };
  }, []);

  useImperativeHandle(ref, () => ({
    getDocument: () => handle.current?.getDocument(),
    commands: () => handle.current?.commands,
    insertVariable: (path, format, fallback) => handle.current?.commands.insertVariable(path, format, fallback),
    destroy: () => handle.current?.destroy(),
  }), []);

  return <div ref={host} className="pde-editor" />;
});

App (variable + PDF/ODT/DOCX):

jsx
import { useRef, useState } from "react";
import { createDocument, createIdGenerator, exportDocument } from "velo-text";
import { PortableEditor } from "./PortableEditor.jsx";

const MIME = {
  pdf: "application/pdf",
  odt: "application/vnd.oasis.opendocument.text",
  docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
};

function sampleDoc() {
  const g = createIdGenerator("react");
  const doc = createDocument({ idGenerator: g, clock: { nowIso: () => new Date().toISOString() } });
  doc.root.children.push({
    type: "paragraph",
    id: g.next(),
    children: [
      { type: "text", id: g.next(), text: "Hello " },
      { type: "variable", id: g.next(), path: "name", source: "{{name}}", valueType: "string" },
    ],
  });
  return doc;
}

export function App() {
  const editorRef = useRef(null);
  const [status, setStatus] = useState("ready");

  async function doExport(format) {
    const document = editorRef.current?.getDocument();
    if (!document) return;
    const chunks = [];
    await exportDocument({
      document,
      data: { name: "Ada" },
      format,
      sink: { write(c) { chunks.push(c); }, close() {} },
      options: { strict: false },
    });
    const a = window.document.createElement("a");
    a.href = URL.createObjectURL(new Blob(chunks, { type: MIME[format] }));
    a.download = `react.${format}`;
    a.click();
    setStatus(`exported ${format}`);
  }

  return (
    <div data-pde-theme="light-neutral">
      <p>{status}</p>
      <button type="button" onClick={() => editorRef.current?.insertVariable("name")}>Insert variable</button>
      <button type="button" onClick={() => doExport("pdf")}>PDF</button>
      <button type="button" onClick={() => doExport("odt")}>ODT</button>
      <button type="button" onClick={() => doExport("docx")}>DOCX</button>
      <PortableEditor
        ref={editorRef}
        document={sampleDoc()}
        theme="light-neutral"
        onChange={() => setStatus("changed")}
      />
    </div>
  );
}

Vue ​

v-model:document via update:document. Expose insertVariable / getDocument.

vue
<script setup>
import { onMounted, onBeforeUnmount, ref } from "vue";
import { mountVanillaEditor } from "velo-text";

const props = defineProps({
  document: { type: Object, required: true },
  theme: { type: String, default: "light-neutral" },
  editable: { type: Boolean, default: true },
  resolveAssetUrl: { type: Function, default: undefined },
  getVariableCatalog: { type: Function, default: undefined },
  getTemplateData: { type: Function, default: undefined },
});
const emit = defineEmits(["update:document"]);
const el = ref(null);
let editor;

onMounted(() => {
  editor = mountVanillaEditor(el.value, {
    document: props.document,
    theme: props.theme,
    editable: props.editable,
    resolveAssetUrl: props.resolveAssetUrl,
    getVariableCatalog: props.getVariableCatalog,
    getTemplateData: props.getTemplateData,
    onChange: (doc) => emit("update:document", doc),
  });
});
onBeforeUnmount(() => editor?.destroy());

defineExpose({
  insertVariable: (path, format, fallback) => editor?.commands.insertVariable(path, format, fallback),
  getDocument: () => editor?.getDocument(),
  commands: () => editor?.commands,
});
</script>
<template>
  <div ref="el" class="pde-editor"></div>
</template>
vue
<script setup>
import { ref } from "vue";
import { createDocument, createIdGenerator, exportDocument } from "velo-text";
import PortableEditor from "./PortableEditor.vue";

const MIME = {
  pdf: "application/pdf",
  odt: "application/vnd.oasis.opendocument.text",
  docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
};

function sampleDoc() {
  const g = createIdGenerator("vue");
  const doc = createDocument({ idGenerator: g, clock: { nowIso: () => new Date().toISOString() } });
  doc.root.children.push({
    type: "paragraph",
    id: g.next(),
    children: [
      { type: "text", id: g.next(), text: "Hello " },
      { type: "variable", id: g.next(), path: "name", source: "{{name}}", valueType: "string" },
    ],
  });
  return doc;
}

const editor = ref(null);
const document = ref(sampleDoc());
const status = ref("ready");

async function doExport(format) {
  const live = editor.value?.getDocument() ?? document.value;
  const chunks = [];
  await exportDocument({
    document: live,
    data: { name: "Ada" },
    format,
    sink: { write(c) { chunks.push(c); }, close() {} },
    options: { strict: false },
  });
  const a = window.document.createElement("a");
  a.href = URL.createObjectURL(new Blob(chunks, { type: MIME[format] }));
  a.download = `vue.${format}`;
  a.click();
  status.value = `exported ${format}`;
}
</script>
<template>
  <div data-pde-theme="light-neutral">
    <p>{{ status }}</p>
    <button type="button" @click="editor?.insertVariable('name')">Insert variable</button>
    <button type="button" @click="doExport('pdf')">PDF</button>
    <button type="button" @click="doExport('odt')">ODT</button>
    <button type="button" @click="doExport('docx')">DOCX</button>
    <PortableEditor ref="editor" v-model:document="document" theme="light-neutral" />
  </div>
</template>

Svelte ​

Action use:portableEditor={opts}. update pushes setDocument / setTheme.

js
import { mountVanillaEditor } from "velo-text";

/** Svelte action: <div use:portableEditor={opts}></div> */
export function portableEditor(node, opts) {
  let editor = mountVanillaEditor(node, opts);
  return {
    update(next) {
      if (next?.document && next.document !== editor.getDocument()) {
        editor.setDocument(next.document);
      }
      if (next?.theme) editor.setTheme(next.theme);
    },
    destroy() {
      editor.destroy();
    },
  };
}
svelte
<script>
  import { createDocument, createIdGenerator, exportDocument } from "velo-text";
  import { portableEditor } from "./portableEditor.js";

  const MIME = {
    pdf: "application/pdf",
    odt: "application/vnd.oasis.opendocument.text",
    docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
  };

  const g = createIdGenerator("svelte");
  let document = createDocument({ idGenerator: g, clock: { nowIso: () => new Date().toISOString() } });
  document.root.children.push({
    type: "paragraph",
    id: g.next(),
    children: [
      { type: "text", id: g.next(), text: "Hello " },
      { type: "variable", id: g.next(), path: "name", source: "{{name}}", valueType: "string" },
    ],
  });
  let host;
  let status = "ready";

  const opts = {
    document,
    theme: "light-neutral",
    onChange: (doc) => { document = doc; status = "changed"; },
  };

  async function doExport(format) {
    const chunks = [];
    await exportDocument({
      document,
      data: { name: "Ada" },
      format,
      sink: { write(c) { chunks.push(c); }, close() {} },
      options: { strict: false },
    });
    const a = window.document.createElement("a");
    a.href = URL.createObjectURL(new Blob(chunks, { type: MIME[format] }));
    a.download = `svelte.${format}`;
    a.click();
    status = `exported ${format}`;
  }
</script>

<div data-pde-theme="light-neutral">
  <p>{status}</p>
  <div bind:this={host} class="pde-editor" use:portableEditor={opts}></div>
  <button type="button" on:click={() => doExport("pdf")}>PDF</button>
  <button type="button" on:click={() => doExport("odt")}>ODT</button>
  <button type="button" on:click={() => doExport("docx")}>DOCX</button>
</div>

Angular ​

Standalone directive on the host element. ViewChild for insertVariable.

ts
import { Directive, ElementRef, OnDestroy, Input, Output, EventEmitter } from "@angular/core";
import { mountVanillaEditor } from "velo-text";

/** Standalone directive: <div portableEditor [document]="doc" (documentChange)="onDoc($event)"></div> */
@Directive({ selector: "[portableEditor]", standalone: true })
export class PortableEditorDirective implements OnDestroy {
  @Input() document;
  @Input() theme = "light-neutral";
  @Input() editable = true;
  @Output() documentChange = new EventEmitter();
  editor;

  constructor(private el: ElementRef<HTMLElement>) {
    queueMicrotask(() => {
      this.editor = mountVanillaEditor(this.el.nativeElement, {
        document: this.document,
        theme: this.theme,
        editable: this.editable,
        onChange: (doc) => this.documentChange.emit(doc),
      });
    });
  }

  insertVariable(path, format, fallback) {
    this.editor?.commands.insertVariable(path, format, fallback);
  }

  getDocument() {
    return this.editor?.getDocument();
  }

  ngOnDestroy() {
    this.editor?.destroy();
  }
}
ts
import { Component, ViewChild } from "@angular/core";
import { createDocument, createIdGenerator, exportDocument } from "velo-text";
import { PortableEditorDirective } from "./portable-editor";

const MIME = {
  pdf: "application/pdf",
  odt: "application/vnd.oasis.opendocument.text",
  docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
};

function sampleDoc() {
  const g = createIdGenerator("ng");
  const doc = createDocument({ idGenerator: g, clock: { nowIso: () => new Date().toISOString() } });
  doc.root.children.push({
    type: "paragraph",
    id: g.next(),
    children: [
      { type: "text", id: g.next(), text: "Hello " },
      { type: "variable", id: g.next(), path: "name", source: "{{name}}", valueType: "string" },
    ],
  });
  return doc;
}

@Component({
  standalone: true,
  selector: "app-root",
  imports: [PortableEditorDirective],
  template: `
    <div data-pde-theme="light-neutral">
      <p>{{ status }}</p>
      <button type="button" (click)="insertVar()">Insert variable</button>
      <button type="button" (click)="doExport('pdf')">PDF</button>
      <button type="button" (click)="doExport('odt')">ODT</button>
      <button type="button" (click)="doExport('docx')">DOCX</button>
      <div class="pde-editor" portableEditor [document]="doc" theme="light-neutral" (documentChange)="onChange($event)"></div>
    </div>
  `,
})
export class AppComponent {
  @ViewChild(PortableEditorDirective) editor;
  doc = sampleDoc();
  status = "ready";

  onChange(doc) {
    this.doc = doc;
    this.status = "changed";
  }

  insertVar() {
    this.editor?.insertVariable("name");
  }

  async doExport(format) {
    const document = this.editor?.getDocument() ?? this.doc;
    const chunks = [];
    await exportDocument({
      document,
      data: { name: "Ada" },
      format,
      sink: { write(c) { chunks.push(c); }, close() {} },
      options: { strict: false },
    });
    const a = window.document.createElement("a");
    a.href = URL.createObjectURL(new Blob(chunks, { type: MIME[format] }));
    a.download = `angular.${format}`;
    a.click();
    this.status = `exported ${format}`;
  }
}

Astro ​

Do not SSR the editor. Static HTML uses renderDocumentToHtml; the editor is a client:load island (React wrapper here; any client framework works).

astro
---
/**
 * Astro island — client only. Do not hydrate the AST on the server.
 * Static HTML: import { renderDocumentToHtml } from "velo-text" in a server component.
 */
import PortableEditor from "../react/PortableEditor.jsx";
---
<div data-pde-theme="light-neutral">
  <PortableEditor
    client:load
    document={Astro.props.document}
    theme={Astro.props.theme ?? "light-neutral"}
    editable={Astro.props.editable ?? true}
    onChange={Astro.props.onChange}
  />
</div>
astro
---
import { createDocument, createIdGenerator, renderDocumentToHtml } from "velo-text";
import PortableEditor from "./PortableEditor.astro";

const g = createIdGenerator("astro");
const document = createDocument({ idGenerator: g, clock: { nowIso: () => new Date().toISOString() } });
document.root.children.push({
  type: "paragraph",
  id: g.next(),
  children: [
    { type: "text", id: g.next(), text: "Hello " },
    { type: "variable", id: g.next(), path: "name", source: "{{name}}", valueType: "string" },
  ],
});
const staticHtml = renderDocumentToHtml(document);
---
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <title>Astro + velo-text</title>
    <link rel="stylesheet" href="../../themes/base.css" />
    <link rel="stylesheet" href="../../themes/components.css" />
  </head>
  <body data-pde-theme="light-neutral">
    <h1>Static HTML (SSR)</h1>
    <div class="pde-editor" set:html={staticHtml}></div>
    <h1>Editor island (client:load)</h1>
    <PortableEditor document={document} theme="light-neutral" />
  </body>
</html>

Zero runtime dependencies — TypeScript strict — MIT