@meetreeve/editor
v0.4.4
Published
Reeve editor kit: tiptap schema factory, framework-agnostic autosave state machine, single-source word/character counts, and broadcast-only presence (avatars + live carets)
Keywords
Readme
@meetreeve/editor
Reeve editor kit: a tiptap-based editor core extracted from Freya's SceneEditor. Ships a schema factory, a framework-agnostic autosave state machine, and a single source of truth for word/character counts.
Subpath entries:
| Entry | Contents |
| --- | --- |
| @meetreeve/editor / ./core | Schema factory, autosave machine, counts (DEV-7584); presence client (createPresenceClient, DEV-7928) |
| ./ext | The 6 extensions lifted off Freya's book model (DEV-7585); BlockId, RemoteCarets (DEV-7928) |
| ./ui | Chrome components: PresenceAvatars, usePresence (DEV-7928); toolbar to come (DEV-7586) |
| ./ai | AI prose surface: transport seam, preview state machine, accept splice (DEV-7587) |
| ./styles/tokens.css | --redit-* CSS tokens |
React, all @tiptap/* packages, yjs, and y-prosemirror are peer
dependencies (yjs/y-prosemirror are optional; only needed when you plug
in collaboration).
Mounting the core with a custom onPersist
import { useEditor, EditorContent } from '@tiptap/react';
import {
createEditorExtensions,
useAutosave,
getCounts,
} from '@meetreeve/editor';
import '@meetreeve/editor/styles/tokens.css';
function MyEditor({ save }: { save: (html: string) => Promise<void> }) {
const { status, noteChange, flush } = useAutosave<string>({
onPersist: async (html) => {
await save(html); // reject to surface a save error
},
debounceMs: 2000, // default
});
const editor = useEditor({
extensions: createEditorExtensions({
placeholder: 'Start writing your scene...',
}),
onUpdate: ({ editor }) => noteChange(editor.getHTML()),
});
return (
<div>
<span>{status}</span> {/* idle | dirty | saving | saved | error */}
<EditorContent editor={editor} />
<button onClick={() => void flush()}>Save now</button>
</div>
);
}Outside React, use createAutosave directly; it is pure TypeScript:
import { createAutosave } from '@meetreeve/editor';
const autosave = createAutosave<string>({
onPersist: (doc) => api.saveScene(doc),
});
const unsubscribe = autosave.onStatusChange((s) => render(s));
autosave.noteChange(editor.getHTML()); // on every edit
await autosave.flush(); // e.g. before navigation
autosave.dispose(); // on teardown
unsubscribe();Status semantics follow Freya's deriveSaveStatus (DEV-2295): saving wins
over everything, then error, then dirty, then saved. A rejected
onPersist can never surface as saved; a new edit after a failure moves
the status from error to dirty and schedules a retry.
Counts: one source of truth
getCounts(editor) and subscribeToCounts(editor, listener) read the live
CharacterCount storage on the editor instance. That is the ONLY count
source consumers may display.
Do NOT mix imported or backend-computed word/character counts with these
live counts (for example showing a backend count until the first keystroke
and a live count afterwards). That split is exactly what caused Freya bug
DEV-2193: two counting rules disagree on whitespace and markup and the
number visibly jumps. If a backend needs counts, send it the values from
getCounts at save time.
import { getCounts, subscribeToCounts } from '@meetreeve/editor';
const { words, characters } = getCounts(editor);
const stop = subscribeToCounts(editor, ({ words }) => setWordCount(words));Collab (Yjs) seam
The core does not ship a collaboration transport (the WebSocket server is
DEV-3392, parked). What it ships is the extension slot: the
extraExtensions option of createEditorExtensions is the seam where a
consumer plugs @tiptap/extension-collaboration bound to its own Y.Doc
and provider. Pass history: false in the same call; Yjs owns undo/redo
and a second history extension would create a duplicate undo stack.
import Collaboration from '@tiptap/extension-collaboration';
import * as Y from 'yjs';
import { createEditorExtensions } from '@meetreeve/editor';
const ydoc = new Y.Doc();
// Bind ydoc to your provider of choice (Hocuspocus, y-websocket, ...).
const extensions = createEditorExtensions({
history: false, // Yjs handles history
extraExtensions: [Collaboration.configure({ document: ydoc })],
});yjs and y-prosemirror are optional peer dependencies: install them only
when you use this seam.
Presence (avatars + live carets)
Presence is the easy half of multiplayer, and it is NOT collaborative
editing: broadcast-only who's-here, names, colours, and caret/selection
positions (DEV-7928, ported from cloudflare/cloudflare-os @ aedcda8; see
ATTRIBUTIONS.md). There is no CRDT and no convergence; simultaneous edits
to the same block are last-write-wins, and PresenceAvatars says so in the
UI. Convergent editing stays parked under DEV-3392.
The transport is the reeve-services presence lane
(wss://api.meetreeve.com/api/presence/ws/{orgId}/{docId}?token=...).
Presence never dials when the URL is unset (solo editing by design; no
dead-host dials on mount, the DEV-3467 regression class).
import { useEditor, EditorContent } from '@tiptap/react';
import { createEditorExtensions } from '@meetreeve/editor';
import { BlockId, RemoteCarets } from '@meetreeve/editor/ext';
import { PresenceAvatars, usePresence } from '@meetreeve/editor/ui';
// clientId: stable per tab. Initialize once in tab-scoped storage so
// remounts (navigation, StrictMode) reuse the same identity instead of
// showing up as a new collaborator.
function tabClientId(): string {
const existing = sessionStorage.getItem('redit-presence-client-id');
if (existing) return existing;
const id = crypto.randomUUID();
sessionStorage.setItem('redit-presence-client-id', id);
return id;
}
function DocEditor({
presenceUrl,
user,
}: {
presenceUrl: string | null; // null → presence disabled, nothing is dialled
user: { name: string };
}) {
const editor = useEditor({
extensions: createEditorExtensions({
extraExtensions: [BlockId, RemoteCarets],
}),
});
const { collaborators } = usePresence({
editor,
url: presenceUrl,
clientId: tabClientId(),
name: user.name,
});
return (
<>
<header>
<PresenceAvatars collaborators={collaborators} />
</header>
<EditorContent editor={editor} />
</>
);
}BlockId stamps every textblock with a stable data-block-id (the
coordinate space carets are addressed in); RemoteCarets renders the other
collaborators' carets and selection highlights as ProseMirror decorations;
usePresence wires the WebSocket client (4s heartbeats, 12s stale expiry,
best-effort pagehide leave) to the editor. Everything is also available
framework-agnostic from @meetreeve/editor/core (createPresenceClient,
getSelectionPresence, subscribeToSelection).
AI prose surface (./ai)
The generalized version of Freya's prose-generation surface (DEV-7587,
extracted from DEV-1937/1939/2293). Preview-first by construction: a
generation result lives only in the preview state machine until Accept, the
transport always requests with save_to_db: false, and Reject/dismiss never
touches the document.
import {
AIProseSurface,
createHttpProseTransport,
} from '@meetreeve/editor/ai';
const transport = createHttpProseTransport({
baseUrl: appConfig.llmApiBaseUrl, // supplied by the HOST app
headers: () => ({ Authorization: `Bearer ${getToken()}` }),
// paths: { generate: '/prose/generate', revise: '/prose/revise' } (defaults)
});
<AIProseSurface
editor={editor}
transport={transport}
context={{ document_id: docId, project_id: projectId }}
onApplied={() => markNextSaveAsAiGenerated()}
onError={(message) => toast.error(message)}
renderPreview={(slot) => <MyPreviewChrome {...slot} />} // optional; T3 chrome
/>;ProseTransport is the consumer-supplied seam: implement
generate(request) (Insert at cursor) and revisePassage(request)
(Enhance a selection) against any backend. createHttpProseTransport is
the generic default HTTP implementation. This package ships NO default
endpoint: wiring the Reeve.LLM base URL (and auth) lives with the
consumer/host app, exactly like the autosave onPersist seam.
Accept semantics, carried over verbatim from Freya:
- Passage replace runs an exact-match staleness check first
(
textBetween(from, to)must equal the captured selection text). If the document changed under the open preview, accept returns astale-selectionerror and does NOT splice; the preview stays open so the user can copy and apply manually. There is deliberately no fuzzy or whitespace-insensitive fallback. - The splice itself is
deleteRange+insertContentAtin ONE chained command, so a single undo restores the original text. - Model markdown scaffolding ("Improved Version" labels, rationale
sections, fences, bold/hr/list markers) is stripped before any content
reaches the document (
extractRevisedProse+sanitizeProse, DEV-2293). - A plain-text response (no HTML tags) has its blank-line-separated
paragraphs wrapped in
<p>before insertion, so tiptap's HTML parser keeps them as separate paragraphs instead of collapsing them into one (toParagraphHtml, DEV-9794). - Full-document replacement (
replaceDocument) usessetContent(content, true): theemitUpdateflag keepsonUpdatefiring so counts and autosave stay fresh (DEV-2301). All counts come fromgetCountsin./core, the single count source.
AIProseSurface's default chrome is styled by
@meetreeve/editor/styles/tokens.css — importing it is enough, no consumer
CSS required (DEV-10206; before that, .redit-ai-* was the one surface the
stylesheet had no rules for, so the Insert/Enhance row and the accept/reject
preview rendered as unstyled inline text). The rules are token-only, so
[data-redit-theme='dark'] and a tenant --redit-accent override retheme
them like every other surface. Restyle by class —
.redit-ai-surface, .redit-ai-toolbar, .redit-ai-action
(.redit-ai-action-insert / .redit-ai-action-enhance),
.redit-ai-panel, .redit-ai-panel-label, .redit-ai-instruction,
.redit-ai-panel-actions, .redit-ai-cancel, .redit-ai-submit,
.redit-ai-busy, .redit-ai-error, .redit-ai-preview,
.redit-ai-preview-note, .redit-ai-preview-content,
.redit-ai-preview-actions, .redit-ai-reject, .redit-ai-accept — or
replace the preview wholesale with renderPreview.
Below React, the pieces compose individually:
createProseGenerationController (framework-agnostic orchestration),
createPreviewMachine (the idle -> requesting -> previewing machine with
stale-generation fencing), captureSnappedSelection, and the splice
helpers. useProseGeneration is the React hook underneath
AIProseSurface.
Development
pnpm -F @meetreeve/editor build # vite lib build (mjs + cjs + d.ts)
pnpm -F @meetreeve/editor test # vitest
pnpm -F @meetreeve/editor typecheck # tsc --noEmit