@clevertask/scribe
v0.1.25
Published
A Radix-based Tiptap rich text editor with a Notion-style block interface for viewing and creating content. Perfect for diverse needs like notes, documents, AI chat, Markdown parsing, and comments.
Maintainers
Readme
@clevertask/scribe
A versatile, block-based rich text editor for diverse applications, built with Tiptap and inspired by Notion's intuitive interface. @clevertask/scribe allows you to seamlessly view, create, and edit rich text content, with added support for Markdown parsing.
[!WARNING] Scribe is experimental and has not reached version 1.0. Its APIs, default extensions, editor behavior, and built-in UI can change between releases, including minor releases. Pin an exact package version and test each upgrade before deployment. Forks and contributions are welcome.
Features
- Block-based Editing: Enjoy a familiar and intuitive Notion-style editing experience.
- Markdown Support: Parse and render Markdown content effortlessly.
- Markdown Paste: Paste plain-text markdown into the editor and have it converted into rich content automatically.
- Table Authoring: Insert, resize, and edit tables with controls that stay next to the active table.
- Experimental External Link Previews: Opt into Compact and Preview card presentations while keeping ordinary links available.
- Versatile Integration: Easily integrate
@clevertask/scribeinto any project requiring rich text editing. - View and Edit: Seamlessly switch between viewing and editing modes.
- Experimental Table of Contents: Subscribe to heading changes and render an app-owned table of contents outside the editor.
Table of Contents
Installation
npm install --save-exact @clevertask/scribe @tiptap/[email protected]Scribe shares this exact ProseMirror runtime with consumer extensions. Keeping one installed version prevents identity conflicts between built-in and custom plugins.
Headless schema
Use the schema subpath when a server or migration tool must parse Scribe content without loading the React editor or Scribe's stylesheet:
npm install --save-exact @clevertask/scribe @tiptap/[email protected] @tiptap/[email protected] @tiptap/[email protected]import { createScribeSchemaExtensions } from "@clevertask/scribe/schema";
import { generateJSON } from "@tiptap/html";
const extensions = createScribeSchemaExtensions({ enableUndoRedo: false });
const document = generateJSON(storedHtml, extensions);The returned extensions define Scribe's persistent nodes and marks without loading Scribe's React editor UI. Use them with Tiptap's server-side HTML utilities; this is not a headless interactive editor. Scribe's menus, placeholders, suggestions, and table-of-contents behavior are not part of this entry point. Consumer-owned nodes must be appended to the list. For example, an application that stores its own resource-reference node must provide that extension before parsing or rendering content containing those references.
Pin Scribe and Tiptap to the exact versions used by the writing clients. This entry point makes the schema reusable; it does not make different schema versions interchangeable.
Scribe preserves inline code together with other text marks, such as bold and links. This applies only to inline code; code-block text remains unmarked.
Servers can also inspect Scribe's document-node capabilities through the same headless entry:
import {
createScribeDocumentNodeCapabilityManifest,
createScribeSchemaExtensions,
} from "@clevertask/scribe/schema";
import { getSchema } from "@tiptap/core";
const extensions = createScribeSchemaExtensions({ enableUndoRedo: false });
const capabilities = createScribeDocumentNodeCapabilityManifest(getSchema(extensions));
capabilities.paragraph.potentialOperations; // [{ type: "replace_content" }]
capabilities.callout.potentialOperations; // [{ type: "set_attributes", attributes: ["variant"] }]
capabilities.taskItem.potentialOperations; // [{ type: "set_attributes", attributes: ["checked"] }]
capabilities.tableCell.potentialOperations; // []The manifest intentionally keeps structural list changes application-owned. A consumer can use the task-item attribute capability while defining its own bounded policy for adding or moving task-list items.
These are structural possibilities, not application permissions. An application must still check authorization, the current node and its ancestors, protected descendants, revision visibility, parser support, and size limits before it offers or performs a write.
If an application appends a consumer-owned node to the Scribe schema, it must also pass an exact
capability declaration to createScribeDocumentNodeCapabilityManifest. Use
defineScribeDocumentNodeCapability to type that declaration. Manifest creation fails when a node
is missing, duplicated, or no longer matches the schema. A custom node can declare no potential
operations and remain explicitly read-only.
Headless table transforms
The same headless entry can insert or delete an exact logical table row or column and can merge or split cells without mounting an editor or accessing the DOM:
import { applyScribeTableTransform, createScribeSchemaExtensions } from "@clevertask/scribe/schema";
import { getSchema } from "@tiptap/core";
const schema = getSchema(createScribeSchemaExtensions({ enableUndoRedo: false }));
const document = schema.nodeFromJSON(storedDocument);
const tablePosition = 42; // ProseMirror position immediately before the table node.
const result = applyScribeTableTransform(document, tablePosition, {
type: "insert_row",
index: 1,
});
persist(result.document);Rows and columns use zero-based logical coordinates from ProseMirror's table grid, not physical
child indexes. An insertion index may equal the current row or column count; deletion indexes must
name an existing logical row or column. Merge rectangles use half-open bounds: topRow and
leftColumn are included, while bottomRowExclusive and rightColumnExclusive are excluded. A
split's row and column must identify the merged cell's top-left logical coordinate. Existing
rowspans and colspans can therefore affect more than one physical cell even though the requested
logical boundary remains exact. The returned before and after geometry reports logical rows,
logical columns, and physical cell count.
applyScribeTableTransform is an immutable, exact-snapshot operation: it returns a new ProseMirror
document and leaves its input unchanged. It does not rebase coordinates or detect that another
writer changed the stored document after the input snapshot was read. On a concurrent change,
discard the candidate, read the latest document, resolve the intended table and coordinates again,
and rerun the transform. Invalid coordinates, malformed or nested tables, and operations that do
not apply throw ScribeTableTransformError.
This API owns only schema-valid table geometry. The host application owns authorization, protected node rules, revision and conflict checks, destructive previews, payload limits, Yjs conversion and persistence, and durable readback. In CleverTask, those checks belong to the collaboration and API layers rather than Scribe.
Usage
Basic usage
import "@radix-ui/themes/styles.css";
import "@clevertask/scribe/styles.css";
import { Theme } from "@radix-ui/themes";
import { Scribe, ScribeRef } from "@clevertask/scribe";
function App() {
const onContentChange = useCallback(
({ markdownContent, htmlContent, jsonContent }: ScribeOnChangeContents) => {
console.log(markdownContent, htmlContent, jsonContent);
},
[],
);
return (
<Theme>
<Scribe onContentChange={onContentChange} />
</Theme>
);
}With ref
import "@radix-ui/themes/styles.css";
import "@clevertask/scribe/styles.css";
import { Theme } from "@radix-ui/themes";
import { Scribe, ScribeOnChangeContents } from "@clevertask/scribe";
function App() {
const editor = useRef<ScribeRef>(null);
const resetContent = useCallback(() => {
editor.current.resetContent();
}, []);
return (
<>
<Theme>
<Scribe ref={editor} />
</Theme>
<button onClick={resetContent}>Reset content</button>
</>
);
}Using Your App Theme
import "@radix-ui/themes/styles.css";
import "@clevertask/scribe/styles.css";
import { Theme } from "@radix-ui/themes";
import { Scribe } from "@clevertask/scribe";
function App() {
return (
<Theme appearance="dark">
<Scribe />
</Theme>
);
}Table Authoring
Type /table to insert a 3 × 3 table with a header row. Selecting a table cell opens nearby controls for adding or deleting rows and columns, toggling the header row, and deleting the table. Drag a column boundary to resize it.
Keyboard users can press Alt + F10 while editing a table to focus its controls, use the arrow, Home, and End keys to move between actions, and press Escape to return to the active cell.
Simple headed tables serialize as GFM Markdown. Tables with merged cells, multiple blocks in a cell, resized columns, or other structures that GFM cannot represent are kept as sanitized raw HTML inside the Markdown output so their structure is not silently lost.
Scribe's insertTable command and /table action do not create a table while the selection is
already inside another table. Existing documents containing nested tables remain loadable so old
content is not destroyed. This guard covers Scribe-owned authoring paths only: arbitrary consumer
calls to insertContent, Markdown table paste, and rich-HTML table paste remain trusted integration
points. Applications that require a hard no-nested-table guarantee must validate or filter those
paths too.
External Link Previews
[!NOTE] External Link Previews are experimental. Their public API, built-in card presentation, and link-options UI may change as we test them in real document workflows.
Compact links work locally without a metadata provider. Scribe builds their visible label from authored link text or, for a raw URL, from its hostname and path. The exact destination—including its query and fragment—remains unchanged. Preview cards are opt-in: Scribe owns their editor behavior and presentation, while your app owns metadata fetching. Pass a resolver that calls an authenticated app endpoint; Scribe never requests the destination website directly.
That endpoint should validate the destination, block private or reserved network addresses, re-check redirects, and enforce response-size and timeout limits before returning sanitized metadata.
import { Scribe, type ExternalLinkPreviewResolver } from "@clevertask/scribe";
import { useCallback } from "react";
function DocumentEditor() {
const resolveLinkPreview = useCallback<ExternalLinkPreviewResolver>(async (href, { signal }) => {
const response = await fetch(`/api/link-previews?url=${encodeURIComponent(href)}`, {
credentials: "include",
signal,
});
if (!response.ok) {
return null;
}
return response.json();
}, []);
const shouldPreview = useCallback((href: string) => {
return new URL(href).origin !== window.location.origin;
}, []);
return (
<Scribe
externalLinkPreview={{
resolve: resolveLinkPreview,
shouldPreview,
// Defaults to false. When enabled, standalone pastes become local Compact links.
autoPreviewOnPaste: false,
}}
/>
);
}By default, pasting a standalone external URL creates an ordinary link. Select it and choose Compact to shorten only its presentation, with no metadata request. A meaningful authored label is kept; a raw URL is shown as hostname/path without its query or fragment. Switching back to Plain restores the original label and exact destination. Set autoPreviewOnPaste: true only if standalone URL pastes should become local Compact links automatically. Pasting a URL over selected text always keeps an ordinary labeled link.
While editing, select a Plain, Compact, or Card link to open its contextual menu. From there you can edit or open the destination, or switch presentation. A Preview card is available only when the link has its own line and metadata is already stored or a resolver is configured. Refresh is available only for Preview cards with a resolver. Keyboard users can press Alt + F10 from a selected link to open the same menu and press Escape to return to the document.
The resolver runs only when a Preview card needs metadata: after explicit Card conversion, programmatic Card insertion, a Card destination edit, or Card refresh. Compact creation, editing, automatic paste, and document reopen make no metadata request. The resolver receives an AbortSignal, and can return pageTitle, description, siteName, faviconUrl, imageUrl, and fetchedAt. Use shouldPreview to keep app-owned or otherwise unsupported destinations on the ordinary-link path.
Preview nodes use sanitized raw HTML when content is serialized as Markdown, so their metadata and presentation survive Scribe's current Markdown round trip. A caller-owned externalEditor must register ExternalLinkPreview itself.
Experimental Table of Contents
Scribe can expose table-of-contents data without rendering a table-of-contents block inside the editable document. Enable the experimental API with enableTableOfContents, keep the latest items in your app state, and call scrollToTableOfContentsItem when a user selects an entry.
import { Scribe, ScribeRef, ScribeTableOfContentsItem } from "@clevertask/scribe";
import { useRef, useState } from "react";
function DocumentEditor() {
const scribe = useRef<ScribeRef>(null);
const [tableOfContentsItems, setTableOfContentsItems] = useState<ScribeTableOfContentsItem[]>([]);
return (
<>
<Scribe
ref={scribe}
enableTableOfContents
onTableOfContentsChange={setTableOfContentsItems}
/>
{tableOfContentsItems.length > 0 ? (
<nav aria-label="Table of contents">
{tableOfContentsItems.map((item) => (
<button
key={item.id}
type="button"
onClick={() => scribe.current?.scrollToTableOfContentsItem(item)}
>
{item.textContent}
</button>
))}
</nav>
) : null}
</>
);
}The table of contents currently tracks default TipTap heading nodes only. Each item includes the heading text, depth, document position, DOM node, and active/scrolled state. This API is marked experimental while we validate the contract in real document surfaces.
Math Expressions
Scribe's default UI is styled with Radix Themes components. Load @radix-ui/themes/styles.css once in your app alongside @clevertask/scribe/styles.css, and render Scribe somewhere inside a Radix <Theme>.
Scribe ships with @tiptap/extension-mathematics. The extension renders math when it receives math nodes in the HTML:
<span data-type="inline-math" data-latex="\alpha"></span>
<div data-type="block-math" data-latex="\sum_{i=1}^{n} x_i"></div>Typing Delimiters (Input Rules)
When typing directly in the editor, the built-in input rules use:
Inline: $$\alpha$$
Block: $$$\sum_{i=1}^{n} x_i$$$Markdown Delimiters
If you are parsing markdown with the Tiptap Markdown extension (not md2html), the tokenizer expects:
Inline: $\alpha$
Block: $$\sum_{i=1}^{n} x_i$$If your content arrives as HTML (for example from a server), use the helper below to convert legacy delimiters into the HTML nodes that the math extension understands.
External Undo and Redo Ownership
Scribe enables its built-in undo and redo history by default. Set enableUndoRedo={false} when a different extension owns history, such as Tiptap Collaboration:
<Scribe enableUndoRedo={false} extensions={[Collaboration.configure({ document: ydoc })]} />Scribe reads this option when it creates the editor. Remount Scribe to change it. If you pass an externalEditor, configure history on that editor instead. Do not add another ordinary history extension through extensions when collaboration owns undo and redo.
The createScribeEditor helper accepts the same enableUndoRedo option.
Props
| Prop | Type | Default | Description |
| ------------------------- | ------------------------------------------------------------------ | -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| content | string | undefined | The initial content of the editor. Controlled updates through this prop are deprecated; prefer ScribeRef.setContent for programmatic updates after mount. |
| ariaLabel | string | Rich text editor | Sets the accessible name for the rich-text editing surface. Use a unique label when a page contains more than one editor. |
| onContentChange | (content: ScribeOnChangeContents) => void; | undefined | A callback function triggered whenever the editor's content changes. It receives an object containing the current content in various formats (jsonContent, htmlContent, markdownContent). Internal table-of-contents metadata updates are ignored. |
| editable | boolean | true | Controls whether the editor is editable. |
| autoFocus | boolean | false | Controls whether the editor should automatically focus when mounted. |
| extensions | Extension[] | undefined | You can set your own extensions for the text editor. For more information, check the tip tap extensions docs |
| externalEditor | Editor | undefined | Uses a caller-owned Tiptap editor. The caller remains responsible for its extension and plugin lifecycle, including table resizing, and for destroying it. |
| enableUndoRedo | boolean | true | Enables Scribe's built-in undo and redo history. Set it to false when another extension owns history. This creation-time option does not configure a caller-owned externalEditor. |
| externalLinkPreview | Partial<ExternalLinkPreviewOptions> | undefined | Experimental. Opts into external-link metadata resolution and enhanced Compact/Card presentation. The consumer owns fetching and destination policy; automatic previews on paste default to disabled. |
| editorProps | EditorProps | undefined | A tiptap-based prop to handle advanced use cases, you can read about it on their documentation |
| showBarMenu | boolean | true | Determines whether to show the text editor top menu bar or not. This menu bar shows options to format the text |
| placeholderText | string | Type "/" for commands... | Change the initial placeholder for your text editor |
| editorContentStyle | React.CSSProperties | undefined | You can send a CSS object to add styles to the editor content container. Useful if you want to limit the editor's height. |
| editorContentClassName | string | undefined | The same idea of editorContentStyle but with classes. |
| mainContainerStyle | React.CSSProperties | undefined | You can send a CSS object to style the main editor container |
| mainContainerClassName | string | undefined | The same idea of mainContainerStyle but with classes. |
| onKeyDown | KeyboardEventHandler | undefined | A callback function that is triggered when a key is pressed within the editor. This allows you to handle custom keyboard shortcuts. For example, you can use this prop to implement a "send message" functionality when Ctrl + Enter is pressed. |
| enableTableOfContents | boolean | false | Experimental. Enables the app-owned table-of-contents API for heading nodes. |
| onTableOfContentsChange | (items: ScribeTableOfContentsItem[], isCreate?: boolean) => void | undefined | Experimental. Receives table-of-contents items whenever heading text, structure, or active/scrolled state changes. |
Helper Functions
md2html
export declare function md2html(md: string): string;Convert markdown to html. Useful if you're rendering an AI-based response, or if you were storing content on markdown in your database and want to show it on the text editor. This function sanitizes the content to prevent XSS attacks.
Editable Scribe instances also use this conversion internally when you paste plain-text markdown into the editor.
Usage Example:
import { md2html, Scribe } from "@clevertask/scribe";
import { Flex, Heading } from "@radix-ui/themes";
import { Message, useChat } from "@ai-sdk/react";
const ChatMessages = () => {
const { messages } = useChat({
/* For more info, see https://sdk.vercel.ai/docs/reference/ai-sdk-ui/use-chat */
});
return messages.map((message) => (
<Flex key={message.id} direction="column" mb="4">
<Heading size="4">{`${message.role}: `}</Heading>
<Scribe editable={false} showBarMenu={false} content={md2html(message.content)} />
</Flex>
));
};html2md
export declare function html2md(html: string): string;Convert html to markdown. Useful if you want to send a text to an AI model by keeping the text format with markdown. This function sanitizes the content to prevent XSS attacks.
Usage Example:
import { html2md, Scribe } from "@clevertask/scribe";
const md = html2md("<h1>Hello world</h1>"); // Output: # Hello worldNote: The Scribe component already exposes a property called
markdownContentwhen theonContentChangeis used. In fact, themarkdownContentis the output of the usage of thehtml2mdfunction.
convertLegacyMathDelimiters
export declare function convertLegacyMathDelimiters(input: string): string;Convert legacy math delimiters into the HTML nodes required by the mathematics extension. This is useful when you receive HTML from a server that contains legacy math like \(...\) or \[...\].
This helper is primarily for consumers upgrading from older Scribe versions who stored math expressions using the legacy formats. It aims to be accurate, but the previous format was ambiguous (no explicit $$ delimiters), so conversion is best-effort and not guaranteed in every case.
Supported legacy delimiters:
\(...\)for inline math\[...\]for block math(...)and[...]when the content looks like LaTeX (contains\,^, or_)
Usage Example:
import { convertLegacyMathDelimiters, md2html, Scribe } from "@clevertask/scribe";
const html = md2html(convertLegacyMathDelimiters(rawMarkdown));
// OR
const html = convertLegacyMathDelimiters(htmlContent);
<Scribe editable={false} showBarMenu={false} content={html} />;If you already receive HTML from the server, call convertLegacyMathDelimiters directly on that HTML before rendering.
Roadmap
We're constantly working to improve @clevertask/scribe. Here are some features we're planning to implement:
- New default blocks/extensions: Such as image, video, callout, and table blocks
- E2E tests: It will ensure this component's working as expected.
We're excited about these upcoming features and welcome any feedback or contributions from the community. If you have any suggestions or would like to contribute to any of these features, please open an issue or submit a pull request on our GitHub repository.
Release Process
Publishing is split into two explicit GitHub Actions after a change reaches main:
- Run Create Release Version from
mainand enter the version to release. - Wait for it to commit the version, create the tag, and create the GitHub release.
- Run Publish Package from the immutable
v<version>tag and choose thelatestornextnpm tag. - Confirm the new version is available in the npm registry before updating consumers.
License
MIT
Credits
This project is built on top of the excellent BlockEditor repository by Sachin Chaurasiya. We extend our sincere gratitude for their work. <3
