@blocknote/server-util
Version:
A "Notion-style" block-based extensible text editor built on top of Prosemirror and Tiptap.
339 lines (308 loc) • 10.9 kB
text/typescript
import {
Block,
BlockNoteEditor,
BlockNoteEditorOptions,
BlockSchema,
DefaultBlockSchema,
DefaultInlineContentSchema,
DefaultStyleSchema,
InlineContentSchema,
PartialBlock,
StyleSchema,
blockToNode,
blocksToMarkdown,
createExternalHTMLExporter,
createInternalHTMLSerializer,
nodeToBlock,
} from "@blocknote/core";
import { BlockNoteViewRaw } from "@blocknote/react";
import { Node } from "@tiptap/pm/model";
import * as jsdom from "jsdom";
import * as React from "react";
import { createElement } from "react";
import { flushSync } from "react-dom";
import { createRoot } from "react-dom/client";
import {
prosemirrorToYDoc,
prosemirrorToYXmlFragment,
yXmlFragmentToProseMirrorRootNode,
} from "y-prosemirror";
import type * as Y from "yjs";
/**
* Use the ServerBlockNoteEditor to interact with BlockNote documents in a server (nodejs) environment.
*/
export class ServerBlockNoteEditor<
BSchema extends BlockSchema = DefaultBlockSchema,
ISchema extends InlineContentSchema = DefaultInlineContentSchema,
SSchema extends StyleSchema = DefaultStyleSchema,
> {
/**
* Internal BlockNoteEditor (not recommended to use directly, use the methods of this class instead)
*/
public readonly editor: BlockNoteEditor<BSchema, ISchema, SSchema>;
/**
* We currently use a JSDOM instance to mock document and window methods
*
* A possible improvement could be to make this:
* a) pluggable so other shims can be used as well
* b) obsolete, but for this all blocks should be React based and we need to remove all references to document / window
* from the core / react package. (and even then, it's likely some custom blocks would still use document / window methods)
*/
private jsdom = new jsdom.JSDOM();
/**
* Calls a function with mocking window and document using JSDOM
*
* We could make this obsolete by passing in a document / window object to the render / serialize methods of Blocks
*/
public async _withJSDOM<T>(fn: () => Promise<T>) {
const prevWindow = globalThis.window;
const prevDocument = globalThis.document;
globalThis.document = this.jsdom.window.document;
(globalThis as any).window = this.jsdom.window;
(globalThis as any).window.__TEST_OPTIONS = (
prevWindow as any
)?.__TEST_OPTIONS;
try {
return await fn();
} finally {
globalThis.document = prevDocument;
globalThis.window = prevWindow;
}
}
public static create<
BSchema extends BlockSchema = DefaultBlockSchema,
ISchema extends InlineContentSchema = DefaultInlineContentSchema,
SSchema extends StyleSchema = DefaultStyleSchema,
>(options: Partial<BlockNoteEditorOptions<BSchema, ISchema, SSchema>> = {}) {
return new ServerBlockNoteEditor(options) as ServerBlockNoteEditor<
BSchema,
ISchema,
SSchema
>;
}
protected constructor(
options: Partial<BlockNoteEditorOptions<any, any, any>>,
) {
this.editor = BlockNoteEditor.create(options) as any;
}
/** PROSEMIRROR / BLOCKNOTE conversions */
/**
* Turn Prosemirror JSON to BlockNote style JSON
* @param json Prosemirror JSON
* @returns BlockNote style JSON
*/
public _prosemirrorNodeToBlocks(pmNode: Node) {
const blocks: Block<BSchema, InlineContentSchema, StyleSchema>[] = [];
// note, this code is similar to editor.document
pmNode.firstChild!.descendants((node) => {
blocks.push(nodeToBlock(node, this.editor.pmSchema));
return false;
});
return blocks;
}
/**
* Turn Prosemirror JSON to BlockNote style JSON
* @param json Prosemirror JSON
* @returns BlockNote style JSON
*/
public _prosemirrorJSONToBlocks(json: any) {
// note: theoretically this should also be possible without creating prosemirror nodes,
// but this is definitely the easiest way
const doc = this.editor.pmSchema.nodeFromJSON(json);
return this._prosemirrorNodeToBlocks(doc);
}
/**
* Turn BlockNote JSON to Prosemirror node / state
* @param blocks BlockNote blocks
* @returns Prosemirror root node
*/
public _blocksToProsemirrorNode(
blocks: PartialBlock<BSchema, ISchema, SSchema>[],
) {
const pmSchema = this.editor.pmSchema;
const pmNodes = blocks.map((b) => blockToNode(b, pmSchema));
const doc = pmSchema.topNodeType.create(
null,
pmSchema.nodes["blockGroup"].create(null, pmNodes),
);
return doc;
}
/** YJS / BLOCKNOTE conversions */
/**
* Turn a Y.XmlFragment collaborative doc into a BlockNote document (BlockNote style JSON of all blocks)
* @returns BlockNote document (BlockNote style JSON of all blocks)
*/
public yXmlFragmentToBlocks(xmlFragment: Y.XmlFragment) {
const pmNode = yXmlFragmentToProseMirrorRootNode(
xmlFragment,
this.editor.pmSchema,
);
return this._prosemirrorNodeToBlocks(pmNode);
}
/**
* Convert blocks to a Y.XmlFragment
*
* This can be used when importing existing content to Y.Doc for the first time,
* note that this should not be used to rehydrate a Y.Doc from a database once
* collaboration has begun as all history will be lost
*
* @param blocks the blocks to convert
* @returns Y.XmlFragment
*/
public blocksToYXmlFragment(
blocks: Block<BSchema, ISchema, SSchema>[],
xmlFragment?: Y.XmlFragment,
) {
return prosemirrorToYXmlFragment(
this._blocksToProsemirrorNode(blocks),
xmlFragment,
);
}
/**
* Turn a Y.Doc collaborative doc into a BlockNote document (BlockNote style JSON of all blocks)
* @returns BlockNote document (BlockNote style JSON of all blocks)
*/
public yDocToBlocks(ydoc: Y.Doc, xmlFragment = "prosemirror") {
return this.yXmlFragmentToBlocks(ydoc.getXmlFragment(xmlFragment));
}
/**
* This can be used when importing existing content to Y.Doc for the first time,
* note that this should not be used to rehydrate a Y.Doc from a database once
* collaboration has begun as all history will be lost
*
* @param blocks
*/
public blocksToYDoc(
blocks: PartialBlock<BSchema, ISchema, SSchema>[],
xmlFragment = "prosemirror",
) {
return prosemirrorToYDoc(
this._blocksToProsemirrorNode(blocks),
xmlFragment,
);
}
/** HTML / BLOCKNOTE conversions */
/**
* Exports blocks into a simplified HTML string. To better conform to HTML standards, children of blocks which aren't list
* items are un-nested in the output HTML.
*
* @param blocks An array of blocks that should be serialized into HTML.
* @returns The blocks, serialized as an HTML string.
*/
public async blocksToHTMLLossy(
blocks: PartialBlock<BSchema, ISchema, SSchema>[],
): Promise<string> {
return this._withJSDOM(async () => {
const exporter = createExternalHTMLExporter(
this.editor.pmSchema,
this.editor,
);
return exporter.exportBlocks(blocks, {
document: this.jsdom.window.document,
});
});
}
/**
* Serializes blocks into an HTML string in the format that would normally be rendered by the editor.
*
* Use this method if you want to server-side render HTML (for example, a blog post that has been edited in BlockNote)
* and serve it to users without loading the editor on the client (i.e.: displaying the blog post)
*
* @param blocks An array of blocks that should be serialized into HTML.
* @returns The blocks, serialized as an HTML string.
*/
public async blocksToFullHTML(
blocks: PartialBlock<BSchema, ISchema, SSchema>[],
): Promise<string> {
return this._withJSDOM(async () => {
const exporter = createInternalHTMLSerializer(
this.editor.pmSchema,
this.editor,
);
return exporter.serializeBlocks(blocks, {
document: this.jsdom.window.document,
});
});
}
/**
* Parses blocks from an HTML string. Tries to create `Block` objects out of any HTML block-level elements, and
* `InlineNode` objects from any HTML inline elements, though not all element types are recognized. If BlockNote
* doesn't recognize an HTML element's tag, it will parse it as a paragraph or plain text.
* @param html The HTML string to parse blocks from.
* @returns The blocks parsed from the HTML string.
*/
public async tryParseHTMLToBlocks(
html: string,
): Promise<Block<BSchema, ISchema, SSchema>[]> {
return this._withJSDOM(async () => {
return this.editor.tryParseHTMLToBlocks(html);
});
}
/** MARKDOWN / BLOCKNOTE conversions */
/**
* Serializes blocks into a Markdown string. The output is simplified as Markdown does not support all features of
* BlockNote - children of blocks which aren't list items are un-nested and certain styles are removed.
* @param blocks An array of blocks that should be serialized into Markdown.
* @returns The blocks, serialized as a Markdown string.
*/
public async blocksToMarkdownLossy(
blocks: PartialBlock<BSchema, ISchema, SSchema>[],
): Promise<string> {
return this._withJSDOM(async () => {
return blocksToMarkdown(blocks, this.editor.pmSchema, this.editor, {
document: this.jsdom.window.document,
});
});
}
/**
* Creates a list of blocks from a Markdown string. Tries to create `Block` and `InlineNode` objects based on
* Markdown syntax, though not all symbols are recognized. If BlockNote doesn't recognize a symbol, it will parse it
* as text.
* @param markdown The Markdown string to parse blocks from.
* @returns The blocks parsed from the Markdown string.
*/
public async tryParseMarkdownToBlocks(
markdown: string,
): Promise<Block<BSchema, ISchema, SSchema>[]> {
return this._withJSDOM(() => {
return this.editor.tryParseMarkdownToBlocks(markdown);
});
}
/**
* If you're using React Context in your blocks, you can use this method to wrap editor calls for importing / exporting / block manipulation
* with the React Context Provider.
*
* Example:
*
* ```tsx
const html = await editor.withReactContext(
({ children }) => (
<YourContext.Provider value={true}>{children}</YourContext.Provider>
),
async () => editor.blocksToFullHTML(blocks)
);
*/
public async withReactContext<T>(comp: React.FC<any>, fn: () => Promise<T>) {
return this._withJSDOM(async () => {
const tmpRoot = createRoot(
this.jsdom.window.document.createElement("div"),
);
flushSync(() => {
tmpRoot.render(
createElement(
comp,
{},
createElement(BlockNoteViewRaw<any, any, any>, {
editor: this.editor,
}),
),
);
});
try {
return await fn();
} finally {
tmpRoot.unmount();
}
});
}
}