@blocknote/core
Version:
A "Notion-style" block-based extensible text editor built on top of Prosemirror and Tiptap.
274 lines (249 loc) • 9.67 kB
text/typescript
import type { BlockNoteEditor } from "../../editor/BlockNoteEditor.js";
import type { Block } from "../../blocks/defaultBlocks.js";
import type { DiffVersioningExtension } from "../../y/extensions/DiffVersioningExtension.js";
import type {
PreviewController,
VersioningEndpoints,
VersioningExtensionOptions,
VersionSnapshot,
} from "./Versioning.js";
import { CURRENT_VERSION_ID, sortSnapshotsNewestFirst } from "./Versioning.js";
/**
* Label shown on a diff's marks for the version that introduced the changes.
* The previewed snapshot is the "new" side of the diff; the current-version
* entry (previewing the live doc) has no name, so it reads "Current version".
*/
function versionLabel(snapshot: VersionSnapshot): string {
if (snapshot.id === CURRENT_VERSION_ID) {
return "Current version";
}
return snapshot.name ?? "Unnamed version";
}
// ---------------------------------------------------------------------------
// Preview Controller
// ---------------------------------------------------------------------------
/**
* Create a {@link PreviewController} that swaps the BlockNote document in and
* out using `editor.replaceBlocks`.
*
* When entering preview mode the current document is saved so it can be
* restored on exit. Successive `enterPreview` calls without an intervening
* `exitPreview` preserve the original saved document.
*/
export function createInMemoryPreviewController(
editor: BlockNoteEditor<any, any, any>,
): PreviewController<Block<any, any, any>[]> {
let savedDoc: Block<any, any, any>[] | undefined;
// True while a diff (attribution marks) is on screen, so exit/restore knows to
// route the cleanup through the diff extension's node-view rebuild.
let showingDiff = false;
const replaceDoc = (blocks: Block<any, any, any>[]) => {
editor.replaceBlocks(editor.document, blocks);
};
// The opt-in diff extension, if the consuming editor registered it. Looked up
// by key so this module keeps zero runtime dependency on `@y/*`.
const getDiff = () =>
editor.getExtension<typeof DiffVersioningExtension>("diffVersioning");
return {
// Comparison is only possible when the (opt-in) diff extension is present —
// otherwise previewing a comparison just statically shows the snapshot, so
// the UI shouldn't offer it. A getter (not a static `true`) so it's
// independent of the order the extensions were registered in: the diff
// extension is typically added after the versioning extension, and this is
// read lazily (on render) once both are registered.
get supportsComparison() {
return getDiff() !== undefined;
},
enterPreview(
snapshotContent: Block<any, any, any>[],
compareToContent?: Block<any, any, any>[],
_attributions?: unknown,
context?: { snapshot: VersionSnapshot; compareTo?: VersionSnapshot },
) {
// Save the live doc on first enter (successive enters keep the original).
if (savedDoc === undefined) {
savedDoc = editor.document;
}
const diff = getDiff();
if (compareToContent && diff) {
// Render a diff of compareTo → snapshot, labelling the changes with the
// previewed version's name (the diff's single "author").
diff.renderDiff(
snapshotContent,
compareToContent,
context && versionLabel(context.snapshot),
);
showingDiff = true;
return;
}
// No comparison requested, or no diff extension registered: just show the
// snapshot content statically.
showingDiff = false;
replaceDoc(snapshotContent);
},
exitPreview() {
if (savedDoc !== undefined) {
const diff = getDiff();
if (showingDiff && diff) {
diff.clearDiff(savedDoc);
} else {
replaceDoc(savedDoc);
}
savedDoc = undefined;
showingDiff = false;
}
},
applyRestore(snapshotContent: Block<any, any, any>[]) {
const diff = getDiff();
if (showingDiff && diff) {
diff.clearDiff(snapshotContent);
} else {
replaceDoc(snapshotContent);
}
// Clear saved doc — the restored content is now the live document.
savedDoc = undefined;
showingDiff = false;
},
};
}
// ---------------------------------------------------------------------------
// Endpoints (in-memory storage)
// ---------------------------------------------------------------------------
/**
* Create a {@link VersioningEndpoints} that stores snapshots entirely in
* memory. Useful for local-only / non-collaborative editors where you want
* versioning without any persistence layer.
*
* Snapshots are stored as BlockNote document JSON (`Block[]`).
*/
export function createInMemoryVersioningEndpoints(): VersioningEndpoints<
Block<any, any, any>[],
Block<any, any, any>[]
> {
const snapshots: VersionSnapshot[] = [];
const contents = new Map<string, Block<any, any, any>[]>();
let nextId = 1;
// `Date.now()` only has millisecond resolution, so two snapshots created in
// the same tick would share a timestamp and `sortSnapshotsNewestFirst` (which
// has nothing else to order on) could list them oldest-first. Hand out
// strictly increasing timestamps so creation order is always preserved.
let lastTimestamp = 0;
function nextTimestamp() {
lastTimestamp = Math.max(Date.now(), lastTimestamp + 1);
return lastTimestamp;
}
return {
async list() {
return sortSnapshotsNewestFirst([...snapshots]);
},
async create(currentDoc, options) {
const now = nextTimestamp();
const id = String(nextId++);
const snapshot: VersionSnapshot = {
id,
name: options?.name,
createdAt: now,
updatedAt: now,
};
snapshots.push(snapshot);
contents.set(id, structuredClone(currentDoc));
return snapshot;
},
async restore(currentDoc, snapshot) {
// Stored snapshots always have string ids (only the synthetic current
// entry carries the symbol, and it never reaches these methods).
const id = String(snapshot.id);
const snapshotContent = contents.get(id);
if (!snapshotContent) {
throw new Error(`Snapshot ${id} not found`);
}
// Create a "Restored from …" snapshot of the current state before
// restoring, so the user can undo the restore.
const now = nextTimestamp();
const backupId = String(nextId++);
const backup: VersionSnapshot = {
id: backupId,
name: "Before restore",
createdAt: now,
updatedAt: now,
restoredFromSnapshotId: id,
};
snapshots.push(backup);
contents.set(backupId, structuredClone(currentDoc));
return structuredClone(snapshotContent);
},
async getContent(snapshot) {
const id = String(snapshot.id);
const content = contents.get(id);
if (!content) {
throw new Error(`Snapshot ${id} not found`);
}
return structuredClone(content);
},
async rename(snapshot, name) {
const stored = snapshots.find((s) => s.id === snapshot.id);
if (!stored) {
throw new Error(`Snapshot ${String(snapshot.id)} not found`);
}
stored.name = name;
stored.updatedAt = nextTimestamp();
},
async remove(snapshot) {
const index = snapshots.findIndex((s) => s.id === snapshot.id);
if (index === -1) {
throw new Error(`Snapshot ${String(snapshot.id)} not found`);
}
snapshots.splice(index, 1);
contents.delete(String(snapshot.id));
},
};
}
// ---------------------------------------------------------------------------
// Adapter (convenience)
// ---------------------------------------------------------------------------
/**
* Create all the options needed to wire a {@link VersioningExtension} with
* fully in-memory storage and BlockNote JSON-based preview.
*
* @example
* ```ts
* import { VersioningExtension } from "@blocknote/core/extensions";
* import { createInMemoryVersioningAdapter } from "@blocknote/core/extensions";
*
* const editor = BlockNoteEditor.create({
* extensions: [
* VersioningExtension(createInMemoryVersioningAdapter(editor)),
* ],
* });
* ```
*/
export function createInMemoryVersioningAdapter(
editor: BlockNoteEditor<any, any, any>,
): VersioningExtensionOptions<Block<any, any, any>[], Block<any, any, any>[]> {
const endpoints = createInMemoryVersioningEndpoints();
return {
// The raw endpoints are pure snapshot storage. The "current version" is a
// view concern owned by the adapter (it's the layer that knows about the
// live editor), so we wrap `list()` to always surface a current entry: the
// live document is the editable working copy, and the entry is how the user
// returns to live editing and compares against saved snapshots. No
// timestamp/author is tracked, so the row just reads "Current version"
// (see CurrentSnapshot in @blocknote/react).
endpoints: {
...endpoints,
list: async () => {
const current: VersionSnapshot = {
id: CURRENT_VERSION_ID,
createdAt: Date.now(),
updatedAt: Date.now(),
};
return [current, ...(await endpoints.list())];
},
},
preview: createInMemoryPreviewController(editor),
getCurrentDocument: () => editor.document,
// The live document is already in the snapshot content format (`Block[]`),
// so previewing "current" as a diff just reuses the live blocks.
serializeCurrentContent: () => editor.document,
};
}