Skip to content

Repository files navigation

HYVOR Rich Text Editor

Used in Hyvor Blogs and Hyvor Post.

Usage

<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}

/>

Nodes

doc

  • The top-level node representing the entire document.

text

  • A text node containing plain text.
  • This is usually the only node that contains marks.
  • Group: inline

paragraph

  • A block-level node representing a paragraph of text.
  • Parsed from <p> HTML tag.
  • Group: block
  • Content: inline*

heading

  • 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*

blockquote

  • A block-level node representing a blockquote.
  • Parsed from <blockquote> HTML tag.
  • Group: block
  • Content: block+

callout

  • 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 #f1f1ef
    • fg: Foreground color of the callout. Default #000000
  • Parsed from <aside>

figure

  • 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.imageEnabled or config.embedEnabled must be true to enable this node.

figcaption

  • An element that represents a caption or legend for a figure.
  • Parsed from <figcaption> HTML tag.
  • Content: inline*
  • Same conditions as the figure node to enable.

image

  • 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 (null by default).
    • height: Custom height of the image in pixels (null by default).
  • Parsed from <img> HTML tag.
  • config.imageEnabled must be true to enable this node.

Note: config.imageUploader must be provided to upload images.

audio

  • A block-level node representing an audio file.
  • Attributes:
    • src: The source URL of the audio file.
  • Parsed from <audio> HTML tag.
  • config.audioEnabled must be true to enable this node.

Note: config.audioUploader must be provided to upload audio files.

embed

  • 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.embedEnabled must be true to enable this node.

bookmark

  • A block-level node representing a link bookmark preview.
  • Attributes:
    • url: The URL of the bookmark.
  • Parsed from <bookmark> HTML tag.
  • Group: block
  • config.bookmarkEnabled must be true to enable this node.

toc

  • 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.tocEnabled must be true to enable this node.

table

  • A block-level node representing a table.
  • Subnodes: table_row, table_cell, table_header
  • Parsed from <table> HTML tag.
  • Group: block
  • config.tableEnabled must be true to enable this node.

button

  • 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.buttonEnabled must be true to enable this node.

code_block

  • 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*

custom_html

  • A block-level node representing custom HTML content.
  • Attributes:
  • Content: text*

Marks

The following marks are supported:

  • code
  • link (attributes: href)
  • em
  • strong
  • strike
  • sup
  • sub
  • suggestion (attributes: type, id, add, remove) - see Suggestions & Comments below. Not meant to be toggled directly; use the suggestions plugin's commands instead.

Suggestions & Comments

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]} ... />

Attribution: author and resolveAuthor

  • author is the id of whoever is currently editing (`user:${string}` or "ai"), attached to every suggestion/comment this session creates.
  • resolveAuthor(author) turns an Author id into { name, picture? } for display in the floating review panel. May be async (e.g. a network/directory lookup).

source: where authorship and comment content actually live

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).

Commands (from @hyvor/richtext)

  • getSuggestions(state) - list all pending suggestions/comments ({id, type, author, from, to, comments, ...}); author/comments are null/[] until source.get resolves 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, only source.
  • 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 on source.get() - useful for suggestions your app generates itself (e.g. buildDiffDoc's output, see below) rather than ones the user typed.

Diff view

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.

Collaboration

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.

Cursors (other users' selections)

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.

About

HYVOR's Rich Text Editor, used in Hyvor Blogs and Hyvor Post

Resources

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages