Used in Hyvor Blogs and Hyvor Post.
<script lang="ts">
import { Editor, getSchema } from '@hyvor/richtext';
// get the schema, which you can share across multiple editors
const schema = getSchema({
codeBlock: true,
customHtml: true,
embed: true,
image: false,
audio: false,
bookmark: true,
toc: true,
table: true,
button: true,
});
</script>
<Editor
bind:editorView
content={content}
schema={schema}
onvaluechange={handleValueChange}
rtl={false}
/>- The top-level node representing the entire document.
- A text node containing plain text.
- This is usually the only node that contains marks.
- Group:
inline
- A block-level node representing a paragraph of text.
- Parsed from
<p>HTML tag. - Group:
block - Content:
inline*
- A block-level node representing a heading.
- Attributes:
level: The level of the heading (1-6).id: Optional ID for the heading, useful for anchors.
- Parsed from
<h1>,<h2>,<h3>, etc. HTML tags. - Group:
block - Content:
inline*
- A block-level node representing a blockquote.
- Parsed from
<blockquote>HTML tag. - Group:
block - Content:
block+
- A block-level node representing a callout box.
- Attributes:
emoji: An emoji to display in the callout. Default💡bg: Background color of the callout. Default#f1f1effg: Foreground color of the callout. Default#000000
- Parsed from
<aside>
- A block-level node representing a figure, containing an image or audio node along with an optional caption.
- Parsed from
<figure>HTML tag. - Group:
block - Content:
(image|audio) figcaption config.imageEnabledorconfig.embedEnabledmust betrueto enable this node.
- An element that represents a caption or legend for a figure.
- Parsed from
<figcaption>HTML tag. - Content:
inline* - Same conditions as the
figurenode to enable.
- A block-level node representing an image, living inside a figure.
- Attributes:
src: The source URL of the image.alt: Alternative text for the image.width: Custom width of the image in pixels (nullby default).height: Custom height of the image in pixels (nullby default).
- Parsed from
<img>HTML tag. config.imageEnabledmust betrueto enable this node.
Note: config.imageUploader must be provided to upload images.
- A block-level node representing an audio file.
- Attributes:
src: The source URL of the audio file.
- Parsed from
<audio>HTML tag. config.audioEnabledmust betrueto enable this node.
Note: config.audioUploader must be provided to upload audio files.
- A block-level node representing an embed, living inside a figure.
- Attributes:
url: The URL of the embedded content.
- Parsed from
<x-embed>HTML tag. - Group:
block config.embedEnabledmust betrueto enable this node.
- A block-level node representing a link bookmark preview.
- Attributes:
url: The URL of the bookmark.
- Parsed from
<bookmark>HTML tag. - Group:
block config.bookmarkEnabledmust betrueto enable this node.
- A block-level node representing a table of contents.
- Attributes:
levels: The heading levels to include in the TOC (e.g.,[1, 2, 3]).
- Group:
block config.tocEnabledmust betrueto enable this node.
- A block-level node representing a table.
- Subnodes:
table_row,table_cell,table_header - Parsed from
<table>HTML tag. - Group:
block config.tableEnabledmust betrueto enable this node.
- A block-level node representing a button.
- Attributes:
href: The URL the button links to.
- Parsed from
<div class="button-wrap">HTML tag. - Group:
block - Content:
inline* config.buttonEnabledmust betrueto enable this node.
- A block-level node representing a block of preformatted code.
- Attributes:
language: The programming language of the code block (optional).annotations: An array of annotations for the code block (optional).name: Filename associated with the code block (optional).
- Parsed from
<pre><code>HTML tags. - Group:
block - Content:
text*
- A block-level node representing custom HTML content.
- Attributes:
- Content:
text*
The following marks are supported:
code
link(attributes:href)emstrongstrikesupsubsuggestion(attributes:type,id,add,remove) - see Suggestions & Comments below. Not meant to be toggled directly; use the suggestions plugin's commands instead.
Track-changes (suggested insertions/deletions/formatting) and comment threads share one
mark (suggestion) and one node attribute (suggestions), so they're set up together via
a single plugin.
<script lang="ts">
import { Editor, getSchema, suggestionsPlugin, type Author, type AuthorInfo } from '@hyvor/richtext';
const schema = getSchema();
const currentAuthor: Author = 'user:42'; // or 'ai'
const plugin = suggestionsPlugin({
author: currentAuthor,
mode: 'suggesting', // 'editing' (default) | 'suggesting'
resolveAuthor,
source,
});
</script>
<Editor {schema} plugins={[plugin]} ... />authoris the id of whoever is currently editing (`user:${string}`or"ai"), attached to every suggestion/comment this session creates.resolveAuthor(author)turns anAuthorid into{ name, picture? }for display in the floating review panel. May be async (e.g. a network/directory lookup).
Author identity and comment/reply content are never stored in the document itself -
only a mark/node-attr id is. This is deliberate: the document is fully client-editable
content, so embedding author in it would let anyone claim any author by hand-editing the
saved JSON. Instead, source is a host-supplied backing store the plugin reads/writes by
id - the same "editor only holds a reference, host owns the real data" pattern as
editorConfig.fileUploader. Real authorship enforcement (e.g. stamping the authenticated
user server-side, rather than trusting whatever a client passes into create/reply) is
the host's responsibility, not the editor's.
import type { SuggestionSource } from '@hyvor/richtext';
const source: SuggestionSource = {
// Called in batches whenever the editor encounters ids it hasn't seen yet
// (e.g. right after loading a document). Return null for an id your
// backend has no record of - it will not be retried.
async get(ids) {
const rows = await api.getSuggestions(ids);
return Object.fromEntries(ids.map((id) => [id, rows[id] ?? null]));
},
// Fire-and-forget notifications - called once per event, after the
// causing change has already been applied locally.
create(id, type, author) {
api.createSuggestion({ id, type, author });
},
reply(id, reply) {
api.addReply(id, reply); // reply: { id, author, content, timestamp }
},
resolve(id, decision) {
// decision: "accept" | "reject" | "resolve"
api.resolveSuggestion(id, decision);
},
};get/create/reply are required; resolve is optional (skip it if you don't need to
clean up resolved/accepted/rejected records host-side).
getSuggestions(state)- list all pending suggestions/comments ({id, type, author, from, to, comments, ...});author/commentsarenull/[]untilsource.getresolves them.acceptSuggestion(view, id)/rejectSuggestion(view, id)- resolve a single insert/delete/format suggestion.acceptAllSuggestions(view)/rejectAllSuggestions(view)- bulk variants (skip comment-type entries).addComment(view, text)- attach a new comment thread to the current selection.replyToSuggestion(view, id, text)- reply to any thread (a comment, or a live suggestion) - this never touches the document, onlysource.resolveComment(view, id)- close a comment thread.setSuggestionMode(view, mode)/getSuggestionMode(state)- toggle'editing'vs'suggesting'; while suggesting, edits are recorded as suggestions instead of applied directly.setCurrentAuthor(view, author)/getCurrentAuthor(state).seedSuggestionSource(view, entries)- pre-populate the plugin's local cache with already-known{id, type, author}triples without waiting onsource.get()- useful for suggestions your app generates itself (e.g.buildDiffDoc's output, see below) rather than ones the user typed.
buildDiffDoc(diffs, schema) (from diffDoc's output) builds a suggestion-annotated
document from a two-document diff, for a track-changes-style merged view. It returns
{ doc, suggestions } - suggestions is the list of generated {id, type} pairs, which
have no author of their own (a diff has no per-change authorship) and must be attributed
by the caller, typically via seedSuggestionSource or by writing directly into your
source's backing store.
Real-time collaborative editing is built on prosemirror-collab.
The editor never talks to a server itself - the host provides a transport via
editorConfig.collab and feeds remote steps back in via editor.collab.receiveSteps().
Server-side implementation (assigning versions, rebroadcasting steps to other clients,
persisting the document) is entirely up to the host app.
<script lang="ts">
import { Editor, getSchema } from '@hyvor/richtext';
const schema = getSchema();
let editor: Editor;
</script>
<Editor
{schema}
editorConfig={{
collab: {
version: 0, // the version this initial doc corresponds to
clientID: currentUserId, // optional, random by default
onSendable(sendable) {
// sendable: { version, steps, clientID } - send it to your server
connection.send(sendable);
},
},
}}
bind:this={editor}
/>Whenever your transport receives a batch of steps from the server (including batches made up of this client's own steps being confirmed), pass them to:
editor.collab.receiveSteps(steps, clientIDs);editor.collab.getVersion() returns the editor's current collab version.
See DEV.md for a minimal local WebSocket server used to try this out during development.
Shows other users' cursors/selections as a colored caret + tinted selection, with a name
tooltip on hovering the caret. Same split as collaboration: the editor never talks to a
server - the host provides editorConfig.cursors and feeds remote cursors in via
editor.cursors.set().
<script lang="ts">
import { Editor, getSchema, type RemoteCursor } from '@hyvor/richtext';
const schema = getSchema();
let editor: Editor;
</script>
<Editor
{schema}
editorConfig={{
cursors: {
debounceMs: 250, // default
onLocalCursorChange(cursor) {
// cursor: { from, to } | null (null on blur) - send it to your server
connection.send(cursor);
},
},
}}
bind:this={editor}
/>Whenever your transport tells you about other users' cursors, pass the full current list to:
editor.cursors.set(cursors); // RemoteCursor[]: { clientId, from, to, user: { name, color, picture? } }clientId identifies which user a given entry belongs to across updates - reusing
editorConfig.collab's clientID is the natural choice if you're running both together.
user.color is any CSS color, used for the caret, tooltip, and (tinted) selection
highlight.
See DEV.md for how the local WebSocket server also relays cursor presence.