Local Storage Versioning (yjs v13)
This example shows how to use the VersioningExtension with collaborative editing using yjs (v13). Snapshots are stored in localStorage using Yjs state updates.
The sidebar opens on a document with a few versions already in its history, so you can preview them, rename them, and restore them right away. The editor is read-only while the sidebar is open: close it to edit the document, then reopen it with the "History" button and press "Save version" to add a version of your own.
Relevant Docs:
import "@blocknote/core/fonts/inter.css";import { withCollaboration } from "@blocknote/core/yjs";import { VersioningExtension } from "@blocknote/core/extensions";import { createYjsVersioningAdapter } from "@blocknote/core/yjs";import { hasStoredVersions, localStorageEndpoints, storeVersions,} from "./localStorageEndpoints";import { BlockNoteViewEditor, useCreateBlockNote } from "@blocknote/react";import { BlockNoteView } from "@blocknote/mantine";import "@blocknote/mantine/style.css";import { useState } from "react";import * as Y from "yjs";import { WebsocketProvider } from "y-websocket";import { toBase64, fromBase64 } from "lib0/buffer";import { VersionHistorySidebar } from "./VersionHistorySidebar";import { blocksToUpdate, DAY_MS, LIVE_DOCUMENT, SAMPLE_HISTORY,} from "./sampleVersions";import "./style.css";const roomName = "blocknote-versioning-yjs-example";const FRAGMENT_NAME = "document-store";// localStorage key for the live ("current version") document. Snapshots are// persisted separately by `localStorageEndpoints`; this keeps the live doc// itself across refreshes since the demo has no server-side persistence.const DOC_STORAGE_KEY = "blocknote-versioning-yjs-current-doc";const doc = new Y.Doc();const fragment = doc.getXmlFragment(FRAGMENT_NAME);// Persist the full document state on every change.doc.on("update", () => { localStorage.setItem(DOC_STORAGE_KEY, toBase64(Y.encodeStateAsUpdate(doc)));});// Restore the persisted live document before the editor is created, so it// adopts the stored content instead of starting empty.const persistedDoc = localStorage.getItem(DOC_STORAGE_KEY);if (persistedDoc) { Y.applyUpdate(doc, fromBase64(persistedDoc));} else if (!hasStoredVersions()) { // First visit: seed a few named versions so the history has something to // show, and open on the newest state of the same document. storeVersions( SAMPLE_HISTORY.map((version) => ({ name: version.name, createdAt: Date.now() - version.daysAgo * DAY_MS, content: blocksToUpdate(version.blocks, FRAGMENT_NAME), })), ); Y.applyUpdate(doc, blocksToUpdate(LIVE_DOCUMENT, FRAGMENT_NAME));}const provider = new WebsocketProvider( "wss://demos.yjs.dev/ws", roomName, doc, { connect: false },);provider.connectBc();export default function App() { const editor = useCreateBlockNote( withCollaboration({ collaboration: { provider, fragment, user: { color: "#ff0000", name: "User", id: "user" }, }, extensions: [ // The v13 CollaborationExtension does not wire up versioning // automatically, so we add VersioningExtension manually and use // createYjsVersioningAdapter to bridge the Yjs v13 preview logic. VersioningExtension((editor) => ({ ...createYjsVersioningAdapter(editor, { fragment } as any), endpoints: localStorageEndpoints, })), ], }), ); const [showSidebar, setShowSidebar] = useState(true); return ( <div className="wrapper"> {/* No `editable` prop: the sidebar makes the editor read-only for as long as it's open, and restores it on close. */} <BlockNoteView editor={editor} renderEditor={false}> <div className="layout"> <div className="editor-panel"> <BlockNoteViewEditor /> {!showSidebar && ( <button className="show-history-button" onClick={() => setShowSidebar(true)} > History </button> )} </div> {showSidebar && ( <VersionHistorySidebar onClose={() => setShowSidebar(false)} /> )} </div> </BlockNoteView> </div> );}import { VersioningSidebar } from "@blocknote/react";export const VersionHistorySidebar = ({ onClose }: { onClose: () => void }) => { return ( <div className={"sidebar-section"}> {/* Filtering to named versions is built in — the sidebar's own header toggle drives it. The header's close button calls `onClose`. */} <VersioningSidebar onClose={onClose} /> </div> );};import * as Y from "yjs";import { toBase64, fromBase64 } from "lib0/buffer";import type { VersioningEndpoints, VersionSnapshot,} from "@blocknote/core/extensions";const DEFAULT_STORAGE_KEY = "blocknote-versioning-yjs-snapshots";function getContentsKey(storageKey: string) { return `${storageKey}-contents`;}function readSnapshots(storageKey: string): VersionSnapshot[] { const snapshots = JSON.parse( localStorage.getItem(storageKey) ?? "[]", ) as VersionSnapshot[]; return snapshots.sort((a, b) => b.createdAt - a.createdAt);}function writeSnapshots(storageKey: string, snapshots: VersionSnapshot[]) { localStorage.setItem( storageKey, JSON.stringify([...snapshots].sort((a, b) => b.createdAt - a.createdAt)), );}function readContents(storageKey: string): Record<string, string> { return JSON.parse( localStorage.getItem(getContentsKey(storageKey)) ?? "{}", ) as Record<string, string>;}function writeContents(storageKey: string, contents: Record<string, string>) { localStorage.setItem(getContentsKey(storageKey), JSON.stringify(contents));}/** * Reference {@link VersioningEndpoints} implementation backed by * `localStorage` for yjs (v13). * * Uses `Y.encodeStateAsUpdate` / `Y.applyUpdate` (v1 encoding) instead of the * v2 encoding used by the `@y/y` (v14) equivalent. */export function createLocalStorageVersioningEndpoints( storageKey = DEFAULT_STORAGE_KEY,): VersioningEndpoints<Y.XmlFragment, Uint8Array> { const listSnapshots: VersioningEndpoints< Y.XmlFragment, Uint8Array >["list"] = async () => { // The current version is the live document. There's no server clock here, // so it's simply stamped "now"; it isn't a stored snapshot, so it's never // passed to `getContent` (the sidebar previews it live via // `previewCurrentVersion`). return { current: { id: "current", createdAt: Date.now() }, snapshots: readSnapshots(storageKey), }; }; const createSnapshot: NonNullable< VersioningEndpoints<Y.XmlFragment, Uint8Array>["create"] > = async (fragment, options) => { const snapshot = { id: crypto.randomUUID(), name: options.name, createdAt: Date.now(), } satisfies VersionSnapshot; const contents = readContents(storageKey); contents[snapshot.id] = toBase64(Y.encodeStateAsUpdate(fragment.doc!)); writeContents(storageKey, contents); writeSnapshots(storageKey, [snapshot, ...readSnapshots(storageKey)]); return snapshot; }; const fetchSnapshotContent: VersioningEndpoints< Y.XmlFragment, Uint8Array >["getContent"] = async (snapshot) => { const encoded = readContents(storageKey)[snapshot.id]; if (encoded === undefined) { throw new Error(`Document snapshot ${snapshot.id} could not be found.`); } return fromBase64(encoded); }; const restoreSnapshot: VersioningEndpoints< Y.XmlFragment, Uint8Array >["restore"] = async (fragment, snapshot) => { await createSnapshot(fragment, { name: "Backup" }); const snapshotContent = await fetchSnapshotContent(snapshot); const yDoc = new Y.Doc(); Y.applyUpdate(yDoc, snapshotContent); await createSnapshot(yDoc.getXmlFragment("document-store"), { name: "Restored Snapshot", }); return snapshotContent; }; const rename: VersioningEndpoints< Y.XmlFragment, Uint8Array >["rename"] = async (snapshot, name) => { const snapshots = readSnapshots(storageKey); const stored = snapshots.find((s) => s.id === snapshot.id); if (stored === undefined) { throw new Error(`Document snapshot ${snapshot.id} could not be found.`); } stored.name = name; writeSnapshots(storageKey, snapshots); }; const remove: VersioningEndpoints< Y.XmlFragment, Uint8Array >["remove"] = async (snapshot) => { const snapshots = readSnapshots(storageKey); if (!snapshots.some((s) => s.id === snapshot.id)) { throw new Error(`Document snapshot ${snapshot.id} could not be found.`); } // Drop the snapshot metadata and its stored content. writeSnapshots( storageKey, snapshots.filter((s) => s.id !== snapshot.id), ); const contents = readContents(storageKey); delete contents[snapshot.id]; writeContents(storageKey, contents); }; return { list: listSnapshots, create: createSnapshot, getContent: fetchSnapshotContent, restore: restoreSnapshot, rename, remove, };}/** Default localStorage-backed endpoints using {@link DEFAULT_STORAGE_KEY}. */export const localStorageEndpoints = createLocalStorageVersioningEndpoints();/** Whether any versions have been stored under `storageKey` yet. */export function hasStoredVersions(storageKey = DEFAULT_STORAGE_KEY): boolean { return localStorage.getItem(storageKey) !== null;}/** * Store versions directly, bypassing `create`: the demo seeds sample history * with back-dated timestamps, which `create` (which stamps "now") can't do. */export function storeVersions( versions: Array<{ name?: string; createdAt: number; content: Uint8Array }>, storageKey = DEFAULT_STORAGE_KEY,) { const snapshots = readSnapshots(storageKey); const contents = readContents(storageKey); for (const version of versions) { const id = crypto.randomUUID(); snapshots.push({ id, name: version.name, createdAt: version.createdAt }); contents[id] = toBase64(version.content); } writeContents(storageKey, contents); writeSnapshots(storageKey, snapshots);}import { BlockNoteEditor, type PartialBlock } from "@blocknote/core";import { prosemirrorToYXmlFragment } from "y-prosemirror";import * as Y from "yjs";export const DAY_MS = 24 * 60 * 60 * 1000;// Stable ids let previews show edits to the same blocks across versions.type SampleBlock = PartialBlock & { id: string; type: "heading" | "paragraph" | "bulletListItem" | "numberedListItem"; content: string;};function updateContent(blocks: SampleBlock[], updates: Record<string, string>) { return blocks.map((block) => ({ ...block, content: updates[block.id] ?? block.content, }));}const firstDraft: SampleBlock[] = [ { id: "title", type: "heading", props: { level: 2 }, content: "Launch plan: Notes 2.0", }, { id: "goal", type: "paragraph", content: "Goal: ship the new editor to every workspace before the end of the quarter.", }, { id: "milestones", type: "heading", props: { level: 3 }, content: "Milestones", }, { id: "m1", type: "bulletListItem", content: "Beta with five design partners", }, { id: "m3", type: "bulletListItem", content: "Public release" },];const addedDates = updateContent( [ ...firstDraft.slice(0, -1), { id: "m2", type: "bulletListItem", content: "Fix the ten most-reported beta issues", }, firstDraft[firstDraft.length - 1]!, ], { goal: "Goal: ship the new editor to every workspace before the end of September.", m1: "Beta with five design partners (June)", m3: "Public release (September)", },);const marketingReview: SampleBlock[] = [ ...updateContent(addedDates, { m3: "Public release (September 15)" }), { id: "announcement", type: "heading", props: { level: 3 }, content: "Announcement", }, { id: "announcement-text", type: "paragraph", content: "The blog post and changelog entry go out on release day. The newsletter follows a week later.", },];const liveDocument: SampleBlock[] = [ ...updateContent(marketingReview, { goal: "Goal: ship the new editor to every workspace before the end of September, keeping the old editor available as a fallback for one release.", }), { id: "questions", type: "heading", props: { level: 3 }, content: "Open questions", }, { id: "q1", type: "numberedListItem", content: "Do we keep the old editor available as a fallback?", }, { id: "q2", type: "numberedListItem", content: "Who owns the migration guide?", },];export const SAMPLE_HISTORY: Array<{ name: string; daysAgo: number; blocks: PartialBlock[];}> = [ { name: "First draft", daysAgo: 9, blocks: firstDraft }, { name: "Added dates", daysAgo: 6, blocks: addedDates }, { name: "Marketing review", daysAgo: 2, blocks: marketingReview },];export const LIVE_DOCUMENT: PartialBlock[] = liveDocument;/** Encode sample blocks as a stored Yjs version. */export function blocksToUpdate( blocks: PartialBlock[], fragmentName: string,): Uint8Array { const editor = BlockNoteEditor.create({ initialContent: blocks }); const doc = new Y.Doc(); prosemirrorToYXmlFragment( editor.prosemirrorState.doc, doc.getXmlFragment(fragmentName), ); return Y.encodeStateAsUpdate(doc);}.wrapper { height: calc(100vh - 20px);}.wrapper > .bn-container { margin: 0; max-width: none; padding: 0;}.layout { display: flex; gap: 8px; height: calc(100vh - 20px);}.editor-panel { flex: 1; height: calc(100vh - 20px); min-width: 0; overflow: auto; position: relative;}.editor-panel .bn-container { height: calc(100vh - 20px); margin: 0; max-width: none; padding: 0;}.editor-panel .bn-editor { height: calc(100vh - 20px); overflow: auto;}.sidebar-section { background-color: var(--bn-colors-disabled-background); display: flex; flex-direction: column; height: calc(100vh - 20px); overflow: auto; width: 350px;}.sidebar-section .settings { padding: 8px;}.bn-versioning-sidebar { flex: 1; overflow: auto; padding-inline: 16px;}.show-history-button { background-color: var(--bn-colors-menu-background); border: var(--bn-border); border-radius: var(--bn-border-radius-medium); box-shadow: var(--bn-shadow-medium); color: var(--bn-colors-menu-text); cursor: pointer; font-size: 13px; font-weight: 600; padding: 6px 12px; position: absolute; right: 16px; top: 16px;}.settings-select { display: flex; gap: 10px;}.settings-select .bn-toolbar { align-items: center;}.settings-select h2 { color: var(--bn-colors-menu-text); margin: 0; font-size: 12px; line-height: 12px; padding-left: 14px;}.bn-snapshot { background-color: var(--bn-colors-menu-background); border: var(--bn-border); border-radius: var(--bn-border-radius-medium); box-shadow: var(--bn-shadow-medium); color: var(--bn-colors-menu-text); cursor: pointer; display: flex; flex-direction: column; gap: 16px; margin-bottom: 10px; overflow: visible; padding: 16px 32px; width: 100%;}.bn-snapshot-name { background: transparent; border: none; color: var(--bn-colors-menu-text); font-size: 16px; font-weight: 600; padding: 0; width: 100%;}.bn-snapshot-name:focus { outline: none;}.bn-snapshot-body { display: flex; flex-direction: column; font-size: 12px; gap: 4px;}.bn-snapshot-button { background-color: #4da3ff; border: none; border-radius: 4px; color: var(--bn-colors-selected-text); cursor: pointer; font-size: 12px; font-weight: 600; padding: 0 8px; width: fit-content;}.dark .bn-snapshot-button { background-color: #0070e8;}.bn-snapshot-button:hover { background-color: #73b7ff;}.dark .bn-snapshot-button:hover { background-color: #3785d8;}.bn-versioning-sidebar .bn-snapshot.selected { background-color: #f5f9fd; border: 2px solid #c2dcf8;}.dark .bn-versioning-sidebar .bn-snapshot.selected { background-color: #20242a; border: 2px solid #23405b;}