Editor
Editor is a full rich text writing surface built on Tiptap. It emits and accepts Tiptap JSON for autosave and server prepopulation, enforces an H1 title, and exposes slash commands, a selection bubble menu, @ mentions, link editing, image blocks, notices, quotes, YouTube embeds, bookmark cards, and syntax-highlighted code. Reader renders the same JSON read-only.
App-specific behaviour is injected, never hardcoded — mention search, internal-link hrefs, image upload, and bookmark metadata loading are all props, so the component stays app-agnostic.
// apps/docs/src/components/examples/editor/default/react.tsx
import { Editor, Prose } from '@cloudvoyant/helical-react';
const seed = JSON.stringify({
type: 'doc',
content: [
{ type: 'heading', attrs: { level: 1 }, content: [{ type: 'text', text: 'Getting Started' }] },
{
type: 'paragraph',
content: [{ type: 'text', text: "Type '/' for commands, or select text for the bubble menu." }],
},
],
});
export default function ReactEditorDefault() {
return (
<Prose>
<Editor content={seed} />
</Prose>
);
}<!-- apps/docs/src/components/examples/editor/default/svelte.svelte -->
<script lang="ts">
import { Editor, Prose } from '@cloudvoyant/helical-svelte';
const seed = JSON.stringify({
type: 'doc',
content: [
{ type: 'heading', attrs: { level: 1 }, content: [{ type: 'text', text: 'Getting Started' }] },
{
type: 'paragraph',
content: [{ type: 'text', text: "Type '/' for commands, or select text for the bubble menu." }],
},
],
});
</script>
<Prose>
<Editor content={seed} />
</Prose>Examples
Bubble Menu
Heading Enforcement
// apps/docs/src/components/examples/editor/heading-enforcement/react.tsx
// The H1 title is enforced by the titleHeading ProseMirror plugin: it is auto-created when
// missing and restored when the first node is changed, so it cannot be deleted or demoted.
import { Editor, Prose } from '@cloudvoyant/helical-react';
const seed = JSON.stringify({
type: 'doc',
content: [
{
type: 'heading',
attrs: { level: 1 },
content: [{ type: 'text', text: 'Try to delete this title' }],
},
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Select all and delete — the H1 is re-created automatically and cannot be removed or demoted.',
},
],
},
],
});
export default function ReactEditorHeadingEnforcement() {
return (
<Prose>
<Editor content={seed} />
</Prose>
);
}<!-- apps/docs/src/components/examples/editor/heading-enforcement/svelte.svelte -->
<script lang="ts">
import { Editor, Prose } from '@cloudvoyant/helical-svelte';
const seed = JSON.stringify({
type: 'doc',
content: [
{
type: 'heading',
attrs: { level: 1 },
content: [{ type: 'text', text: 'Try to delete this title' }],
},
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Select all and delete — the H1 is re-created automatically and cannot be removed or demoted.',
},
],
},
],
});
</script>
<Prose>
<Editor content={seed} />
</Prose>Link Editing
// apps/docs/src/components/examples/editor/link-editing/react.tsx
// Chat-style input (no Prose) demonstrating paste-a-URL and inline link editing.
import { Editor } from '@cloudvoyant/helical-react';
const seed = JSON.stringify({
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Paste a URL (e.g. https://tiptap.dev) to create a normal link; select linked text to edit its URL and label.',
},
],
},
],
});
export default function ReactEditorLinkEditing() {
return (
<div className="rounded-lg border border-input p-3">
<Editor content={seed} enforceTitle={false} />
</div>
);
}<!-- apps/docs/src/components/examples/editor/link-editing/svelte.svelte -->
<!-- Chat-style input (no Prose) demonstrating paste-a-URL and inline link editing. -->
<script lang="ts">
import { Editor } from '@cloudvoyant/helical-svelte';
const seed = JSON.stringify({
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Paste a URL (e.g. https://tiptap.dev) to create a normal link; select linked text to edit its URL and label.',
},
],
},
],
});
const compactEditor = { enforceTitle: false };
</script>
<div class="rounded-lg border border-input p-3">
<Editor content={seed} {...compactEditor} />
</div>Mentions
// apps/docs/src/components/examples/editor/mentions/react.tsx
// Minimal, no Prose wrapper — a chat-style input with @user mentions. The mention source is a
// static in-memory list, demonstrating the injected `mentionSource` seam.
import { Editor } from '@cloudvoyant/helical-react';
import type { MentionItem } from '@cloudvoyant/helical-ui';
const PEOPLE: MentionItem[] = [
{ id: '1', label: 'Ada Lovelace', type: 'user' },
{ id: '2', label: 'Alan Turing', type: 'user' },
{ id: '3', label: 'Grace Hopper', type: 'user' },
];
async function mentionSource(query: string): Promise<MentionItem[]> {
const q = query.toLowerCase();
return PEOPLE.filter((person) => person.label.toLowerCase().includes(q));
}
export default function ReactEditorMentions() {
return (
<div className="rounded-lg border border-input p-3">
<Editor content="" enforceTitle={false} mentionSource={mentionSource} />
</div>
);
}<!-- apps/docs/src/components/examples/editor/mentions/svelte.svelte -->
<!-- Minimal, no Prose wrapper — a chat-style input with @user mentions, backed by the -->
<!-- injected mentionSource seam (a static in-memory list here). -->
<script lang="ts">
import { Editor } from '@cloudvoyant/helical-svelte';
import type { MentionItem } from '@cloudvoyant/helical-ui';
const PEOPLE: MentionItem[] = [
{ id: '1', label: 'Ada Lovelace', type: 'user' },
{ id: '2', label: 'Alan Turing', type: 'user' },
{ id: '3', label: 'Grace Hopper', type: 'user' },
];
const compactEditor = { enforceTitle: false };
async function mentionSource(query: string): Promise<MentionItem[]> {
const q = query.toLowerCase();
return PEOPLE.filter((person) => person.label.toLowerCase().includes(q));
}
</script>
<div class="rounded-lg border border-input p-3">
<Editor content="" {...compactEditor} {mentionSource} />
</div>Slash Menu
Guide
Heading levels
When enforceTitle is enabled, the first editor block is the document H1. Body headings start at H2. Typing # or ## therefore creates an H2; deeper Markdown markers keep their normal HTML level. Slash-menu Heading 1 through Heading 4 create H2 through H5. When enforceTitle is disabled, render the page H1 outside the editor.
Pasting links
Pasting a bare HTTP or HTTPS URL inserts a normal text link immediately. Use the Bookmark slash command when you want a bookmark card; pasting does not open a representation-choice menu.
Bookmark metadata
Pass fetchLinkPreview to load Open Graph or search metadata for bookmark cards. The function can call your server or a CORS-enabled metadata service and must return the bookmark title, description, image, favicon, and provider. Without it, the editor inserts a bookmark that uses the URL as its title.
Browsers cannot usually read metadata directly from arbitrary sites because cross-origin rules block the request. Use a server endpoint for general URLs. A browser-only function is suitable only when the target site permits cross-origin requests.
Client-side persistence
Use onChange or onchange to save each serialized Tiptap JSON update. This example writes to a versioned localStorage key and restores the saved draft after the page reloads:
Loading saved draft…
Loading saved draft…
// Persist serialized Tiptap JSON in browser storage and restore it after a reload.
import { useEffect, useState } from 'react';
import { Editor, Prose } from '@cloudvoyant/helical-react';
const STORAGE_KEY = 'vortex-editor-client-persistence-v1';
const seed = JSON.stringify({
type: 'doc',
content: [
{ type: 'heading', attrs: { level: 1 }, content: [{ type: 'text', text: 'Persistent draft' }] },
{ type: 'paragraph', content: [{ type: 'text', text: 'Edit this text, then reload the page.' }] },
],
});
function loadDraft() {
const stored = localStorage.getItem(STORAGE_KEY);
if (!stored) return seed;
try {
return JSON.parse(stored)?.type === 'doc' ? stored : seed;
} catch {
localStorage.removeItem(STORAGE_KEY);
return seed;
}
}
export default function ReactEditorClientPersistence() {
const [content, setContent] = useState(seed);
const [ready, setReady] = useState(false);
useEffect(() => {
setContent(loadDraft());
setReady(true);
}, []);
if (!ready) return <p className="text-sm text-muted-foreground">Loading saved draft…</p>;
return (
<Prose>
<Editor
content={content}
onChange={({ content: nextContent }) => localStorage.setItem(STORAGE_KEY, nextContent)}
/>
<p className="mt-2 text-xs text-muted-foreground" data-editor-persistence>
Changes are saved in this browser. Reload the page to restore them.
</p>
</Prose>
);
}<!-- Persist serialized Tiptap JSON in browser storage and restore it after a reload. -->
<script lang="ts">
import { onMount } from 'svelte';
import { Editor, Prose } from '@cloudvoyant/helical-svelte';
const STORAGE_KEY = 'vortex-editor-client-persistence-v1';
const seed = JSON.stringify({
type: 'doc',
content: [
{ type: 'heading', attrs: { level: 1 }, content: [{ type: 'text', text: 'Persistent draft' }] },
{ type: 'paragraph', content: [{ type: 'text', text: 'Edit this text, then reload the page.' }] },
],
});
let content = $state('');
let ready = $state(false);
onMount(() => {
const stored = localStorage.getItem(STORAGE_KEY);
content = seed;
if (stored) {
try {
if (JSON.parse(stored)?.type === 'doc') content = stored;
} catch {
localStorage.removeItem(STORAGE_KEY);
}
}
ready = true;
});
</script>
{#if ready}
<Prose>
<Editor
{content}
onchange={({ content: nextContent }) => localStorage.setItem(STORAGE_KEY, nextContent)}
/>
<p class="mt-2 text-xs text-muted-foreground" data-editor-persistence>
Changes are saved in this browser. Reload the page to restore them.
</p>
</Prose>
{:else}
<p class="text-sm text-muted-foreground">Loading saved draft…</p>
{/if}Browser storage is client-only. Use a cookie, database, or server-side store when multiple devices or server rendering must share the saved document.
Server-side pre-population
Fetch serialized Tiptap JSON on the server and pass the same content to both Reader and Editor. This example sends populated Reader HTML first, then replaces it with an interactive editor during hydration:
// Astro can fetch this serialized JSON on the server and pass it as the content prop.
import { useEffect, useState } from 'react';
import { Editor, Prose, Reader } from '@cloudvoyant/helical-react';
const serverContent = JSON.stringify({
type: 'doc',
content: [
{ type: 'heading', attrs: { level: 1 }, content: [{ type: 'text', text: 'Server-populated draft' }] },
{
type: 'paragraph',
content: [{ type: 'text', text: 'This content is present in the server HTML before the editor hydrates.' }],
},
],
});
export default function ReactEditorServerPrepopulation({ content = serverContent }: { content?: string }) {
const [hydrated, setHydrated] = useState(false);
useEffect(() => setHydrated(true), []);
return (
<Prose>
{hydrated ? (
<Editor content={content} />
) : (
<div data-editor-server-prepopulation>
<Reader content={content} />
</div>
)}
</Prose>
);
}<!-- Astro or SvelteKit can fetch this serialized JSON on the server and pass it as content. -->
<script lang="ts">
import { onMount } from 'svelte';
import { Editor, Prose, Reader } from '@cloudvoyant/helical-svelte';
const serverContent = JSON.stringify({
type: 'doc',
content: [
{ type: 'heading', attrs: { level: 1 }, content: [{ type: 'text', text: 'Server-populated draft' }] },
{
type: 'paragraph',
content: [{ type: 'text', text: 'This content is present in the server HTML before the editor hydrates.' }],
},
],
});
let { content = serverContent }: { content?: string } = $props();
let hydrated = $state(false);
onMount(() => {
hydrated = true;
});
</script>
<Prose>
{#if hydrated}
<Editor {content} />
{:else}
<div data-editor-server-prepopulation>
<Reader {content} />
</div>
{/if}
</Prose>Read-only server rendering
Reader converts saved Tiptap JSON to HTML without creating a browser editor instance. This demo is rendered by Astro on the server with no client directive:
// Server-rendered Reader example. Demo.astro loads this through SsrRouter without a client directive.
import { Reader } from '@cloudvoyant/helical-react';
const content = JSON.stringify({
type: 'doc',
content: [
{ type: 'heading', attrs: { level: 1 }, content: [{ type: 'text', text: 'Server-rendered article' }] },
{
type: 'paragraph',
content: [{ type: 'text', text: 'Reader turns saved Tiptap JSON into HTML during server rendering.' }],
},
{
type: 'blockquote',
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'No browser editor instance is created.' }] }],
},
],
});
export default function ReactEditorServerReader() {
return <Reader content={content} />;
}<!-- Server-rendered Reader example. Demo.astro loads this through SsrRouter without a client directive. -->
<script lang="ts">
import { Reader } from '@cloudvoyant/helical-svelte';
const content = JSON.stringify({
type: 'doc',
content: [
{ type: 'heading', attrs: { level: 1 }, content: [{ type: 'text', text: 'Server-rendered article' }] },
{
type: 'paragraph',
content: [{ type: 'text', text: 'Reader turns saved Tiptap JSON into HTML during server rendering.' }],
},
{
type: 'blockquote',
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'No browser editor instance is created.' }] }],
},
],
});
</script>
<Reader {content} />Astro or TanStack Start with React
In Astro, load the JSON in frontmatter and pass it to a hydrated React island:
---
import { Reader } from '@cloudvoyant/helical-react';
import EditorIsland from './EditorIsland.tsx';
const content = await loadSavedTiptapJson();
---
<Reader content={content} />
<EditorIsland content={content} client:load />
In TanStack Start, load the JSON through a server function and return it from the route loader:
import { createFileRoute } from '@tanstack/react-router';
import { createServerFn } from '@tanstack/react-start';
import { Editor } from '@cloudvoyant/helical-react';
const loadContent = createServerFn({ method: 'GET' })
.inputValidator((data: { documentId: string }) => data)
.handler(async ({ data }) => {
const { loadSavedTiptapJson } = await import('~/server/documents');
return loadSavedTiptapJson(data.documentId);
});
export const Route = createFileRoute('/edit/$documentId')({
loader: ({ params }) => loadContent({ data: { documentId: params.documentId } }),
component: EditDocument,
});
function EditDocument() {
const content = Route.useLoaderData();
return <Editor content={content} />;
}
Astro or SvelteKit with Svelte
<script lang="ts">
import { Editor, Reader } from '@cloudvoyant/helical-svelte';
let { data } = $props(); // `data.content` was loaded on the server.
</script>
<Reader content={data.content} />
<Editor content={data.content} />
In SvelteKit, put the editor in a client-only boundary when its parent is server-rendered. Do not construct a Tiptap Editor instance in a server load function or module scope.
API Reference
Editor — content (Tiptap JSON string), editable (default true), enforceTitle (default true; set false for compact inputs), onChange / onchange (({ content, title }) => void), and four optional seams: mentionSource ((query) => Promise<MentionItem[]>), hrefBuilder ((item) => string), onUpload ((file) => Promise<{ src; srcset? }>), and fetchLinkPreview ((url) => Promise<{ title; description; image; favicon; provider }>). Without onUpload, the image form inserts a local data URL for demos and offline editing. Without fetchLinkPreview, a bookmark uses its URL as the title.
Imperative: focus(), updateContent(json), getCounts(): EditorCounts — React via ref, Svelte via bound exports. EditorCounts reports wordCount, charCount, pageCount (300 words/page) and readDuration (250 words/minute).
Reader — content (Tiptap JSON string). Renders static HTML from the same schema, with anchor-linkable heading ids.