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):
type EditorHandle = {
getDocument(): PortableDocument;
setDocument(doc: PortableDocument): void;
setTheme(theme: ThemeName): void;
commands: Editor["commands"];
destroy(): void;
};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-wrapperCSS 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.
| Host | Adapter | App sample |
|---|---|---|
| Vanilla | examples/vanilla/index.html | same file |
| React | examples/react/PortableEditor.jsx | examples/react/App.jsx |
| Vue | examples/vue/PortableEditor.vue | examples/vue/App.vue |
| Svelte | examples/svelte/portableEditor.js | examples/svelte/App.svelte |
| Angular | examples/angular/portable-editor.ts | examples/angular/app.component.ts |
| Astro | examples/astro/PortableEditor.astro | examples/astro/index.astro |
Each sample: mount → insertVariable → exportDocument (pdf / odt / docx).
Vanilla
Official API. Own the host element; call destroy() on unmount.
<!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.
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):
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.
<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><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.
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();
},
};
}<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.
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();
}
}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 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>---
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>