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.
Try it out: Edit the document, then click the "Version History" button to open the sidebar. From there you can save snapshots, preview older versions, rename them, and restore them.
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 { localStorageEndpoints } from "./localStorageEndpoints";import { BlockNoteViewEditor, useCreateBlockNote, useExtensionState,} from "@blocknote/react";import { BlockNoteView } from "@blocknote/mantine";import "@blocknote/mantine/style.css";import * as Y from "yjs";import { WebsocketProvider } from "y-websocket";import { toBase64, fromBase64 } from "lib0/buffer";import { VersionHistorySidebar } from "./VersionHistorySidebar";import "./style.css";const roomName = "blocknote-versioning-yjs-example";// 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("document-store");// 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));}// Persist the full document state on every change.doc.on("update", () => { localStorage.setItem(DOC_STORAGE_KEY, toBase64(Y.encodeStateAsUpdate(doc)));});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 { previewedSnapshotId } = useExtensionState(VersioningExtension, { editor, }); return ( <div className="wrapper"> <BlockNoteView editor={editor} editable={previewedSnapshotId === undefined} renderEditor={false} > <div className="layout"> <div className="editor-panel"> <BlockNoteViewEditor /> </div> <VersionHistorySidebar /> </div> </BlockNoteView> </div> );}import { ComponentProps, useComponentsContext } from "@blocknote/react";// This component is used to display a selection dropdown with a label. By using// the useComponentsContext hook, we can create it out of existing components// within the same UI library that `BlockNoteView` uses (Mantine, Ariakit, or// ShadCN), to match the design of the editor.export const SettingsSelect = (props: { label: string; items: ComponentProps["FormattingToolbar"]["Select"]["items"];}) => { const Components = useComponentsContext()!; return ( <div className={"settings-select"}> <Components.Generic.Toolbar.Root className={"bn-toolbar"}> <h2>{props.label + ":"}</h2> <Components.Generic.Toolbar.Select className={"bn-select"} items={props.items} /> </Components.Generic.Toolbar.Root> </div> );};import { VersioningSidebar } from "@blocknote/react";import { useState } from "react";import { SettingsSelect } from "./SettingsSelect";export const VersionHistorySidebar = () => { const [filter, setFilter] = useState<"named" | "all">("all"); return ( <div className={"sidebar-section"}> <div className={"settings"}> <SettingsSelect label={"Filter"} items={[ { text: "All", icon: null, onClick: () => setFilter("all"), isSelected: filter === "all", }, { text: "Named", icon: null, onClick: () => setFilter("named"), isSelected: filter === "named", }, ]} /> </div> <VersioningSidebar filter={filter} /> </div> );};import * as Y from "yjs";import { toBase64, fromBase64 } from "lib0/buffer";import { CURRENT_VERSION_ID, sortSnapshotsNewestFirst, type VersioningEndpoints, type 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[] { return sortSnapshotsNewestFirst( JSON.parse(localStorage.getItem(storageKey) ?? "[]") as VersionSnapshot[], );}function writeSnapshots(storageKey: string, snapshots: VersionSnapshot[]) { localStorage.setItem( storageKey, JSON.stringify(sortSnapshotsNewestFirst(snapshots)), );}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 () => { // Surface the live document as a "current version" entry at the top — it's // how the user returns to live editing and compares against saved // snapshots. It isn't a stored snapshot, so it's never passed to // `getContent` (the sidebar previews it live via `previewCurrentVersion`). const current: VersionSnapshot = { id: CURRENT_VERSION_ID, createdAt: Date.now(), updatedAt: Date.now(), }; return [current, ...readSnapshots(storageKey)]; }; const createSnapshot: VersioningEndpoints< Y.XmlFragment, Uint8Array >["create"] = async (fragment, options) => { const snapshot = { id: crypto.randomUUID(), name: options?.name, createdAt: Date.now(), updatedAt: Date.now(), restoredFromSnapshotId: options?.restoredFromSnapshot?.id, } 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", restoredFromSnapshot: snapshot, }); return snapshotContent; }; const updateSnapshotName: VersioningEndpoints< Y.XmlFragment, Uint8Array >["updateSnapshotName"] = 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; stored.updatedAt = Date.now(); writeSnapshots(storageKey, snapshots); }; const deleteSnapshot: VersioningEndpoints< Y.XmlFragment, Uint8Array >["deleteSnapshot"] = 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, updateSnapshotName, deleteSnapshot, };}/** Default localStorage-backed endpoints using {@link DEFAULT_STORAGE_KEY}. */export const localStorageEndpoints = createLocalStorageVersioningEndpoints();.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;}.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;}.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;}