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 { VersionHistorySidebar } from "./VersionHistorySidebar";import "./style.css";const roomName = "blocknote-versioning-yjs-example";const doc = new Y.Doc();const fragment = doc.getXmlFragment("document-store");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" }, }, 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 { 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 () => 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); }; return { list: listSnapshots, create: createSnapshot, getContent: fetchSnapshotContent, restore: restoreSnapshot, updateSnapshotName, };}/** 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;}