diff --git a/docs/package.json b/docs/package.json index 76294747a1..232c29ad47 100644 --- a/docs/package.json +++ b/docs/package.json @@ -113,7 +113,8 @@ "yjs": "^13.6.27", "zod": "^4.3.5", "@blocknote/xl-typst-exporter": "workspace:*", - "@blocknote/xl-typst-compiler": "workspace:*" + "@blocknote/xl-typst-compiler": "workspace:*", + "y-prosemirror": "^1.3.7" }, "devDependencies": { "@blocknote/code-block": "workspace:*", diff --git a/examples/07-collaboration/10-suggestion-multi-editor/package.json b/examples/07-collaboration/10-suggestion-multi-editor/package.json index 683ec57c6d..717caac454 100644 --- a/examples/07-collaboration/10-suggestion-multi-editor/package.json +++ b/examples/07-collaboration/10-suggestion-multi-editor/package.json @@ -21,8 +21,8 @@ "react": "^19.2.3", "react-dom": "^19.2.3", "@y/protocols": "^1.0.6-rc.1", - "@y/y": "^14.0.0-rc.23", - "@y/prosemirror": "^2.0.0-6", + "@y/y": "^14.0.0-rc.26", + "@y/prosemirror": "^2.0.0-11", "@y/websocket": "^4.0.0-rc.2" }, "devDependencies": { diff --git a/examples/07-collaboration/10-suggestion-multi-editor/src/App.tsx b/examples/07-collaboration/10-suggestion-multi-editor/src/App.tsx index 5725db5d8f..4f4ff0616d 100644 --- a/examples/07-collaboration/10-suggestion-multi-editor/src/App.tsx +++ b/examples/07-collaboration/10-suggestion-multi-editor/src/App.tsx @@ -25,7 +25,7 @@ provider2.awareness.setLocalStateField("user", { color: "#6eeb83", }); -const attrs = new Y.Attributions(); +const attrs = Y.createContentMap(); // Batch timestamps: reuse the same timestamp for edits from the same user // within a 10-second window of inactivity. @@ -62,7 +62,7 @@ function getBatchedTimestamp(userName: string): number { function trackAttributions( trackedDoc: Y.Doc, userName: string, - attributions: Y.Attributions, + attributions: Y.ContentMap, ) { trackedDoc.on( "update", @@ -105,7 +105,9 @@ suggestingProvider.awareness.setLocalStateField("user", { name: "Charlie", color: "#ffbc42", }); -const suggestingRenderer = Y.createDiffRenderer(doc, suggestingDoc, { attrs }); +const suggestingRenderer = Y.createDiffRenderer(doc, suggestingDoc, { + attributions: attrs, +}); suggestingRenderer.suggestionMode = false; const suggestionModeDoc = new Y.Doc({ isSuggestionDoc: true }); @@ -117,7 +119,7 @@ suggestionModeProvider.awareness.setLocalStateField("user", { color: "#ee6352", }); const suggestionModeRenderer = Y.createDiffRenderer(doc, suggestionModeDoc, { - attrs, + attributions: attrs, }); suggestionModeRenderer.suggestionMode = true; @@ -155,7 +157,7 @@ function Editor({ userName, userColor, }: { - fragment: Y.Type; + fragment: Y.Node; provider: { awareness?: Awareness }; renderer?: Y.DiffRenderer; userName: string; diff --git a/examples/07-collaboration/11-versioning-yjs13/.bnexample.json b/examples/07-collaboration/11-versioning-yjs13/.bnexample.json index d04a59bb2e..dd773d8bc7 100644 --- a/examples/07-collaboration/11-versioning-yjs13/.bnexample.json +++ b/examples/07-collaboration/11-versioning-yjs13/.bnexample.json @@ -6,6 +6,7 @@ "dependencies": { "y-websocket": "^2.1.0", "yjs": "^13.6.27", - "lib0": "^0.2.99" + "lib0": "^0.2.99", + "y-prosemirror": "^1.3.7" } } diff --git a/examples/07-collaboration/11-versioning-yjs13/README.md b/examples/07-collaboration/11-versioning-yjs13/README.md index 3482e62a55..f74f239a4e 100644 --- a/examples/07-collaboration/11-versioning-yjs13/README.md +++ b/examples/07-collaboration/11-versioning-yjs13/README.md @@ -2,7 +2,7 @@ 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. +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:** diff --git a/examples/07-collaboration/11-versioning-yjs13/package.json b/examples/07-collaboration/11-versioning-yjs13/package.json index 7d1f2d3db0..a8f63e767c 100644 --- a/examples/07-collaboration/11-versioning-yjs13/package.json +++ b/examples/07-collaboration/11-versioning-yjs13/package.json @@ -22,7 +22,8 @@ "react-dom": "^19.2.3", "y-websocket": "^2.1.0", "yjs": "^13.6.27", - "lib0": "^0.2.99" + "lib0": "^0.2.99", + "y-prosemirror": "^1.3.7" }, "devDependencies": { "@types/react": "^19.2.3", diff --git a/examples/07-collaboration/11-versioning-yjs13/src/App.tsx b/examples/07-collaboration/11-versioning-yjs13/src/App.tsx index 015b90c6ec..24b0f5d827 100644 --- a/examples/07-collaboration/11-versioning-yjs13/src/App.tsx +++ b/examples/07-collaboration/11-versioning-yjs13/src/App.tsx @@ -2,42 +2,61 @@ 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"; + 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 { VersioningSidebar } from "@blocknote/react/versioning"; +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("document-store"); +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)); } -// 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, @@ -66,22 +85,30 @@ export default function App() { }), ); - const { previewedSnapshotId } = useExtensionState(VersioningExtension, { - editor, - }); + const [showSidebar, setShowSidebar] = useState(true); return (
- + {/* No `editable` prop: the sidebar makes the editor read-only for as long + as it's open, and restores it on close. */} +
+ {!showSidebar && ( + + )}
- + {showSidebar && ( +
+ setShowSidebar(false)} /> +
+ )}
diff --git a/examples/07-collaboration/11-versioning-yjs13/src/SettingsSelect.tsx b/examples/07-collaboration/11-versioning-yjs13/src/SettingsSelect.tsx deleted file mode 100644 index 0dfc79dc3f..0000000000 --- a/examples/07-collaboration/11-versioning-yjs13/src/SettingsSelect.tsx +++ /dev/null @@ -1,24 +0,0 @@ -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 ( -
- -

{props.label + ":"}

- -
-
- ); -}; diff --git a/examples/07-collaboration/11-versioning-yjs13/src/VersionHistorySidebar.tsx b/examples/07-collaboration/11-versioning-yjs13/src/VersionHistorySidebar.tsx deleted file mode 100644 index a37cd3b31b..0000000000 --- a/examples/07-collaboration/11-versioning-yjs13/src/VersionHistorySidebar.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { VersioningSidebar } from "@blocknote/react"; -import { useState } from "react"; - -import { SettingsSelect } from "./SettingsSelect"; - -export const VersionHistorySidebar = () => { - const [filter, setFilter] = useState<"named" | "all">("all"); - - return ( -
-
- setFilter("all"), - isSelected: filter === "all", - }, - { - text: "Named", - icon: null, - onClick: () => setFilter("named"), - isSelected: filter === "named", - }, - ]} - /> -
- -
- ); -}; diff --git a/examples/07-collaboration/11-versioning-yjs13/src/localStorageEndpoints.ts b/examples/07-collaboration/11-versioning-yjs13/src/localStorageEndpoints.ts index d1e6a187af..f90dee4cb5 100644 --- a/examples/07-collaboration/11-versioning-yjs13/src/localStorageEndpoints.ts +++ b/examples/07-collaboration/11-versioning-yjs13/src/localStorageEndpoints.ts @@ -1,11 +1,9 @@ import * as Y from "yjs"; import { toBase64, fromBase64 } from "lib0/buffer"; -import { - CURRENT_VERSION_ID, - sortSnapshotsNewestFirst, - type VersioningEndpoints, - type VersionSnapshot, +import type { + VersioningEndpoints, + VersionSnapshot, } from "@blocknote/core/extensions"; const DEFAULT_STORAGE_KEY = "blocknote-versioning-yjs-snapshots"; @@ -15,15 +13,16 @@ function getContentsKey(storageKey: string) { } function readSnapshots(storageKey: string): VersionSnapshot[] { - return sortSnapshotsNewestFirst( - JSON.parse(localStorage.getItem(storageKey) ?? "[]") as 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(sortSnapshotsNewestFirst(snapshots)), + JSON.stringify([...snapshots].sort((a, b) => b.createdAt - a.createdAt)), ); } @@ -51,32 +50,23 @@ export function createLocalStorageVersioningEndpoints( 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(), + // 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), }; - return [current, ...readSnapshots(storageKey)]; }; - // Stored snapshots always have string ids (only the synthetic current - // entry carries the CURRENT_VERSION_ID symbol, and it never reaches these - // endpoints), so coercing ids to strings below is safe. const createSnapshot: NonNullable< VersioningEndpoints["create"] > = async (fragment, options) => { const snapshot = { id: crypto.randomUUID(), - name: options?.name, + name: options.name, createdAt: Date.now(), - updatedAt: Date.now(), - restoredFromSnapshotId: options?.restoredFromSnapshot - ? String(options.restoredFromSnapshot.id) - : undefined, } satisfies VersionSnapshot; const contents = readContents(storageKey); @@ -92,10 +82,9 @@ export function createLocalStorageVersioningEndpoints( Y.XmlFragment, Uint8Array >["getContent"] = async (snapshot) => { - const id = String(snapshot.id); - const encoded = readContents(storageKey)[id]; + const encoded = readContents(storageKey)[snapshot.id]; if (encoded === undefined) { - throw new Error(`Document snapshot ${id} could not be found.`); + throw new Error(`Document snapshot ${snapshot.id} could not be found.`); } return fromBase64(encoded); }; @@ -112,7 +101,6 @@ export function createLocalStorageVersioningEndpoints( await createSnapshot(yDoc.getXmlFragment("document-store"), { name: "Restored Snapshot", - restoredFromSnapshot: snapshot, }); return snapshotContent; @@ -125,13 +113,10 @@ export function createLocalStorageVersioningEndpoints( const snapshots = readSnapshots(storageKey); const stored = snapshots.find((s) => s.id === snapshot.id); if (stored === undefined) { - throw new Error( - `Document snapshot ${String(snapshot.id)} could not be found.`, - ); + throw new Error(`Document snapshot ${snapshot.id} could not be found.`); } stored.name = name; - stored.updatedAt = Date.now(); writeSnapshots(storageKey, snapshots); }; @@ -141,9 +126,7 @@ export function createLocalStorageVersioningEndpoints( >["remove"] = async (snapshot) => { const snapshots = readSnapshots(storageKey); if (!snapshots.some((s) => s.id === snapshot.id)) { - throw new Error( - `Document snapshot ${String(snapshot.id)} could not be found.`, - ); + throw new Error(`Document snapshot ${snapshot.id} could not be found.`); } // Drop the snapshot metadata and its stored content. @@ -153,7 +136,7 @@ export function createLocalStorageVersioningEndpoints( ); const contents = readContents(storageKey); - delete contents[String(snapshot.id)]; + delete contents[snapshot.id]; writeContents(storageKey, contents); }; @@ -169,3 +152,27 @@ export function createLocalStorageVersioningEndpoints( /** 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); +} diff --git a/examples/07-collaboration/11-versioning-yjs13/src/sampleVersions.ts b/examples/07-collaboration/11-versioning-yjs13/src/sampleVersions.ts new file mode 100644 index 0000000000..636f4546fb --- /dev/null +++ b/examples/07-collaboration/11-versioning-yjs13/src/sampleVersions.ts @@ -0,0 +1,127 @@ +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) { + 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); +} diff --git a/examples/07-collaboration/11-versioning-yjs13/src/style.css b/examples/07-collaboration/11-versioning-yjs13/src/style.css index e75d6ef7b8..737be422bf 100644 --- a/examples/07-collaboration/11-versioning-yjs13/src/style.css +++ b/examples/07-collaboration/11-versioning-yjs13/src/style.css @@ -19,6 +19,7 @@ height: calc(100vh - 20px); min-width: 0; overflow: auto; + position: relative; } .editor-panel .bn-container { @@ -46,10 +47,19 @@ 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 { diff --git a/examples/07-collaboration/12-multi-doc-versioning/.bnexample.json b/examples/07-collaboration/12-multi-doc-versioning/.bnexample.json index 1b0dae709c..dc2f272c07 100644 --- a/examples/07-collaboration/12-multi-doc-versioning/.bnexample.json +++ b/examples/07-collaboration/12-multi-doc-versioning/.bnexample.json @@ -7,6 +7,7 @@ "@y/protocols": "^1.0.6-rc.1", "@y/websocket": "^4.0.0-3", "@y/y": "^14.0.0-rc.23", - "lib0": "1.0.0-rc.22" + "lib0": "1.0.0-rc.22", + "@y/prosemirror": "^2.0.0-6" } } diff --git a/examples/07-collaboration/12-multi-doc-versioning/README.md b/examples/07-collaboration/12-multi-doc-versioning/README.md index af4adf48e0..e5f4889d5c 100644 --- a/examples/07-collaboration/12-multi-doc-versioning/README.md +++ b/examples/07-collaboration/12-multi-doc-versioning/README.md @@ -1,6 +1,8 @@ # YHub Multi-Doc -This example shows a multi-document collaborative editor with per-document version history, using BlockNote's `VersioningExtension` and Y.js v14. +This example shows a multi-document collaborative editor with per-document version history, using BlockNote's `VersioningExtension` and Y.js v14. Sync and history both come from [YHub](https://github.com/yjs/yhub), which records every edit and groups them into versions. + +A first visit creates a sample document whose history already has several versions by several users, so the history sidebar has something to show right away. The editor is read-only while the sidebar is open: close it to edit, then reopen it with the "History" button. **Features:** @@ -8,7 +10,7 @@ This example shows a multi-document collaborative editor with per-document versi - Left sidebar with document list (create, rename, delete) - Collaborative editing with Y.js (including suggestion mode) - Right sidebar with version history powered by `VersioningSidebar` -- Per-document versioning backed by `localStorage` +- Per-document version history backed by YHub - Open multiple tabs with different users via the `?as=` URL param **Relevant Docs:** diff --git a/examples/07-collaboration/12-multi-doc-versioning/package.json b/examples/07-collaboration/12-multi-doc-versioning/package.json index f9bed71f4e..e77cf44321 100644 --- a/examples/07-collaboration/12-multi-doc-versioning/package.json +++ b/examples/07-collaboration/12-multi-doc-versioning/package.json @@ -22,8 +22,9 @@ "react-dom": "^19.2.3", "@y/protocols": "^1.0.6-rc.1", "@y/websocket": "^4.0.0-3", - "@y/y": "^14.0.0-rc.23", - "lib0": "1.0.0-rc.22" + "@y/y": "^14.0.0-rc.26", + "lib0": "1.0.0-rc.32", + "@y/prosemirror": "^2.0.0-11" }, "devDependencies": { "@types/react": "^19.2.3", diff --git a/examples/07-collaboration/12-multi-doc-versioning/src/App.tsx b/examples/07-collaboration/12-multi-doc-versioning/src/App.tsx index 2d5d2e5d25..48563b779c 100644 --- a/examples/07-collaboration/12-multi-doc-versioning/src/App.tsx +++ b/examples/07-collaboration/12-multi-doc-versioning/src/App.tsx @@ -9,6 +9,16 @@ import { generateRandomId } from "./utils.js"; import { LoginScreen } from "./LoginScreen.js"; import { DocumentList } from "./DocumentList.js"; import { DocumentEditor } from "./DocumentEditor.js"; +import { + SAMPLE_DOCUMENT_TITLE, + seedSampleDocument, + hasPendingSampleDocument, +} from "./sampleDocument.js"; +import { YHUB_API_URL } from "./yhub.js"; + +// Set once the sample document has been created, so deleting every document +// leaves the workspace empty rather than bringing the sample back. +const SEEDED_KEY = "bn-multi-doc-seeded"; export default function App() { const user = useCurrentUser(); @@ -54,6 +64,44 @@ function Workspace({ const activeDoc = docId ? index.docs.find((d) => d.id === docId) : null; const [copied, setCopied] = useState(false); + // A first visit gets a sample document with a few versions in its history, + // so the history sidebar has something to show before anyone has edited. + const [seedStatus, setSeedStatus] = useState<"idle" | "seeding" | "failed">( + "idle", + ); + const [seedAttempt, setSeedAttempt] = useState(0); + const seedStartedRef = useRef(false); + useEffect(() => { + if ( + docId || + (index.docs.length > 0 && + !hasPendingSampleDocument({ + baseUrl: YHUB_API_URL, + org: workspaceId, + })) || + localStorage.getItem(SEEDED_KEY) || + seedStartedRef.current + ) { + return; + } + seedStartedRef.current = true; + setSeedStatus("seeding"); + void seedSampleDocument({ + baseUrl: YHUB_API_URL, + org: workspaceId, + }) + .then((id) => { + index.ensure(id, SAMPLE_DOCUMENT_TITLE); + localStorage.setItem(SEEDED_KEY, "1"); + setSeedStatus("idle"); + navigate(`/w/${workspaceId}/${id}`); + }) + .catch((error: unknown) => { + console.error("Could not seed the sample document", error); + setSeedStatus("failed"); + }); + }, [docId, index, workspaceId, seedAttempt]); + // A shared doc URL can reference a doc this browser has never seen (the // index is localStorage-only). Register it so the editor mounts and syncs // the content from the server. Ensure each id at most once per mount so @@ -134,7 +182,22 @@ function Workspace({ workspaceId={workspaceId} activeDocId={docId} /> - {activeDoc ? ( + {seedStatus === "seeding" ? ( +
Preparing a sample document…
+ ) : seedStatus === "failed" ? ( +
+

Could not prepare the sample document.

+ +
+ ) : activeDoc ? ( { + const onSync = (isSynced: boolean) => { + if (isSynced) { + setSynced(true); + } + }; + provider.on("sync", onSync); + if (provider.synced) { + setSynced(true); + } + return () => { + provider.off("sync", onSync); + }; + }, [provider]); + const editor = useCreateBlockNote( withCollaboration({ collaboration: { @@ -185,23 +200,13 @@ export function DocumentEditor({ }), ); - // The version history is derived entirely from YHub's activity timeline. - // Fetch it once on mount so the sidebar reflects the server's history rather - // than only changes made during this session. - const versioning = useExtension(VersioningExtension, { editor }); - useEffect(() => { - versioning.list(); - const interval = setInterval(() => { - versioning.list(); - }, 10000); - return () => { - clearInterval(interval); - }; - }, [versioning]); - - const { previewedSnapshotId } = useExtensionState(VersioningExtension, { + // The version history is derived entirely from YHub's activity timeline; the + // sidebar fetches it once when it opens. + const versioningView = useExtensionState(VersioningExtension, { editor, + selector: (state) => state.view, }); + const previewing = versioningView.mode !== "live"; const { enableSuggestions, disableSuggestions, viewSuggestions } = useExtension(SuggestionsExtension, { editor }); @@ -212,11 +217,11 @@ export function DocumentEditor({ // Exit suggestion modes when entering version preview useEffect(() => { - if (previewedSnapshotId !== undefined && editingMode !== "editing") { + if (previewing && editingMode !== "editing") { disableSuggestions(); setEditingMode("editing"); } - }, [previewedSnapshotId]); + }, [previewing]); const modeOptions = useMemo( () => [ @@ -244,11 +249,9 @@ export function DocumentEditor({ }; return ( - + // No `editable` prop: the versioning sidebar owns editability while it's + // open, and restores it on close. +

{docTitle || "Untitled"}

- {previewedSnapshotId === undefined && ( + {!previewing && ( - applyGroupMaxGap(Number(e.currentTarget.value)) - } - > - {GROUP_GAP_STOPS.map((ms) => ( - - ))} - -
- -
- - -
- -
- -
-
- )} - - {showSidebar && (
diff --git a/examples/07-collaboration/13-versioning-yjs14/src/sampleDocument.ts b/examples/07-collaboration/13-versioning-yjs14/src/sampleDocument.ts index 78a637204e..4d499448fb 100644 --- a/examples/07-collaboration/13-versioning-yjs14/src/sampleDocument.ts +++ b/examples/07-collaboration/13-versioning-yjs14/src/sampleDocument.ts @@ -3,6 +3,7 @@ import { BlockNoteEditor } from "@blocknote/core"; import { buildEditHistory } from "./snapshotBuilder"; import type { EditHistoryStep } from "./snapshotBuilder"; import { seedYHubDocument } from "./seed"; +import type { SeededVersion } from "./seed"; import { VERSIONS } from "./versions"; /** @@ -26,8 +27,8 @@ import { VERSIONS } from "./versions"; * * Each emitted op becomes its own captured transaction, attributed to one of * the version's authors at random, and `seedYHubDocument` lands them as - * separate authored content before committing a single version marker — so the - * one version is attributed to several authors. + * separate authored content — so grouping merges them back into one version + * attributed to several authors. */ /** Each version's target tree plus the 2–3 users who collaborate on it. */ @@ -61,19 +62,22 @@ const VERSION_PLAN: EditHistoryStep[] = [ /** * Build the sample document's history offline and seed it to YHub under the * given coordinates, so the live editor syncs the content and the version - * sidebar shows one snapshot per step. + * sidebar shows one version per step. * * The `fragment` must match the key the live editor reads (`doc.get(fragment)`). + * + * @returns each version's name and the server timestamp it should be named + * against — the caller writes those into the live doc's `__bn_versions` array. */ export async function seedSampleVersions(opts: { baseUrl: string; org: string; docId: string; fragment: string; -}): Promise { +}): Promise { const editor = BlockNoteEditor.create(); const build = await buildEditHistory(editor, VERSION_PLAN, { fragment: opts.fragment, }); - await seedYHubDocument(opts, build); + return seedYHubDocument(opts, build); } diff --git a/examples/07-collaboration/13-versioning-yjs14/src/seed.ts b/examples/07-collaboration/13-versioning-yjs14/src/seed.ts index 52e1779274..a0aea349f7 100644 --- a/examples/07-collaboration/13-versioning-yjs14/src/seed.ts +++ b/examples/07-collaboration/13-versioning-yjs14/src/seed.ts @@ -18,9 +18,13 @@ export interface SeedYHubDocumentOptions { headers?: Record; } -/** A version marker created on the server while seeding. */ +/** A named version produced by seeding. */ export interface SeededVersion { - id: string; + /** + * The version's server timestamp — the `to` of its last seeded edit, and so + * the key its name is stored under in the live doc's `__bn_versions` array. + */ + to: number; name: string; } @@ -42,45 +46,32 @@ type YHubPatch = { by?: string; /** Timestamp override (unix ms), so backfilled history stays ordered. */ at?: number; - /** Custom attributions riding this patch's content (e.g. version markers). */ + /** Custom attributions riding this patch's content. */ customAttributions?: Array<{ k: string; v: string }>; }; -/** Build the throwaway novel content a version marker rides on (see yhub.ts `patchDoc`). */ -function makeVersionMarkerUpdate(): Uint8Array { - // YHub only records custom attributions when they attach to NEW content that - // survives its server-side diff. The version's real content was already - // PATCHed (attributed to individual users), so the marker needs its own scrap - // of novel content: a single insert into a dedicated `__bn_version_markers` - // fragment the editor never renders. A fresh Y.Doc guarantees a clientID the - // server has never seen, so the diff is non-empty and the marker lands. - const markerDoc = new Y.Doc(); - markerDoc.get("__bn_version_markers", "XmlFragment").insert(0, ["v"]); - return Y.encodeStateAsUpdate(markerDoc); -} - /** * Pre-populate a YHub document with content **and** version history from a * {@link buildEditHistory} result, without a live editor / sync connection. * - * Each step's captured transactions are PATCHed to `/api/ydoc/v1/{org}/{docId}` as a - * single ordered `patches` bulk request: one content patch per captured - * transaction (attributed via `by`, **no** version marker), followed by one - * marker patch carrying a `type:version` custom attribution — the same marker - * {@link createYHubVersioningEndpoints}'s `create` uses. Because the version's - * attribution window spans all of its content patches, **multiple users are - * attributed within the one version**. The starting document state - * ({@link BuildEditHistoryResult.baseUpdate}) is PATCHed first, without a - * marker, so the step patches have their baseline to merge onto. + * Each step's captured transactions are PATCHed to `/api/ydoc/v1/{org}/{docId}` + * as a single ordered `patches` bulk request: one content patch per captured + * transaction, attributed via `by`. Nothing marks a version on the server — + * YHub's history *is* the version list — so a version is simply a run of edits + * separated from the next by a large gap, which is why **multiple users end up + * attributed within one version**. The starting document state + * ({@link BuildEditHistoryResult.baseUpdate}) is PATCHed first so the step + * patches have their baseline to merge onto. * * Every patch carries the explicit `at` timestamp captured by * {@link buildEditHistory}, so the backfilled history stays deterministically - * ordered (content before its marker, each version after the previous one). + * ordered (each version after the previous one). * * YHub speaks the V1 update format, so the V2 updates `buildEditHistory` - * produces are converted; the synthetic marker update is already V1. + * produces are converted. * - * @returns the version markers created, in order. + * @returns each version's name and its last edit's timestamp, in order — the + * caller writes those into the live doc's `__bn_versions` array to name them. * * @example * ```ts @@ -113,17 +104,18 @@ export async function seedYHubDocument( } }; - // 1. Starting document state — content only, no version marker. Timestamp it - // just before the first captured transaction so it sorts first. + // 1. Starting document state. Timestamp it just before the first captured + // transaction so it sorts first. await send({ update: Y.convertUpdateFormatV2ToV1(build.baseUpdate), at: build.steps[0]?.patches[0]?.at ?? Date.now(), customAttributions: [], }); - // 2. Each step: one content patch per captured transaction, then a single - // `type:version` marker patch so it appears as one snapshot attributed to - // every author. + // 2. Each step: one content patch per captured transaction. The step's last + // edit ends its group (the next version is days away, well past the + // example's `groupMaxGap`), so `step.at` is the timestamp the version's + // name attaches to. const versions: SeededVersion[] = []; for (const step of build.steps) { const patches: YHubPatch[] = step.patches.map((p) => ({ @@ -132,22 +124,9 @@ export async function seedYHubDocument( at: p.at, customAttributions: [], })); - // The marker patch carries the version itself. YHub attributes an entry to a - // single user, so credit the version to its last contributor (the per-content - // attribution still records who authored each part). - patches.push({ - update: makeVersionMarkerUpdate(), - by: step.by, - at: step.at, - customAttributions: [ - { k: "type", v: "version" }, - { k: "id", v: step.id }, - { k: "name", v: step.name }, - ], - }); await send({ patches }); - versions.push({ id: step.id, name: step.name }); + versions.push({ to: step.at, name: step.name }); } return versions; diff --git a/examples/07-collaboration/13-versioning-yjs14/src/snapshotBuilder.ts b/examples/07-collaboration/13-versioning-yjs14/src/snapshotBuilder.ts index de104ba037..db4bb3bf4e 100644 --- a/examples/07-collaboration/13-versioning-yjs14/src/snapshotBuilder.ts +++ b/examples/07-collaboration/13-versioning-yjs14/src/snapshotBuilder.ts @@ -2,7 +2,6 @@ import { BlockNoteEditor } from "@blocknote/core"; import { docDiffToDelta } from "@blocknote/core/y"; import { docToDelta } from "@y/prosemirror"; import * as Y from "@y/y"; -import { uint32 } from "lib0/random"; import { applyVersionUnbatched, type VersionBlock } from "./reconcile"; @@ -13,10 +12,11 @@ import { applyVersionUnbatched, type VersionBlock } from "./reconcile"; * reconciles the *same* editor instance towards a target document, producing a * burst of ProseMirror transactions. We capture every content-changing * transaction, diff its before/after ProseMirror docs (`docDiffToDelta`), apply - * that delta to a plain Y.Type in its own Yjs transaction (tagged with a random + * that delta to a plain Y.Node in its own Yjs transaction (tagged with a random * author as origin), and record the resulting V2 update. The captured updates - * can later be PATCHed to a server (see seed.ts) to rebuild the history, with a - * `type:version` marker committed at the end of each step. + * can later be PATCHed to a server (see seed.ts) to rebuild the history: each + * step's edits are separated from the next step's by a large gap, which is what + * makes them read as one version. * * The backing Y.Doc has gc disabled so history stays reconstructable. */ @@ -50,7 +50,6 @@ export type BuildEditHistoryResult = { /** One entry per step, in order, each carrying its captured transactions. */ steps: Array<{ name: string; - id: string; by?: string; at: number; patches: CapturedPatch[]; @@ -139,8 +138,8 @@ export async function buildEditHistory( const ydoc = new Y.Doc({ gc: false }); const yType = ydoc.get(options.fragment); - // Seed the Y.Type with the editor's starting doc so that every subsequent - // diff is relative to a Y.Type that actually mirrors the editor. Capture the + // Seed the Y.Node with the editor's starting doc so that every subsequent + // diff is relative to a Y.Node that actually mirrors the editor. Capture the // empty state vector first so we can expose the seed as `baseUpdate`. const emptyStateVector = Y.encodeStateVector(ydoc); ydoc.transact(() => { @@ -215,9 +214,8 @@ export async function buildEditHistory( }); resultSteps.push({ name: step.name, - id: String(uint32()), by: lastAuthor, - // Marker right after this version's last edit. + // This version's last edit, which is what its name attaches to. at: Math.floor(clock), patches, }); diff --git a/examples/07-collaboration/13-versioning-yjs14/src/style.css b/examples/07-collaboration/13-versioning-yjs14/src/style.css index 7edddb292a..a1ee1379c0 100644 --- a/examples/07-collaboration/13-versioning-yjs14/src/style.css +++ b/examples/07-collaboration/13-versioning-yjs14/src/style.css @@ -67,108 +67,6 @@ padding: 8px; } -/* Floating gear button pinned to the bottom-left of the viewport, opening the - live grouping "Configuration" panel. Styled like `.show-history-button`. - Both the gear and the panel are `position: fixed` with a very high z-index so - they stay above BlockNote's floating UI (toolbars, menus), which is portaled - to `document.body` and would otherwise pop up over an in-flow panel. */ -.config-gear-button { - align-items: center; - background-color: var(--bn-colors-menu-background); - border: var(--bn-border); - border-radius: 50%; - box-shadow: var(--bn-shadow-medium); - bottom: 16px; - color: var(--bn-colors-menu-text); - cursor: pointer; - display: flex; - font-size: 18px; - height: 40px; - justify-content: center; - left: 16px; - line-height: 1; - position: fixed; - width: 40px; - z-index: 99999; -} - -.config-panel { - background-color: var(--bn-colors-menu-background); - border: var(--bn-border); - border-radius: var(--bn-border-radius-medium); - bottom: 68px; - box-shadow: var(--bn-shadow-medium); - color: var(--bn-colors-menu-text); - display: flex; - flex-direction: column; - gap: 14px; - left: 16px; - padding: 14px; - position: fixed; - width: 260px; - z-index: 99999; -} - -.config-panel-title { - font-size: 13px; - font-weight: 600; -} - -.config-row { - display: flex; - flex-direction: column; - gap: 6px; -} - -.config-row > label { - align-items: baseline; - display: flex; - font-size: 12px; - font-weight: 500; - justify-content: space-between; -} - -.config-row .config-value { - color: var(--bn-colors-menu-text); - font-weight: 400; - opacity: 0.7; -} - -.config-row input[type="range"] { - width: 100%; -} - -.config-row input[type="number"], -.config-select { - background: var(--bn-colors-editor-background); - border: 1px solid var(--bn-colors-border); - border-radius: var(--bn-border-radius-small); - color: var(--bn-colors-menu-text); - font-size: 12px; - padding: 4px 6px; - width: 100%; -} - -.config-unlimited { - align-items: center; - display: flex; - font-size: 12px; - gap: 6px; -} - -/* The mergeUsers boolean row: checkbox + label + a muted hint on the right. */ -.config-toggle { - align-items: center; - display: flex; - font-size: 12px; - font-weight: 500; - gap: 6px; -} - -.config-toggle .config-value { - margin-left: auto; -} - .show-history-button { background-color: var(--bn-colors-menu-background); border: var(--bn-border); @@ -200,36 +98,3 @@ line-height: 12px; padding-left: 14px; } - -/* The versioning sidebar's tab switcher (Named Versions / Version History). - The theme stylesheets (@blocknote/mantine etc.) also style these, but this - example imports the editor without a single theme stylesheet in scope for - the sidebar, so the tab rules are repeated here to guarantee they're styled. */ -.bn-versioning-sidebar-tabs { - border-bottom: 1px solid var(--bn-colors-border); - display: flex; - gap: 4px; - margin-bottom: 8px; -} - -.bn-versioning-sidebar-tab { - background: transparent; - border: none; - border-bottom: 2px solid transparent; - color: var(--bn-colors-menu-text); - cursor: pointer; - font-size: 13px; - font-weight: 500; - margin-bottom: -1px; - opacity: 0.6; - padding: 8px 4px; -} - -.bn-versioning-sidebar-tab:hover { - opacity: 0.85; -} - -.bn-versioning-sidebar-tab[aria-selected="true"] { - border-bottom-color: var(--bn-colors-menu-text); - opacity: 1; -} diff --git a/examples/07-collaboration/13-versioning-yjs14/src/userdata.ts b/examples/07-collaboration/13-versioning-yjs14/src/userdata.ts index e692a99add..dbf2a04141 100644 --- a/examples/07-collaboration/13-versioning-yjs14/src/userdata.ts +++ b/examples/07-collaboration/13-versioning-yjs14/src/userdata.ts @@ -4,12 +4,15 @@ import type { User, UserStore } from "@blocknote/core"; // version sidebar / diff tooltips would show a bare number (e.g. "1") instead // of a name. The seed (`sampleDocument.ts`) attributes each contribution to one // of these ids via `attribution.by`. +// Colors are the `dark` values of BlockNote's own attribution palette +// (`userColorPalette`). Only `color` is set, so the pale mark background is +// derived from it (see `userMarkColors`). export const USERS: User[] = [ - { id: "1", username: "Alice", avatarUrl: "", color: "#e6194b" }, - { id: "2", username: "Bob", avatarUrl: "", color: "#3cb44b" }, - { id: "3", username: "Carol", avatarUrl: "", color: "#f58231" }, - { id: "4", username: "Dave", avatarUrl: "", color: "#4363d8" }, - { id: "5", username: "Erin", avatarUrl: "", color: "#911eb4" }, + { id: "1", username: "Alice", avatarUrl: "", color: "#3b3f9c" }, + { id: "2", username: "Bob", avatarUrl: "", color: "#0f6e62" }, + { id: "3", username: "Carol", avatarUrl: "", color: "#1e4fb0" }, + { id: "4", username: "Dave", avatarUrl: "", color: "#6b2fa3" }, + { id: "5", username: "Erin", avatarUrl: "", color: "#46525f" }, ]; /** diff --git a/examples/07-collaboration/14-suggestion-gallery/package.json b/examples/07-collaboration/14-suggestion-gallery/package.json index 34aeb8f0b1..fea558e6bf 100644 --- a/examples/07-collaboration/14-suggestion-gallery/package.json +++ b/examples/07-collaboration/14-suggestion-gallery/package.json @@ -22,7 +22,7 @@ "react-dom": "^19.2.3", "@blocknote/xl-multi-column": "latest", "@y/protocols": "^1.0.6-rc.1", - "@y/y": "^14.0.0-rc.23" + "@y/y": "^14.0.0-rc.26" }, "devDependencies": { "@types/react": "^19.2.3", diff --git a/examples/07-collaboration/14-suggestion-gallery/src/App.tsx b/examples/07-collaboration/14-suggestion-gallery/src/App.tsx index 15a1111dac..e90c1cbf1f 100644 --- a/examples/07-collaboration/14-suggestion-gallery/src/App.tsx +++ b/examples/07-collaboration/14-suggestion-gallery/src/App.tsx @@ -35,8 +35,8 @@ function makeAwareness(doc: Y.Doc, name: string, color: string): Awareness { // Hardcoded to match the attribution-mark palette (the colors BlockNote derives // per author id "A" / "B"), so a user's pane chrome matches their color in the // Diff / Merged panes. -const USER_A = { name: "User A", color: "#8a6d1a" }; -const USER_B = { name: "User B", color: "#8a2e24" }; +const USER_A = { name: "User A", color: "#46525f" }; +const USER_B = { name: "User B", color: "#8a6d1a" }; type Renderer = ReturnType; @@ -113,14 +113,8 @@ function SuggestionsView({ scenario }: { scenario: SuggestionScenario }) { }, []); const authors = suggestionAuthors(scenario); - const paneCount = 1 + authors.length + (authors.length > 1 ? 1 : 0); return ( -
= 4 ? " bn-gallery-editors--four" : "") - } - > +
Base (editable)
@@ -236,9 +230,7 @@ function UserSuggestion({ className="bn-gallery-pane" style={{ borderTopColor: user.color, borderTopWidth: 3 }} > -
- {label} -
+
{label}
); @@ -373,14 +365,7 @@ function VersioningView({ scenario }: { scenario: SuggestionScenario }) { }, []); return ( -
1 - ? "bn-gallery-editors--four" - : "bn-gallery-editors--three") - } - > +
Version 1 (editable)
@@ -535,9 +520,7 @@ function UserVersion({ className="bn-gallery-pane" style={{ borderTopColor: user.color, borderTopWidth: 3 }} > -
- {label} -
+
{label}
); diff --git a/examples/07-collaboration/14-suggestion-gallery/src/scenarioDocs.ts b/examples/07-collaboration/14-suggestion-gallery/src/scenarioDocs.ts index 551db51ed1..2844ba6d6a 100644 --- a/examples/07-collaboration/14-suggestion-gallery/src/scenarioDocs.ts +++ b/examples/07-collaboration/14-suggestion-gallery/src/scenarioDocs.ts @@ -64,7 +64,7 @@ export function buildSuggestionScenarioDocs( const suggestionDoc = cloneDoc(baseDoc, { isSuggestionDoc: true }); suggestionDoc.clientID = i + 2; const manager = Y.createDiffRenderer(baseDoc, suggestionDoc, { - attrs: createAttributionStore(suggestionDoc, (tr) => + attributions: createAttributionStore(suggestionDoc, (tr) => tr.local ? id : null, ), }); @@ -78,7 +78,7 @@ export function buildSuggestionScenarioDocs( const doc = cloneDoc(baseDoc, { isSuggestionDoc: true }); doc.clientID = authorIds.length + 2; const manager = Y.createDiffRenderer(baseDoc, doc, { - attrs: createAttributionStore(doc, (tr) => + attributions: createAttributionStore(doc, (tr) => authorIds.includes(String(tr.origin)) ? String(tr.origin) : null, ), }); @@ -92,7 +92,7 @@ export function buildSuggestionScenarioDocs( /** * In-memory attribution store — records the author of each transaction into a - * mutable `Y.Attributions` so suggestion marks render in their author's color. + * mutable `Y.ContentMap` so suggestion marks render in their author's color. * `resolveUserId` returns the author id, or null to leave a change unattributed * (the base seed and the manager's own base→suggestion flow carry no author). * Mirrors the store in `concurrentSuggestionFixture.tsx`. @@ -100,8 +100,8 @@ export function buildSuggestionScenarioDocs( export function createAttributionStore( doc: Y.Doc, resolveUserId: (tr: any) => string | null, -): Y.Attributions { - const attrs = new Y.Attributions(); +): Y.ContentMap { + const attrs = Y.createContentMap(); doc.on("beforeObserverCalls", (tr: any) => { const userId = resolveUserId(tr); if (userId == null) { diff --git a/examples/07-collaboration/14-suggestion-gallery/src/scenarios.ts b/examples/07-collaboration/14-suggestion-gallery/src/scenarios.ts index e485ed3f87..8635231c59 100644 --- a/examples/07-collaboration/14-suggestion-gallery/src/scenarios.ts +++ b/examples/07-collaboration/14-suggestion-gallery/src/scenarios.ts @@ -178,12 +178,6 @@ export const scenarios: SuggestionScenario[] = [ { kind: "single", id: "add-nested-bullets", - feedback: [ - { - severity: "low", - note: "Nested bullets all render as • instead of •/◦/▪ — the suggestion-mark wrappers (display: contents) break the depth-detecting CSS chains. Fix: compute each bullet's nesting level in JS and expose it as data-bullet-level, then pick the glyph with a wrapper-independent attribute selector (as numbered lists do with data-index).", - }, - ], title: "Add nested bullets", category: "Add / remove blocks", description: @@ -237,16 +231,6 @@ export const scenarios: SuggestionScenario[] = [ { kind: "single", id: "nest-bullet-existing", - feedback: [ - { - severity: "low", - note: "Nested bullets all render as • instead of •/◦/▪ — the suggestion-mark wrappers (display: contents) break the depth-detecting CSS chains. Fix: compute each bullet's nesting level in JS and expose it as data-bullet-level, then pick the glyph with a wrapper-independent attribute selector (as numbered lists do with data-index).", - }, - { - severity: "low", - note: "Going from 0 to 1+ children re-creates the block as a new one — so concurrent edits to the original block can be lost, the whole new block is attributed to whoever made the change, and the diff takes more space than needed. A consequence of the schema fix.", - }, - ], title: "Nest a bullet under another", category: "Add / remove blocks", description: "Nest the second bullet under the first.", @@ -300,12 +284,6 @@ export const scenarios: SuggestionScenario[] = [ { kind: "single", id: "delete-nested", - feedback: [ - { - severity: "low", - note: "Going from 1+ to 0 children re-creates the block as a new one — so concurrent edits to the original block can be lost, the whole new block is attributed to whoever made the change, and the diff takes more space than needed. A consequence of the schema fix.", - }, - ], title: "Delete a nested block", category: "Add / remove blocks", description: "Delete the nested child of a parent block.", @@ -577,12 +555,6 @@ export const scenarios: SuggestionScenario[] = [ { kind: "single", id: "nesting-indent", - feedback: [ - { - severity: "low", - note: "Going from 0 to 1+ children re-creates the block as a new one — so concurrent edits to the original block can be lost, the whole new block is attributed to whoever made the change, and the diff takes more space than needed. A consequence of the schema fix.", - }, - ], title: "Indent a block", category: "Nesting", description: @@ -601,12 +573,6 @@ export const scenarios: SuggestionScenario[] = [ kind: "single", id: "nesting-unindent", title: "Unindent a block", - feedback: [ - { - severity: "low", - note: "Going from 1+ to 0 children re-creates the block as a new one — so concurrent edits to the original block can be lost, the whole new block is attributed to whoever made the change, and the diff takes more space than needed. A consequence of the schema fix.", - }, - ], category: "Nesting", description: "Un-nest N1 out of N0 (outdent) back to a top-level sibling.", initial: [ @@ -625,12 +591,6 @@ export const scenarios: SuggestionScenario[] = [ { kind: "single", id: "nesting-change-parent-type", - feedback: [ - { - severity: "low", - note: "Changing a parent's type deletes the old block and creates a new one — so concurrent edits to the original block can be lost, and the entire new block is attributed to whoever changed the type. A consequence of the schema fix.", - }, - ], title: "Change type of a parent block", category: "Nesting", description: @@ -654,17 +614,11 @@ export const scenarios: SuggestionScenario[] = [ { kind: "single", id: "prop-text-alignment", - feedback: [ - { - severity: "low", - note: "Block-level prop changes produce no y-attributed-* mark, so the pending change renders as if already accepted — it's invisible in the diff.", - }, - ], title: "Center-align", category: "Prop changes", description: "Change a paragraph's text alignment from left to center — a block-level " + - "prop change (no insert/delete marks are generated).", + "prop change highlighted as a formatting change.", initial: [{ id: "block-hello", type: "paragraph", content: "hello world" }], apply: (editor) => { const [block] = editor.document; @@ -677,12 +631,6 @@ export const scenarios: SuggestionScenario[] = [ { kind: "single", id: "prop-heading-level", - feedback: [ - { - severity: "low", - note: "Block-level prop changes produce no y-attributed-* mark, so the pending change renders as if already accepted — it's invisible in the diff.", - }, - ], title: "Demote heading", category: "Prop changes", description: "Change a heading from level 1 to level 2.", @@ -702,12 +650,6 @@ export const scenarios: SuggestionScenario[] = [ { kind: "single", id: "prop-image-width", - feedback: [ - { - severity: "low", - note: "Block-level prop changes produce no y-attributed-* mark, so the pending change renders as if already accepted — it's invisible in the diff.", - }, - ], title: "Resize image", category: "Prop changes", description: "Change an image's previewWidth (200 → 400).", @@ -729,12 +671,6 @@ export const scenarios: SuggestionScenario[] = [ { kind: "single", id: "prop-image-source", - feedback: [ - { - severity: "low", - note: "Block-level prop changes produce no y-attributed-* mark, so the pending change renders as if already accepted — it's invisible in the diff.", - }, - ], title: "Change image source", category: "Prop changes", description: "Swap an image's url for a different source.", @@ -1062,14 +998,9 @@ export const scenarios: SuggestionScenario[] = [ title: "Text color vs background color", category: "Prop changes", description: - "A sets text color red while B sets background yellow; both apply.", + "A sets text color red while B sets background yellow; both prop changes " + + "merge, each highlighted in its author's color.", initial: [{ id: "block-hello", type: "paragraph", content: "hello world" }], - feedback: [ - { - severity: "low", - note: "Block-level prop changes produce no y-attributed-* mark, so the pending change renders as if already accepted — it's invisible in the diff.", - }, - ], applyA: (editor) => { const [block] = editor.document; editor.updateBlock(block, { diff --git a/examples/07-collaboration/14-suggestion-gallery/src/style.css b/examples/07-collaboration/14-suggestion-gallery/src/style.css index 68dae69154..034b54a5fd 100644 --- a/examples/07-collaboration/14-suggestion-gallery/src/style.css +++ b/examples/07-collaboration/14-suggestion-gallery/src/style.css @@ -1,4 +1,13 @@ .bn-gallery { + color-scheme: light dark; + --gallery-text: light-dark(#333, #e0e0e0); + --gallery-muted: light-dark(#666, #aaa); + --gallery-border: light-dark(#e6e6e6, #484848); + --gallery-surface: light-dark(#fafafa, #2e2e2e); + --gallery-hover: light-dark(#f2f2f2, #383838); + --gallery-selected: light-dark(#e7f1ff, #193b59); + --gallery-accent: light-dark(#1971c2, #91caff); + color: var(--gallery-text); display: grid; grid-template-columns: 240px 1fr; gap: 16px; @@ -9,7 +18,7 @@ .bn-gallery-sidebar { overflow-y: auto; - border-right: 1px solid #e6e6e6; + border-right: 1px solid var(--gallery-border); padding-right: 12px; } @@ -17,7 +26,7 @@ font-size: 14px; text-transform: uppercase; letter-spacing: 0.04em; - color: #888; + color: var(--gallery-muted); margin: 0 0 12px; } @@ -28,7 +37,7 @@ .bn-gallery-category-label { font-size: 12px; font-weight: 600; - color: #aaa; + color: var(--gallery-muted); margin-bottom: 4px; } @@ -42,20 +51,22 @@ background: transparent; cursor: pointer; font-size: 14px; - color: #333; + color: var(--gallery-text); } .bn-gallery-item:hover { - background: #f2f2f2; + background: var(--gallery-hover); } -.bn-gallery-item--active { - background: #e7f1ff; - color: #1971c2; +.bn-gallery-item--active, +.bn-gallery-item--active:hover { + background: var(--gallery-selected); + color: var(--gallery-accent); font-weight: 600; } .bn-gallery-main { + min-width: 0; overflow-y: auto; } @@ -69,7 +80,7 @@ .bn-gallery-modes { display: inline-flex; - border: 1px solid #d8d8d8; + border: 1px solid var(--gallery-border); border-radius: 8px; overflow: hidden; flex-shrink: 0; @@ -78,14 +89,14 @@ .bn-gallery-mode { padding: 6px 14px; border: none; - background: #fff; + background: var(--gallery-surface); cursor: pointer; font-size: 14px; - color: #555; + color: var(--gallery-text); } .bn-gallery-mode + .bn-gallery-mode { - border-left: 1px solid #d8d8d8; + border-left: 1px solid var(--gallery-border); } .bn-gallery-mode--active { @@ -94,33 +105,25 @@ font-weight: 600; } -.bn-gallery-editors--three { - grid-template-columns: 1fr 1fr 1fr; -} - -.bn-gallery-editors--four { - grid-template-columns: 1fr 1fr 1fr 1fr; -} - .bn-gallery-title { font-size: 20px; margin: 0 0 4px; } .bn-gallery-description { - color: #666; + color: var(--gallery-muted); margin: 0 0 16px; max-width: 60ch; } .bn-gallery-editors { display: grid; - grid-template-columns: 1fr 1fr; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 360px), 1fr)); gap: 12px; } .bn-gallery-pane { - border: 1px solid #e6e6e6; + border: 1px solid var(--gallery-border); border-radius: 8px; padding: 8px; min-width: 0; @@ -129,16 +132,16 @@ .bn-gallery-pane-label { font-size: 12px; font-weight: 600; - color: #888; + color: var(--gallery-muted); padding: 4px 8px; } .bn-gallery-feedback { - border: 1px solid #ececec; + border: 1px solid var(--gallery-border); border-radius: 8px; padding: 10px 12px; margin-bottom: 16px; - background: #fafafa; + background: var(--gallery-surface); } .bn-gallery-feedback-title { @@ -146,7 +149,7 @@ font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; - color: #888; + color: var(--gallery-muted); margin-bottom: 6px; } @@ -156,13 +159,13 @@ align-items: baseline; font-size: 13px; line-height: 1.45; - color: #444; + color: var(--gallery-text); padding: 5px 0 5px 8px; border-left: 3px solid transparent; } .bn-gallery-feedback-item + .bn-gallery-feedback-item { - border-top: 1px solid #efefef; + border-top: 1px solid var(--gallery-border); } .bn-gallery-feedback-item--high { @@ -194,10 +197,28 @@ } .bn-gallery-feedback-item--info { - border-left-color: #1971c2; + border-left-color: var(--gallery-accent); } .bn-gallery-feedback-item--info .bn-gallery-feedback-badge { - background: #e7f1ff; - color: #1971c2; + background: var(--gallery-selected); + color: var(--gallery-accent); +} + +@media (max-width: 700px) { + .bn-gallery { + grid-template-columns: 1fr; + height: auto; + } + + .bn-gallery-sidebar { + max-height: 240px; + border-right: none; + border-bottom: 1px solid var(--gallery-border); + padding-bottom: 12px; + } + + .bn-gallery-header { + flex-wrap: wrap; + } } diff --git a/examples/08-extensions/02-versioning/.bnexample.json b/examples/08-extensions/02-versioning/.bnexample.json index c7fc1ec4a4..4d5b180aec 100644 --- a/examples/08-extensions/02-versioning/.bnexample.json +++ b/examples/08-extensions/02-versioning/.bnexample.json @@ -5,6 +5,7 @@ "tags": ["Extension"], "dependencies": { "@y/y": "^14.0.0-rc.23", - "@y/prosemirror": "^2.0.0-6" + "@y/prosemirror": "^2.0.0-6", + "react-icons": "^5.5.0" } } diff --git a/examples/08-extensions/02-versioning/README.md b/examples/08-extensions/02-versioning/README.md index 7d018afd9b..ba4ec53f96 100644 --- a/examples/08-extensions/02-versioning/README.md +++ b/examples/08-extensions/02-versioning/README.md @@ -2,4 +2,4 @@ This example shows how to use the `VersioningExtension` without any collaboration layer (no Yjs required). Snapshots are stored in memory using ProseMirror JSON. -**Try it out:** Edit the document, then use the Version History sidebar to save snapshots, preview older versions, rename them, and restore them. You can hide the sidebar with the close button and reopen it with the "History" button. +The sidebar opens on a document with a few versions already in its history, so you can preview them, compare 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. diff --git a/examples/08-extensions/02-versioning/package.json b/examples/08-extensions/02-versioning/package.json index 46b9bb8380..2d4caaa6dd 100644 --- a/examples/08-extensions/02-versioning/package.json +++ b/examples/08-extensions/02-versioning/package.json @@ -20,8 +20,9 @@ "@mantine/hooks": "^9.0.2", "react": "^19.2.3", "react-dom": "^19.2.3", - "@y/y": "^14.0.0-rc.23", - "@y/prosemirror": "^2.0.0-6" + "@y/y": "^14.0.0-rc.26", + "@y/prosemirror": "^2.0.0-11", + "react-icons": "^5.5.0" }, "devDependencies": { "@types/react": "^19.2.3", diff --git a/examples/08-extensions/02-versioning/src/App.tsx b/examples/08-extensions/02-versioning/src/App.tsx index ce6c388233..ff0a17b58a 100644 --- a/examples/08-extensions/02-versioning/src/App.tsx +++ b/examples/08-extensions/02-versioning/src/App.tsx @@ -1,46 +1,46 @@ +import { BlockNoteViewEditor, useCreateBlockNote } from "@blocknote/react"; import "@blocknote/core/fonts/inter.css"; +import { BlockNoteEditor } from "@blocknote/core"; import { VersioningExtension, createInMemoryVersioningAdapter, } from "@blocknote/core/extensions"; import { DiffVersioningExtension } from "@blocknote/core/y"; import { - BlockNoteViewEditor, - useCreateBlockNote, - useExtensionState, + DefaultVersionMenuItems, + useVersionSnapshot, + VersionMenu, + VersionMenuItem, VersioningSidebar, -} from "@blocknote/react"; +} from "@blocknote/react/versioning"; +import { RiFileCopyLine } from "react-icons/ri"; import { BlockNoteView } from "@blocknote/mantine"; import "@blocknote/mantine/style.css"; import { useState } from "react"; +import { DAY_MS, LIVE_DOCUMENT, SAMPLE_HISTORY } from "./sampleVersions"; import "./style.css"; export default function App() { - // `createInMemoryVersioningAdapter` is passed as a factory function. The - // VersioningExtension will call it with the editor instance once it's ready. + // The adapter is created per editor, so it's passed as a factory: the + // VersioningExtension calls it with the editor instance once that's ready. + // The store starts out with a few versions, the way an application would + // load the history it persisted. const editor = useCreateBlockNote({ - initialContent: [ - { - type: "heading", - content: "In-Memory Versioning Example", - props: { level: 2 }, - }, - { - type: "paragraph", - content: - "This example demonstrates versioning without any collaboration layer. " + - "Snapshots are stored in memory using ProseMirror JSON — no Yjs required.", - }, - { - type: "paragraph", - content: - "Try editing this document, then use the Version History sidebar to " + - "save snapshots. You can preview and restore older versions.", - }, - ], + initialContent: LIVE_DOCUMENT, extensions: [ - VersioningExtension(createInMemoryVersioningAdapter), + VersioningExtension((editor) => + createInMemoryVersioningAdapter(editor, { + initialVersions: SAMPLE_HISTORY.map((version) => ({ + name: version.name, + createdAt: Date.now() - version.daysAgo * DAY_MS, + // The store keeps `Block[]`; a headless editor fills in the block + // defaults the sample leaves out. + content: BlockNoteEditor.create({ initialContent: version.blocks }) + .document, + })), + }), + ), // Opt into rendering version diffs: when comparing two versions the // sidebar shows insertions/deletions as attributed marks. Without this // extension the in-memory versioning falls back to a plain document swap. @@ -48,19 +48,13 @@ export default function App() { ], }); - const { previewedSnapshotId } = useExtensionState(VersioningExtension, { - editor, - }); - const [showSidebar, setShowSidebar] = useState(true); return (
- + {/* No `editable` prop: the sidebar makes the editor read-only for as + long as it's open, and restores it on close. */} +
@@ -76,8 +70,15 @@ export default function App() { {showSidebar && (
setShowSidebar(false)} + // Extend the row menu by composing it: the default items plus + // an app-specific one. Order is yours to choose. + snapshotMenu={ + + + + + } />
)} @@ -86,3 +87,24 @@ export default function App() {
); } + +/** + * An application-specific row action. `useVersionSnapshot()` hands it the row + * it was rendered in, so it needs no props — the sidebar knows nothing about it. + */ +function MakeCopyItem() { + const { snapshot, isCurrent } = useVersionSnapshot(); + + return ( + } + onClick={() => { + window.alert( + `Would copy ${isCurrent ? "the current version" : (snapshot.name ?? new Date(snapshot.createdAt).toLocaleString())} into a new document.`, + ); + }} + > + Make a copy + + ); +} diff --git a/examples/08-extensions/02-versioning/src/sampleVersions.ts b/examples/08-extensions/02-versioning/src/sampleVersions.ts new file mode 100644 index 0000000000..202c5af663 --- /dev/null +++ b/examples/08-extensions/02-versioning/src/sampleVersions.ts @@ -0,0 +1,114 @@ +import type { PartialBlock } from "@blocknote/core"; + +export const DAY_MS = 24 * 60 * 60 * 1000; + +export type SampleVersion = { + name: string; + /** How long ago the version was saved. */ + daysAgo: number; + blocks: PartialBlock[]; +}; + +// 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) { + 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.slice(-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.", + }, +]; + +/** Saved versions, oldest first. */ +export const SAMPLE_HISTORY: SampleVersion[] = [ + { name: "First draft", daysAgo: 9, blocks: firstDraft }, + { name: "Added dates", daysAgo: 6, blocks: addedDates }, + { name: "Marketing review", daysAgo: 2, blocks: marketingReview }, +]; + +/** The newest version with unsaved edits to compare against. */ +export const LIVE_DOCUMENT: PartialBlock[] = [ + ...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?", + }, +]; diff --git a/packages/ariakit/src/components.ts b/packages/ariakit/src/components.ts index d97a129f3f..ae9d28a63c 100644 --- a/packages/ariakit/src/components.ts +++ b/packages/ariakit/src/components.ts @@ -95,6 +95,9 @@ export const components: Components = { Versioning: { Sidebar: VersioningSidebar, Snapshot: VersioningSnapshot, + // The sidebar's loader is the same dots/spinner as the suggestion menu's — + // one spinner per UI package, not one per feature. + Loader: SuggestionMenuLoader, }, Generic: { Badge: { diff --git a/packages/ariakit/src/menu/Menu.tsx b/packages/ariakit/src/menu/Menu.tsx index c2a401204a..58d261fe51 100644 --- a/packages/ariakit/src/menu/Menu.tsx +++ b/packages/ariakit/src/menu/Menu.tsx @@ -63,8 +63,16 @@ export const MenuItem = forwardRef< HTMLDivElement, ComponentProps["Generic"]["Menu"]["Item"] >((props, ref) => { - const { className, children, icon, checked, subTrigger, onClick, ...rest } = - props; + const { + className, + children, + icon, + checked, + disabled, + subTrigger, + onClick, + ...rest + } = props; assertEmpty(rest); @@ -75,6 +83,7 @@ export const MenuItem = forwardRef< className={mergeCSSClasses("bn-ak-menu-item", className || "")} ref={ref} onClick={onClick} + disabled={disabled} > {icon} {children} @@ -88,6 +97,7 @@ export const MenuItem = forwardRef< className={mergeCSSClasses("bn-ak-menu-item", className || "")} ref={ref} onClick={onClick} + disabled={disabled} > {icon} {children} diff --git a/packages/ariakit/src/toolbar/Toolbar.tsx b/packages/ariakit/src/toolbar/Toolbar.tsx index 46fff5c986..b112315dd1 100644 --- a/packages/ariakit/src/toolbar/Toolbar.tsx +++ b/packages/ariakit/src/toolbar/Toolbar.tsx @@ -10,9 +10,11 @@ export const Toolbar = forwardRef( (props, ref) => { const { className, + "aria-label": ariaLabel, children, onMouseEnter, onMouseLeave, + trapFocus: _trapFocus, variant: _variant, ...rest } = props; @@ -22,6 +24,7 @@ export const Toolbar = forwardRef( return ( ((props, ref) => { - const { className, children, ...rest } = props; - - assertEmpty(rest, false); - - return ( -
- {children} -
- ); -}); - -export const Snapshot = forwardRef< - HTMLDivElement, - ComponentProps["Versioning"]["Snapshot"] ->((props, ref) => { - const { - className, - selected, - comparing, - onClick, - actions, - children, - ...rest - } = props; - - assertEmpty(rest, false); - - return ( -
- {children} - {actions && ( - // Isolate the actions area so clicks on the menu (trigger and items, - // which render inline rather than in a portal) don't bubble to the - // row's select handler. -
event.stopPropagation()} - > - {actions} -
- )} -
- ); -}); +export { + VersioningSidebarRoot as Sidebar, + VersioningSnapshotRow as Snapshot, +} from "@blocknote/react/versioning"; diff --git a/packages/core/package.json b/packages/core/package.json index a9a159025a..51cd2a2ba0 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -108,7 +108,7 @@ "@tiptap/pm": "^3.29.2", "emoji-mart": "^5.6.0", "fast-deep-equal": "^3.1.3", - "lib0": "1.0.0-rc.22", + "lib0": "1.0.0-rc.32", "prosemirror-highlight": "^0.15.3", "prosemirror-model": "^1.25.11", "prosemirror-state": "^1.4.4", @@ -127,9 +127,9 @@ "yjs": "^13.6.27" }, "peerDependencies": { - "@y/prosemirror": "^2.0.0-6", + "@y/prosemirror": "^2.0.0-11", "@y/protocols": "^1.0.6-rc.1", - "@y/y": "^14.0.0-rc.23", + "@y/y": "^14.0.0-rc.26", "y-prosemirror": "^1.3.7", "y-protocols": "^1.0.6", "yjs": "^13.6.27" diff --git a/packages/core/src/editor/Block.css b/packages/core/src/editor/Block.css index ef2867121d..af62b569d4 100644 --- a/packages/core/src/editor/Block.css +++ b/packages/core/src/editor/Block.css @@ -81,13 +81,15 @@ NESTED BLOCKS margin-left: 24px; } -.bn-block-group .bn-block-group > .bn-block-outer { +/* Attribution wrappers use display: contents; descendant selectors also reach + nested blocks inside those wrappers. */ +.bn-block-group .bn-block-group .bn-block-outer { position: relative; } .bn-block-group .bn-block-group - > .bn-block-outer:not([data-prev-depth-changed])::before { + .bn-block-outer:not([data-prev-depth-changed])::before { content: " "; display: inline; position: absolute; @@ -98,7 +100,7 @@ NESTED BLOCKS .bn-block-group .bn-block-group - > .bn-block-outer[data-prev-depth-change="-2"]::before { + .bn-block-outer[data-prev-depth-change="-2"]::before { height: 0; } @@ -197,11 +199,23 @@ NESTED BLOCKS .bn-block-outer:not([data-prev-type]) > .bn-block > .bn-block-content[data-content-type="heading"], +.bn-block-outer:not([data-prev-type]) + > .bn-block + > :is(ins, del, [data-type="attributes"]) + > span + > :is(ins, del, [data-type="attributes"]) + > span + > .bn-block-content[data-content-type="heading"], .bn-block-outer:not([data-prev-type]) > .bn-block > div[data-type="modification"] > div[data-type="modification"] > .bn-block-content[data-content-type="heading"], +.bn-block-outer:not([data-prev-type]) + > .bn-block + > [data-type="attributes"] + > span + > .bn-block-content[data-content-type="heading"], .bn-block-outer:not([data-prev-type]) > .bn-block > :is(ins, del) @@ -259,6 +273,18 @@ NESTED BLOCKS .bn-block-outer:not([data-prev-type]) > .bn-block > .bn-block-content[data-content-type="numberedListItem"]::before, +.bn-block-outer:not([data-prev-type]) + > .bn-block + > :is(ins, del, [data-type="attributes"]) + > span + > :is(ins, del, [data-type="attributes"]) + > span + > .bn-block-content[data-content-type="numberedListItem"]::before, +.bn-block-outer:not([data-prev-type]) + > .bn-block + > [data-type="attributes"] + > span + > .bn-block-content[data-content-type="numberedListItem"]::before, .bn-block-outer:not([data-prev-type]) > .bn-block > div[data-type="modification"] @@ -364,78 +390,52 @@ NESTED BLOCKS background-color: var(--bn-colors-hovered-background); } -/* No list nesting */ -.bn-block-outer[data-prev-type="bulletListItem"] - > .bn-block - > .bn-block-content::before { - content: "•"; +/* Keep the marker on the group so it inherits through attribution wrappers + between the group and its blocks. Each group resets it, so a list nested + under a paragraph starts with a disc again. */ +.bn-block-group { + --bn-bullet-marker: "•"; } -.bn-block-outer:not([data-prev-type]) - > .bn-block - > .bn-block-content[data-content-type="bulletListItem"]::before, -.bn-block-outer:not([data-prev-type]) - > .bn-block - > div[data-type="modification"] - > .bn-block-content[data-content-type="bulletListItem"]::before, -.bn-block-outer:not([data-prev-type]) - > .bn-block - > :is(ins, del) - > .bn-suggestion-node - .bn-block-content[data-content-type="bulletListItem"]::before { - content: "•"; +[data-content-type="bulletListItem"] ~ .bn-block-group { + --bn-bullet-marker: "◦"; } -/* 1 level of list nesting */ [data-content-type="bulletListItem"] ~ .bn-block-group - > .bn-block-outer[data-prev-type="bulletListItem"] - > .bn-block - > .bn-block-content::before { - content: "◦"; + [data-content-type="bulletListItem"] + ~ .bn-block-group { + --bn-bullet-marker: "▪\FE0E"; } -[data-content-type="bulletListItem"] - ~ .bn-block-group - > .bn-block-outer:not([data-prev-type]) +.bn-block-outer[data-prev-type="bulletListItem"] > .bn-block - > .bn-block-content[data-content-type="bulletListItem"]::before, -[data-content-type="bulletListItem"] - ~ .bn-block-group - > .bn-block-outer:not([data-prev-type]) + > .bn-block-content::before, +.bn-block-outer:not([data-prev-type]) > .bn-block - > div[data-type="modification"] - > .bn-block-content[data-content-type="bulletListItem"]::before { - content: "◦"; -} - -/* 2 levels of list nesting */ -[data-content-type="bulletListItem"] - ~ .bn-block-group - [data-content-type="bulletListItem"] - ~ .bn-block-group - > .bn-block-outer[data-prev-type="bulletListItem"] + > .bn-block-content[data-content-type="bulletListItem"]::before, +.bn-block-outer:not([data-prev-type]) > .bn-block - > .bn-block-content::before { - content: "▪\FE0E"; -} - -[data-content-type="bulletListItem"] - ~ .bn-block-group - [data-content-type="bulletListItem"] - ~ .bn-block-group - > .bn-block-outer:not([data-prev-type]) + > :is(ins, del, [data-type="attributes"]) + > span + > :is(ins, del, [data-type="attributes"]) + > span + > .bn-block-content[data-content-type="bulletListItem"]::before, +.bn-block-outer:not([data-prev-type]) > .bn-block + > [data-type="attributes"] + > span > .bn-block-content[data-content-type="bulletListItem"]::before, -[data-content-type="bulletListItem"] - ~ .bn-block-group - [data-content-type="bulletListItem"] - ~ .bn-block-group - > .bn-block-outer:not([data-prev-type]) +.bn-block-outer:not([data-prev-type]) > .bn-block > div[data-type="modification"] - > .bn-block-content[data-content-type="bulletListItem"]::before { - content: "▪\FE0E"; + > .bn-block-content[data-content-type="bulletListItem"]::before, +.bn-block-outer:not([data-prev-type]) + > .bn-block + > :is(ins, del) + > .bn-suggestion-node + .bn-block-content[data-content-type="bulletListItem"]::before { + content: var(--bn-bullet-marker); } /* CODE BLOCKS */ @@ -1090,9 +1090,15 @@ div[data-type="modification"] { border-radius: 4px; } +/* Lift attribution fills above the dark editor surface. Mixing the already + dark author color with black made short insertions nearly invisible. */ .dark.bn-root ins, .dark.bn-root del { - background-color: color-mix(in srgb, var(--user-color-dark) 50%, black); + background-color: color-mix( + in srgb, + var(--user-color-light) 25%, + var(--bn-colors-editor-background) + ); color: var(--user-color-light); } @@ -1111,7 +1117,11 @@ serialized/static output, where the wrapper is a real, painted box. } .dark.bn-root .bn-suggestion-mark { - background-color: color-mix(in srgb, var(--user-color-dark) 50%, black); + background-color: color-mix( + in srgb, + var(--user-color-light) 25%, + var(--bn-colors-editor-background) + ); color: white; } @@ -1132,7 +1142,11 @@ row/cell). Block deletions that *do* wrap blocks are restyled per-block below. } .dark.bn-root .bn-suggestion-node > * { - background-color: color-mix(in srgb, var(--user-color-dark) 50%, black); + background-color: color-mix( + in srgb, + var(--user-color-light) 25%, + var(--bn-colors-editor-background) + ); } /* @@ -1149,11 +1163,12 @@ that wrap blocks get a per-block badge below instead. display: inline-block; margin-right: 6px; padding: 0 4px; + /* Matches the block-level badge below, so the two read as one label style. */ font-size: 11px; - font-weight: bold; + font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; - line-height: 1.4; + line-height: 16px; vertical-align: middle; /* Use the editor's text color (themed for light/dark) rather than inheriting, which would pick up the deleted content's user color. */ @@ -1202,8 +1217,8 @@ hence the descendant match. Deleted table cells are a special case: a / has no `.bn-block-content` and its text sits in a bare

(not `.bn-inline-content`), so neither the strikethrough above nor the block card reaches it — and a table row/cell can't -host the "Deleted" card anyway. Treat them like inline deletions instead: strike -the cell text through in the author's color, and suppress the fallback badge. +host the "Deleted" card anyway. Strike the cell text through in the author's +color and suppress badges inside cells, including nested paragraph badges. */ .bn-suggestion-node--delete :is(td, th) p { color: var(--user-color-dark); @@ -1214,7 +1229,8 @@ the cell text through in the author's color, and suppress the fallback badge. color: var(--user-color-light); } -.bn-suggestion-node--delete > :is(table, tr, td, th):first-child::before { +.bn-suggestion-node--delete > :is(table, tr, td, th):first-child::before, +:is(td, th) .bn-suggestion-node--delete > :first-child::before { content: none; } @@ -1227,16 +1243,34 @@ spans the whole subtree; the media wrapper exists only for files), but it sets only non-collapsing properties — background / radius / padding never depend on the content's intrinsic size, so no block can break. */ +/* Attribute changes use the same card for text blocks as for media blocks. */ +[data-type="attributes"] > .bn-suggestion-node > .bn-block-content, .bn-suggestion-node .bn-block-content:not(:has(.bn-inline-content)) { + /* The card bleeds this far into the gutters on both sides, so tinting a block + never shifts its content sideways. */ + --bn-suggestion-card-inset: 6px; background-color: color-mix(in srgb, var(--user-color-light) 50%, white); - border-radius: 16px; - padding: 12px; + border-radius: 6px; + /* A hairline in the author's color, so a pale tint still reads as a card. */ + box-shadow: 0 0 0 1px + color-mix(in srgb, var(--user-color-dark) 25%, transparent); + padding: 3px var(--bn-suggestion-card-inset); + margin-left: calc(-1 * var(--bn-suggestion-card-inset)); + width: calc(100% + 2 * var(--bn-suggestion-card-inset)); } +.dark.bn-root + [data-type="attributes"] + > .bn-suggestion-node + > .bn-block-content, .dark.bn-root .bn-suggestion-node .bn-block-content:not(:has(.bn-inline-content)) { - background-color: color-mix(in srgb, var(--user-color-dark) 50%, black); + background-color: color-mix( + in srgb, + var(--user-color-light) 25%, + var(--bn-colors-editor-background) + ); } /* @@ -1256,11 +1290,12 @@ gated on a wrapper that only width-bearing blocks have. /* A deletion additionally flags the block with the localized "Deleted" label, placed -above the content (out of flow) with extra top padding reserving its row. +above the content (out of flow) with extra top padding reserving its row: the +card's own 3px, the label's 16px line box, and 2px of breathing room under it. */ .bn-suggestion-node--delete .bn-block-content:not(:has(.bn-inline-content)) { position: relative; - padding: 48px 24px 24px; + padding-top: calc(3px + 16px + 2px); } .bn-suggestion-node--delete @@ -1269,11 +1304,13 @@ above the content (out of flow) with extra top padding reserving its row. /* Sits in the reserved top padding, above the content. Out of flow so it never becomes a flex item beside the block. */ position: absolute; - top: 16px; - left: 24px; - font-size: 18px; - font-weight: 500; - line-height: 1.2; + top: 3px; + left: var(--bn-suggestion-card-inset); + font-size: 11px; + font-weight: 600; + line-height: 16px; + letter-spacing: 0.04em; + text-transform: uppercase; /* Use the editor's text color (themed for light/dark) rather than inheriting, which would pick up the suggestion's user color. */ color: var(--bn-colors-editor-text); @@ -1311,7 +1348,11 @@ left untouched so only the dotted underline carries the color. Both the inline .dark.bn-root [data-type="modification"] .bn-suggestion-mark:hover, .dark.bn-root [data-type="modification"] .bn-suggestion-node:hover > * { - background-color: color-mix(in srgb, var(--user-color-dark) 50%, black); + background-color: color-mix( + in srgb, + var(--user-color-light) 25%, + var(--bn-colors-editor-background) + ); } /* diff --git a/packages/core/src/editor/BlockNoteEditor.ts b/packages/core/src/editor/BlockNoteEditor.ts index 25b93d03f4..9746010d8b 100644 --- a/packages/core/src/editor/BlockNoteEditor.ts +++ b/packages/core/src/editor/BlockNoteEditor.ts @@ -101,7 +101,7 @@ export interface BlockNoteEditorOptions< dictionary?: Dictionary & Record; /** - * Disable internal extensions (based on keys / extension name) + * Disable internal extensions (based on keys / extension name). * * @note Advanced */ @@ -498,6 +498,8 @@ export class BlockNoteEditor< const tiptapOptions: EditorOptions = { ...blockNoteTipTapOptions, ...newOptions._tiptapOptions, + // ReadOnlyExtension owns editability, including the initial application preference. + editable: true, element: null, autofocus: newOptions.autofocus ?? false, extensions: tiptapExtensions, @@ -1039,7 +1041,10 @@ export class BlockNoteEditor< } /** - * Makes the editor editable or locks it, depending on the argument passed. + * Sets the application's editable preference. Feature read-only restrictions + * still apply when set to true. + * Plugins can temporarily prevent editing without changing this setting. + * The getter reports whether editing is currently allowed by both. * @param editable True to make the editor editable, or false to lock it. */ public set isEditable(editable: boolean) { diff --git a/packages/core/src/editor/editor.css b/packages/core/src/editor/editor.css index a1a3dda7b0..748073c66a 100644 --- a/packages/core/src/editor/editor.css +++ b/packages/core/src/editor/editor.css @@ -195,3 +195,64 @@ For the ShowSelectionPlugin background-color: highlight; padding: 2px 0; } + +/* Shared loading indicator. Override the color and size on the host element. */ +.bn-loader, +.bn-editor.bn-loading::before { + animation: + bn-loader-rotate 1s linear infinite, + bn-loader-clip 2s linear infinite; + border: calc(5 * var(--bn-loader-size, 1px)) solid + var(--bn-loader-color, currentColor); + border-radius: 50%; + box-sizing: border-box; + display: block; + height: calc(48 * var(--bn-loader-size, 1px)); + width: calc(48 * var(--bn-loader-size, 1px)); +} + +/* Keep the editor's loader visible while scrolling without moving content. */ +.bn-editor.bn-loading > .bn-block-group { + opacity: 0.4; + transition: opacity 0.2s ease; +} + +.bn-editor.bn-loading::before { + content: ""; + margin: 0 auto calc(-48 * var(--bn-loader-size, 1px)); + position: sticky; + top: 16px; + z-index: 1; +} + +@keyframes bn-loader-rotate { + 100% { + transform: rotate(360deg); + } +} + +@keyframes bn-loader-clip { + 0% { + clip-path: polygon(50% 50%, 0 0, 0 0, 0 0, 0 0, 0 0); + } + 25% { + clip-path: polygon(50% 50%, 0 0, 100% 0, 100% 0, 100% 0, 100% 0); + } + 50% { + clip-path: polygon(50% 50%, 0 0, 100% 0, 100% 100%, 100% 100%, 100% 100%); + } + 75% { + clip-path: polygon(50% 50%, 0 0, 100% 0, 100% 100%, 0 100%, 0 100%); + } + 100% { + clip-path: polygon(50% 50%, 0 0, 100% 0, 100% 100%, 0 100%, 0 0); + } +} + +@media (prefers-reduced-motion: reduce) { + .bn-loader, + .bn-editor.bn-loading::before { + animation: none; + clip-path: polygon(50% 50%, 0 0, 100% 0, 100% 100%, 0 100%, 0 100%); + } +} diff --git a/packages/core/src/editor/managers/ExtensionManager/extensions.ts b/packages/core/src/editor/managers/ExtensionManager/extensions.ts index 853cca2493..0eb62d9e7d 100644 --- a/packages/core/src/editor/managers/ExtensionManager/extensions.ts +++ b/packages/core/src/editor/managers/ExtensionManager/extensions.ts @@ -21,6 +21,7 @@ import { PlaceholderExtension, PositionMappingExtension, PreviousBlockTypeExtension, + ReadOnlyExtension, ShowSelectionExtension, SideMenuExtension, SourceBlockWithPreviewExtension, @@ -168,6 +169,7 @@ export function getDefaultExtensions( LinkToolbarExtension(options), NodeSelectionKeyboardExtension(), PlaceholderExtension(options), + ReadOnlyExtension({ editable: options._tiptapOptions?.editable }), ShowSelectionExtension(options), SideMenuExtension(options), SourceBlockWithPreviewExtension(), diff --git a/packages/core/src/editor/managers/StateManager.ts b/packages/core/src/editor/managers/StateManager.ts index 9dc3eebff2..c6a2edbdcb 100644 --- a/packages/core/src/editor/managers/StateManager.ts +++ b/packages/core/src/editor/managers/StateManager.ts @@ -1,4 +1,5 @@ import { Command, Transaction } from "prosemirror-state"; +import { ReadOnlyExtension } from "../../extensions/ReadOnly/ReadOnly.js"; import type { HistoryExtension } from "../../extensions/History/History.js"; import { BlockNoteEditor } from "../BlockNoteEditor.js"; @@ -188,13 +189,24 @@ export class StateManager { } return false; } + if (this.editor.headless) { + // No live view while unmounted, so tiptap can't consult plugin props + // (its unmounted view stub reports editable: true). Mirror the + // ReadOnly plugin's `editable` prop directly so the application + // preference and feature restrictions still read back correctly, + // e.g. for static/server-side rendering via block render functions. + const state = this.editor.getExtension(ReadOnlyExtension)?.store.state; + if (state) { + return state.isEditable && state.enabledSet.size === 0; + } + } return this.editor._tiptapEditor.isEditable === undefined ? true : this.editor._tiptapEditor.isEditable; } /** - * Makes the editor editable or locks it, depending on the argument passed. + * Sets the application's editable preference without releasing feature restrictions. * @param editable True to make the editor editable, or false to lock it. */ public set isEditable(editable: boolean) { @@ -205,9 +217,7 @@ export class StateManager { // not relevant on headless return; } - if (this.editor._tiptapEditor.options.editable !== editable) { - this.editor._tiptapEditor.setEditable(editable); - } + this.editor.getExtension(ReadOnlyExtension)!.setEditable(editable); } /** diff --git a/packages/core/src/extensions/ReadOnly/ReadOnly.test.ts b/packages/core/src/extensions/ReadOnly/ReadOnly.test.ts new file mode 100644 index 0000000000..6cb09d0592 --- /dev/null +++ b/packages/core/src/extensions/ReadOnly/ReadOnly.test.ts @@ -0,0 +1,181 @@ +/** @vitest-environment jsdom */ +import { + afterEach, + beforeEach, + describe, + expect, + it, + vi, +} from "vite-plus/test"; +import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; +import { ReadOnlyExtension } from "./ReadOnly.js"; + +describe("ReadOnlyExtension", () => { + let editor: BlockNoteEditor; + let readOnly: ReturnType>; + + beforeEach(() => { + editor = BlockNoteEditor.create(); + editor.mount(document.createElement("div")); + readOnly = editor.getExtension(ReadOnlyExtension)!; + }); + + afterEach(() => editor.unmount()); + + it("keeps editing disabled until every feature releases its restriction", () => { + readOnly.setReadOnly(true, "preview"); + readOnly.setReadOnly(true, "upload"); + readOnly.setReadOnly(true, "upload"); + expect(editor.isEditable).toBe(false); + + readOnly.setReadOnly(false, "preview"); + readOnly.setReadOnly(false, "unrelated"); + expect(editor.isEditable).toBe(false); + + readOnly.setReadOnly(false, "upload"); + expect(editor.isEditable).toBe(true); + }); + + it("preserves the application's latest editable setting", () => { + readOnly.setReadOnly(true, "preview"); + editor.isEditable = true; + expect(editor.isEditable).toBe(false); + + editor.isEditable = false; + readOnly.setReadOnly(false, "preview"); + expect(editor.isEditable).toBe(false); + + editor.isEditable = true; + expect(editor.isEditable).toBe(true); + }); + + it("notifies transaction subscribers without reporting document changes", () => { + const changes = vi.fn(); + const editableStates: boolean[] = []; + editor.onChange(changes); + editor._tiptapEditor.on("transaction", () => { + editableStates.push(editor.isEditable); + }); + + readOnly.setReadOnly(true, "preview"); + expect(editableStates.length).toBeGreaterThan(0); + expect(editableStates.every((editable) => !editable)).toBe(true); + editableStates.length = 0; + readOnly.setReadOnly(true, "preview"); + expect(editableStates).toEqual([]); + readOnly.setReadOnly(false, "preview"); + + expect(editableStates.length).toBeGreaterThan(0); + expect(editableStates.every((editable) => editable)).toBe(true); + expect(changes).not.toHaveBeenCalled(); + }); + + it("uses editable metadata for both inputs and skips changes that keep editing locked", () => { + const metadata: unknown[] = []; + const changes = vi.fn(); + editor.onChange(changes); + editor._tiptapEditor.on("transaction", ({ transaction }) => { + metadata.push(transaction.getMeta("editable")); + }); + + editor.isEditable = false; + expect(metadata.filter((value) => value !== undefined)).toEqual([true]); + metadata.length = 0; + readOnly.setReadOnly(true, "preview"); + editor.isEditable = true; + readOnly.setReadOnly(true, "upload"); + readOnly.setReadOnly(false, "preview"); + expect(metadata).toEqual([]); + expect(editor.isEditable).toBe(false); + + readOnly.setReadOnly(false, "upload"); + expect(metadata.filter((value) => value !== undefined)).toEqual([true]); + expect(editor.isEditable).toBe(true); + expect(changes).not.toHaveBeenCalled(); + }); + + it("applies initial editability and preserves it across remounts", () => { + editor.unmount(); + editor = BlockNoteEditor.create({ _tiptapOptions: { editable: false } }); + editor.mount(document.createElement("div")); + expect(editor.isEditable).toBe(false); + expect(editor.prosemirrorView.editable).toBe(false); + + editor.isEditable = true; + expect(editor.isEditable).toBe(true); + editor.isEditable = false; + editor.unmount(); + editor.mount(document.createElement("div")); + expect(editor.prosemirrorView.editable).toBe(false); + }); + + it("reports application and feature editability while unmounted", () => { + editor.unmount(); + editor = BlockNoteEditor.create(); + readOnly = editor.getExtension(ReadOnlyExtension)!; + + expect(editor.isEditable).toBe(true); + + editor.isEditable = false; + expect(editor.isEditable).toBe(false); + + editor.isEditable = true; + expect(editor.isEditable).toBe(true); + + readOnly.setReadOnly(true, "preview"); + expect(editor.isEditable).toBe(false); + + editor.isEditable = false; + readOnly.setReadOnly(false, "preview"); + expect(editor.isEditable).toBe(false); + + editor.isEditable = true; + expect(editor.isEditable).toBe(true); + }); + + it("honours editability set before mount", () => { + editor.unmount(); + editor = BlockNoteEditor.create(); + editor.isEditable = false; + expect(editor.isEditable).toBe(false); + editor.mount(document.createElement("div")); + expect(editor.isEditable).toBe(false); + expect(editor.prosemirrorView.editable).toBe(false); + }); + + it("groups application editability changes into the pending transaction", () => { + const transactions = vi.fn(); + const changes = vi.fn(); + editor._tiptapEditor.on("transaction", transactions); + editor.onChange(changes); + + editor.transact(() => { + editor.isEditable = false; + }); + expect(editor.isEditable).toBe(false); + expect(transactions).toHaveBeenCalled(); + expect(changes).not.toHaveBeenCalled(); + + transactions.mockClear(); + editor.transact((tr) => { + tr.insertText("hello", 1); + editor.isEditable = true; + }); + expect(editor.isEditable).toBe(true); + expect(editor.prosemirrorState.doc.textContent).toContain("hello"); + expect(transactions).toHaveBeenCalled(); + expect(changes).toHaveBeenCalledTimes(1); + }); + + it("composes with pending document and metadata-only transactions", () => { + editor.transact((tr) => { + tr.insertText("hello", 1); + readOnly.setReadOnly(true, "preview"); + }); + expect(editor.prosemirrorState.doc.textContent).toContain("hello"); + expect(editor.isEditable).toBe(false); + + editor.transact(() => readOnly.setReadOnly(false, "preview")); + expect(editor.isEditable).toBe(true); + }); +}); diff --git a/packages/core/src/extensions/ReadOnly/ReadOnly.ts b/packages/core/src/extensions/ReadOnly/ReadOnly.ts new file mode 100644 index 0000000000..a15876bbb4 --- /dev/null +++ b/packages/core/src/extensions/ReadOnly/ReadOnly.ts @@ -0,0 +1,73 @@ +import { Plugin, PluginKey } from "prosemirror-state"; +import { + createExtension, + createStore, + type ExtensionOptions, +} from "../../editor/BlockNoteExtension.js"; + +const PLUGIN_KEY = new PluginKey("bn-read-only"); + +/** Owns application editability and independent feature restrictions. */ +export const ReadOnlyExtension = createExtension( + ({ + editor, + options, + }: ExtensionOptions<{ editable?: boolean } | undefined>) => { + const store = createStore( + { + isEditable: options?.editable ?? true, + enabledSet: new Set(), + }, + { + onUpdate(state, prevState) { + if ( + (state.isEditable && state.enabledSet.size === 0) === + (prevState.isEditable && prevState.enabledSet.size === 0) + ) { + return; + } + if (!editor.headless) { + // Recompute plugin editability and notify UI subscribers without a + // document change. Reuse any transaction already in progress. + editor.transact((tr) => tr.setMeta("editable", true)); + } + }, + }, + ); + + return { + key: "readOnly", + store, + prosemirrorPlugins: [ + new Plugin({ + key: PLUGIN_KEY, + props: { + editable: () => + store.state.isEditable && store.state.enabledSet.size === 0, + }, + }), + ], + /** Set the application's preference without releasing feature restrictions. */ + setEditable(editable: boolean) { + if (store.state.isEditable === editable) { + return; + } + store.setState({ ...store.state, isEditable: editable }); + }, + /** + * Enable or disable read-only mode for a feature identified by key. + * Passing false releases only that feature's restriction; other features + * and the application's editor.isEditable setting still apply. + * Repeated calls with the same key are idempotent. + */ + setReadOnly(readOnly: boolean, key: string) { + store.setState({ + ...store.state, + enabledSet: readOnly + ? new Set([...store.state.enabledSet, key]) + : new Set([...store.state.enabledSet].filter((k) => k !== key)), + }); + }, + } as const; + }, +); diff --git a/packages/core/src/extensions/Versioning/Versioning.test.ts b/packages/core/src/extensions/Versioning/Versioning.test.ts index 158c152da4..16d6f7d41c 100644 --- a/packages/core/src/extensions/Versioning/Versioning.test.ts +++ b/packages/core/src/extensions/Versioning/Versioning.test.ts @@ -6,14 +6,28 @@ import { beforeEach, describe, expect, + expectTypeOf, it, vi, } from "vite-plus/test"; +import type { Block } from "../../blocks/defaultBlocks.js"; import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; import type { UserStoreOrResolver } from "../../user/index.js"; -import { sortSnapshotsNewestFirst, VersioningExtension } from "./Versioning.js"; -import type { VersionSnapshot } from "./Versioning.js"; +import { ReadOnlyExtension } from "../ReadOnly/ReadOnly.js"; +import { SCROLL_TO_FIRST_CHANGE_DELAY_MS } from "./scrollToFirstChange.js"; +import { + LOADING_PREVIEW_CLASS, + LOADING_PREVIEW_DELAY_MS, + VersioningExtension, +} from "./Versioning.js"; +import type { + PreviewController, + VersioningEndpoints, + VersioningExtensionOptions, + VersioningState, + VersionSnapshot, +} from "./Versioning.js"; import { createInMemoryPreviewController, createInMemoryVersioningEndpoints, @@ -23,11 +37,30 @@ import { // Helpers // --------------------------------------------------------------------------- -function createEditor() { - const editor = BlockNoteEditor.create(); - const div = document.createElement("div"); - editor.mount(div); - return editor; +/** + * A mounted editor with a `VersioningExtension` registered on it — registered + * rather than built alongside, so the extension's own ProseMirror plugins (the + * read-only-while-held one) are installed. `build` receives the editor, + * for options that need to close over it. + */ +function setupWith( + build: ( + editor: BlockNoteEditor, + ) => Pick & + Partial>, +) { + const editor = BlockNoteEditor.create({ + extensions: [ + (ctx) => + VersioningExtension({ + preview: createInMemoryPreviewController(ctx.editor), + getCurrentDocument: () => ctx.editor.document, + ...build(ctx.editor), + })(ctx), + ], + }); + editor.mount(document.createElement("div")); + return { editor, ext: editor.getExtension(VersioningExtension)! }; } function getEditorText(editor: BlockNoteEditor): string { @@ -38,19 +71,50 @@ function setEditorText(editor: BlockNoteEditor, text: string) { editor.replaceBlocks(editor.document, [{ type: "paragraph", content: text }]); } -/** Minimal snapshot factory for the sortSnapshotsNewestFirst unit test. */ +/** Resolve or reject a request at an explicit point in a loading transition. */ +function deferred() { + let resolve!: (value: T) => void; + let reject!: (error: Error) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +/** Minimal version factory for versioning tests. */ function snap( id: string, createdAt: number, extra?: Partial, ): VersionSnapshot { - return { id, createdAt, updatedAt: createdAt, ...extra }; + return { id, createdAt, ...extra }; +} + +/** A rest state, with the in-flight flags clear. */ +function state(overrides?: Partial): VersioningState { + return { + list: { loaded: false }, + view: { mode: "live" }, + listing: false, + restoring: false, + ...overrides, + }; +} + +/** The loaded list, or a failure — every test that reads it has listed first. */ +function loadedList(ext: { store: { state: VersioningState } }) { + const { list } = ext.store.state; + if (!list.loaded) { + throw new Error("expected the version list to be loaded"); + } + return list; } /** * Wire up a real editor with the in-memory versioning adapter. * - * Returns the extension instance, the editor, and helpers to seed snapshots + * Returns the extension instance, the editor, and helpers to seed versions * directly into the backend (bypassing the extension). */ function setup(opts?: { @@ -58,28 +122,42 @@ function setup(opts?: { withoutRestore?: boolean; withoutUpdateName?: boolean; resolveUsers?: UserStoreOrResolver; + scrollToFirstChange?: boolean; }) { - const editor = createEditor(); - setEditorText(editor, opts?.initialText ?? "initial doc"); - const endpoints = createInMemoryVersioningEndpoints(); - const preview = createInMemoryPreviewController(editor); - if (opts?.withoutRestore) { - (endpoints as any).restore = undefined; + endpoints.restore = undefined; } if (opts?.withoutUpdateName) { - (endpoints as any).rename = undefined; + endpoints.rename = undefined; } - const ext = VersioningExtension({ - endpoints, - preview, - getCurrentDocument: () => editor.document, - resolveUsers: opts?.resolveUsers, - })({ editor }); + // Registered on the editor rather than built beside it, so the extension's + // own ProseMirror plugins (the read-only-while-held one) are installed. + let preview!: ReturnType; + const editor = BlockNoteEditor.create({ + extensions: [ + (ctx) => { + preview = createInMemoryPreviewController(ctx.editor); + return VersioningExtension({ + endpoints, + preview, + // Through the controller, as the real adapter does: while previewing, + // `editor.document` holds the previewed version, not the live one. + getCurrentDocument: () => preview.getLiveDocument(), + serializeCurrentContent: () => preview.getLiveDocument(), + resolveUsers: opts?.resolveUsers, + scrollToFirstChange: opts?.scrollToFirstChange, + })(ctx); + }, + ], + }); + editor.mount(document.createElement("div")); + setEditorText(editor, opts?.initialText ?? "initial doc"); + + const ext = editor.getExtension(VersioningExtension)!; - /** Seed a snapshot into the backend by capturing the current editor doc. */ + /** Seed a version into the backend by capturing the current editor doc. */ const seed = async (text: string, name?: string) => { // Temporarily set editor text, create via endpoints, then restore. const savedBlocks = editor.document; @@ -88,8 +166,8 @@ function setup(opts?: { const snapshot = await endpoints.create!(blocks, { name }); // Restore original text. editor.replaceBlocks(editor.document, savedBlocks); - // Refresh the store so the extension can resolve the seeded snapshot by id - // (preview/restore look snapshots up in the store, as the UI would after + // Refresh the store so the extension can resolve the seeded version by id + // (preview/restore look versions up in the store, as the UI would after // listing). await ext.list(); return snapshot; @@ -102,15 +180,13 @@ function setup(opts?: { // Tests // --------------------------------------------------------------------------- -describe("sortSnapshotsNewestFirst", () => { - it("sorts newest-first by createdAt", () => { - const input = [snap("a", 100), snap("b", 300), snap("c", 200)]; - const sorted = sortSnapshotsNewestFirst(input); - expect(sorted.map((s) => s.id)).toEqual(["b", "c", "a"]); +describe("VersioningExtension", () => { + it("requires preview controllers to render synchronously", () => { + expectTypeOf<() => Promise>().not.toExtend< + PreviewController["enterPreview"] + >(); }); -}); -describe("VersioningExtension", () => { let ctx: ReturnType; beforeEach(() => { @@ -122,137 +198,438 @@ describe("VersioningExtension", () => { }); // ------------------------------------------------------------------------- - // Listing snapshots + // Loading state // ------------------------------------------------------------------------- - describe("listing snapshots", () => { + describe("getLoadingState", () => { + it("is idle when neither operation is in flight", () => { + expect(ctx.ext.getLoadingState(state())).toEqual({ type: "idle" }); + }); + + it("reports listing when only the list is fetching", () => { + expect(ctx.ext.getLoadingState(state({ listing: true }))).toEqual({ + type: "listing", + }); + }); + + it("reports loading-preview while a preview loads, outranking listing", () => { + const view = { mode: "snapshot", snapshotId: "a" } as const; + const previewing = { type: "loading-preview", view } as const; + expect(ctx.ext.getLoadingState(state({ loadingView: view }))).toEqual( + previewing, + ); + expect( + ctx.ext.getLoadingState(state({ listing: true, loadingView: view })), + ).toEqual(previewing); + }); + }); + + // ------------------------------------------------------------------------- + // Listing versions + // ------------------------------------------------------------------------- + + describe("listing versions", () => { + it("starts unloaded and live", () => { + expect(ctx.ext.store.state.list).toEqual({ loaded: false }); + expect(ctx.ext.store.state.view).toEqual({ mode: "live" }); + expect(ctx.ext.getLoadingState()).toEqual({ type: "idle" }); + }); + it("populates the store from the backend, sorted newest-first", async () => { vi.useFakeTimers(); - // Seed snapshots with distinct timestamps directly via endpoints. - await ctx.endpoints.create!([ - { - id: "1", - type: "paragraph" as const, - content: "v1" as any, - props: {} as any, - children: [], - }, - ]); + await ctx.endpoints.create!([], {}); vi.advanceTimersByTime(1000); - await ctx.endpoints.create!([ - { - id: "2", - type: "paragraph" as const, - content: "v2" as any, - props: {} as any, - children: [], - }, - ]); + await ctx.endpoints.create!([], {}); vi.advanceTimersByTime(1000); - await ctx.endpoints.create!([ - { - id: "3", - type: "paragraph" as const, - content: "v3" as any, - props: {} as any, - children: [], - }, - ]); + await ctx.endpoints.create!([], {}); const result = await ctx.ext.list(); - expect(result).toHaveLength(3); - // Newest first: v3, v2, v1 - expect(result[0]!.createdAt).toBeGreaterThan(result[1]!.createdAt); - expect(result[1]!.createdAt).toBeGreaterThan(result[2]!.createdAt); - expect(ctx.ext.store.state.snapshots).toEqual(result); + expect(result.snapshots).toHaveLength(3); + expect(result.snapshots[0]!.createdAt).toBeGreaterThan( + result.snapshots[1]!.createdAt, + ); + expect(result.snapshots[1]!.createdAt).toBeGreaterThan( + result.snapshots[2]!.createdAt, + ); + expect(result.current).toBeDefined(); + expect(ctx.ext.store.state.list).toEqual(result); vi.useRealTimers(); }); + it("never touches the view", async () => { + const seeded = await ctx.seed("snapshot content"); + await ctx.ext.previewSnapshot(seeded.id); + expect(ctx.ext.store.state.view).toEqual({ + mode: "snapshot", + snapshotId: seeded.id, + compareToId: undefined, + }); + + await ctx.ext.list(); + + expect(ctx.ext.store.state.view).toEqual({ + mode: "snapshot", + snapshotId: seeded.id, + compareToId: undefined, + }); + }); + it("reflects backend changes on subsequent calls", async () => { - expect(await ctx.ext.list()).toEqual([]); - - await ctx.endpoints.create!([ - { - id: "1", - type: "paragraph" as const, - content: "external" as any, - props: {} as any, - children: [], - }, - ]); + expect((await ctx.ext.list()).snapshots).toEqual([]); + + await ctx.endpoints.create!([], {}); - const after = await ctx.ext.list(); - expect(after).toHaveLength(1); + expect((await ctx.ext.list()).snapshots).toHaveLength(1); }); }); // ------------------------------------------------------------------------- - // Creating snapshots + // Editability // ------------------------------------------------------------------------- - describe("creating snapshots", () => { - it("captures the current state and adds the snapshot to the store", async () => { + describe("editability", () => { + it("is read-only while previewing and editable again on exit", async () => { + const seeded = await ctx.seed("snapshot content"); + expect(ctx.editor.isEditable).toBe(true); + + await ctx.ext.previewSnapshot(seeded.id); + expect(ctx.editor.isEditable).toBe(false); + + ctx.ext.exitPreview(); + expect(ctx.editor.isEditable).toBe(true); + }); + + it("stays read-only across preview switches", async () => { + const s1 = await ctx.seed("content s1"); + const s2 = await ctx.seed("content s2"); + + await ctx.ext.previewSnapshot(s1.id); + await ctx.ext.previewSnapshot(s2.id); + expect(ctx.editor.isEditable).toBe(false); + + ctx.ext.exitPreview(); + expect(ctx.editor.isEditable).toBe(true); + }); + + it("ignores an `isEditable` set while previewing, and honours it on exit", async () => { + const seeded = await ctx.seed("snapshot content"); + await ctx.ext.previewSnapshot(seeded.id); + + // What a React re-render does: re-applies the host's `editable` prop. + ctx.editor.isEditable = true; + expect(ctx.editor.isEditable).toBe(false); + + ctx.ext.exitPreview(); + expect(ctx.editor.isEditable).toBe(true); + }); + + it("preserves a host change to read-only made during preview", async () => { + const seeded = await ctx.seed("snapshot content"); + await ctx.ext.previewSnapshot(seeded.id); + + ctx.editor.isEditable = false; + ctx.ext.exitPreview(); + + expect(ctx.editor.isEditable).toBe(false); + ctx.editor.isEditable = true; + expect(ctx.editor.isEditable).toBe(true); + }); + + it("leaves another feature's read-only restriction in place on exit", async () => { + const seeded = await ctx.seed("snapshot content"); + const readOnly = ctx.editor.getExtension(ReadOnlyExtension)!; + await ctx.ext.previewSnapshot(seeded.id); + readOnly.setReadOnly(true, "upload"); + + ctx.ext.exitPreview(); + expect(ctx.editor.isEditable).toBe(false); + + readOnly.setReadOnly(false, "upload"); + expect(ctx.editor.isEditable).toBe(true); + }); + + it("leaves a read-only editor read-only", async () => { + const seeded = await ctx.seed("snapshot content"); + ctx.editor.isEditable = false; + + await ctx.ext.previewSnapshot(seeded.id); + expect(ctx.editor.isEditable).toBe(false); + + ctx.ext.exitPreview(); + expect(ctx.editor.isEditable).toBe(false); + }); + + it("can be changed from inside a transaction", async () => { + // A dispatch of its own in here would leave the pending transaction + // built on a stale state; the change has to ride along with it instead. + ctx.editor.transact((tr) => { + tr.insertText("!", 1); + ctx.editor.isEditable = false; + }); + expect(ctx.editor.isEditable).toBe(false); + expect(getEditorText(ctx.editor)).toBe("!initial doc"); + + ctx.editor.transact(() => { + ctx.editor.isEditable = true; + }); + expect(ctx.editor.isEditable).toBe(true); + }); + + it("does not report a document change for the editability change", async () => { + const seeded = await ctx.seed("snapshot content"); + await ctx.ext.previewSnapshot(seeded.id); + + let changes = 0; + ctx.editor.onChange(() => changes++); + ctx.ext.exitPreview(); + + // Exiting restores the live document through the preview controller, + // which is one change; becoming editable again is not another. + expect(changes).toBe(1); + }); + }); + + // ------------------------------------------------------------------------- + // Status + // ------------------------------------------------------------------------- + + describe("status", () => { + it.each(["resolve", "reject"] as const)( + "keeps the latest preview busy when an older request completes via %s", + async (outcome) => { + const first = await ctx.seed("first content"); + const second = await ctx.seed("second content"); + const firstRequest = deferred(); + const secondRequest = deferred(); + vi.spyOn(ctx.endpoints, "getContent") + .mockReturnValueOnce(firstRequest.promise) + .mockReturnValueOnce(secondRequest.promise); + + vi.useFakeTimers(); + try { + const older = ctx.ext.previewSnapshot(first.id); + vi.advanceTimersByTime(LOADING_PREVIEW_DELAY_MS); + const newer = ctx.ext.previewSnapshot(second.id); + const latestView = { + mode: "snapshot", + snapshotId: second.id, + compareToId: undefined, + }; + expect( + ctx.editor.domElement!.classList.contains(LOADING_PREVIEW_CLASS), + ).toBe(true); + + if (outcome === "reject") { + const failure = expect(older).rejects.toThrow("old request failed"); + firstRequest.reject(new Error("old request failed")); + await failure; + } else { + firstRequest.resolve(ctx.editor.document); + await older; + } + expect(ctx.ext.store.state.view).toEqual(latestView); + expect(ctx.ext.getLoadingState()).toEqual({ + type: "loading-preview", + view: latestView, + }); + expect( + ctx.editor.domElement!.classList.contains(LOADING_PREVIEW_CLASS), + ).toBe(true); + expect(ctx.editor.isEditable).toBe(false); + expect(getEditorText(ctx.editor)).toBe("initial doc"); + + secondRequest.resolve([]); + await newer; + expect(ctx.ext.store.state.view).toEqual(latestView); + expect(ctx.ext.getLoadingState()).toEqual({ + type: "idle", + }); + expect( + ctx.editor.domElement!.classList.contains(LOADING_PREVIEW_CLASS), + ).toBe(false); + expect(getEditorText(ctx.editor)).toBe(""); + } finally { + vi.useRealTimers(); + } + }, + ); + + it.each(["content", "baseline", "attributions"] as const)( + "stays busy until comparison %s finishes", + async (stage) => { + const gate = deferred(); + const current = snap("current", 30); + const shown = snap("shown", 20); + const baseline = snap("baseline", 10); + const enterPreview = vi.fn(() => undefined); + const { editor, ext } = setupWith(() => ({ + endpoints: { + list: async () => ({ current, snapshots: [shown, baseline] }), + getContent: async (snapshot) => { + if ( + (stage === "content" && snapshot.id === shown.id) || + (stage === "baseline" && snapshot.id === baseline.id) + ) { + await gate.promise; + } + return []; + }, + getAttributions: async () => { + if (stage === "attributions") { + await gate.promise; + } + return undefined; + }, + }, + preview: { enterPreview, exitPreview: () => {} }, + })); + try { + await ext.list(); + const pending = ext.previewSnapshot(shown.id, { + compareTo: baseline.id, + }); + expect(ext.getLoadingState()).toEqual({ + type: "loading-preview", + view: { + mode: "snapshot", + snapshotId: shown.id, + compareToId: baseline.id, + }, + }); + expect(editor.isEditable).toBe(false); + expect(enterPreview).not.toHaveBeenCalled(); + + gate.resolve(); + await pending; + expect(enterPreview).toHaveBeenCalledOnce(); + expect(ext.getLoadingState()).toEqual({ type: "idle" }); + expect(ext.store.state.view).toEqual({ + mode: "snapshot", + snapshotId: shown.id, + compareToId: baseline.id, + }); + expect(editor.isEditable).toBe(false); + } finally { + gate.resolve(); + editor.unmount(); + } + }, + ); + + it.each([0, LOADING_PREVIEW_DELAY_MS])( + "clears pending preview loading on exit after %i ms", + async (elapsed) => { + const seeded = await ctx.seed("old content"); + const { promise: gate, resolve: release } = deferred(); + const getContent = ctx.endpoints.getContent; + ctx.endpoints.getContent = async (snapshot) => { + await gate; + return getContent(snapshot); + }; + + vi.useFakeTimers(); + try { + const pending = ctx.ext.previewSnapshot(seeded.id); + vi.advanceTimersByTime(elapsed); + ctx.ext.exitPreview(); + + expect(ctx.ext.getLoadingState()).toEqual({ + type: "idle", + }); + expect(ctx.editor.isEditable).toBe(true); + expect( + ctx.editor.domElement!.classList.contains(LOADING_PREVIEW_CLASS), + ).toBe(false); + // Exiting before the delay must also cancel the scheduled indicator. + vi.advanceTimersByTime(LOADING_PREVIEW_DELAY_MS); + expect( + ctx.editor.domElement!.classList.contains(LOADING_PREVIEW_CLASS), + ).toBe(false); + + release(); + await pending; + expect(ctx.ext.store.state.view).toEqual({ mode: "live" }); + expect(getEditorText(ctx.editor)).toBe("initial doc"); + } finally { + release(); + vi.useRealTimers(); + } + }, + ); + }); + + // ------------------------------------------------------------------------- + // Naming the current version + // ------------------------------------------------------------------------- + + describe("naming the current version", () => { + it("captures the current state and re-lists", async () => { setEditorText(ctx.editor, "my document content"); const snapshot = await ctx.ext.create!({ name: "Draft 1" }); expect(snapshot.name).toBe("Draft 1"); - expect(snapshot.id).toBeDefined(); - expect(ctx.ext.store.state.snapshots).toHaveLength(1); + expect(loadedList(ctx.ext).snapshots).toHaveLength(1); + expect(loadedList(ctx.ext).snapshots[0]!.name).toBe("Draft 1"); - // The snapshot content should round-trip — verify by previewing. + // The version content should round-trip — verify by previewing. await ctx.ext.previewSnapshot(snapshot.id); expect(getEditorText(ctx.editor)).toBe("my document content"); }); - it("maintains newest-first order when adding to existing snapshots", async () => { + it("maintains newest-first order", async () => { vi.useFakeTimers(); - // Seed an older snapshot. const old = await ctx.seed("old content", "Old"); vi.advanceTimersByTime(1000); - // List so the store knows about the seeded snapshot. - await ctx.ext.list(); - const newer = await ctx.ext.create!({ name: "Newer" }); - expect(ctx.ext.store.state.snapshots[0]!.id).toBe(newer.id); - expect(ctx.ext.store.state.snapshots[1]!.id).toBe(old.id); + expect(loadedList(ctx.ext).snapshots[0]!.id).toBe(newer.id); + expect(loadedList(ctx.ext).snapshots[1]!.id).toBe(old.id); vi.useRealTimers(); }); }); // ------------------------------------------------------------------------- - // Previewing snapshots + // Previewing // ------------------------------------------------------------------------- - describe("previewing snapshots", () => { - it("shows a snapshot and tracks it in the store", async () => { - const snap = await ctx.seed("snapshot content"); + describe("previewing versions", () => { + it("shows a version and tracks it in the view", async () => { + const seeded = await ctx.seed("snapshot content"); - await ctx.ext.previewSnapshot(snap.id); + await ctx.ext.previewSnapshot(seeded.id); - expect(ctx.ext.store.state.previewedSnapshotId).toBe(snap.id); + expect(ctx.ext.store.state.view).toEqual({ + mode: "snapshot", + snapshotId: seeded.id, + compareToId: undefined, + }); expect(getEditorText(ctx.editor)).toBe("snapshot content"); }); - it("supports comparing against an older snapshot", async () => { - const _v1 = await ctx.seed("content v1"); + it("supports comparing against an older version", async () => { + const v1 = await ctx.seed("content v1"); const v2 = await ctx.seed("content v2"); // The in-memory preview controller doesn't render diffs, but the call - // should succeed and show the primary snapshot content. - await ctx.ext.previewSnapshot(v2.id, { compareTo: _v1.id }); - + // should succeed and show the primary version's content. + await ctx.ext.previewSnapshot(v2.id, { compareTo: v1.id }); + + expect(ctx.ext.store.state.view).toEqual({ + mode: "snapshot", + snapshotId: v2.id, + compareToId: v1.id, + }); expect(getEditorText(ctx.editor)).toBe("content v2"); }); - it("switching previews updates to the new snapshot", async () => { + it("switching previews updates to the new version", async () => { const s1 = await ctx.seed("content s1"); const s2 = await ctx.seed("content s2"); @@ -260,11 +637,118 @@ describe("VersioningExtension", () => { expect(getEditorText(ctx.editor)).toBe("content s1"); await ctx.ext.previewSnapshot(s2.id); - expect(ctx.ext.store.state.previewedSnapshotId).toBe(s2.id); + expect(ctx.ext.store.state.view).toEqual({ + mode: "snapshot", + snapshotId: s2.id, + compareToId: undefined, + }); expect(getEditorText(ctx.editor)).toBe("content s2"); }); }); + // ------------------------------------------------------------------------- + // Scroll to first change + // ------------------------------------------------------------------------- + + describe("scroll to first change", () => { + /** + * A preview controller that stamps one attribution mark into the editor + * DOM, the way the real diff renderer does. It has to happen inside + * `enterPreview`: locking editability redraws the ProseMirror view, which + * strips any foreign node put there beforehand. + */ + function setupScrollProbe(opts?: { scroll?: boolean; withMark?: boolean }) { + const { editor, ext } = setupWith((editor) => ({ + endpoints: { + list: async () => ({ + current: snap("current", 30), + snapshots: [snap("a", 10), snap("b", 5)], + }), + getContent: async () => [], + getAttributions: async () => undefined, + } satisfies VersioningEndpoints, + preview: { + enterPreview: () => { + if (opts?.withMark === false) { + return; + } + const mark = document.createElement("span"); + mark.dataset["userIds"] = '["u1"]'; + // jsdom has no layout, and the scroll skips marks without a box. + const content = document.createElement("span"); + content.getBoundingClientRect = () => + ({ width: 100, height: 20 }) as DOMRect; + mark.appendChild(content); + editor.domElement!.appendChild(mark); + }, + exitPreview: () => {}, + applyRestore: () => {}, + }, + scrollToFirstChange: opts?.scroll, + })); + return { editor, ext }; + } + + /** Wait out the delay between a preview rendering and its scroll. */ + async function awaitScrollDelay() { + await new Promise((resolve) => + setTimeout(resolve, SCROLL_TO_FIRST_CHANGE_DELAY_MS + 50), + ); + } + + // jsdom doesn't implement `scrollIntoView` at all, so this installs it + // rather than spying on an existing method. + const hadScrollIntoView = "scrollIntoView" in Element.prototype; + let scrollIntoView: ReturnType>; + + beforeEach(() => { + scrollIntoView = vi.fn(); + Element.prototype.scrollIntoView = scrollIntoView; + }); + + afterEach(() => { + if (!hadScrollIntoView) { + Reflect.deleteProperty(Element.prototype, "scrollIntoView"); + } + }); + + it("scrolls to the first attribution mark once, after a comparison", async () => { + const { editor, ext } = setupScrollProbe(); + + await ext.list(); + await ext.previewSnapshot("a", { compareTo: "b" }); + await awaitScrollDelay(); + + expect(scrollIntoView).toHaveBeenCalledTimes(1); + + editor.unmount(); + }); + + it("does nothing when the preview has no attribution marks", async () => { + const { editor, ext } = setupScrollProbe({ withMark: false }); + + await ext.list(); + await ext.previewSnapshot("a", { compareTo: "b" }); + await awaitScrollDelay(); + + expect(scrollIntoView).not.toHaveBeenCalled(); + + editor.unmount(); + }); + + it("does nothing when disabled", async () => { + const { editor, ext } = setupScrollProbe({ scroll: false }); + + await ext.list(); + await ext.previewSnapshot("a", { compareTo: "b" }); + await awaitScrollDelay(); + + expect(scrollIntoView).not.toHaveBeenCalled(); + + editor.unmount(); + }); + }); + // ------------------------------------------------------------------------- // Exiting preview // ------------------------------------------------------------------------- @@ -272,79 +756,306 @@ describe("VersioningExtension", () => { describe("exiting preview", () => { it("clears the preview state and restores the live document", async () => { setEditorText(ctx.editor, "live content"); - const snap = await ctx.seed("snapshot content"); + const seeded = await ctx.seed("snapshot content"); - await ctx.ext.previewSnapshot(snap.id); + await ctx.ext.previewSnapshot(seeded.id); expect(getEditorText(ctx.editor)).toBe("snapshot content"); ctx.ext.exitPreview(); - expect(ctx.ext.store.state.previewedSnapshotId).toBeUndefined(); + expect(ctx.ext.store.state.view).toEqual({ mode: "live" }); expect(getEditorText(ctx.editor)).toBe("live content"); }); }); // ------------------------------------------------------------------------- - // Restoring snapshots + // The live document behind a preview // ------------------------------------------------------------------------- - describe("restoring snapshots", () => { - it("applies the snapshot content and exits any active preview", async () => { - setEditorText(ctx.editor, "current doc"); - const snap = await ctx.seed("old content"); + describe("the live document while previewing", () => { + it("previews the current version as the live document, not what is on screen", async () => { + setEditorText(ctx.editor, "live content"); + const seeded = await ctx.seed("snapshot content"); - // Enter preview first, then restore. - await ctx.ext.previewSnapshot(snap.id); - await ctx.ext.restore!(snap.id); + await ctx.ext.previewSnapshot(seeded.id); + expect(getEditorText(ctx.editor)).toBe("snapshot content"); + await ctx.ext.previewCurrentVersion!(); + expect(getEditorText(ctx.editor)).toBe("live content"); + expect(ctx.ext.store.state.view).toEqual({ + mode: "current", + compareToId: undefined, + }); + }); + + it("names the live document, not the previewed one", async () => { + setEditorText(ctx.editor, "live content"); + const seeded = await ctx.seed("snapshot content"); + + await ctx.ext.previewSnapshot(seeded.id); + const named = await ctx.ext.create!({ name: "named while previewing" }); + + await ctx.ext.previewSnapshot(named.id); + expect(getEditorText(ctx.editor)).toBe("live content"); + }); + }); + + // ------------------------------------------------------------------------- + // Restoring + // ------------------------------------------------------------------------- + + describe("restoring versions", () => { + it("re-lists once after restoring", async () => { + const seeded = await ctx.seed("old content"); + const list = vi.spyOn(ctx.endpoints, "list"); + await ctx.ext.restore!(seeded.id); + expect(list).toHaveBeenCalledTimes(1); + expect(ctx.editor.isEditable).toBe(true); expect(getEditorText(ctx.editor)).toBe("old content"); - expect(ctx.ext.store.state.previewedSnapshotId).toBeUndefined(); }); - it("picks up server-side backup snapshots after re-listing", async () => { - const snap = await ctx.seed("original"); - await ctx.ext.list(); + it("applies the version content and exits any active preview", async () => { + setEditorText(ctx.editor, "current doc"); + const seeded = await ctx.seed("old content"); + + await ctx.ext.previewSnapshot(seeded.id); + await ctx.ext.restore!(seeded.id); - await ctx.ext.restore!(snap.id); + expect(getEditorText(ctx.editor)).toBe("old content"); + expect(ctx.ext.store.state.view).toEqual({ mode: "live" }); + }); - // The in-memory endpoints create a backup snapshot on restore. - const updated = await ctx.ext.list(); - expect(updated.length).toBe(2); - expect(updated.some((s) => s.restoredFromSnapshotId === snap.id)).toBe( - true, + it("stays read-only and in preview until the backend has answered", async () => { + const seeded = await ctx.seed("old content"); + await ctx.ext.previewSnapshot(seeded.id); + + const gate = deferred(); + const backendRestore = ctx.endpoints.restore!; + ctx.endpoints.restore = vi.fn( + async (doc: Block[], snapshot: VersionSnapshot) => { + await gate.promise; + return backendRestore(doc, snapshot); + }, ); + + const restoring = ctx.ext.restore!(seeded.id); + // Mid-restore: nothing can be typed into a document about to be replaced. + expect(ctx.editor.isEditable).toBe(false); + expect(ctx.ext.store.state.view.mode).toBe("snapshot"); + + gate.resolve(); + await restoring; + expect(ctx.editor.isEditable).toBe(true); + expect(getEditorText(ctx.editor)).toBe("old content"); + }); + + it("stays read-only until the list has been refreshed as well", async () => { + const seeded = await ctx.seed("old content"); + await ctx.ext.previewSnapshot(seeded.id); + + const gate = deferred(); + const backendList = ctx.endpoints.list; + ctx.endpoints.list = vi.fn(async () => { + await gate.promise; + return backendList(); + }); + + const restoring = ctx.ext.restore!(seeded.id); + await vi.waitFor(() => expect(ctx.endpoints.list).toHaveBeenCalled()); + // The restored content is already live, but remains read-only while + // the sidebar's list catches up. + expect(ctx.editor.isEditable).toBe(false); + expect(ctx.ext.store.state.view.mode).toBe("live"); + expect(getEditorText(ctx.editor)).toBe("old content"); + + gate.resolve(); + await restoring; + expect(ctx.editor.isEditable).toBe(true); + expect(ctx.ext.store.state.view).toEqual({ mode: "live" }); + expect(getEditorText(ctx.editor)).toBe("old content"); + }); + + it.each([false, true])( + "keeps a successful restore when re-listing fails (preview: %s)", + async (previewing) => { + setEditorText(ctx.editor, "live content"); + const seeded = await ctx.seed("old content"); + if (previewing) { + await ctx.ext.previewSnapshot(seeded.id); + } + ctx.endpoints.list = async () => { + throw new Error("list offline"); + }; + + await expect(ctx.ext.restore!(seeded.id)).rejects.toThrow( + "list offline", + ); + + expect(ctx.ext.store.state.view).toEqual({ mode: "live" }); + expect(ctx.ext.store.state.restoring).toBe(false); + expect(ctx.editor.isEditable).toBe(true); + expect(getEditorText(ctx.editor)).toBe("old content"); + }, + ); + + it("leaves the user where they were when the backend rejects", async () => { + setEditorText(ctx.editor, "live content"); + const seeded = await ctx.seed("old content"); + await ctx.ext.previewSnapshot(seeded.id); + ctx.endpoints.restore = vi.fn(async () => { + throw new Error("network"); + }); + + await expect(ctx.ext.restore!(seeded.id)).rejects.toThrow("network"); + + expect(ctx.editor.isEditable).toBe(false); + expect(ctx.ext.store.state.view).toEqual({ + mode: "snapshot", + snapshotId: seeded.id, + compareToId: undefined, + }); + expect(getEditorText(ctx.editor)).toBe("old content"); + }); + + it("picks up server-side rows after re-listing", async () => { + const seeded = await ctx.seed("original"); + + await ctx.ext.restore!(seeded.id); + + const updated = loadedList(ctx.ext); + expect(updated.snapshots).toHaveLength(2); + expect(updated.current.restoredFrom).toEqual({ + id: seeded.id, + createdAt: seeded.createdAt, + }); }); it("reports restore as unavailable when endpoint omits it", () => { const noRestore = setup({ withoutRestore: true }); - expect(noRestore.ext.canRestore).toBe(false); expect(noRestore.ext.restore).toBeUndefined(); noRestore.editor.unmount(); }); + + it("reports restore as unavailable when the preview controller can't apply it", () => { + const noApply = setupWith((editor) => { + const controller = createInMemoryPreviewController(editor); + return { + endpoints: createInMemoryVersioningEndpoints(), + // Delegated rather than spread: the controller's + // `supportsComparison` is a getter that needs the mounted editor, + // so spreading it during `create` would throw. The methods are + // closure-based, so detaching them is safe. + preview: { + enterPreview: controller.enterPreview, + exitPreview: controller.exitPreview, + get supportsComparison() { + return controller.supportsComparison; + }, + }, + getCurrentDocument: () => controller.getLiveDocument(), + serializeCurrentContent: () => controller.getLiveDocument(), + }; + }); + + expect(noApply.ext.restore).toBeUndefined(); + noApply.editor.unmount(); + }); }); // ------------------------------------------------------------------------- - // Updating snapshot names + // Removing // ------------------------------------------------------------------------- - describe("updating snapshot names", () => { - it("renames a snapshot in the store and backend", async () => { - const snap = await ctx.seed("content", "Original"); - await ctx.ext.list(); + describe("removing versions", () => { + it("exits the preview when the removed version is being previewed", async () => { + const seeded = await ctx.seed("content"); + await ctx.ext.previewSnapshot(seeded.id); - await ctx.ext.rename!(snap.id, "Renamed"); + await ctx.ext.remove!(seeded.id); - // Store was updated optimistically. - expect(ctx.ext.store.state.snapshots[0]!.name).toBe("Renamed"); + expect(ctx.ext.store.state.view).toEqual({ mode: "live" }); + expect(ctx.editor.isEditable).toBe(true); + expect(loadedList(ctx.ext).snapshots).toHaveLength(0); + }); + + it("exits the preview when the removed version is the baseline", async () => { + const baseline = await ctx.seed("content v1"); + const shown = await ctx.seed("content v2"); + await ctx.ext.previewSnapshot(shown.id, { compareTo: baseline.id }); + + await ctx.ext.remove!(baseline.id); + + expect(ctx.ext.store.state.view).toEqual({ mode: "live" }); + expect(loadedList(ctx.ext).snapshots.map((s) => s.id)).toEqual([ + shown.id, + ]); + }); + + it("keeps the preview when an unrelated version is removed", async () => { + const other = await ctx.seed("content v1"); + const shown = await ctx.seed("content v2"); + await ctx.ext.previewSnapshot(shown.id); + + await ctx.ext.remove!(other.id); + + expect(ctx.ext.store.state.view).toEqual({ + mode: "snapshot", + snapshotId: shown.id, + compareToId: undefined, + }); + }); + + it("keeps the preview when the backend keeps the row", async () => { + // A continuous-history backend (YHub): removing a version only drops + // its name, and the row is still there to look at. + const shown = await ctx.seed("content", "named"); + await ctx.ext.previewSnapshot(shown.id); + ctx.endpoints.remove = vi.fn(async (snapshot) => { + await ctx.endpoints.rename!(snapshot, undefined); + }); + + await ctx.ext.remove!(shown.id); + + expect(ctx.ext.store.state.view).toEqual({ + mode: "snapshot", + snapshotId: shown.id, + compareToId: undefined, + }); + expect(ctx.editor.isEditable).toBe(false); + expect(loadedList(ctx.ext).snapshots[0]!.name).toBeUndefined(); + }); + }); + + // ------------------------------------------------------------------------- + // Renaming + // ------------------------------------------------------------------------- + + describe("renaming versions", () => { + it("renames a version in the store and backend", async () => { + const seeded = await ctx.seed("content", "Original"); + + await ctx.ext.rename!(seeded.id, "Renamed"); + + // Store was patched in place. + expect(loadedList(ctx.ext).snapshots[0]!.name).toBe("Renamed"); // Backend was also updated (verified via list). const list = await ctx.ext.list(); - expect(list.find((s) => s.id === snap.id)!.name).toBe("Renamed"); + expect(list.snapshots.find((s) => s.id === seeded.id)!.name).toBe( + "Renamed", + ); + }); + + it("clears the name when renamed to undefined", async () => { + const seeded = await ctx.seed("content", "Original"); + + await ctx.ext.rename!(seeded.id, undefined); + + expect(loadedList(ctx.ext).snapshots[0]!.name).toBeUndefined(); }); it("reports name updates as unavailable when endpoint omits it", () => { const noUpdate = setup({ withoutUpdateName: true }); - expect(noUpdate.ext.canRename).toBe(false); expect(noUpdate.ext.rename).toBeUndefined(); noUpdate.editor.unmount(); }); @@ -379,23 +1090,23 @@ describe("VersioningExtension", () => { }); it("passes `by` author ids through list() untouched", async () => { - const editor = createEditor(); - const ext = VersioningExtension({ + const { editor, ext } = setupWith(() => ({ endpoints: { - list: async () => [snap("1", 100, { by: ["u1", "u2"] })], + list: async () => ({ + current: snap("current", 200), + snapshots: [snap("1", 100, { by: ["u1", "u2"] })], + }), getContent: async () => [], - }, - preview: createInMemoryPreviewController(editor), - getCurrentDocument: () => editor.document, - })({ editor }); + } satisfies VersioningEndpoints, + })); const result = await ext.list(); // Raw ids are preserved — resolving them to user info is the view // layer's job (via `ext.userStore`), never the extension's. - expect(result[0]!.by).toEqual(["u1", "u2"]); - expect(result[0]!.secondaryLabel).toBeUndefined(); - expect(ext.store.state.snapshots).toEqual(result); + expect(result.snapshots[0]!.by).toEqual(["u1", "u2"]); + expect(result.snapshots[0]!.secondaryLabel).toBeUndefined(); + expect(ext.store.state.list).toEqual(result); editor.unmount(); }); @@ -405,20 +1116,20 @@ describe("VersioningExtension", () => { // End-to-end workflow // ------------------------------------------------------------------------- - describe("workflow: create, preview with diff, then restore", () => { + describe("workflow: name, preview with diff, then restore", () => { it("handles the full version-history flow", async () => { vi.useFakeTimers(); - // 1. Create version 1. + // 1. Name version 1. setEditorText(ctx.editor, "doc v1"); const v1 = await ctx.ext.create!({ name: "Version 1" }); vi.advanceTimersByTime(1000); - // 2. Modify and create version 2. + // 2. Modify and name version 2. setEditorText(ctx.editor, "doc v2"); const v2 = await ctx.ext.create!({ name: "Version 2" }); - expect(ctx.ext.store.state.snapshots[0]!.id).toBe(v2.id); + expect(loadedList(ctx.ext).snapshots[0]!.id).toBe(v2.id); // 3. Preview v1 with diff comparison against v2. await ctx.ext.previewSnapshot(v1.id, { compareTo: v2.id }); @@ -427,7 +1138,7 @@ describe("VersioningExtension", () => { // 4. Restore v1. await ctx.ext.restore!(v1.id); expect(getEditorText(ctx.editor)).toBe("doc v1"); - expect(ctx.ext.store.state.previewedSnapshotId).toBeUndefined(); + expect(ctx.ext.store.state.view).toEqual({ mode: "live" }); vi.useRealTimers(); }); diff --git a/packages/core/src/extensions/Versioning/Versioning.ts b/packages/core/src/extensions/Versioning/Versioning.ts index cea8566ac5..4b049c73d6 100644 --- a/packages/core/src/extensions/Versioning/Versioning.ts +++ b/packages/core/src/extensions/Versioning/Versioning.ts @@ -4,316 +4,29 @@ import { createStore, type ExtensionOptions, } from "../../editor/BlockNoteExtension.js"; -import { - normalizeToUserStore, - type User, - type UserStoreOrResolver, -} from "../../user/index.js"; - -/** - * Represents a single snapshot of a document's history, including metadata and content information. - * Snapshots are used for versioning and can be created, listed, restored, and previewed through the - * {@link VersioningEndpoints}. - */ -export interface VersionSnapshot { - /** - * The unique identifier for the snapshot. A plain string for real snapshots; - * the {@link CURRENT_VERSION_ID} symbol for the synthetic "Current version" - * entry (which no backend ever persists or round-trips). - */ - id: string | typeof CURRENT_VERSION_ID; - - /** - * The name of the snapshot. - */ - name?: string; - - /** - * The timestamp when the snapshot was created (unix timestamp). - */ - createdAt: number; - - /** - * The timestamp when the snapshot was last updated (unix timestamp). - */ - updatedAt: number; - - /** - * An optional secondary label for the snapshot, which can display additional information such as a custom description. - * This is for display purposes only and is not used for any logic in the versioning system. - * - * For author attribution, prefer {@link by}: it holds raw user ids that the - * view layer resolves to user info (and keeps up to date as users load). - * When both are set, `secondaryLabel` wins. - */ - secondaryLabel?: string; - - /** - * The id(s) of the user(s) that authored this version, as raw user ids — - * never pre-resolved to display names. The view layer resolves them via the - * {@link VersioningExtension}'s user store (see - * {@link VersioningExtensionOptions.resolveUsers}), reactively updating as - * user info loads. Only used when {@link secondaryLabel} is unset. - */ - by?: User["id"] | User["id"][]; - - /** - * The ID of the previous snapshot that this snapshot was restored from. - */ - restoredFromSnapshotId?: string; -} - -/** - * Identifier for a single {@link VersionSnapshot}, either the bare id or the - * whole reference. Tracks {@link VersionSnapshot.id}, so it also accepts the - * {@link CURRENT_VERSION_ID} symbol. - */ -export type VersionSnapshotIdentifier = - | VersionSnapshot["id"] - | Pick; - -/** - * The `id` of the synthetic "Current version" entry — the live document shown at - * the top of `list()` and set as `previewedSnapshotId` while previewing it (see - * {@link VersioningExtension.previewCurrentVersion}). - * - * A `unique symbol`, not a string, so it can never clash with a real snapshot id. - * It's client-only — never fetched via `getContent` / `getAttributions` (the row - * is previewed live) and never serialised, so no backend round-trips it. Because - * {@link VersionSnapshot.id} is `string | typeof CURRENT_VERSION_ID`, code that - * needs a string form for this one row (e.g. a React `key`) derives it locally. - */ -export const CURRENT_VERSION_ID: unique symbol = Symbol("bn-current-version"); - -/** - * The backend contract for versioning: **where snapshot data lives** (pure - * storage — in-memory, `localStorage`, HTTP, …). Counterpart to - * {@link PreviewController} (*how a snapshot is rendered*) and - * {@link VersioningExtensionOptions} (*how the live editor is bridged in*); - * {@link VersioningExtension} orchestrates the three. - * - * Type params trace the data flow: - * @typeParam Input - Live document handle passed to {@link create} / {@link restore}, - * from {@link VersioningExtensionOptions.getCurrentDocument} (e.g. `Y.Type`, `Block[]`). - * @typeParam Output - Serialised snapshot content from {@link getContent} / - * {@link restore}, rendered by {@link PreviewController.enterPreview} (e.g. `Uint8Array`). - * @typeParam Attributions - Optional diff-authorship data from {@link getAttributions}, - * also consumed by {@link PreviewController.enterPreview} (e.g. `Y.ContentMap`). - */ -export interface VersioningEndpoints< - Input = any, - Output = any, - Attributions = any, -> { - /** - * List all snapshots for this document, sorted newest-first by - * {@link VersionSnapshot.createdAt}. - */ - list: () => Promise; - /** - * Create a new snapshot from the current content. - * - * @note omit for backends with continuous history (e.g. YHub's activity - * timeline). Gates the extension's `canCreate` flag. - */ - create?: ( - /** Live document to snapshot, from {@link VersioningExtensionOptions.getCurrentDocument}. */ - content: Input, - options?: { - /** Optional name for this snapshot. */ - name?: string; - /** Id of the snapshot this one was restored from, if any. */ - restoredFromSnapshot?: VersionSnapshot; - }, - ) => Promise; - /** - * Restore the document to a snapshot. Implementations should create any backup - * snapshots they need before returning. - * - * @returns The restored content ({@link Output}, **not `void`**) — passed to - * {@link PreviewController.applyRestore}. - * @note omit to disable restore. Gates the extension's `canRestore` flag. - */ - restore?: ( - /** Live document, from {@link VersioningExtensionOptions.getCurrentDocument} (for backup). */ - doc: Input, - /** The snapshot to restore. */ - snapshot: VersionSnapshot, - ) => Promise; - /** - * Fetch a snapshot's content ({@link Output}) for preview — same format as - * {@link VersioningExtensionOptions.serializeCurrentContent}. Sibling of - * {@link getAttributions}; both are the storage-side fetch that - * {@link PreviewController.enterPreview} renders. - */ - getContent: (snapshot: VersionSnapshot) => Promise; - /** - * Fetch diff-authorship data ({@link Attributions}: who/when) for the range - * `compareTo → snapshot`, rendered by {@link PreviewController.enterPreview} - * (its only consumer). Lives on the endpoint, not `enterPreview`, so one - * preview controller pairs with attribution-capable (YHub) or attribution-less - * (`localStorage`) backends — {@link Attributions} is that seam. - * - * @note omit and previews still render the content diff, minus attribution. - */ - getAttributions?: ( - /** The previewed snapshot (the "new" side of the diff). */ - snapshot: VersionSnapshot, - /** The baseline it's diffed against (the "old" side). */ - compareTo?: VersionSnapshot, - ) => Promise; - /** - * Rename a snapshot. - * - * @note omit to disable rename. Gates the extension's `canRename` flag. - */ - rename?: (snapshot: VersionSnapshot, name?: string) => Promise; - /** - * Permanently remove a snapshot. - * - * @note omit for immutable-history backends (e.g. YHub). Gates the extension's - * `canRemove` flag. - */ - remove?: (snapshot: VersionSnapshot) => Promise; -} +import { normalizeToUserStore } from "../../user/index.js"; +import { ReadOnlyExtension } from "../ReadOnly/ReadOnly.js"; +import { createVersioningCommands } from "./commands.js"; +import { createListSession } from "./list.js"; +import { createPreviewSession } from "./preview.js"; +import { findSnapshot, isReadOnly } from "./state.js"; +import type { + VersioningExtensionOptions, + VersioningLoadingState, + VersioningState, + VersionSnapshotIdentifier, +} from "./types.js"; + +export { LOADING_PREVIEW_CLASS, LOADING_PREVIEW_DELAY_MS } from "./preview.js"; +export type * from "./types.js"; /** - * A factory function for the endpoints to receive a reference to the editor. - * - * @typeParam Input - See {@link VersioningEndpoints}. - * @typeParam Output - See {@link VersioningEndpoints}. - * @typeParam Attributions - See {@link VersioningEndpoints}. + * The composition root: resolves options, creates the store, wires the three + * sessions (list, preview, commands) together, and exposes the extension + * facade. Each store field has exactly one writer — `list`/`listing` the list + * session, `view`/`loadingView` the preview session, `restoring` the commands + * — and the busy status is read through from those flags by `getLoadingState`. */ -export type VersioningEndpointsFactory< - Input = any, - Output = any, - Attributions = any, -> = ( - editor: BlockNoteEditor, -) => VersioningEndpoints; - -/** - * Controls **how a snapshot is rendered** — the render-side counterpart to - * {@link VersioningEndpoints} (storage). {@link VersioningExtension} fetches - * content/attributions from the endpoints and delegates rendering here; keeping - * the two separate lets one controller pair with different backends. - * - * @typeParam Output - Serialised snapshot content; matches the endpoints' `Output`. - * @typeParam Attributions - Optional attribution data; matches the endpoints' `Attributions`. - */ -export interface PreviewController { - /** - * Whether {@link enterPreview} can render a diff (uses `compareToContent`). - * Defaults to `true`; `false` for show-one-version-only backends (e.g. the Yjs - * v13 adapter). Surfaced as {@link VersioningExtension.canCompare}. - */ - supportsComparison?: boolean; - /** - * Enter preview mode. Arguments come from the endpoints: - * {@link VersioningEndpoints.getContent} (content) and - * {@link VersioningEndpoints.getAttributions} (attributions). - */ - enterPreview: ( - /** Snapshot to preview ({@link Output}, from {@link VersioningEndpoints.getContent}). */ - snapshotContent: Output, - /** When set, diff `compareToContent` (baseline) against `snapshotContent`. */ - compareToContent?: Output, - /** - * Diff attributions ({@link Attributions}, from - * {@link VersioningEndpoints.getAttributions}). Only meaningful with - * `compareToContent`. - */ - attributions?: Attributions, - /** - * The snapshot(s) this preview is for (metadata only — the content is - * `snapshotContent` / `compareToContent`). Lets a controller label the - * preview with e.g. the version's name, without smuggling it through the - * {@link Attributions} channel. `snapshot` is the previewed version (the - * {@link CURRENT_VERSION_ID} entry when previewing the live document); - * `compareTo` is the baseline it's diffed against, if any. - */ - context?: { snapshot: VersionSnapshot; compareTo?: VersionSnapshot }, - ) => void; - /** Exit preview mode and resume normal editing. */ - exitPreview: () => void; - /** - * Apply restored content to the live document. Called with the {@link Output} - * from {@link VersioningEndpoints.restore}, after preview mode has exited. - */ - applyRestore: (snapshotContent: Output) => void; -} - -/** Sort snapshots newest-first by creation time. */ -export function sortSnapshotsNewestFirst( - snapshots: VersionSnapshot[], -): VersionSnapshot[] { - return [...snapshots].sort((a, b) => b.createdAt - a.createdAt); -} - -/** - * Options accepted by the {@link VersioningExtension} — **how the live editor is - * bridged in**, alongside the {@link VersioningEndpoints} (storage) and - * {@link PreviewController} (rendering). - * - * @typeParam Input - See {@link VersioningEndpoints}. - * @typeParam Output - See {@link VersioningEndpoints}. - * @typeParam Attributions - See {@link VersioningEndpoints}. - */ -export type VersioningExtensionOptions< - Input = any, - Output = any, - Attributions = any, -> = { - /** - * Backend storage for snapshots. - */ - endpoints: - | VersioningEndpoints - | VersioningEndpointsFactory; - /** - * Controls how snapshot previews and restores are rendered in the editor. - */ - preview: PreviewController; - /** - * The **live, mutable document handle** ({@link Input}) the backend snapshots - * *from* / restores *into*. Passed to {@link VersioningEndpoints.create} and - * {@link VersioningEndpoints.restore}. Cf. {@link serializeCurrentContent} (a - * detached copy); the two coincide for some backends (in-memory: - * `Input === Output === Block[]`) and differ for others (Yjs: `Y.Type` vs `Uint8Array`). - */ - getCurrentDocument: () => Input; - /** - * The live document **serialised to snapshot format** ({@link Output}, matching - * {@link VersioningEndpoints.getContent}), for diffing the live doc against a - * snapshot (see {@link VersioningExtension.previewCurrentVersion}). Cf. - * {@link getCurrentDocument} (the live handle). - * - * @note omit and the UI can't offer a "Current version" diff. Gates the - * extension's `canPreviewCurrent` flag. - */ - serializeCurrentContent?: () => Output | Promise; - /** - * Resolve user information for the author ids in {@link VersionSnapshot.by}, - * used by the view layer to render version-author labels. - * - * Either a resolver function (called with the ids of users that are not yet - * cached, returning their information — a user store is built from it - * internally) or a pre-built user store (see `createUserStore`). Pass the - * same store you give the comments/collaboration extensions so a single - * de-duped user cache is shared across features. - * - * @note omit and author ids are displayed as-is. - */ - resolveUsers?: UserStoreOrResolver; -}; - -function snapshotNotFoundError( - id: VersionSnapshotIdentifier | undefined, -): never { - const idResolved = typeof id === "object" ? id.id : id; - throw new Error(`Snapshot not found: ${String(idResolved)}`); -} - export const VersioningExtension = createExtension( ({ options: optionsOrFactory, @@ -328,179 +41,80 @@ export const VersioningExtension = createExtension( getCurrentDocument, serializeCurrentContent, resolveUsers, + scrollToFirstChange: scrollToFirstChangeEnabled = true, } = typeof optionsOrFactory === "function" ? optionsOrFactory(editor) : optionsOrFactory; const endpoints = typeof endpointsRaw === "function" ? endpointsRaw(editor) : endpointsRaw; + // Capture the controller method so the restore branch has a callable type. + const applyRestore = preview.applyRestore?.bind(preview); // With no resolver this is an empty store: `getUser` always misses, so the // view layer falls back to showing the raw ids from `VersionSnapshot.by`. const userStore = normalizeToUserStore(resolveUsers); - const store = createStore<{ - snapshots: VersionSnapshot[]; - /** - * The id of the version currently shown in the editor (the "new" side of - * a diff). `undefined` means the live, editable document. Is the - * {@link CURRENT_VERSION_ID} symbol when previewing the live document as a - * read-only diff against a snapshot. - */ - previewedSnapshotId?: string | typeof CURRENT_VERSION_ID; - /** - * The id of the snapshot the preview is being diffed against (the - * "baseline" / old side). `undefined` when not showing a diff. Always a - * real snapshot id (never the current entry), but typed as the same union - * as {@link VersionSnapshot.id} since it's copied from one. Used to render - * the "Comparing to" indicator in the sidebar. - */ - compareToSnapshotId?: string | typeof CURRENT_VERSION_ID; - }>({ - snapshots: [], - previewedSnapshotId: undefined, - compareToSnapshotId: undefined, - }); - - const getSnapshot = (id: VersionSnapshotIdentifier | undefined) => { - const idResolved = typeof id === "object" ? id.id : id; - return store.state.snapshots.find( - (snapshot) => snapshot.id === idResolved, - ); - }; - - const updateSnapshots = async () => { - const snapshots = sortSnapshotsNewestFirst(await endpoints.list()); - store.setState((state) => ({ - ...state, - snapshots, - })); - - return snapshots; - }; - - const previewSnapshot = async ( - id: VersionSnapshotIdentifier, - previewOptions?: { - /** - * When set, the preview shows a diff against this snapshot (typically the - * chronologically previous version in the history list). - */ - compareTo?: VersionSnapshotIdentifier; + const store = createStore( + { + list: { loaded: false }, + view: { mode: "live" }, + listing: false, + restoring: false, }, - ) => { - const snapshot = getSnapshot(id); - - if (!snapshot) { - snapshotNotFoundError(id); - } - - const compareToSnapshot = previewOptions?.compareTo - ? getSnapshot(previewOptions.compareTo) - : undefined; - - store.setState((state) => ({ - ...state, - previewedSnapshotId: snapshot.id, - compareToSnapshotId: compareToSnapshot?.id, - })); - - let compareToContent: unknown; - let attributions: unknown; - if (compareToSnapshot) { - compareToContent = await endpoints.getContent(compareToSnapshot); - // Attributions describe the diff between the baseline and this - // snapshot, so they're only meaningful when comparing against another - // version. Fetching them is optional: previews still render the content - // diff without author/timestamp information when unavailable. - if (endpoints.getAttributions) { - attributions = await endpoints.getAttributions( - snapshot, - compareToSnapshot, - ); - } - } - - const snapshotContent = await endpoints.getContent(snapshot); - preview.enterPreview(snapshotContent, compareToContent, attributions, { - snapshot, - compareTo: compareToSnapshot, - }); - }; - - /** - * Preview the live ("current") document as a read-only diff against a - * snapshot baseline. Unlike {@link previewSnapshot}, the "new" side of the - * diff is the live document — serialised via `serializeCurrentContent` — - * rather than a stored snapshot. The editor becomes non-editable while - * previewing (editing is gated on `previewedSnapshotId === undefined`). - */ - const previewCurrentVersion = async (previewOptions?: { - /** - * The snapshot to diff the live document against (the baseline). When - * omitted, the live document is shown without a diff. - */ - compareTo?: VersionSnapshotIdentifier; - }) => { - if (!serializeCurrentContent) { - throw new Error( - "previewCurrentVersion requires `serializeCurrentContent` to be " + - "provided to the VersioningExtension options.", - ); - } - - const compareToSnapshot = previewOptions?.compareTo - ? getSnapshot(previewOptions.compareTo) - : undefined; - - store.setState((state) => ({ - ...state, - previewedSnapshotId: CURRENT_VERSION_ID, - compareToSnapshotId: compareToSnapshot?.id, - })); - - // Synthesise a snapshot for the live document so timestamp-based backends - // (e.g. YHub) resolve the changeset window up to "now", and so the preview - // controller gets a snapshot to key off. The id is the current-version - // sentinel; backends ignore it and resolve the window from `createdAt`. - const currentSnapshot: VersionSnapshot = { - id: CURRENT_VERSION_ID, - createdAt: Date.now(), - updatedAt: Date.now(), - }; - - let compareToContent: unknown; - let attributions: unknown; - if (compareToSnapshot) { - compareToContent = await endpoints.getContent(compareToSnapshot); - if (endpoints.getAttributions) { - attributions = await endpoints.getAttributions( - currentSnapshot, - compareToSnapshot, - ); - } - } - - const currentContent = await serializeCurrentContent(); - preview.enterPreview(currentContent, compareToContent, attributions, { - snapshot: currentSnapshot, - compareTo: compareToSnapshot, - }); - }; + { + // Sync the ReadOnly gate with the new state. Writing through the + // store keeps this in one place, including for external `setState`. + onUpdate(state, prevState) { + if (isReadOnly(state) !== isReadOnly(prevState)) { + editor + .getExtension(ReadOnlyExtension)! + .setReadOnly(isReadOnly(state), "versioning"); + } + }, + }, + ); - const exitPreview = () => { - store.setState((state) => ({ - ...state, - previewedSnapshotId: undefined, - compareToSnapshotId: undefined, - })); - preview.exitPreview(); - }; + const listSession = createListSession({ store, endpoints }); + const previewSession = createPreviewSession({ + store, + endpoints, + preview, + serializeCurrentContent, + editor, + scrollToFirstChangeEnabled, + }); + const commands = createVersioningCommands({ + store, + endpoints, + getCurrentDocument, + applyRestore, + refreshList: listSession.refresh, + exitPreview: previewSession.exitPreview, + }); return { key: "versioning", store, userStore, - list: async (): Promise => { - return await updateSnapshots(); + /** Open history: fetch its list from the backend. */ + list: listSession.refresh, + getSnapshot: (id: VersionSnapshotIdentifier) => + findSnapshot(store.state.list, id), + /** + * The busy status the sidebar shows, read through from the two in-flight + * flags the sessions publish. Preview loading outranks listing: a fetch + * is the more urgent thing to communicate, and reverting to `listing` + * when it settles keeps a slow list request visible. + * + * Defaults to this store's state, so it doubles as a store selector when + * the caller passes the selected state. + */ + getLoadingState: ( + state: VersioningState = store.state, + ): VersioningLoadingState => { + if (state.loadingView) { + return { type: "loading-preview", view: state.loadingView }; + } + return state.listing ? { type: "listing" } : { type: "idle" }; }, // Comparison is only offered when the preview controller can actually // render a diff (see PreviewController.supportsComparison). A getter so a @@ -510,119 +124,19 @@ export const VersioningExtension = createExtension( get canCompare() { return preview.supportsComparison !== false; }, - canCreate: endpoints.create !== undefined, - create: endpoints.create - ? async (options?: { - /** - * The optional name for this snapshot. - */ - name?: string; - /** - * The ID of the snapshot this one was restored from, if applicable. - */ - restoredFromSnapshot?: VersionSnapshotIdentifier; - }): Promise => { - const snapshot = await endpoints.create!(getCurrentDocument(), { - name: options?.name, - restoredFromSnapshot: getSnapshot(options?.restoredFromSnapshot), - }); - // Show the new version immediately. Some backends (e.g. YHub) build - // their version list from an activity timeline that lags a beat - // behind the create, so waiting on a re-list would leave the UI - // briefly stale. - store.setState((state) => ({ - ...state, - snapshots: sortSnapshotsNewestFirst([ - ...state.snapshots, - snapshot, - ]), - })); - // Reconcile with the backend's `list()` — it owns the "current - // version" entry and any server-assigned metadata. If the refreshed - // list doesn't include the just-created version yet (indexing lag), - // keep the optimistic entry so it never flickers out. - const listed = await endpoints.list(); - store.setState((state) => ({ - ...state, - snapshots: sortSnapshotsNewestFirst( - listed.some((s) => s.id === snapshot.id) - ? listed - : [...listed, snapshot], - ), - })); - return snapshot; - } - : undefined, - canRestore: endpoints.restore !== undefined, - restore: endpoints.restore - ? async (id: VersionSnapshotIdentifier) => { - exitPreview(); - const snapshot = getSnapshot(id); - - if (!snapshot) { - snapshotNotFoundError(id); - } - const snapshotContent = await endpoints.restore!( - getCurrentDocument(), - snapshot, - ); - preview.applyRestore(snapshotContent); - await updateSnapshots(); - return snapshotContent; - } - : undefined, - canRename: endpoints.rename !== undefined, - rename: endpoints.rename - ? async ( - id: VersionSnapshotIdentifier, - name?: string, - ): Promise => { - const snapshot = getSnapshot(id); - if (!snapshot) { - snapshotNotFoundError(id); - } - await endpoints.rename!(snapshot, name); - store.setState((state) => ({ - ...state, - snapshots: state.snapshots.map((s) => - s.id === id ? { ...s, name, updatedAt: Date.now() } : s, - ), - })); - } - : undefined, - canRemove: endpoints.remove !== undefined, - remove: endpoints.remove - ? async (id: VersionSnapshotIdentifier): Promise => { - const snapshot = getSnapshot(id); - if (!snapshot) { - snapshotNotFoundError(id); - } - // If the snapshot being removed is the one currently previewed, or - // the baseline it's being diffed against, exit preview first so the - // editor returns to the live document instead of showing (or - // comparing against) a version that no longer exists. - if ( - store.state.previewedSnapshotId === snapshot.id || - store.state.compareToSnapshotId === snapshot.id - ) { - exitPreview(); - } - await endpoints.remove!(snapshot); - // Remove it optimistically so the row disappears immediately, then - // reconcile with the backend's authoritative list. - store.setState((state) => ({ - ...state, - snapshots: state.snapshots.filter((s) => s.id !== snapshot.id), - })); - await updateSnapshots(); - } - : undefined, - previewSnapshot, - canPreviewCurrent: serializeCurrentContent !== undefined, - previewCurrentVersion: serializeCurrentContent - ? previewCurrentVersion - : undefined, - exitPreview, + create: commands.create, + restore: commands.restore, + rename: commands.rename, + remove: commands.remove, + previewSnapshot: previewSession.previewSnapshot, + previewCurrentVersion: previewSession.previewCurrentVersion, + exitPreview: previewSession.exitPreview, + /** + * Scroll the first change of the rendered diff into view. Runs + * automatically after preview unless disabled; exposed for hosts. + * @returns whether a change was found. + */ + scrollToFirstChange: previewSession.scrollToFirstChange, } as const; }, ); diff --git a/packages/core/src/extensions/Versioning/commands.ts b/packages/core/src/extensions/Versioning/commands.ts new file mode 100644 index 0000000000..1b8a1c8b55 --- /dev/null +++ b/packages/core/src/extensions/Versioning/commands.ts @@ -0,0 +1,126 @@ +import type { Store } from "../../util/Store.js"; +import { findSnapshot } from "./state.js"; +import type { + VersioningEndpoints, + VersionSnapshotIdentifier, + VersioningState, + VersionSnapshot, +} from "./types.js"; + +/** + * The mutation commands: create, restore, rename and remove. Each composes a + * backend call with a list refresh and, where the result affects the screen, + * an exit from preview. They know nothing about supersession or status — that + * is the preview session's and the root's business. + */ +export function createVersioningCommands({ + store, + endpoints, + getCurrentDocument, + applyRestore, + refreshList, + exitPreview, +}: { + store: Store; + endpoints: VersioningEndpoints; + getCurrentDocument: () => any; + applyRestore?: (content: any) => void; + refreshList: () => Promise; + exitPreview: () => void; +}) { + return { + create: endpoints.create + ? async (options?: { name?: string }): Promise => { + const snapshot = await endpoints.create!(getCurrentDocument(), { + name: options?.name, + }); + // Re-list rather than patching optimistically: naming the current + // version can turn it into a stored row (and shift what "current" + // is), which only the backend can resolve. + await refreshList(); + return snapshot; + } + : undefined, + restore: + endpoints.restore && applyRestore + ? async (id: VersionSnapshotIdentifier) => { + const snapshot = findSnapshot(store.state.list, id); + if (snapshot === undefined) { + throw new Error( + `Snapshot not found: ${typeof id === "object" ? id.id : id}`, + ); + } + // Prevent edits while the live document is about to be replaced. + store.setState((state) => ({ ...state, restoring: true })); + try { + const snapshotContent = await endpoints.restore!( + getCurrentDocument(), + snapshot, + ); + exitPreview(); + applyRestore(snapshotContent); + // Re-list so the sidebar reflects the restore. Backends whose + // history settles asynchronously may need a reopen to show + // the newest rows. + await refreshList(); + return snapshotContent; + } finally { + store.setState((state) => ({ ...state, restoring: false })); + } + } + : undefined, + rename: endpoints.rename + ? async (id: VersionSnapshotIdentifier, name?: string): Promise => { + const snapshot = findSnapshot(store.state.list, id); + if (snapshot === undefined) { + throw new Error( + `Snapshot not found: ${typeof id === "object" ? id.id : id}`, + ); + } + await endpoints.rename!(snapshot, name); + // Patch the name in place: a rename changes nothing else about the + // list, so re-listing would only cost a round-trip and a flicker. + store.setState((state) => { + if (!state.list.loaded) { + return state; + } + const patch = (s: VersionSnapshot) => + s.id === snapshot.id ? { ...s, name } : s; + return { + ...state, + list: { + loaded: true, + current: patch(state.list.current), + snapshots: state.list.snapshots.map(patch), + }, + }; + }); + } + : undefined, + remove: endpoints.remove + ? async (id: VersionSnapshotIdentifier): Promise => { + const snapshot = findSnapshot(store.state.list, id); + if (snapshot === undefined) { + throw new Error( + `Snapshot not found: ${typeof id === "object" ? id.id : id}`, + ); + } + await endpoints.remove!(snapshot); + await refreshList(); + // The removed row may survive as unnamed history; leave only if + // what is on screen (or what it is diffed against) is really gone + // (`exitPreview` no-ops when live). + const { view } = store.state; + const gone = (shown: string | undefined) => + shown !== undefined && !findSnapshot(store.state.list, shown); + if ( + view.mode !== "live" && + (gone(view.mode === "snapshot" ? view.snapshotId : undefined) || + gone(view.compareToId)) + ) { + exitPreview(); + } + } + : undefined, + }; +} diff --git a/packages/core/src/extensions/Versioning/inMemoryVersioning.test.ts b/packages/core/src/extensions/Versioning/inMemoryVersioning.test.ts index 8d9c7567eb..d830dd3057 100644 --- a/packages/core/src/extensions/Versioning/inMemoryVersioning.test.ts +++ b/packages/core/src/extensions/Versioning/inMemoryVersioning.test.ts @@ -12,7 +12,7 @@ import { import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; import { DiffVersioningExtension } from "../../y/extensions/DiffVersioningExtension.js"; -import { CURRENT_VERSION_ID, VersioningExtension } from "./Versioning.js"; +import { VersioningExtension } from "./Versioning.js"; import { createInMemoryPreviewController, createInMemoryVersioningAdapter, @@ -65,34 +65,75 @@ describe("createInMemoryVersioningEndpoints", () => { expect(content).not.toBe(blocks); }); + it("starts with the given initial versions, newest-first", async () => { + const older = [{ type: "paragraph", content: "older" }] as any; + const newer = [{ type: "paragraph", content: "newer" }] as any; + const endpoints = createInMemoryVersioningEndpoints({ + initialVersions: [ + { name: "Older", createdAt: 1000, content: older }, + { createdAt: 2000, content: newer }, + ], + }); + + const { snapshots } = await endpoints.list(); + expect( + snapshots.map((s) => ({ name: s.name, createdAt: s.createdAt })), + ).toEqual([ + { name: undefined, createdAt: 2000 }, + { name: "Older", createdAt: 1000 }, + ]); + expect(await endpoints.getContent(snapshots[1]!)).toEqual(older); + // Stored as a copy: mutating what was passed in doesn't change history. + older[0].content = "changed"; + expect(await endpoints.getContent(snapshots[1]!)).not.toEqual(older); + }); + + it("sorts versions created later above the initial ones", async () => { + const future = Date.now() + 60_000; + const endpoints = createInMemoryVersioningEndpoints({ + initialVersions: [{ name: "Loaded", createdAt: future, content: [] }], + }); + + const created = await endpoints.create!([], { name: "New" }); + expect(created.createdAt).toBeGreaterThan(future); + const { snapshots } = await endpoints.list(); + expect(snapshots.map((s) => s.name)).toEqual(["New", "Loaded"]); + }); + it("lists snapshots newest-first", async () => { vi.useFakeTimers(); try { const endpoints = createInMemoryVersioningEndpoints(); - const s1 = await endpoints.create!([ - { - id: "1", - type: "paragraph" as const, - content: "v1" as any, - props: {} as any, - children: [], - }, - ]); + const s1 = await endpoints.create!( + [ + { + id: "1", + type: "paragraph" as const, + content: "v1" as any, + props: {} as any, + children: [], + }, + ], + {}, + ); vi.advanceTimersByTime(1000); - const s2 = await endpoints.create!([ - { - id: "2", - type: "paragraph" as const, - content: "v2" as any, - props: {} as any, - children: [], - }, - ]); - - const list = await endpoints.list(); - expect(list[0].id).toBe(s2.id); - expect(list[1].id).toBe(s1.id); + const s2 = await endpoints.create!( + [ + { + id: "2", + type: "paragraph" as const, + content: "v2" as any, + props: {} as any, + children: [], + }, + ], + {}, + ); + + const { snapshots } = await endpoints.list(); + expect(snapshots[0].id).toBe(s2.id); + expect(snapshots[1].id).toBe(s1.id); } finally { vi.useRealTimers(); } @@ -110,7 +151,7 @@ describe("createInMemoryVersioningEndpoints", () => { children: [], }, ]; - const snap = await endpoints.create!(original); + const snap = await endpoints.create!(original, {}); const currentDoc = [ { @@ -125,10 +166,15 @@ describe("createInMemoryVersioningEndpoints", () => { expect(restored).toEqual(original); - // A backup snapshot was created - const list = await endpoints.list(); - expect(list.length).toBe(2); - const backup = list.find((s) => s.restoredFromSnapshotId === snap.id); + // A backup version was created, and the current row records what the + // document was restored from. + const { current, snapshots } = await endpoints.list(); + expect(snapshots.length).toBe(2); + expect(current.restoredFrom).toEqual({ + id: snap.id, + createdAt: snap.createdAt, + }); + const backup = snapshots.find((s) => s.name === "Before restore"); expect(backup).toBeDefined(); // The backup contains the current (pre-restore) doc @@ -153,33 +199,36 @@ describe("createInMemoryVersioningEndpoints", () => { await endpoints.rename!(snap, "new"); - const list = await endpoints.list(); - expect(list.find((s) => s.id === snap.id)!.name).toBe("new"); + const { snapshots } = await endpoints.list(); + expect(snapshots.find((s) => s.id === snap.id)!.name).toBe("new"); }); it("deletes a snapshot and its content", async () => { const endpoints = createInMemoryVersioningEndpoints(); - const snap = await endpoints.create!([ - { - id: "1", - type: "paragraph" as const, - content: "v1" as any, - props: {} as any, - children: [], - }, - ]); + const snap = await endpoints.create!( + [ + { + id: "1", + type: "paragraph" as const, + content: "v1" as any, + props: {} as any, + children: [], + }, + ], + {}, + ); await endpoints.remove!(snap); // No longer listed - expect(await endpoints.list()).toHaveLength(0); + expect((await endpoints.list()).snapshots).toHaveLength(0); // Its content is gone too await expect(endpoints.getContent(snap)).rejects.toThrow(/not found/i); }); it("throws for unknown snapshot ID", async () => { const endpoints = createInMemoryVersioningEndpoints(); - const missing = { id: "nope", createdAt: 0, updatedAt: 0 }; + const missing = { id: "nope", createdAt: 0 }; await expect(endpoints.getContent(missing)).rejects.toThrow(/not found/i); await expect(endpoints.restore!([], missing)).rejects.toThrow(/not found/i); await expect(endpoints.rename!(missing, "x")).rejects.toThrow(/not found/i); @@ -296,38 +345,70 @@ describe("VersioningExtension + in-memory adapter", () => { // 3. Create another snapshot await ext.create!({ name: "v2" }); - // 4. List — both present (the adapter also surfaces a "current version" - // entry, which isn't a stored snapshot). - const list = (await ext.list()).filter((s) => s.id !== CURRENT_VERSION_ID); - expect(list).toHaveLength(2); - expect(list.map((s) => s.name)).toContain("v1"); - expect(list.map((s) => s.name)).toContain("v2"); + // 4. List — both stored versions are present, alongside the current entry. + const { snapshots } = await ext.list(); + expect(snapshots).toHaveLength(2); + expect(snapshots.map((s) => s.name)).toContain("v1"); + expect(snapshots.map((s) => s.name)).toContain("v2"); - // 5. Preview the first snapshot + // 5. Preview the first version await ext.previewSnapshot(snap1.id); expect(getEditorText(editor)).toBe("initial doc"); - expect(ext.store.state.previewedSnapshotId).toBe(snap1.id); + expect(ext.store.state.view).toEqual({ + mode: "snapshot", + snapshotId: snap1.id, + compareToId: undefined, + }); // 6. Exit preview — back to modified doc ext.exitPreview(); expect(getEditorText(editor)).toBe("modified doc"); - expect(ext.store.state.previewedSnapshotId).toBeUndefined(); + expect(ext.store.state.view).toEqual({ mode: "live" }); - // 7. Restore the first snapshot + // 7. Restore the first version const restored = await ext.restore!(snap1.id); expect(restored).toBeDefined(); expect(getEditorText(editor)).toBe("initial doc"); - // 8. A backup snapshot was created by the endpoints (plus the adapter's - // "current version" entry, which isn't a stored snapshot). - const afterRestore = (await ext.list()).filter( - (s) => s.id !== CURRENT_VERSION_ID, - ); - expect(afterRestore.length).toBe(3); - const backup = afterRestore.find( - (s) => s.restoredFromSnapshotId === snap1.id, - ); - expect(backup).toBeDefined(); + // 8. A backup version was created by the endpoints, and the current row + // records where the restore came from. + const afterRestore = await ext.list(); + expect(afterRestore.snapshots.length).toBe(3); + expect(afterRestore.current.restoredFrom).toEqual({ + id: snap1.id, + createdAt: snap1.createdAt, + }); + }); + + it("stamps the current row with the last edit time", async () => { + const adapter = createInMemoryVersioningAdapter(editor); + const ext = VersioningExtension(adapter)({ editor }); + + const before = Date.now(); + setEditorText(editor, "edited doc"); + + const { current } = await ext.list(); + expect(current.createdAt).toBeGreaterThanOrEqual(before); + }); + + it("stamps a restore before refreshing the current row", async () => { + const adapter = createInMemoryVersioningAdapter(editor); + const ext = VersioningExtension(adapter)({ editor }); + const snapshot = await ext.create!(); + setEditorText(editor, "new content"); + await ext.previewSnapshot(snapshot.id); + + const restoredAt = Date.now() + 1000; + const clock = vi.spyOn(Date, "now").mockReturnValue(restoredAt); + try { + await ext.restore!(snapshot.id); + expect(ext.store.state.list).toMatchObject({ + loaded: true, + current: { createdAt: restoredAt }, + }); + } finally { + clock.mockRestore(); + } }); it("preview with compareTo fetches both contents", async () => { @@ -357,16 +438,12 @@ describe("VersioningExtension + in-memory adapter", () => { const snap2 = await ext.create!({ name: "remove" }); await ext.list(); - expect(ext.canRemove).toBe(true); + expect(ext.remove).toBeDefined(); await ext.remove!(snap2.id); - // Gone from the optimistic store... - expect( - ext.store.state.snapshots.find((s) => s.id === snap2.id), - ).toBeUndefined(); - // ...and gone from the backend's authoritative list. - const list = (await ext.list()).filter((s) => s.id !== CURRENT_VERSION_ID); - expect(list.map((s) => s.id)).toEqual([snap1.id]); + // Gone from the backend's authoritative list. + const { snapshots } = await ext.list(); + expect(snapshots.map((s) => s.id)).toEqual([snap1.id]); }); it("deleting the previewed snapshot exits preview", async () => { @@ -376,14 +453,18 @@ describe("VersioningExtension + in-memory adapter", () => { const snap = await ext.create!({ name: "v1" }); setEditorText(editor, "modified doc"); - // Preview the snapshot, then delete the version being previewed. + // Preview the version, then delete the one being previewed. await ext.previewSnapshot(snap.id); - expect(ext.store.state.previewedSnapshotId).toBe(snap.id); + expect(ext.store.state.view).toEqual({ + mode: "snapshot", + snapshotId: snap.id, + compareToId: undefined, + }); await ext.remove!(snap.id); // Preview was exited and the live document restored. - expect(ext.store.state.previewedSnapshotId).toBeUndefined(); + expect(ext.store.state.view).toEqual({ mode: "live" }); expect(getEditorText(editor)).toBe("modified doc"); }); @@ -394,14 +475,18 @@ describe("VersioningExtension + in-memory adapter", () => { const snap = await ext.create!({ name: "draft" }); await ext.rename!(snap.id, "final"); - // Store was updated optimistically - expect(ext.store.state.snapshots.find((s) => s.id === snap.id)!.name).toBe( - "final", - ); + // Store was patched in place + const listed = ext.store.state.list; + expect(listed.loaded).toBe(true); + expect( + listed.loaded + ? listed.snapshots.find((s) => s.id === snap.id)!.name + : undefined, + ).toBe("final"); // Backend also updated (verified via list which calls endpoints.list) - const list = await ext.list(); - expect(list.find((s) => s.id === snap.id)!.name).toBe("final"); + const { snapshots } = await ext.list(); + expect(snapshots.find((s) => s.id === snap.id)!.name).toBe("final"); }); }); diff --git a/packages/core/src/extensions/Versioning/inMemoryVersioning.ts b/packages/core/src/extensions/Versioning/inMemoryVersioning.ts index 75aae103d0..266d0b45bf 100644 --- a/packages/core/src/extensions/Versioning/inMemoryVersioning.ts +++ b/packages/core/src/extensions/Versioning/inMemoryVersioning.ts @@ -1,45 +1,51 @@ import type { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; import type { Block } from "../../blocks/defaultBlocks.js"; +import type { Dictionary } from "../../i18n/dictionary.js"; import type { DiffVersioningExtension } from "../../y/extensions/DiffVersioningExtension.js"; import type { PreviewController, + PreviewTarget, 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"; +/** Reserved current-row id; stored versions use numeric ids. */ +export const IN_MEMORY_CURRENT_VERSION_ID = "current"; + +/** Label for the version introducing the diff's changes. */ +function versionLabel(target: PreviewTarget, dictionary: Dictionary): string { + switch (target.kind) { + case "current": + return target.snapshot.name ?? dictionary.versioning.current_version; + case "snapshot": + return target.snapshot.name ?? dictionary.versioning.unnamed_version; } - return snapshot.name ?? "Unnamed version"; } // --------------------------------------------------------------------------- // Preview Controller // --------------------------------------------------------------------------- +/** Preview controller exposing the live document while a preview replaces it. */ +export type InMemoryPreviewController = PreviewController< + Block[] +> & { + applyRestore: (snapshotContent: Block[]) => void; + /** Saved live content while previewing, otherwise the editor document. */ + getLiveDocument: () => Block[]; + /** Whether a preview has replaced the live document on screen. */ + readonly isPreviewing: boolean; +}; + /** - * 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. + * Swap preview content through `replaceBlocks`, preserving the live document + * across successive previews until exit. */ export function createInMemoryPreviewController( editor: BlockNoteEditor, -): PreviewController[]> { +): InMemoryPreviewController { let savedDoc: Block[] | 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[]) => { editor.replaceBlocks(editor.document, blocks); @@ -60,11 +66,17 @@ export function createInMemoryPreviewController( get supportsComparison() { return getDiff() !== undefined; }, + get isPreviewing() { + return savedDoc !== undefined; + }, + getLiveDocument() { + return savedDoc ?? editor.document; + }, enterPreview( snapshotContent: Block[], compareToContent?: Block[], _attributions?: unknown, - context?: { snapshot: VersionSnapshot; compareTo?: VersionSnapshot }, + context?: { target: PreviewTarget; compareTo?: VersionSnapshot }, ) { // Save the live doc on first enter (successive enters keep the original). if (savedDoc === undefined) { @@ -78,41 +90,30 @@ export function createInMemoryPreviewController( diff.renderDiff( snapshotContent, compareToContent, - context && versionLabel(context.snapshot), + context && versionLabel(context.target, editor.dictionary), ); - 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); - } + // Replacing the blocks also drops the attribution marks a diff leaves. + replaceDoc(savedDoc); savedDoc = undefined; - showingDiff = false; } }, applyRestore(snapshotContent: Block[]) { - const diff = getDiff(); - if (showingDiff && diff) { - diff.clearDiff(snapshotContent); - } else { - replaceDoc(snapshotContent); - } - // Clear saved doc — the restored content is now the live document. + // The restored content is the live document from here on, so leave + // preview state *before* replacing it: the replace below is an edit, not + // a preview transition. savedDoc = undefined; - showingDiff = false; + replaceDoc(snapshotContent); }, }; } @@ -122,23 +123,37 @@ export function createInMemoryPreviewController( // --------------------------------------------------------------------------- /** - * 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[]`). + * A version to start an in-memory store with + * (see {@link InMemoryVersioningOptions.initialVersions}). */ -export function createInMemoryVersioningEndpoints(): VersioningEndpoints< - Block[], - Block[] -> { +export type InMemoryVersion = { + /** The version's name. Leave unset for an automatic (unnamed) version. */ + name?: string; + /** When the version was created (unix ms). */ + createdAt: number; + /** The document as of this version. */ + content: Block[]; +}; + +export type InMemoryVersioningOptions = { + /** Preloaded history. New versions always sort above these, even with future dates. */ + initialVersions?: InMemoryVersion[]; +}; + +/** In-memory snapshot storage using BlockNote document JSON (`Block[]`). */ +export function createInMemoryVersioningEndpoints( + options: InMemoryVersioningOptions = {}, +): VersioningEndpoints[], Block[]> { const snapshots: VersionSnapshot[] = []; const contents = new Map[]>(); let nextId = 1; + // Set by `restore`, so the current row can show "Restored from " until + // the next version is named. + let currentRestoredFrom: VersionSnapshot["restoredFrom"]; - // `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 + // `Date.now()` only has millisecond resolution, so two versions created in + // the same tick would share a timestamp and sorting by creation time could + // list them oldest-first. Hand out // strictly increasing timestamps so creation order is always preserved. let lastTimestamp = 0; function nextTimestamp() { @@ -146,9 +161,28 @@ export function createInMemoryVersioningEndpoints(): VersioningEndpoints< return lastTimestamp; } + for (const version of options.initialVersions ?? []) { + const id = String(nextId++); + snapshots.push({ id, name: version.name, createdAt: version.createdAt }); + contents.set(id, structuredClone(version.content)); + // Whatever is created from here on must sort above the loaded history, + // even when that history carries timestamps from the future. + lastTimestamp = Math.max(lastTimestamp, version.createdAt); + } + return { async list() { - return sortSnapshotsNewestFirst([...snapshots]); + // The current row is the live document. It has no stored content (it *is* + // the editor's content), so it only carries display metadata; the adapter + // overrides `createdAt` with the real last-edit time it tracks. + return { + current: { + id: IN_MEMORY_CURRENT_VERSION_ID, + createdAt: nextTimestamp(), + restoredFrom: currentRestoredFrom, + }, + snapshots: [...snapshots].sort((a, b) => b.createdAt - a.createdAt), + }; }, async create(currentDoc, options) { @@ -156,46 +190,44 @@ export function createInMemoryVersioningEndpoints(): VersioningEndpoints< const id = String(nextId++); const snapshot: VersionSnapshot = { id, - name: options?.name, + name: options.name, createdAt: now, - updatedAt: now, }; snapshots.push(snapshot); contents.set(id, structuredClone(currentDoc)); + // The named version now covers everything up to now, so the current row + // starts fresh. + currentRestoredFrom = undefined; 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 id = 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. + // Capture the pre-restore state as its own version so the restore can be + // undone — the in-memory backend has no continuous history to fall back + // on the way a server-backed one does. const now = nextTimestamp(); const backupId = String(nextId++); - const backup: VersionSnapshot = { + snapshots.push({ id: backupId, name: "Before restore", createdAt: now, - updatedAt: now, - restoredFromSnapshotId: id, - }; - snapshots.push(backup); + }); contents.set(backupId, structuredClone(currentDoc)); + currentRestoredFrom = { id: snapshot.id, createdAt: snapshot.createdAt }; return structuredClone(snapshotContent); }, async getContent(snapshot) { - const id = String(snapshot.id); - const content = contents.get(id); + const content = contents.get(snapshot.id); if (!content) { - throw new Error(`Snapshot ${id} not found`); + throw new Error(`Snapshot ${snapshot.id} not found`); } return structuredClone(content); }, @@ -203,19 +235,18 @@ export function createInMemoryVersioningEndpoints(): VersioningEndpoints< async rename(snapshot, name) { const stored = snapshots.find((s) => s.id === snapshot.id); if (!stored) { - throw new Error(`Snapshot ${String(snapshot.id)} not found`); + throw new Error(`Snapshot ${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`); + throw new Error(`Snapshot ${snapshot.id} not found`); } snapshots.splice(index, 1); - contents.delete(String(snapshot.id)); + contents.delete(snapshot.id); }, }; } @@ -235,39 +266,61 @@ export function createInMemoryVersioningEndpoints(): VersioningEndpoints< * * const editor = BlockNoteEditor.create({ * extensions: [ - * VersioningExtension(createInMemoryVersioningAdapter(editor)), + * VersioningExtension(createInMemoryVersioningAdapter), * ], * }); + * + * // With history loaded from elsewhere: + * VersioningExtension((editor) => + * createInMemoryVersioningAdapter(editor, { initialVersions }), + * ); * ``` */ export function createInMemoryVersioningAdapter( editor: BlockNoteEditor, + options?: InMemoryVersioningOptions, ): VersioningExtensionOptions[], Block[]> { - const endpoints = createInMemoryVersioningEndpoints(); + const endpoints = createInMemoryVersioningEndpoints(options); + const preview = createInMemoryPreviewController(editor); + + // With no server there is no authoritative "last edit" timestamp, so the + // adapter keeps one off the editor's own change stream. The client clock is + // fine here: nothing else reads these timestamps back. Only edits to the + // *live* document count: a preview replaces the document too, and swapping + // versions on screen is not editing. + const loadedAt = Date.now(); + let lastEditedAt: number | undefined; + editor.onChange(() => { + if (!preview.isPreviewing) { + lastEditedAt = Date.now(); + } + }); 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). + // The raw endpoints are pure version storage. The current version is the + // live document, so the adapter — the layer that knows about the editor — + // stamps it with the real last-edit time. endpoints: { ...endpoints, - list: async () => { - const current: VersionSnapshot = { - id: CURRENT_VERSION_ID, - createdAt: Date.now(), - updatedAt: Date.now(), + async list() { + const { current, snapshots } = await endpoints.list(); + return { + current: { ...current, createdAt: lastEditedAt ?? loadedAt }, + snapshots, }; - 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, + preview, + // Both read the *live* document through the controller: while a preview is + // open, `editor.document` holds the previewed version, and naming or + // showing the current version must not capture that. + getCurrentDocument() { + return preview.getLiveDocument(); + }, + // The live document is already in the version content format (`Block[]`), + // so previewing the current version just reuses the live blocks. + serializeCurrentContent() { + return preview.getLiveDocument(); + }, }; } diff --git a/packages/core/src/extensions/Versioning/index.ts b/packages/core/src/extensions/Versioning/index.ts index c24920adc1..980281d4eb 100644 --- a/packages/core/src/extensions/Versioning/index.ts +++ b/packages/core/src/extensions/Versioning/index.ts @@ -1,2 +1,3 @@ export * from "./Versioning.js"; export * from "./inMemoryVersioning.js"; +export * from "./scrollToFirstChange.js"; diff --git a/packages/core/src/extensions/Versioning/list.test.ts b/packages/core/src/extensions/Versioning/list.test.ts new file mode 100644 index 0000000000..7215d37775 --- /dev/null +++ b/packages/core/src/extensions/Versioning/list.test.ts @@ -0,0 +1,183 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it, vi } from "vite-plus/test"; + +import { Store } from "../../util/Store.js"; +import { createListSession } from "./list.js"; +import type { + VersioningEndpoints, + VersioningState, + VersionSnapshot, +} from "./types.js"; + +function snap(id: string, createdAt: number): VersionSnapshot { + return { id, createdAt }; +} + +/** Resolve or reject a request at an explicit point in a loading transition. */ +function deferred() { + let resolve!: (value: T) => void; + let reject!: (error: Error) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +type ListResult = Awaited>; + +function setup(initialState?: VersioningState) { + const store = new Store( + initialState ?? { + list: { loaded: false }, + view: { mode: "live" }, + listing: false, + restoring: false, + }, + ); + const list = vi.fn(); + const endpoints: VersioningEndpoints = { + list, + // `createListSession` only calls `list`; the other endpoints are stubs to + // satisfy the interface. + getContent: async () => undefined, + }; + const session = createListSession({ store, endpoints }); + return { store, list, endpoints, session }; +} + +describe("createListSession", () => { + it("starts idle and unloaded", () => { + const { store } = setup(); + expect(store.state.listing).toBe(false); + expect(store.state.list).toEqual({ loaded: false }); + expect(store.state.view).toEqual({ mode: "live" }); + }); + + it("fetches, stores the list sorted newest-first, and returns it", async () => { + const snapshots = [snap("a", 100), snap("b", 300), snap("c", 200)]; + const current = snap("current", 400); + const { list, store, session } = setup(); + list.mockResolvedValue({ current, snapshots }); + + const result = await session.refresh(); + + expect(list).toHaveBeenCalledTimes(1); + // The returned list is sorted newest-first... + expect(result.snapshots.map((s) => s.id)).toEqual(["b", "c", "a"]); + // ...without mutating the backend array... + expect(snapshots.map((s) => s.id)).toEqual(["a", "b", "c"]); + // ...and is what landed in the store. + expect(store.state.list).toBe(result); + expect(store.state.list).toEqual({ + loaded: true, + current, + snapshots: [snap("b", 300), snap("c", 200), snap("a", 100)], + }); + }); + + it("publishes listing on the idle→busy and busy→idle transitions", async () => { + const request = deferred(); + const { list, store, session } = setup(); + list.mockReturnValue(request.promise); + let listingTransitions = 0; + store.subscribe(({ prevVal, currentVal }) => { + if (prevVal.listing !== currentVal.listing) { + listingTransitions++; + } + }); + + const pending = session.refresh(); + expect(store.state.listing).toBe(true); + expect(listingTransitions).toBe(1); + + request.resolve({ current: snap("current", 10), snapshots: [] }); + await pending; + expect(store.state.listing).toBe(false); + expect(listingTransitions).toBe(2); + }); + + it("is listing while pending and idle once settled", async () => { + const request = deferred(); + const { list, store, session } = setup(); + list.mockReturnValue(request.promise); + + const pending = session.refresh(); + expect(store.state.listing).toBe(true); + + request.resolve({ current: snap("current", 10), snapshots: [] }); + await pending; + expect(store.state.listing).toBe(false); + }); + + it("joins an in-flight fetch instead of re-listing", async () => { + const request = deferred(); + const { list, store, session } = setup(); + list.mockReturnValue(request.promise); + let listingTransitions = 0; + store.subscribe(({ prevVal, currentVal }) => { + if (prevVal.listing !== currentVal.listing) { + listingTransitions++; + } + }); + + const first = session.refresh(); + const second = session.refresh(); + expect(second).toBe(first); + expect(list).toHaveBeenCalledTimes(1); + // Only one busy transition despite two callers. + expect(listingTransitions).toBe(1); + + request.resolve({ current: snap("current", 10), snapshots: [] }); + await Promise.all([first, second]); + // Busy→idle fires once too. + expect(listingTransitions).toBe(2); + + // Once settled, a new refresh fetches again. + const third = session.refresh(); + expect(list).toHaveBeenCalledTimes(2); + expect(third).not.toBe(first); + await third; + }); + + it("keeps the previous list and reports idle when a fetch fails, then retries", async () => { + const previousList = { + loaded: true as const, + current: snap("current", 30), + snapshots: [snap("a", 10)], + }; + const { store, list, session } = setup({ + list: previousList, + view: { mode: "live" }, + listing: false, + restoring: false, + }); + + const request = deferred(); + list.mockReturnValue(request.promise); + + const pending = session.refresh(); + expect(store.state.listing).toBe(true); + + const failure = expect(pending).rejects.toThrow("offline"); + request.reject(new Error("offline")); + await failure; + + // The store keeps its previous list; the fetch is no longer in flight. + expect(store.state.list).toBe(previousList); + expect(store.state.listing).toBe(false); + + // A later refresh retries and succeeds. + list.mockResolvedValue({ + current: snap("current", 40), + snapshots: [snap("b", 20)], + }); + const retried = await session.refresh(); + expect(list).toHaveBeenCalledTimes(2); + expect(retried.snapshots.map((s) => s.id)).toEqual(["b"]); + expect(store.state.list).toEqual(retried); + expect(store.state.listing).toBe(false); + }); +}); diff --git a/packages/core/src/extensions/Versioning/list.ts b/packages/core/src/extensions/Versioning/list.ts new file mode 100644 index 0000000000..bc9c08df86 --- /dev/null +++ b/packages/core/src/extensions/Versioning/list.ts @@ -0,0 +1,66 @@ +import type { Store } from "../../util/Store.js"; +import type { + LoadedVersioningList, + VersioningEndpoints, + VersioningState, +} from "./types.js"; + +/** + * The list half of the versioning store. Owns the `list` field and publishes + * whether a fetch is in flight (`listing`) so the busy status can be derived + * on read. + */ +export function createListSession({ + store, + endpoints, +}: { + store: Store; + endpoints: VersioningEndpoints; +}) { + // At most one fetch is ever in flight: a call made while one is pending joins + // it rather than hitting the backend again. Listing is a short-lived GET and + // while it is pending the UI shows a loading state, so no mutation can land + // in that window. With no second concurrent fetch there is no out-of-order + // write and no need for a counter — this object is both the join key and the + // busy flag. + let latestRequest: { + pending: boolean; + promise: Promise; + } | null = null; + + /** + * Fetch the list, joining an in-flight fetch rather than duplicating it. + * Listing never touches `view`: the preview owns that. + */ + function refresh(): Promise { + if (latestRequest?.pending) { + return latestRequest.promise; + } + + const promise = endpoints.list().then(({ current, snapshots }) => { + const result: LoadedVersioningList = { + loaded: true as const, + current, + snapshots: [...snapshots].sort((a, b) => b.createdAt - a.createdAt), + }; + store.setState((state) => ({ ...state, list: result })); + return result; + }); + + const entry = { pending: true, promise }; + latestRequest = entry; + store.setState((state) => ({ ...state, listing: true })); + + const settle = () => { + // Nothing can supersede `entry` while it is pending: `refresh` joins the + // in-flight fetch instead of starting another, so this always applies. + entry.pending = false; + store.setState((state) => ({ ...state, listing: false })); + }; + promise.then(settle, settle); + + return promise; + } + + return { refresh }; +} diff --git a/packages/core/src/extensions/Versioning/preview.test.ts b/packages/core/src/extensions/Versioning/preview.test.ts new file mode 100644 index 0000000000..48d764c1be --- /dev/null +++ b/packages/core/src/extensions/Versioning/preview.test.ts @@ -0,0 +1,452 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it, vi } from "vite-plus/test"; + +import type { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; +import { Store } from "../../util/Store.js"; +import { + createPreviewSession, + LOADING_PREVIEW_CLASS, + LOADING_PREVIEW_DELAY_MS, +} from "./preview.js"; +import type { + PreviewController, + VersioningEndpoints, + VersioningList, + VersioningState, + VersionSnapshot, +} from "./types.js"; + +function snap( + id: string, + createdAt: number, + extra?: Partial, +): VersionSnapshot { + return { id, createdAt, ...extra }; +} + +function loadedList( + snapshots: VersionSnapshot[], + current: VersionSnapshot = snap("current", 30), +): VersioningList { + return { loaded: true, current, snapshots }; +} + +/** Resolve or reject a request at an explicit point in a loading transition. */ +function deferred() { + let resolve!: (value: T) => void; + let reject!: (error: Error) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +function makeSession(opts?: { + list?: VersioningList; + serializeCurrentContent?: () => any; +}) { + const store = new Store({ + list: opts?.list ?? { loaded: false }, + view: { mode: "live" }, + listing: false, + restoring: false, + }); + const preview = { + enterPreview: vi.fn(), + exitPreview: vi.fn(), + applyRestore: vi.fn>(), + } satisfies PreviewController; + const classList = { add: vi.fn(), remove: vi.fn() }; + const editor = { + domElement: { classList }, + } as unknown as BlockNoteEditor; + const getContent = vi.fn(); + const getAttributions = + vi.fn>(); + const endpoints: VersioningEndpoints = { + list: async () => ({ current: snap("current", 30), snapshots: [] }), + getContent, + getAttributions, + }; + const session = createPreviewSession({ + store, + endpoints, + preview, + serializeCurrentContent: opts?.serializeCurrentContent, + editor, + scrollToFirstChangeEnabled: false, + }); + return { + store, + preview, + classList, + editor, + getContent, + getAttributions, + session, + }; +} + +describe("createPreviewSession", () => { + it("previews a snapshot: renders it, tracks the view, and reports loading", async () => { + const stored = snap("a", 10); + const { store, preview, getContent, session } = makeSession({ + list: loadedList([stored]), + }); + getContent.mockResolvedValue("content a"); + + const pending = session.previewSnapshot("a"); + + // Synchronous before the fetch settles: view is set, loading reported. + expect(store.state.view).toEqual({ + mode: "snapshot", + snapshotId: "a", + compareToId: undefined, + }); + expect(store.state.loadingView).toEqual({ + mode: "snapshot", + snapshotId: "a", + compareToId: undefined, + }); + + await pending; + expect(preview.enterPreview).toHaveBeenCalledTimes(1); + expect(preview.enterPreview).toHaveBeenCalledWith( + "content a", + undefined, + undefined, + { target: { kind: "snapshot", snapshot: stored }, compareTo: undefined }, + ); + expect(store.state.loadingView).toBeUndefined(); + }); + + it("rejects when the snapshot id is unknown", async () => { + const { session } = makeSession({ list: loadedList([snap("a", 10)]) }); + await expect(session.previewSnapshot("nope")).rejects.toThrow( + "Snapshot not found: nope", + ); + }); + + it("fetches the baseline and attributions when comparing against an older version", async () => { + const baseline = snap("baseline", 5); + const shown = snap("shown", 10); + const { store, preview, getContent, getAttributions, session } = + makeSession({ + list: loadedList([shown, baseline], snap("current", 30)), + }); + getContent.mockImplementation(async (snapshot) => + snapshot.id === "shown" ? "shown content" : "baseline content", + ); + getAttributions.mockResolvedValue(["attr"]); + + await session.previewSnapshot("shown", { compareTo: "baseline" }); + + expect(getContent).toHaveBeenCalledTimes(2); + expect(preview.enterPreview).toHaveBeenCalledWith( + "shown content", + "baseline content", + ["attr"], + { target: { kind: "snapshot", snapshot: shown }, compareTo: baseline }, + ); + expect(store.state.view).toEqual({ + mode: "snapshot", + snapshotId: "shown", + compareToId: "baseline", + }); + }); + + it("a superseded preview never renders", async () => { + const { store, preview, getContent, session } = makeSession({ + list: loadedList([snap("b", 20), snap("a", 10)]), + }); + const aRequest = deferred(); + const bRequest = deferred(); + getContent.mockImplementation(async (snapshot) => + snapshot.id === "a" ? aRequest.promise : bRequest.promise, + ); + + const first = session.previewSnapshot("a"); + const second = session.previewSnapshot("b"); + + // Resolve the *newer* request first: it renders. + bRequest.resolve("content b"); + await second; + expect(preview.enterPreview).toHaveBeenCalledTimes(1); + expect(preview.enterPreview).toHaveBeenCalledWith( + "content b", + undefined, + undefined, + expect.anything(), + ); + + // The older request settling late must not draw over the newer preview. + aRequest.resolve("content a"); + await first; + expect(preview.enterPreview).toHaveBeenCalledTimes(1); + expect(store.state.view).toEqual({ + mode: "snapshot", + snapshotId: "b", + compareToId: undefined, + }); + }); + + it("rolls the view back to live and clears loading when the latest fetch throws", async () => { + const { store, preview, getContent, session } = makeSession({ + list: loadedList([snap("a", 10)]), + }); + getContent.mockRejectedValue(new Error("boom")); + + await expect(session.previewSnapshot("a")).rejects.toThrow("boom"); + + expect(store.state.view).toEqual({ mode: "live" }); + expect(store.state.loadingView).toBeUndefined(); + expect(preview.enterPreview).not.toHaveBeenCalled(); + }); + + it("rolls back to what was actually rendered when a switch fails", async () => { + const { store, preview, getContent, session } = makeSession({ + list: loadedList([snap("shown", 20), snap("next", 10)]), + }); + getContent.mockImplementation(async (snapshot) => { + if (snapshot.id === "shown") { + return "shown content"; + } + throw new Error("offline"); + }); + + await session.previewSnapshot("shown"); + expect(preview.enterPreview).toHaveBeenCalledTimes(1); + + await expect(session.previewSnapshot("next")).rejects.toThrow("offline"); + + // The failed switch never rendered, so the view falls back to the shown one. + expect(store.state.view).toEqual({ + mode: "snapshot", + snapshotId: "shown", + compareToId: undefined, + }); + expect(store.state.loadingView).toBeUndefined(); + expect(preview.enterPreview).toHaveBeenCalledTimes(1); + }); + + it("exits a rendered preview through the controller and restores the live view", async () => { + const { store, preview, getContent, session } = makeSession({ + list: loadedList([snap("a", 10)]), + }); + getContent.mockResolvedValue("content a"); + await session.previewSnapshot("a"); + expect(preview.enterPreview).toHaveBeenCalledTimes(1); + + session.exitPreview(); + + expect(store.state.view).toEqual({ mode: "live" }); + expect(preview.exitPreview).toHaveBeenCalledTimes(1); + expect(store.state.loadingView).toBeUndefined(); + }); + + it("leaves the controller alone while the preview is still fetching", async () => { + const { store, preview, getContent, session } = makeSession({ + list: loadedList([snap("a", 10)]), + }); + const request = deferred(); + getContent.mockReturnValue(request.promise); + + const pending = session.previewSnapshot("a"); + session.exitPreview(); + + // Nothing replaced the document yet, so the controller has nothing to + // put back. + expect(preview.exitPreview).not.toHaveBeenCalled(); + expect(store.state.view).toEqual({ mode: "live" }); + expect(store.state.loadingView).toBeUndefined(); + + // Exiting bumped the token: the in-flight fetch bails without rendering. + request.resolve("content a"); + await pending; + expect(preview.enterPreview).not.toHaveBeenCalled(); + expect(store.state.view).toEqual({ mode: "live" }); + }); + + it("leaves the controller alone when already live", async () => { + const { store, preview, session } = makeSession(); + session.exitPreview(); + session.exitPreview(); + expect(preview.exitPreview).not.toHaveBeenCalled(); + expect(store.state.view).toEqual({ mode: "live" }); + }); + + it("exits through a controller that threw while rendering", async () => { + const { store, preview, getContent, session } = makeSession({ + list: loadedList([snap("a", 10)]), + }); + getContent.mockResolvedValue("content a"); + preview.enterPreview.mockImplementation(() => { + throw new Error("render failed"); + }); + + await expect(session.previewSnapshot("a")).rejects.toThrow("render failed"); + + // The controller was asked to render, so the view stays on the snapshot + // until it has been asked to leave. + expect(store.state.view).toEqual({ + mode: "snapshot", + snapshotId: "a", + compareToId: undefined, + }); + expect(store.state.loadingView).toBeUndefined(); + + session.exitPreview(); + expect(preview.exitPreview).toHaveBeenCalledTimes(1); + expect(store.state.view).toEqual({ mode: "live" }); + }); + + it("marks the editor as loading only once a preview has taken a while", async () => { + const { classList, getContent, session } = makeSession({ + list: loadedList([snap("a", 10)]), + }); + const request = deferred(); + getContent.mockReturnValue(request.promise); + + vi.useFakeTimers(); + try { + const pending = session.previewSnapshot("a"); + // Not yet: a fast load must not flash a loading state. + vi.advanceTimersByTime(LOADING_PREVIEW_DELAY_MS - 1); + expect(classList.add).not.toHaveBeenCalled(); + vi.advanceTimersByTime(1); + expect(classList.add).toHaveBeenCalledTimes(1); + expect(classList.add).toHaveBeenCalledWith(LOADING_PREVIEW_CLASS); + + request.resolve("content a"); + await pending; + expect(classList.remove).toHaveBeenCalledWith(LOADING_PREVIEW_CLASS); + + // A load that ends before the delay never marks the editor. + await session.previewSnapshot("a"); + vi.advanceTimersByTime(LOADING_PREVIEW_DELAY_MS); + expect(classList.add).toHaveBeenCalledTimes(1); + } finally { + vi.useRealTimers(); + } + }); + + it("does not restart the loading timer on fast preview switches", async () => { + const { classList, getContent, session } = makeSession({ + list: loadedList([snap("b", 20), snap("a", 10)]), + }); + const aRequest = deferred(); + const bRequest = deferred(); + getContent.mockImplementation(async (snapshot) => + snapshot.id === "a" ? aRequest.promise : bRequest.promise, + ); + + vi.useFakeTimers(); + try { + const first = session.previewSnapshot("a"); + vi.advanceTimersByTime(LOADING_PREVIEW_DELAY_MS - 1); + const second = session.previewSnapshot("b"); + vi.advanceTimersByTime(1); + + // One delay across both previews: the class appears once, not per switch. + expect(classList.add).toHaveBeenCalledTimes(1); + expect(classList.add).toHaveBeenCalledWith(LOADING_PREVIEW_CLASS); + + bRequest.resolve("content b"); + await second; + expect(classList.remove).toHaveBeenCalledWith(LOADING_PREVIEW_CLASS); + + aRequest.resolve("content a"); + await first; + expect(classList.add).toHaveBeenCalledTimes(1); + } finally { + vi.useRealTimers(); + } + }); + + it("clears the delayed loader after a failed switch and can retry", async () => { + const { store, preview, classList, getContent, session } = makeSession({ + list: loadedList([snap("shown", 20), snap("next", 10)]), + }); + getContent.mockImplementation(async (snapshot) => + snapshot.id === "shown" ? "shown content" : "next content", + ); + await session.previewSnapshot("shown"); + expect(preview.enterPreview).toHaveBeenCalledTimes(1); + + const firstAttempt = deferred(); + getContent.mockReturnValueOnce(firstAttempt.promise); + + vi.useFakeTimers(); + try { + const pending = session.previewSnapshot("next"); + vi.advanceTimersByTime(LOADING_PREVIEW_DELAY_MS); + expect(classList.add).toHaveBeenCalledWith(LOADING_PREVIEW_CLASS); + + const failure = expect(pending).rejects.toThrow("offline"); + firstAttempt.reject(new Error("offline")); + await failure; + + // Rolled back to what was rendered; the loader is cleared. + expect(store.state.view).toEqual({ + mode: "snapshot", + snapshotId: "shown", + compareToId: undefined, + }); + expect(store.state.loadingView).toBeUndefined(); + expect(classList.remove).toHaveBeenCalledWith(LOADING_PREVIEW_CLASS); + + // A retry succeeds. + await session.previewSnapshot("next"); + expect(preview.enterPreview).toHaveBeenCalledTimes(2); + } finally { + vi.useRealTimers(); + } + }); + + it("exposes previewCurrentVersion only when serializeCurrentContent is provided", () => { + const without = makeSession(); + expect(without.session.previewCurrentVersion).toBeUndefined(); + + const withSerialize = makeSession({ + serializeCurrentContent: () => "live", + }); + expect(withSerialize.session.previewCurrentVersion).toBeDefined(); + }); + + it("previewCurrentVersion passes the current row as the target", async () => { + const current = snap("current", 30, { by: ["u1"] }); + const stored = snap("a", 10); + const serialize = vi.fn(() => "live content"); + const { store, preview, getContent, getAttributions, session } = + makeSession({ + list: loadedList([stored], current), + serializeCurrentContent: serialize, + }); + getContent.mockResolvedValue("baseline content"); + getAttributions.mockResolvedValue(undefined); + + await session.previewCurrentVersion!({ compareTo: "a" }); + + expect(serialize).toHaveBeenCalledTimes(1); + expect(preview.enterPreview).toHaveBeenCalledWith( + "live content", + "baseline content", + undefined, + { target: { kind: "current", snapshot: current }, compareTo: stored }, + ); + expect(store.state.view).toEqual({ mode: "current", compareToId: "a" }); + }); + + it("previewCurrentVersion requires the list to be loaded", async () => { + const { session } = makeSession({ + list: { loaded: false }, + serializeCurrentContent: () => "live", + }); + await expect(session.previewCurrentVersion!()).rejects.toThrow( + "requires the version list to be loaded", + ); + }); +}); diff --git a/packages/core/src/extensions/Versioning/preview.ts b/packages/core/src/extensions/Versioning/preview.ts new file mode 100644 index 0000000000..5406b40127 --- /dev/null +++ b/packages/core/src/extensions/Versioning/preview.ts @@ -0,0 +1,210 @@ +import type { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; +import type { Store } from "../../util/Store.js"; +import { + scheduleScrollToFirstChange, + scrollToFirstChange, +} from "./scrollToFirstChange.js"; +import { findSnapshot, resolveCompareTo } from "./state.js"; +import type { + PreviewController, + PreviewTarget, + VersioningEndpoints, + VersioningPreviewView, + VersionSnapshotIdentifier, + VersioningState, + VersioningView, + VersionSnapshot, +} from "./types.js"; + +/** Editor loading class, applied after {@link LOADING_PREVIEW_DELAY_MS}. */ +export const LOADING_PREVIEW_CLASS = "bn-loading"; + +/** Delay the loading indicator so fast previews do not flash. */ +export const LOADING_PREVIEW_DELAY_MS = 400; + +/** + * The preview loading indicator. Owns the loading class on the editor, shown + * after {@link LOADING_PREVIEW_DELAY_MS} while a preview is fetching and + * hidden when it settles. Nothing else — the caller publishes the loading + * view to the store. + */ +function createLoadingIndicator(editor: BlockNoteEditor) { + let loaderTimeout: ReturnType | undefined; + + return { + /** + * Show or hide the loading class. Showing keeps one delay across fast + * preview switches: restarting the timer on every row would re-flash the + * class for a preview that is already loading. + */ + setLoading(loading: boolean) { + if (loading) { + if (loaderTimeout === undefined) { + loaderTimeout = setTimeout(() => { + editor.domElement?.classList.add(LOADING_PREVIEW_CLASS); + }, LOADING_PREVIEW_DELAY_MS); + } + } else { + if (loaderTimeout !== undefined) { + clearTimeout(loaderTimeout); + loaderTimeout = undefined; + } + editor.domElement?.classList.remove(LOADING_PREVIEW_CLASS); + } + }, + }; +} + +/** + * The preview half of the versioning store. Owns the `view` and `loadingView` + * fields, the loading indicator, and which preview is currently on screen. + * + * The controller renders synchronously, so the only async step is fetching + * content. A single token (bumped for every request and on exit) guarantees + * only the newest request renders: an older fetch that settles late — or one + * that settles after exit — bails instead of drawing over the document. + */ +export function createPreviewSession({ + store, + endpoints, + preview, + serializeCurrentContent, + editor, + scrollToFirstChangeEnabled, +}: { + store: Store; + endpoints: VersioningEndpoints; + preview: PreviewController; + serializeCurrentContent?: () => any; + editor: BlockNoteEditor; + scrollToFirstChangeEnabled: boolean; +}) { + // Newest request wins: only the call holding this token may render. + let latestPreview = 0; + // The view whose content is actually on screen. Trails the requested view + // while content loads, so a failed fetch can put back what was there before + // (see the catch below). Set before `enterPreview` so a controller that + // throws mid-render still owns the screen until `exitPreview`. + let renderedView: VersioningView = { mode: "live" }; + const loadingIndicator = createLoadingIndicator(editor); + + function setLoading(view: VersioningPreviewView | undefined) { + loadingIndicator.setLoading(view !== undefined); + if (store.state.loadingView !== view) { + store.setState((state) => ({ ...state, loadingView: view })); + } + } + + async function showPreview( + view: VersioningPreviewView, + target: PreviewTarget, + compareTo: VersionSnapshot | undefined, + getPrimaryContent: () => Promise, + ) { + const request = ++latestPreview; + store.setState((state) => ({ ...state, view })); + setLoading(view); + try { + const [content, compareToContent, attributions] = await Promise.all([ + getPrimaryContent(), + compareTo && endpoints.getContent(compareTo), + compareTo && endpoints.getAttributions?.(target, compareTo), + ]); + // A restore is replacing the document: don't draw a preview over it. + if (request !== latestPreview || store.state.restoring) { + return; + } + renderedView = view; + preview.enterPreview(content, compareToContent, attributions, { + target, + compareTo, + }); + setLoading(undefined); + + scheduleScrollToFirstChange(() => editor.domElement, { + enabled: scrollToFirstChangeEnabled, + isCurrent: () => store.state.view === view, + }); + } catch (error) { + if (request === latestPreview) { + // Back to what is actually on screen; a superseded request owns nothing. + store.setState((state) => ({ ...state, view: renderedView })); + setLoading(undefined); + } + throw error; + } + } + + return { + async previewSnapshot( + this: void, + id: VersionSnapshotIdentifier, + previewOptions?: { compareTo?: VersionSnapshotIdentifier }, + ) { + const snapshot = findSnapshot(store.state.list, id); + if (snapshot === undefined) { + throw new Error( + `Snapshot not found: ${typeof id === "object" ? id.id : id}`, + ); + } + const compareTo = resolveCompareTo( + store.state.list, + previewOptions?.compareTo, + ); + await showPreview( + { + mode: "snapshot", + snapshotId: snapshot.id, + compareToId: compareTo?.id, + }, + { kind: "snapshot", snapshot }, + compareTo, + () => endpoints.getContent(snapshot), + ); + }, + ...(serializeCurrentContent + ? { + async previewCurrentVersion( + this: void, + previewOptions?: { + compareTo?: VersionSnapshotIdentifier; + }, + ) { + const versions = store.state.list; + if (!versions.loaded) { + throw new Error( + "previewCurrentVersion requires the version list to be loaded; " + + "call `list()` first.", + ); + } + const compareTo = resolveCompareTo( + store.state.list, + previewOptions?.compareTo, + ); + await showPreview( + { mode: "current", compareToId: compareTo?.id }, + { kind: "current", snapshot: versions.current }, + compareTo, + serializeCurrentContent!, + ); + }, + } + : {}), + exitPreview(this: void) { + // In-flight fetches bail out instead of rendering over the live document. + latestPreview++; + setLoading(undefined); + if (store.state.view.mode === "live") { + return; + } + store.setState((state) => ({ ...state, view: { mode: "live" } })); + // Only leave what was rendered; a still-fetching preview never touched + // the document. + if (renderedView.mode !== "live") { + renderedView = { mode: "live" }; + preview.exitPreview(); + } + }, + scrollToFirstChange: () => scrollToFirstChange(editor.domElement), + }; +} diff --git a/packages/core/src/extensions/Versioning/scrollToFirstChange.test.ts b/packages/core/src/extensions/Versioning/scrollToFirstChange.test.ts new file mode 100644 index 0000000000..2a0a650a3a --- /dev/null +++ b/packages/core/src/extensions/Versioning/scrollToFirstChange.test.ts @@ -0,0 +1,320 @@ +/** + * @vitest-environment jsdom + */ +import { + afterEach, + beforeEach, + describe, + expect, + it, + vi, +} from "vite-plus/test"; + +import { scrollToFirstChange } from "./scrollToFirstChange.js"; + +// jsdom implements neither `scrollIntoView` nor layout, so both are installed +// here: `scrollIntoView` to observe the call, `getBoundingClientRect` per +// element to model which nodes have a box. +const originalAnimate = Object.getOwnPropertyDescriptor( + Element.prototype, + "animate", +); +const animate = vi.fn( + ( + _frames: Keyframe[] | PropertyIndexedKeyframes | null, + _options?: number | KeyframeAnimationOptions, + ): { cancel: ReturnType; onfinish?: () => void } => ({ + cancel: vi.fn(), + }), +); +const hadScrollIntoView = "scrollIntoView" in Element.prototype; +let scrollIntoView: ReturnType>; + +/** Give `element` a non-empty layout box. */ +function withBox(element: Element): Element { + element.getBoundingClientRect = () => ({ width: 100, height: 20 }) as DOMRect; + return element; +} + +/** Give `element` a zero-sized box, as `display: contents` wrappers have. */ +function withoutBox(element: Element): Element { + element.getBoundingClientRect = () => ({ width: 0, height: 0 }) as DOMRect; + return element; +} + +function makeRoot(): HTMLElement { + const root = document.createElement("div"); + document.body.appendChild(root); + return root; +} + +beforeEach(() => { + animate.mockClear(); + Object.defineProperty(Element.prototype, "animate", { + configurable: true, + value: animate, + }); + scrollIntoView = vi.fn(); + Element.prototype.scrollIntoView = scrollIntoView; +}); + +afterEach(() => { + document.body.innerHTML = ""; + if (originalAnimate) { + Object.defineProperty(Element.prototype, "animate", originalAnimate); + } else { + Reflect.deleteProperty(Element.prototype, "animate"); + } + if (!hadScrollIntoView) { + Reflect.deleteProperty(Element.prototype, "scrollIntoView"); + } +}); + +describe("scrollToFirstChange", () => { + it("returns false when there is no root", () => { + expect(scrollToFirstChange(undefined)).toBe(false); + expect(scrollIntoView).not.toHaveBeenCalled(); + }); + + it("returns false when the document has no attribution marks", () => { + expect(scrollToFirstChange(makeRoot())).toBe(false); + expect(scrollIntoView).not.toHaveBeenCalled(); + }); + + it("scrolls to the content element of the first mark", () => { + const root = makeRoot(); + const wrapper = document.createElement("span"); + wrapper.dataset["userIds"] = '["u1"]'; + const content = withBox(document.createElement("span")); + wrapper.appendChild(content); + root.appendChild(wrapper); + + expect(scrollToFirstChange(root)).toBe(true); + expect(scrollIntoView).toHaveBeenCalledTimes(1); + expect(scrollIntoView.mock.instances[0]).toBe(content); + }); + + it("descends one level further when the content element has no box", () => { + const root = makeRoot(); + const wrapper = document.createElement("div"); + wrapper.dataset["userIds"] = '["u1"]'; + const content = withoutBox(document.createElement("div")); + const inner = withBox(document.createElement("p")); + content.appendChild(inner); + wrapper.appendChild(content); + root.appendChild(wrapper); + + expect(scrollToFirstChange(root)).toBe(true); + expect(scrollIntoView.mock.instances[0]).toBe(inner); + }); + + it("picks the first mark in document order", () => { + const root = makeRoot(); + for (const id of ["u1", "u2"]) { + const wrapper = document.createElement("span"); + wrapper.dataset["userIds"] = `["${id}"]`; + wrapper.appendChild(withBox(document.createElement("span"))); + root.appendChild(wrapper); + } + + scrollToFirstChange(root); + + expect(scrollIntoView.mock.instances[0]).toBe( + root.firstElementChild!.firstElementChild, + ); + }); + + it("scrolls smoothly by default and instantly under reduced motion", () => { + const root = makeRoot(); + const wrapper = document.createElement("span"); + wrapper.dataset["userIds"] = '["u1"]'; + wrapper.appendChild(withBox(document.createElement("span"))); + root.appendChild(wrapper); + + // jsdom has no `matchMedia`; the helper optional-calls it, so the + // no-preference default is exercised by simply leaving it out. + scrollToFirstChange(root); + expect(scrollIntoView).toHaveBeenLastCalledWith({ + block: "center", + behavior: "smooth", + }); + + window.matchMedia = vi.fn(() => ({ matches: true }) as MediaQueryList); + scrollToFirstChange(root); + expect(scrollIntoView).toHaveBeenLastCalledWith({ + block: "center", + behavior: "auto", + }); + + const frames = animate.mock.calls.at(-1)?.[0]; + expect(frames).toEqual([ + expect.not.objectContaining({ transform: expect.anything() }), + expect.not.objectContaining({ transform: expect.anything() }), + ]); + + Reflect.deleteProperty(window, "matchMedia"); + }); + + it("highlights without DOM mutations and releases the finished animation", () => { + const root = makeRoot(); + const block = document.createElement("div"); + block.className = "bn-block-content"; + const wrapper = document.createElement("span"); + wrapper.dataset["userIds"] = '["u1"]'; + wrapper.appendChild(withBox(document.createElement("span"))); + block.appendChild(wrapper); + root.appendChild(block); + + const observer = new MutationObserver(() => {}); + observer.observe(root, { + attributes: true, + childList: true, + subtree: true, + }); + scrollToFirstChange(root); + expect(observer.takeRecords()).toEqual([]); + observer.disconnect(); + // The whole block, not the mark that was scrolled to. + expect(animate.mock.instances[0]).toBe(block); + expect(block.className).toBe("bn-block-content"); + expect(block.hasAttribute("style")).toBe(false); + expect(animate).toHaveBeenCalledWith(expect.any(Array), { + duration: 1500, + fill: "none", + }); + + const animation = animate.mock.results[0]!.value; + scrollToFirstChange(root); + // Fire-and-forget highlight: the finished animation is cancelled by the + // next scroll instead of released via `onfinish`. + expect(animation.cancel).toHaveBeenCalledOnce(); + }); + + it("highlights the scrolled-to element when it is in no block", () => { + const root = makeRoot(); + const wrapper = document.createElement("span"); + wrapper.dataset["userIds"] = '["u1"]'; + const content = withBox(document.createElement("span")); + wrapper.appendChild(content); + root.appendChild(wrapper); + + scrollToFirstChange(root); + expect(animate.mock.instances.at(-1)).toBe(content); + }); + + /** A mark wrapper of the given element type around a content span. */ + function makeMark(tag: "ins" | "del" | "span", content: Element): Element { + const wrapper = document.createElement(tag); + wrapper.dataset["userIds"] = '["u1"]'; + wrapper.appendChild(content); + return wrapper; + } + + it("skips marks with no layout box, such as inside a collapsed toggle", () => { + const root = makeRoot(); + // A block-level mark whose whole subtree is hidden: nothing below it has a + // box, so descending would never find one. + const hiddenContent = withoutBox(document.createElement("span")); + hiddenContent.appendChild(withoutBox(document.createElement("div"))); + root.appendChild(makeMark("ins", hiddenContent)); + const visible = withBox(document.createElement("span")); + root.appendChild(makeMark("ins", visible)); + + expect(scrollToFirstChange(root)).toBe(true); + expect(scrollIntoView.mock.instances[0]).toBe(visible); + }); + + it("falls back to the containing block when every mark is hidden", () => { + const root = makeRoot(); + // A collapsed toggle: its content is laid out, its child group is not. + const outer = withBox(document.createElement("div")); + outer.className = "bn-block-outer"; + const block = withBox(document.createElement("div")); + block.className = "bn-block"; + const toggleContent = withBox(document.createElement("div")); + toggleContent.className = "bn-block-content"; + const hiddenGroup = withoutBox(document.createElement("div")); + hiddenGroup.className = "bn-block-group"; + hiddenGroup.appendChild( + makeMark("ins", withoutBox(document.createElement("span"))), + ); + block.append(toggleContent, hiddenGroup); + outer.appendChild(block); + root.appendChild(outer); + + expect(scrollToFirstChange(root)).toBe(true); + expect(scrollIntoView.mock.instances[0]).toBe(toggleContent); + expect(animate.mock.instances.at(-1)).toBe(toggleContent); + }); + + it("returns false when a hidden mark has no laid-out ancestor below the root", () => { + const root = makeRoot(); + root.appendChild( + makeMark("ins", withoutBox(document.createElement("span"))), + ); + + expect(scrollToFirstChange(root)).toBe(false); + expect(scrollIntoView).not.toHaveBeenCalled(); + }); + + it("scrolls to the first change in document order, regardless of kind", () => { + const root = makeRoot(); + root.appendChild(makeMark("del", withBox(document.createElement("span")))); + const formatted = withBox(document.createElement("span")); + root.appendChild(makeMark("span", formatted)); + + scrollToFirstChange(root); + expect(scrollIntoView.mock.instances[0]).toBe( + root.firstElementChild!.firstElementChild, + ); + + root.removeChild(root.firstElementChild!); + scrollToFirstChange(root); + expect(scrollIntoView.mock.instances[1]).toBe(formatted); + }); + + it("scrolls to and highlights the block's own content for a block-level mark", () => { + const root = makeRoot(); + // `` > content span (display: contents) > .bn-block-outer > .bn-block > + // .bn-block-content, with a nested child block group after the content. + const content = withoutBox(document.createElement("span")); + const outer = withBox(document.createElement("div")); + outer.className = "bn-block-outer"; + const block = withBox(document.createElement("div")); + block.className = "bn-block"; + const blockContent = withBox(document.createElement("div")); + blockContent.className = "bn-block-content"; + const childContent = withBox(document.createElement("div")); + childContent.className = "bn-block-content"; + block.append(blockContent, childContent); + outer.appendChild(block); + content.appendChild(outer); + root.appendChild(makeMark("ins", content)); + + scrollToFirstChange(root); + + expect(scrollIntoView.mock.instances[0]).toBe(blockContent); + expect(animate.mock.instances.at(-1)).toBe(blockContent); + expect(animate).toHaveBeenCalledTimes(1); + }); + + it("moves the highlight when a new preview scrolls elsewhere", () => { + const root = makeRoot(); + const first = withBox(document.createElement("span")); + root.appendChild(makeMark("ins", first)); + scrollToFirstChange(root); + expect(animate.mock.instances.at(-1)).toBe(first); + + root.replaceChildren(); + const second = withBox(document.createElement("span")); + root.appendChild(makeMark("ins", second)); + scrollToFirstChange(root); + + expect(animate.mock.results[0]!.value.cancel).toHaveBeenCalledOnce(); + expect(animate.mock.instances.at(-1)).toBe(second); + // A late finish event from the cancelled pulse must not clear its successor. + animate.mock.results[0]!.value.onfinish?.(); + scrollToFirstChange(root); + expect(animate.mock.results[1]!.value.cancel).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/core/src/extensions/Versioning/scrollToFirstChange.ts b/packages/core/src/extensions/Versioning/scrollToFirstChange.ts new file mode 100644 index 0000000000..e4232455b7 --- /dev/null +++ b/packages/core/src/extensions/Versioning/scrollToFirstChange.ts @@ -0,0 +1,137 @@ +// Attribution wrappers carry `data-user-ids` but may be `display: contents`. +// Their layout boxes are resolved locally to avoid depending on the `@y/*` stack. + +/** Duration of the transient block highlight. */ +const HIGHLIGHT_MS = 1500; + +/** Allow the preview layout to settle; animation frames pause in background tabs. */ +export const SCROLL_TO_FIRST_CHANGE_DELAY_MS = 200; + +function hasBox(element: Element): boolean { + const { width, height } = element.getBoundingClientRect(); + return width !== 0 || height !== 0; +} + +/** Descend through `display: contents` wrappers to the first laid-out node. */ +function findVisibleTarget(mark: Element): Element | undefined { + for ( + let element: Element | null = mark; + element; + element = element.firstElementChild + ) { + if (hasBox(element)) { + return element; + } + } + return undefined; +} + +/** + * Nearest ancestor of `mark` (below `root`) with a layout box: for a change + * hidden inside a collapsed toggle, that's the toggle block itself. + */ +function findVisibleAncestor( + mark: Element, + root: Element, +): Element | undefined { + for ( + let element = mark.parentElement; + element && element !== root && root.contains(element); + element = element.parentElement + ) { + if (hasBox(element)) { + return element; + } + } + return undefined; +} + +/** Cancel the previous highlight when another change is revealed. */ +let activeHighlight: Animation | undefined; + +function highlight(block: Element) { + activeHighlight?.cancel(); + // Fire-and-forget pulse: no DOM mutations for ProseMirror to observe. A + // finished animation stays referenced until the next scroll cancels it, + // which is a harmless no-op. Scrolling still works without WAAPI. + activeHighlight = block.animate?.( + [ + { + backgroundColor: "color-mix(in srgb, #3e5de7 14%, transparent)", + boxShadow: "0 0 0 1px color-mix(in srgb, #3e5de7 45%, transparent)", + borderRadius: "4px", + easing: "ease-out", + }, + { + backgroundColor: "transparent", + boxShadow: "0 0 0 1px transparent", + borderRadius: "4px", + }, + ], + { duration: HIGHLIGHT_MS, fill: "none" }, + ); +} + +function prefersReducedMotion(): boolean { + return ( + typeof window !== "undefined" && + (window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false) + ); +} + +/** + * Scroll to the first change after preview layout settles. No-ops when + * disabled or when `isCurrent` reports the preview as superseded. + */ +export function scheduleScrollToFirstChange( + getRoot: () => Element | undefined, + options?: { enabled?: boolean; isCurrent?: () => boolean }, +): void { + if (options?.enabled === false) { + return; + } + // Let preview layout settle; timers also run in background tabs. + setTimeout(() => { + if (options?.isCurrent && !options.isCurrent()) { + return; + } + scrollToFirstChange(getRoot()); + }, SCROLL_TO_FIRST_CHANGE_DELAY_MS); +} + +/** + * Centre the first change in document order and highlight its block, + * respecting reduced motion. Changes hidden inside collapsed content fall + * back to their toggle block. + * @returns Whether a change was found and scrolled to. + */ +export function scrollToFirstChange(root: Element | undefined): boolean { + if (!root) { + return false; + } + + let firstMark: Element | undefined; + let target: Element | undefined; + for (const mark of root.querySelectorAll("[data-user-ids]")) { + firstMark ??= mark; + target = findVisibleTarget(mark); + if (target) { + break; + } + } + target ??= firstMark ? findVisibleAncestor(firstMark, root) : undefined; + if (!target) { + return false; + } + // A block-level mark wraps the block; point at its content instead so the + // highlight doesn't span nested children. + target = target.querySelector(".bn-block-content") ?? target; + + target.scrollIntoView({ + block: "center", + behavior: prefersReducedMotion() ? "auto" : "smooth", + }); + highlight(target.closest(".bn-block-content") ?? target); + + return true; +} diff --git a/packages/core/src/extensions/Versioning/state.test.ts b/packages/core/src/extensions/Versioning/state.test.ts new file mode 100644 index 0000000000..b0b1e65aec --- /dev/null +++ b/packages/core/src/extensions/Versioning/state.test.ts @@ -0,0 +1,130 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from "vite-plus/test"; + +import { findSnapshot, isReadOnly, resolveCompareTo } from "./state.js"; +import type { + LoadedVersioningList, + VersioningState, + VersionSnapshot, +} from "./types.js"; + +function snap(id: string, createdAt: number): VersionSnapshot { + return { id, createdAt }; +} + +function loadedList( + snapshots: VersionSnapshot[], + current: VersionSnapshot = snap("current", 30), +): LoadedVersioningList { + return { loaded: true, current, snapshots }; +} + +function state(overrides?: Partial): VersioningState { + return { + list: { loaded: false }, + view: { mode: "live" }, + listing: false, + restoring: false, + ...overrides, + }; +} + +describe("isReadOnly", () => { + it("is read-only while previewing any mode", () => { + expect( + isReadOnly(state({ view: { mode: "snapshot", snapshotId: "a" } })), + ).toBe(true); + expect(isReadOnly(state({ view: { mode: "current" } }))).toBe(true); + expect( + isReadOnly(state({ view: { mode: "current", compareToId: "a" } })), + ).toBe(true); + }); + + it("is read-only while restoring, even when the view is live", () => { + expect(isReadOnly(state({ restoring: true }))).toBe(true); + }); + + it("is editable when live and not restoring", () => { + expect(isReadOnly(state())).toBe(false); + }); +}); + +describe("findSnapshot", () => { + it("resolves the current row by id", () => { + const current = snap("current", 30); + expect(findSnapshot(loadedList([], current), "current")).toBe(current); + }); + + it("resolves a stored snapshot by id", () => { + const stored = snap("a", 10); + expect(findSnapshot(loadedList([stored]), "a")).toBe(stored); + }); + + it("accepts `{ id }` object identifiers", () => { + const current = snap("current", 30); + const stored = snap("a", 10); + expect(findSnapshot(loadedList([stored], current), { id: "current" })).toBe( + current, + ); + expect(findSnapshot(loadedList([stored], current), { id: "a" })).toBe( + stored, + ); + }); + + it("returns undefined for an unknown id", () => { + expect(findSnapshot(loadedList([snap("a", 10)]), "nope")).toBeUndefined(); + }); + + it("returns undefined when the list is not loaded", () => { + expect(findSnapshot({ loaded: false }, "a")).toBeUndefined(); + expect(findSnapshot({ loaded: false }, { id: "a" })).toBeUndefined(); + }); + + it("returns undefined when no id is given", () => { + expect( + findSnapshot(loadedList([snap("a", 10)]), undefined), + ).toBeUndefined(); + }); + + it("never reports the current row as a stored snapshot", () => { + // `current` is resolved by id even though it is not among `snapshots`. + const current = snap("current", 30); + const list = loadedList([snap("a", 10)], current); + expect(findSnapshot(list, "current")).toBe(current); + expect(list.snapshots).not.toContain(current); + }); +}); + +describe("resolveCompareTo", () => { + it("returns undefined when no baseline is given", () => { + expect( + resolveCompareTo(loadedList([snap("a", 10)]), undefined), + ).toBeUndefined(); + }); + + it("resolves a known id to its snapshot", () => { + const stored = snap("a", 10); + const current = snap("current", 30); + expect(resolveCompareTo(loadedList([stored], current), "a")).toBe(stored); + expect(resolveCompareTo(loadedList([stored], current), { id: "a" })).toBe( + stored, + ); + expect(resolveCompareTo(loadedList([stored], current), "current")).toBe( + current, + ); + }); + + it("throws for unknown string ids", () => { + expect(() => resolveCompareTo(loadedList([snap("a", 10)]), "nope")).toThrow( + "Snapshot not found: nope", + ); + }); + + it("throws for unknown object ids", () => { + expect(() => + resolveCompareTo(loadedList([snap("a", 10)]), { id: "nope" }), + ).toThrow("Snapshot not found: nope"); + }); +}); diff --git a/packages/core/src/extensions/Versioning/state.ts b/packages/core/src/extensions/Versioning/state.ts new file mode 100644 index 0000000000..734e64de0a --- /dev/null +++ b/packages/core/src/extensions/Versioning/state.ts @@ -0,0 +1,42 @@ +import type { + VersionSnapshot, + VersionSnapshotIdentifier, + VersioningList, + VersioningState, +} from "./types.js"; + +/** Previewing and restoring both hold the editor read-only. */ +export function isReadOnly(state: VersioningState): boolean { + return state.view.mode !== "live" || state.restoring; +} + +/** The current row when `id` names it, otherwise a stored snapshot. */ +export function findSnapshot( + list: VersioningList, + id: VersionSnapshotIdentifier | undefined, +): VersionSnapshot | undefined { + if (id === undefined || !list.loaded) { + return undefined; + } + const key = typeof id === "object" ? id.id : id; + return list.current.id === key + ? list.current + : list.snapshots.find((snapshot) => snapshot.id === key); +} + +/** Resolve a comparison baseline, or `undefined` when none is given. */ +export function resolveCompareTo( + list: VersioningList, + compareTo: VersionSnapshotIdentifier | undefined, +): VersionSnapshot | undefined { + if (compareTo === undefined) { + return undefined; + } + const snapshot = findSnapshot(list, compareTo); + if (snapshot === undefined) { + throw new Error( + `Snapshot not found: ${typeof compareTo === "object" ? compareTo.id : compareTo}`, + ); + } + return snapshot; +} diff --git a/packages/core/src/extensions/Versioning/types.ts b/packages/core/src/extensions/Versioning/types.ts new file mode 100644 index 0000000000..29cc33cb56 --- /dev/null +++ b/packages/core/src/extensions/Versioning/types.ts @@ -0,0 +1,234 @@ +import type { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; +import type { User, UserStoreOrResolver } from "../../user/index.js"; + +/** Metadata for a point in document history, managed by {@link VersioningEndpoints}. */ +export interface VersionSnapshot { + /** Backend-defined identifier (e.g. a YHub server timestamp or an in-memory id). */ + id: string; + + /** A version is named exactly when this is defined; used by the named-only filter. */ + name?: string; + + /** + * Last included edit, in Unix milliseconds. Timestamp-addressed backends use + * this to resolve content and attribution windows. + */ + createdAt: number; + + /** + * Raw author ids, resolved reactively through the extension's user store. + * Only displayed when {@link secondaryLabel} is unset. + */ + by?: User["id"] | User["id"][]; + + /** Custom display label, taking precedence over author labels from {@link by}. */ + secondaryLabel?: string; + + /** + * Source of a restore. Carried explicitly because the source may no longer + * be listed (e.g. merged into another activity window). + */ + restoredFrom?: { + /** The restored version's {@link VersionSnapshot.id}. */ + id: string; + /** Source timestamp, displayed in the "Restored from" label. */ + createdAt: number; + }; + + /** Application metadata for custom actions; BlockNote does not interpret it. */ + metadata?: Record; +} + +/** A version id or an object carrying it. */ +export type VersionSnapshotIdentifier = string | Pick; + +/** + * Preview content source: + * - `current`: serialize the live document. Snapshot metadata comes from the last + * listing and must not cap attribution windows for newer live edits. + * - `snapshot`: fetch stored content via {@link VersioningEndpoints.getContent}. + */ +export type PreviewTarget = + | { kind: "current"; snapshot: VersionSnapshot } + | { kind: "snapshot"; snapshot: VersionSnapshot }; + +/** The editable live document, or a read-only preview with an optional baseline. */ +export type VersioningView = + | { mode: "live" } + | { mode: "current"; compareToId?: string } + | { mode: "snapshot"; snapshotId: string; compareToId?: string }; + +/** The {@link VersioningView} members that put the editor in preview mode. */ +export type VersioningPreviewView = Exclude; + +/** What the extension is currently loading: a list fetch or a preview. */ +export type VersioningLoadingState = + | { type: "idle" } + | { type: "listing" } + | { type: "loading-preview"; view: VersioningPreviewView }; + +/** Unknown until the first listing; once loaded, always contains a current row. */ +export type VersioningList = + | { loaded: false } + | { + loaded: true; + /** The live document's row — always the top row of the sidebar. */ + current: VersionSnapshot; + /** Stored versions, newest first. Never contains {@link current}. */ + snapshots: VersionSnapshot[]; + }; + +/** The {@link VersioningList} once {@link VersioningExtension.list} has run. */ +export type LoadedVersioningList = Extract; + +/** The {@link VersioningExtension}'s store state. */ +export type VersioningState = { + list: VersioningList; + view: VersioningView; + /** A list fetch is in flight; `getLoadingState` derives from this. */ + listing: boolean; + /** The view whose content is still loading, if any; `getLoadingState` derives from this. */ + loadingView?: VersioningPreviewView; + /** Holds the editor read-only during restore, including while the view is live. */ + restoring: boolean; +}; + +/** + * Version storage, paired with a {@link PreviewController} for rendering. + * @typeParam Input - Live document handle supplied by `getCurrentDocument`. + * @typeParam Output - Serialized content fetched/restored and passed to the controller. + * @typeParam Attributions - Diff authorship data passed to the controller. + */ +export interface VersioningEndpoints< + Input = any, + Output = any, + Attributions = any, +> { + /** + * Current metadata and stored versions (excluding current). The extension + * sorts stored versions newest-first. + */ + list: () => Promise<{ + current: VersionSnapshot; + snapshots: VersionSnapshot[]; + }>; + /** + * Name the current version: capture content for snapshot backends, or label + * the newest edit for continuous-history backends. Omit to disable naming. + */ + create?: ( + /** Live document, from {@link VersioningExtensionOptions.getCurrentDocument}. */ + content: Input, + options: { + /** The name to give the current version. */ + name?: string; + }, + ) => Promise; + /** + * Restore a version and return content for {@link PreviewController.applyRestore}. + * Omit to disable restore. + */ + restore?: ( + /** Live document, from {@link VersioningExtensionOptions.getCurrentDocument}. */ + doc: Input, + /** The version to restore. */ + snapshot: VersionSnapshot, + ) => Promise; + /** Fetch serialized content for {@link PreviewController.enterPreview}. */ + getContent: (snapshot: VersionSnapshot) => Promise; + /** + * Fetch authorship for `compareTo → target`, passed to the preview controller. + * Omit for content comparisons without authorship. + */ + getAttributions?: ( + /** What's being previewed (the "new" side of the diff). */ + target: PreviewTarget, + /** The baseline it's diffed against (the "old" side). */ + compareTo?: VersionSnapshot, + ) => Promise; + /** Rename a version; undefined or empty clears its name. Omit to disable rename. */ + rename?: (snapshot: VersionSnapshot, name?: string) => Promise; + /** + * Remove a stored version, or just its name on continuous-history backends. + * Omit to disable removal. + */ + remove?: (snapshot: VersionSnapshot) => Promise; +} + +/** Editor-aware endpoint factory. Type parameters match {@link VersioningEndpoints}. */ +export type VersioningEndpointsFactory< + Input = any, + Output = any, + Attributions = any, +> = ( + editor: BlockNoteEditor, +) => VersioningEndpoints; + +/** + * Renders content fetched by {@link VersioningEndpoints}. + * Type parameters match the endpoints' serialized content and authorship data. + */ +export interface PreviewController { + /** Whether comparisons are supported; defaults to true. Exposed as `canCompare`. */ + supportsComparison?: boolean; + /** + * Render fetched content synchronously so superseded requests cannot render + * after exit. Put asynchronous work in the endpoints. + */ + enterPreview: ( + /** Content to preview ({@link Output}). */ + snapshotContent: Output, + /** When set, diff `compareToContent` (baseline) against `snapshotContent`. */ + compareToContent?: Output, + /** Diff authorship; only meaningful with `compareToContent`. */ + attributions?: Attributions, + /** Preview metadata for labels, separate from content and authorship. */ + context?: { target: PreviewTarget; compareTo?: VersionSnapshot }, + ) => undefined; + /** Exit preview mode and resume normal editing. */ + exitPreview: () => void; + /** Apply the restore endpoint's content after exiting preview. Omit if unsupported. */ + applyRestore?: (snapshotContent: Output) => void; +} + +/** + * Bridges live editor data to version storage and rendering. + * Type parameters match {@link VersioningEndpoints}. + */ +export type VersioningExtensionOptions< + Input = any, + Output = any, + Attributions = any, +> = { + /** + * Backend storage for versions. + */ + endpoints: + | VersioningEndpoints + | VersioningEndpointsFactory; + /** + * Controls how version previews and restores are rendered in the editor. + */ + preview: PreviewController; + /** + * Live handle passed to create/restore (e.g. `Y.Node` or `Block[]`). + * Unlike `serializeCurrentContent`, this need not be detached or serialized. + */ + getCurrentDocument: () => Input; + /** + * Serialize live content to the endpoint's output format for current previews. + * Omit to disable the extension's `previewCurrentVersion` method. + */ + serializeCurrentContent?: () => Output | Promise; + /** + * Resolve {@link VersionSnapshot.by} for author labels; unresolved ids display raw. + * Accepts a resolver or a shared store to deduplicate loading across features. + */ + resolveUsers?: UserStoreOrResolver; + /** + * Scroll to and highlight the first change after preview. Prefers insertions, + * then formatting, then deletions; collapsed changes use a visible ancestor. + * @default true + */ + scrollToFirstChange?: boolean; +}; diff --git a/packages/core/src/extensions/index.ts b/packages/core/src/extensions/index.ts index eb1d455e33..93a9d8c232 100644 --- a/packages/core/src/extensions/index.ts +++ b/packages/core/src/extensions/index.ts @@ -10,6 +10,7 @@ export * from "./NodeSelectionKeyboard/NodeSelectionKeyboard.js"; export * from "./Placeholder/Placeholder.js"; export * from "./PositionMapping/PositionMapping.js"; export * from "./PreviousBlockType/PreviousBlockType.js"; +export * from "./ReadOnly/ReadOnly.js"; export * from "./ShowSelection/ShowSelection.js"; export * from "./SideMenu/SideMenu.js"; export * from "./SourceBlockWithPreview/SourceBlockWithPreview.js"; diff --git a/packages/core/src/i18n/locales/ar.ts b/packages/core/src/i18n/locales/ar.ts index 094671d920..31370fc669 100644 --- a/packages/core/src/i18n/locales/ar.ts +++ b/packages/core/src/i18n/locales/ar.ts @@ -399,6 +399,32 @@ export const ar: Dictionary = { formatting_change_by: (formats: string, users: string) => `تغيير التنسيق (${formats}) بواسطة: ${users}`, }, + versioning: { + title: "السجل", + close: "إغلاق", + save_version: "حفظ الإصدار", + show_named_only: "إظهار الإصدارات المسماة فقط", + show_all: "إظهار جميع الإصدارات", + comparison_on: "تفعيل المقارنة", + comparison_off: "إيقاف المقارنة", + versions_list: "الإصدارات", + loading: "جارٍ تحميل الإصدارات", + empty: "لا توجد إصدارات بعد", + empty_named_only: "لا توجد إصدارات مسماة", + current_version: "الإصدار الحالي", + unnamed_version: "Unnamed version", + comparing_to: "مقارنة بـ", + restored_from: (date: string) => `تمت الاستعادة من ${date}`, + more_actions: "إجراءات أخرى", + version_name_input: "اسم الإصدار", + name_version_menuitem: "تسمية هذا الإصدار", + rename_menuitem: "إعادة تسمية", + compare_with_menuitem: "المقارنة بهذا الإصدار", + compare_since_beginning_menuitem: "المقارنة منذ البداية", + restore_menuitem: "استعادة", + delete_menuitem: "حذف", + action_failed: "حدث خطأ ما. يرجى المحاولة مرة أخرى.", + }, exporter: { open_file: "فتح الملف", open_video_file: "فتح الفيديو", diff --git a/packages/core/src/i18n/locales/de.ts b/packages/core/src/i18n/locales/de.ts index bf77a36a01..1ab14267f4 100644 --- a/packages/core/src/i18n/locales/de.ts +++ b/packages/core/src/i18n/locales/de.ts @@ -433,6 +433,32 @@ export const de: Dictionary = { formatting_change_by: (formats: string, users: string) => `Formatierungsänderung (${formats}) von: ${users}`, }, + versioning: { + title: "Verlauf", + close: "Schließen", + save_version: "Version speichern", + show_named_only: "Nur benannte Versionen anzeigen", + show_all: "Alle Versionen anzeigen", + comparison_on: "Vergleich einschalten", + comparison_off: "Vergleich ausschalten", + versions_list: "Versionen", + loading: "Versionen werden geladen", + empty: "Noch keine Versionen", + empty_named_only: "Keine benannten Versionen", + current_version: "Aktuelle Version", + unnamed_version: "Unnamed version", + comparing_to: "Verglichen mit", + restored_from: (date: string) => `Wiederhergestellt aus ${date}`, + more_actions: "Weitere Aktionen", + version_name_input: "Versionsname", + name_version_menuitem: "Diese Version benennen", + rename_menuitem: "Umbenennen", + compare_with_menuitem: "Mit dieser Version vergleichen", + compare_since_beginning_menuitem: "Seit Beginn vergleichen", + restore_menuitem: "Wiederherstellen", + delete_menuitem: "Löschen", + action_failed: "Etwas ist schiefgelaufen. Bitte versuche es erneut.", + }, exporter: { open_file: "Datei öffnen", open_video_file: "Video öffnen", diff --git a/packages/core/src/i18n/locales/en.ts b/packages/core/src/i18n/locales/en.ts index e5386f3020..0311ad5ff8 100644 --- a/packages/core/src/i18n/locales/en.ts +++ b/packages/core/src/i18n/locales/en.ts @@ -414,6 +414,32 @@ export const en = { formatting_change_by: (formats: string, users: string) => `Formatting change (${formats}) by: ${users}`, }, + versioning: { + title: "History", + close: "Close", + save_version: "Save version", + show_named_only: "Show named versions only", + show_all: "Show all versions", + comparison_on: "Turn on comparison", + comparison_off: "Turn off comparison", + versions_list: "Versions", + loading: "Loading versions", + empty: "No versions yet", + empty_named_only: "No named versions", + current_version: "Current version", + unnamed_version: "Unnamed version", + comparing_to: "Comparing to", + restored_from: (date: string) => `Restored from ${date}`, + more_actions: "More actions", + version_name_input: "Version name", + name_version_menuitem: "Name this version", + rename_menuitem: "Rename", + compare_with_menuitem: "Compare with this version", + compare_since_beginning_menuitem: "Compare since beginning", + restore_menuitem: "Restore", + delete_menuitem: "Delete", + action_failed: "Something went wrong. Please try again.", + }, exporter: { open_file: "Open file", open_video_file: "Open video", diff --git a/packages/core/src/i18n/locales/es.ts b/packages/core/src/i18n/locales/es.ts index 743a1be05c..d9b26fdf79 100644 --- a/packages/core/src/i18n/locales/es.ts +++ b/packages/core/src/i18n/locales/es.ts @@ -412,6 +412,32 @@ export const es: Dictionary = { formatting_change_by: (formats: string, users: string) => `Cambio de formato (${formats}) por: ${users}`, }, + versioning: { + title: "Historial", + close: "Cerrar", + save_version: "Guardar versión", + show_named_only: "Mostrar solo versiones con nombre", + show_all: "Mostrar todas las versiones", + comparison_on: "Activar comparación", + comparison_off: "Desactivar comparación", + versions_list: "Versiones", + loading: "Cargando versiones", + empty: "Aún no hay versiones", + empty_named_only: "No hay versiones con nombre", + current_version: "Versión actual", + unnamed_version: "Unnamed version", + comparing_to: "Comparando con", + restored_from: (date: string) => `Restaurado desde ${date}`, + more_actions: "Más acciones", + version_name_input: "Nombre de la versión", + name_version_menuitem: "Nombrar esta versión", + rename_menuitem: "Cambiar nombre", + compare_with_menuitem: "Comparar con esta versión", + compare_since_beginning_menuitem: "Comparar desde el principio", + restore_menuitem: "Restaurar", + delete_menuitem: "Eliminar", + action_failed: "Algo salió mal. Inténtalo de nuevo.", + }, exporter: { open_file: "Abrir archivo", open_video_file: "Abrir vídeo", diff --git a/packages/core/src/i18n/locales/fa.ts b/packages/core/src/i18n/locales/fa.ts index 6b2783ab68..ef6e905828 100644 --- a/packages/core/src/i18n/locales/fa.ts +++ b/packages/core/src/i18n/locales/fa.ts @@ -383,6 +383,32 @@ export const fa = { formatting_change_by: (formats: string, users: string) => `تغییر قالب‌بندی (${formats}) توسط: ${users}`, }, + versioning: { + title: "تاریخچه", + close: "بستن", + save_version: "ذخیره نسخه", + show_named_only: "فقط نسخه‌های نام‌گذاری‌شده نمایش داده شود", + show_all: "نمایش همه نسخه‌ها", + comparison_on: "روشن کردن مقایسه", + comparison_off: "خاموش کردن مقایسه", + versions_list: "نسخه‌ها", + loading: "در حال بارگذاری نسخه‌ها", + empty: "هنوز نسخه‌ای وجود ندارد", + empty_named_only: "نسخه‌ای با نام وجود ندارد", + current_version: "نسخه فعلی", + unnamed_version: "Unnamed version", + comparing_to: "مقایسه با", + restored_from: (date: string) => `بازیابی‌شده از ${date}`, + more_actions: "اقدامات بیشتر", + version_name_input: "نام نسخه", + name_version_menuitem: "نام‌گذاری این نسخه", + rename_menuitem: "تغییر نام", + compare_with_menuitem: "مقایسه با این نسخه", + compare_since_beginning_menuitem: "مقایسه از ابتدا", + restore_menuitem: "بازیابی", + delete_menuitem: "حذف", + action_failed: "مشکلی پیش آمد. لطفاً دوباره تلاش کنید.", + }, exporter: { open_file: "باز کردن فایل", open_video_file: "باز کردن ویدیو", diff --git a/packages/core/src/i18n/locales/fr.ts b/packages/core/src/i18n/locales/fr.ts index ad605db24a..85ebad750e 100644 --- a/packages/core/src/i18n/locales/fr.ts +++ b/packages/core/src/i18n/locales/fr.ts @@ -460,6 +460,32 @@ export const fr: Dictionary = { formatting_change_by: (formats: string, users: string) => `Modification de mise en forme (${formats}) par : ${users}`, }, + versioning: { + title: "Historique", + close: "Fermer", + save_version: "Enregistrer la version", + show_named_only: "Afficher uniquement les versions nommées", + show_all: "Afficher toutes les versions", + comparison_on: "Activer la comparaison", + comparison_off: "Désactiver la comparaison", + versions_list: "Versions", + loading: "Chargement des versions", + empty: "Aucune version pour l'instant", + empty_named_only: "Aucune version nommée", + current_version: "Version actuelle", + unnamed_version: "Unnamed version", + comparing_to: "Comparé à", + restored_from: (date: string) => `Restauré depuis ${date}`, + more_actions: "Plus d'actions", + version_name_input: "Nom de la version", + name_version_menuitem: "Nommer cette version", + rename_menuitem: "Renommer", + compare_with_menuitem: "Comparer avec cette version", + compare_since_beginning_menuitem: "Comparer depuis le début", + restore_menuitem: "Restaurer", + delete_menuitem: "Supprimer", + action_failed: "Une erreur s'est produite. Veuillez réessayer.", + }, exporter: { open_file: "Ouvrir le fichier", open_video_file: "Ouvrir la vidéo", diff --git a/packages/core/src/i18n/locales/he.ts b/packages/core/src/i18n/locales/he.ts index 4662a94202..6e95ea808d 100644 --- a/packages/core/src/i18n/locales/he.ts +++ b/packages/core/src/i18n/locales/he.ts @@ -414,6 +414,32 @@ export const he: Dictionary = { formatting_change_by: (formats: string, users: string) => `שינוי עיצוב (${formats}) על ידי: ${users}`, }, + versioning: { + title: "היסטוריה", + close: "סגירה", + save_version: "שמור גרסה", + show_named_only: "הצג גרסאות בעלות שם בלבד", + show_all: "הצג את כל הגרסאות", + comparison_on: "הפעלת השוואה", + comparison_off: "כיבוי השוואה", + versions_list: "גרסאות", + loading: "טוען גרסאות", + empty: "אין עדיין גרסאות", + empty_named_only: "אין גרסאות עם שם", + current_version: "גרסה נוכחית", + unnamed_version: "Unnamed version", + comparing_to: "משווה מול", + restored_from: (date: string) => `שוחזר מ-${date}`, + more_actions: "פעולות נוספות", + version_name_input: "שם הגרסה", + name_version_menuitem: "מתן שם לגרסה זו", + rename_menuitem: "שינוי שם", + compare_with_menuitem: "השוואה לגרסה זו", + compare_since_beginning_menuitem: "השוואה מההתחלה", + restore_menuitem: "שחזור", + delete_menuitem: "מחיקה", + action_failed: "משהו השתבש. נסו שוב.", + }, exporter: { open_file: "פתח קובץ", open_video_file: "פתח וידאו", diff --git a/packages/core/src/i18n/locales/hr.ts b/packages/core/src/i18n/locales/hr.ts index 03eb016eed..06ff82cd90 100644 --- a/packages/core/src/i18n/locales/hr.ts +++ b/packages/core/src/i18n/locales/hr.ts @@ -428,6 +428,32 @@ export const hr: Dictionary = { formatting_change_by: (formats: string, users: string) => `Promjena oblikovanja (${formats}) od: ${users}`, }, + versioning: { + title: "Povijest", + close: "Zatvori", + save_version: "Spremi verziju", + show_named_only: "Prikaži samo imenovane verzije", + show_all: "Prikaži sve verzije", + comparison_on: "Uključi usporedbu", + comparison_off: "Isključi usporedbu", + versions_list: "Verzije", + loading: "Učitavanje verzija", + empty: "Još nema verzija", + empty_named_only: "Nema imenovanih verzija", + current_version: "Trenutna verzija", + unnamed_version: "Unnamed version", + comparing_to: "Uspoređuje se s", + restored_from: (date: string) => `Vraćeno s ${date}`, + more_actions: "Više radnji", + version_name_input: "Naziv verzije", + name_version_menuitem: "Imenuj ovu verziju", + rename_menuitem: "Preimenuj", + compare_with_menuitem: "Usporedi s ovom verzijom", + compare_since_beginning_menuitem: "Usporedi od početka", + restore_menuitem: "Vrati", + delete_menuitem: "Izbriši", + action_failed: "Nešto je pošlo po zlu. Pokušajte ponovno.", + }, exporter: { open_file: "Otvori datoteku", open_video_file: "Otvori videozapis", diff --git a/packages/core/src/i18n/locales/is.ts b/packages/core/src/i18n/locales/is.ts index 913b2324b0..91d64198b3 100644 --- a/packages/core/src/i18n/locales/is.ts +++ b/packages/core/src/i18n/locales/is.ts @@ -428,6 +428,32 @@ export const is: Dictionary = { formatting_change_by: (formats: string, users: string) => `Sniðbreyting (${formats}) af: ${users}`, }, + versioning: { + title: "Ferill", + close: "Loka", + save_version: "Vista útgáfu", + show_named_only: "Sýna aðeins nefndar útgáfur", + show_all: "Sýna allar útgáfur", + comparison_on: "Kveikja á samanburði", + comparison_off: "Slökkva á samanburði", + versions_list: "Útgáfur", + loading: "Hleð útgáfum", + empty: "Engar útgáfur enn", + empty_named_only: "Engar nefndar útgáfur", + current_version: "Núverandi útgáfa", + unnamed_version: "Unnamed version", + comparing_to: "Borið saman við", + restored_from: (date: string) => `Endurheimt frá ${date}`, + more_actions: "Fleiri aðgerðir", + version_name_input: "Heiti útgáfu", + name_version_menuitem: "Nefna þessa útgáfu", + rename_menuitem: "Endurnefna", + compare_with_menuitem: "Bera saman við þessa útgáfu", + compare_since_beginning_menuitem: "Bera saman frá upphafi", + restore_menuitem: "Endurheimta", + delete_menuitem: "Eyða", + action_failed: "Eitthvað fór úrskeiðis. Reyndu aftur.", + }, exporter: { open_file: "Opna skrá", open_video_file: "Opna myndband", diff --git a/packages/core/src/i18n/locales/it.ts b/packages/core/src/i18n/locales/it.ts index 44be22c1bd..fb4c996f9f 100644 --- a/packages/core/src/i18n/locales/it.ts +++ b/packages/core/src/i18n/locales/it.ts @@ -436,6 +436,32 @@ export const it: Dictionary = { formatting_change_by: (formats: string, users: string) => `Modifica formattazione (${formats}) da: ${users}`, }, + versioning: { + title: "Cronologia", + close: "Chiudi", + save_version: "Salva versione", + show_named_only: "Mostra solo le versioni con nome", + show_all: "Mostra tutte le versioni", + comparison_on: "Attiva il confronto", + comparison_off: "Disattiva il confronto", + versions_list: "Versioni", + loading: "Caricamento delle versioni", + empty: "Nessuna versione", + empty_named_only: "Nessuna versione con nome", + current_version: "Versione corrente", + unnamed_version: "Unnamed version", + comparing_to: "Confronto con", + restored_from: (date: string) => `Ripristinato dal ${date}`, + more_actions: "Altre azioni", + version_name_input: "Nome della versione", + name_version_menuitem: "Assegna un nome a questa versione", + rename_menuitem: "Rinomina", + compare_with_menuitem: "Confronta con questa versione", + compare_since_beginning_menuitem: "Confronta dall'inizio", + restore_menuitem: "Ripristina", + delete_menuitem: "Elimina", + action_failed: "Qualcosa è andato storto. Riprova.", + }, exporter: { open_file: "Apri file", open_video_file: "Apri video", diff --git a/packages/core/src/i18n/locales/ja.ts b/packages/core/src/i18n/locales/ja.ts index ead1f2fb30..81e3569f74 100644 --- a/packages/core/src/i18n/locales/ja.ts +++ b/packages/core/src/i18n/locales/ja.ts @@ -454,6 +454,32 @@ export const ja: Dictionary = { formatting_change_by: (formats: string, users: string) => `書式の変更 (${formats}) 変更者: ${users}`, }, + versioning: { + title: "履歴", + close: "閉じる", + save_version: "バージョンを保存", + show_named_only: "名前付きバージョンのみ表示", + show_all: "すべてのバージョンを表示", + comparison_on: "比較を有効にする", + comparison_off: "比較を無効にする", + versions_list: "バージョン", + loading: "バージョンを読み込んでいます", + empty: "バージョンはまだありません", + empty_named_only: "名前付きのバージョンはありません", + current_version: "現在のバージョン", + unnamed_version: "Unnamed version", + comparing_to: "比較対象", + restored_from: (date: string) => `${date} から復元`, + more_actions: "その他の操作", + version_name_input: "バージョン名", + name_version_menuitem: "このバージョンに名前を付ける", + rename_menuitem: "名前を変更", + compare_with_menuitem: "このバージョンと比較", + compare_since_beginning_menuitem: "最初から比較", + restore_menuitem: "復元", + delete_menuitem: "削除", + action_failed: "問題が発生しました。もう一度お試しください。", + }, exporter: { open_file: "ファイルを開く", open_video_file: "動画を開く", diff --git a/packages/core/src/i18n/locales/ko.ts b/packages/core/src/i18n/locales/ko.ts index 2981ff1c36..883f036286 100644 --- a/packages/core/src/i18n/locales/ko.ts +++ b/packages/core/src/i18n/locales/ko.ts @@ -427,6 +427,32 @@ export const ko: Dictionary = { formatting_change_by: (formats: string, users: string) => `서식 변경 (${formats}) 변경한 사람: ${users}`, }, + versioning: { + title: "기록", + close: "닫기", + save_version: "버전 저장", + show_named_only: "이름이 지정된 버전만 표시", + show_all: "모든 버전 표시", + comparison_on: "비교 켜기", + comparison_off: "비교 끄기", + versions_list: "버전", + loading: "버전 불러오는 중", + empty: "아직 버전이 없습니다", + empty_named_only: "이름이 지정된 버전이 없습니다", + current_version: "현재 버전", + unnamed_version: "Unnamed version", + comparing_to: "비교 대상", + restored_from: (date: string) => `${date}에서 복원됨`, + more_actions: "추가 작업", + version_name_input: "버전 이름", + name_version_menuitem: "이 버전의 이름 지정", + rename_menuitem: "이름 바꾸기", + compare_with_menuitem: "이 버전과 비교", + compare_since_beginning_menuitem: "처음부터 비교", + restore_menuitem: "복원", + delete_menuitem: "삭제", + action_failed: "문제가 발생했습니다. 다시 시도해 주세요.", + }, exporter: { open_file: "파일 열기", open_video_file: "동영상 열기", diff --git a/packages/core/src/i18n/locales/nl.ts b/packages/core/src/i18n/locales/nl.ts index da599e017c..24b42e0c53 100644 --- a/packages/core/src/i18n/locales/nl.ts +++ b/packages/core/src/i18n/locales/nl.ts @@ -415,6 +415,32 @@ export const nl: Dictionary = { formatting_change_by: (formats: string, users: string) => `Opmaakwijziging (${formats}) door: ${users}`, }, + versioning: { + title: "Geschiedenis", + close: "Sluiten", + save_version: "Versie opslaan", + show_named_only: "Alleen benoemde versies tonen", + show_all: "Alle versies tonen", + comparison_on: "Vergelijking inschakelen", + comparison_off: "Vergelijking uitschakelen", + versions_list: "Versies", + loading: "Versies laden", + empty: "Nog geen versies", + empty_named_only: "Geen benoemde versies", + current_version: "Huidige versie", + unnamed_version: "Unnamed version", + comparing_to: "Vergeleken met", + restored_from: (date: string) => `Hersteld vanaf ${date}`, + more_actions: "Meer acties", + version_name_input: "Versienaam", + name_version_menuitem: "Deze versie een naam geven", + rename_menuitem: "Naam wijzigen", + compare_with_menuitem: "Vergelijken met deze versie", + compare_since_beginning_menuitem: "Vergelijken vanaf het begin", + restore_menuitem: "Herstellen", + delete_menuitem: "Verwijderen", + action_failed: "Er is iets misgegaan. Probeer het opnieuw.", + }, exporter: { open_file: "Bestand openen", open_video_file: "Video openen", diff --git a/packages/core/src/i18n/locales/no.ts b/packages/core/src/i18n/locales/no.ts index 72efc096ed..3134d9e094 100644 --- a/packages/core/src/i18n/locales/no.ts +++ b/packages/core/src/i18n/locales/no.ts @@ -432,6 +432,32 @@ export const no: Dictionary = { formatting_change_by: (formats: string, users: string) => `Formateringsendring (${formats}) av: ${users}`, }, + versioning: { + title: "Historikk", + close: "Lukk", + save_version: "Lagre versjon", + show_named_only: "Vis bare navngitte versjoner", + show_all: "Vis alle versjoner", + comparison_on: "Slå på sammenligning", + comparison_off: "Slå av sammenligning", + versions_list: "Versjoner", + loading: "Laster versjoner", + empty: "Ingen versjoner ennå", + empty_named_only: "Ingen navngitte versjoner", + current_version: "Gjeldende versjon", + unnamed_version: "Unnamed version", + comparing_to: "Sammenligner med", + restored_from: (date: string) => `Gjenopprettet fra ${date}`, + more_actions: "Flere handlinger", + version_name_input: "Versjonsnavn", + name_version_menuitem: "Gi denne versjonen et navn", + rename_menuitem: "Gi nytt navn", + compare_with_menuitem: "Sammenlign med denne versjonen", + compare_since_beginning_menuitem: "Sammenlign fra begynnelsen", + restore_menuitem: "Gjenopprett", + delete_menuitem: "Slett", + action_failed: "Noe gikk galt. Prøv igjen.", + }, exporter: { open_file: "Åpne fil", open_video_file: "Åpne video", diff --git a/packages/core/src/i18n/locales/pl.ts b/packages/core/src/i18n/locales/pl.ts index d00039633c..7f5e269109 100644 --- a/packages/core/src/i18n/locales/pl.ts +++ b/packages/core/src/i18n/locales/pl.ts @@ -405,6 +405,32 @@ export const pl: Dictionary = { formatting_change_by: (formats: string, users: string) => `Zmiana formatowania (${formats}) przez: ${users}`, }, + versioning: { + title: "Historia", + close: "Zamknij", + save_version: "Zapisz wersję", + show_named_only: "Pokaż tylko nazwane wersje", + show_all: "Pokaż wszystkie wersje", + comparison_on: "Włącz porównywanie", + comparison_off: "Wyłącz porównywanie", + versions_list: "Wersje", + loading: "Ładowanie wersji", + empty: "Brak wersji", + empty_named_only: "Brak nazwanych wersji", + current_version: "Bieżąca wersja", + unnamed_version: "Unnamed version", + comparing_to: "Porównanie z", + restored_from: (date: string) => `Przywrócono z ${date}`, + more_actions: "Więcej działań", + version_name_input: "Nazwa wersji", + name_version_menuitem: "Nazwij tę wersję", + rename_menuitem: "Zmień nazwę", + compare_with_menuitem: "Porównaj z tą wersją", + compare_since_beginning_menuitem: "Porównaj od początku", + restore_menuitem: "Przywróć", + delete_menuitem: "Usuń", + action_failed: "Coś poszło nie tak. Spróbuj ponownie.", + }, exporter: { open_file: "Otwórz plik", open_video_file: "Otwórz wideo", diff --git a/packages/core/src/i18n/locales/pt.ts b/packages/core/src/i18n/locales/pt.ts index fe719ce023..3083d34c41 100644 --- a/packages/core/src/i18n/locales/pt.ts +++ b/packages/core/src/i18n/locales/pt.ts @@ -407,6 +407,32 @@ export const pt: Dictionary = { formatting_change_by: (formats: string, users: string) => `Alteração de formatação (${formats}) por: ${users}`, }, + versioning: { + title: "Histórico", + close: "Fechar", + save_version: "Salvar versão", + show_named_only: "Mostrar apenas versões nomeadas", + show_all: "Mostrar todas as versões", + comparison_on: "Ativar comparação", + comparison_off: "Desativar comparação", + versions_list: "Versões", + loading: "Carregando versões", + empty: "Ainda não há versões", + empty_named_only: "Não há versões nomeadas", + current_version: "Versão atual", + unnamed_version: "Unnamed version", + comparing_to: "Comparando com", + restored_from: (date: string) => `Restaurado de ${date}`, + more_actions: "Mais ações", + version_name_input: "Nome da versão", + name_version_menuitem: "Nomear esta versão", + rename_menuitem: "Renomear", + compare_with_menuitem: "Comparar com esta versão", + compare_since_beginning_menuitem: "Comparar desde o início", + restore_menuitem: "Restaurar", + delete_menuitem: "Excluir", + action_failed: "Algo deu errado. Tente novamente.", + }, exporter: { open_file: "Abrir arquivo", open_video_file: "Abrir vídeo", diff --git a/packages/core/src/i18n/locales/ru.ts b/packages/core/src/i18n/locales/ru.ts index a4a7987dfc..a33f1df716 100644 --- a/packages/core/src/i18n/locales/ru.ts +++ b/packages/core/src/i18n/locales/ru.ts @@ -458,6 +458,32 @@ export const ru: Dictionary = { formatting_change_by: (formats: string, users: string) => `Изменение форматирования (${formats}): ${users}`, }, + versioning: { + title: "История", + close: "Закрыть", + save_version: "Сохранить версию", + show_named_only: "Показывать только именованные версии", + show_all: "Показывать все версии", + comparison_on: "Включить сравнение", + comparison_off: "Выключить сравнение", + versions_list: "Версии", + loading: "Загрузка версий", + empty: "Пока нет версий", + empty_named_only: "Нет именованных версий", + current_version: "Текущая версия", + unnamed_version: "Unnamed version", + comparing_to: "Сравнение с", + restored_from: (date: string) => `Восстановлено из ${date}`, + more_actions: "Другие действия", + version_name_input: "Название версии", + name_version_menuitem: "Назвать эту версию", + rename_menuitem: "Переименовать", + compare_with_menuitem: "Сравнить с этой версией", + compare_since_beginning_menuitem: "Сравнить с начала", + restore_menuitem: "Восстановить", + delete_menuitem: "Удалить", + action_failed: "Что-то пошло не так. Попробуйте ещё раз.", + }, exporter: { open_file: "Открыть файл", open_video_file: "Открыть видео", diff --git a/packages/core/src/i18n/locales/sk.ts b/packages/core/src/i18n/locales/sk.ts index 4e73dc7eca..be01d183e0 100644 --- a/packages/core/src/i18n/locales/sk.ts +++ b/packages/core/src/i18n/locales/sk.ts @@ -412,6 +412,32 @@ export const sk = { formatting_change_by: (formats: string, users: string) => `Zmena formátovania (${formats}) od: ${users}`, }, + versioning: { + title: "História", + close: "Zavrieť", + save_version: "Uložiť verziu", + show_named_only: "Zobraziť iba pomenované verzie", + show_all: "Zobraziť všetky verzie", + comparison_on: "Zapnúť porovnávanie", + comparison_off: "Vypnúť porovnávanie", + versions_list: "Verzie", + loading: "Načítavajú sa verzie", + empty: "Zatiaľ žiadne verzie", + empty_named_only: "Žiadne pomenované verzie", + current_version: "Aktuálna verzia", + unnamed_version: "Unnamed version", + comparing_to: "Porovnáva sa s", + restored_from: (date: string) => `Obnovené z ${date}`, + more_actions: "Ďalšie akcie", + version_name_input: "Názov verzie", + name_version_menuitem: "Pomenovať túto verziu", + rename_menuitem: "Premenovať", + compare_with_menuitem: "Porovnať s touto verziou", + compare_since_beginning_menuitem: "Porovnať od začiatku", + restore_menuitem: "Obnoviť", + delete_menuitem: "Odstrániť", + action_failed: "Niečo sa pokazilo. Skúste to znova.", + }, exporter: { open_file: "Otvoriť súbor", open_video_file: "Otvoriť video", diff --git a/packages/core/src/i18n/locales/uk.ts b/packages/core/src/i18n/locales/uk.ts index e9d379ac0b..d87dd1ced5 100644 --- a/packages/core/src/i18n/locales/uk.ts +++ b/packages/core/src/i18n/locales/uk.ts @@ -438,6 +438,32 @@ export const uk: Dictionary = { formatting_change_by: (formats: string, users: string) => `Зміна форматування (${formats}) користувачем: ${users}`, }, + versioning: { + title: "Історія", + close: "Закрити", + save_version: "Зберегти версію", + show_named_only: "Показувати лише названі версії", + show_all: "Показувати всі версії", + comparison_on: "Увімкнути порівняння", + comparison_off: "Вимкнути порівняння", + versions_list: "Версії", + loading: "Завантаження версій", + empty: "Версій ще немає", + empty_named_only: "Немає іменованих версій", + current_version: "Поточна версія", + unnamed_version: "Unnamed version", + comparing_to: "Порівняння з", + restored_from: (date: string) => `Відновлено з ${date}`, + more_actions: "Інші дії", + version_name_input: "Назва версії", + name_version_menuitem: "Назвати цю версію", + rename_menuitem: "Перейменувати", + compare_with_menuitem: "Порівняти з цією версією", + compare_since_beginning_menuitem: "Порівняти від початку", + restore_menuitem: "Відновити", + delete_menuitem: "Видалити", + action_failed: "Щось пішло не так. Спробуйте ще раз.", + }, exporter: { open_file: "Відкрити файл", open_video_file: "Відкрити відео", diff --git a/packages/core/src/i18n/locales/uz.ts b/packages/core/src/i18n/locales/uz.ts index 13aee55a73..9d860dcef6 100644 --- a/packages/core/src/i18n/locales/uz.ts +++ b/packages/core/src/i18n/locales/uz.ts @@ -448,6 +448,32 @@ export const uz: Dictionary = { formatting_change_by: (formats: string, users: string) => `Formatlash o'zgarishi (${formats}), o'zgartirgan: ${users}`, }, + versioning: { + title: "Tarix", + close: "Yopish", + save_version: "Versiyani saqlash", + show_named_only: "Faqat nomlangan versiyalarni ko'rsatish", + show_all: "Barcha versiyalarni ko'rsatish", + comparison_on: "Taqqoslashni yoqish", + comparison_off: "Taqqoslashni o'chirish", + versions_list: "Versiyalar", + loading: "Versiyalar yuklanmoqda", + empty: "Hozircha versiyalar yo'q", + empty_named_only: "Nomlangan versiyalar yo'q", + current_version: "Joriy versiya", + unnamed_version: "Unnamed version", + comparing_to: "Taqqoslanmoqda", + restored_from: (date: string) => `${date} dan tiklangan`, + more_actions: "Boshqa amallar", + version_name_input: "Versiya nomi", + name_version_menuitem: "Bu versiyaga nom berish", + rename_menuitem: "Nomini o'zgartirish", + compare_with_menuitem: "Shu versiya bilan taqqoslash", + compare_since_beginning_menuitem: "Boshidan taqqoslash", + restore_menuitem: "Tiklash", + delete_menuitem: "O'chirish", + action_failed: "Xatolik yuz berdi. Qayta urinib ko‘ring.", + }, exporter: { open_file: "Faylni ochish", open_video_file: "Videoni ochish", diff --git a/packages/core/src/i18n/locales/vi.ts b/packages/core/src/i18n/locales/vi.ts index 8733fbf0ba..fd675094b8 100644 --- a/packages/core/src/i18n/locales/vi.ts +++ b/packages/core/src/i18n/locales/vi.ts @@ -413,6 +413,32 @@ export const vi: Dictionary = { formatting_change_by: (formats: string, users: string) => `Thay đổi định dạng (${formats}) bởi: ${users}`, }, + versioning: { + title: "Lịch sử", + close: "Đóng", + save_version: "Lưu phiên bản", + show_named_only: "Chỉ hiển thị các phiên bản đã đặt tên", + show_all: "Hiển thị tất cả phiên bản", + comparison_on: "Bật so sánh", + comparison_off: "Tắt so sánh", + versions_list: "Phiên bản", + loading: "Đang tải phiên bản", + empty: "Chưa có phiên bản nào", + empty_named_only: "Không có phiên bản nào được đặt tên", + current_version: "Phiên bản hiện tại", + unnamed_version: "Unnamed version", + comparing_to: "Đang so sánh với", + restored_from: (date: string) => `Khôi phục từ ${date}`, + more_actions: "Thao tác khác", + version_name_input: "Tên phiên bản", + name_version_menuitem: "Đặt tên cho phiên bản này", + rename_menuitem: "Đổi tên", + compare_with_menuitem: "So sánh với phiên bản này", + compare_since_beginning_menuitem: "So sánh từ đầu", + restore_menuitem: "Khôi phục", + delete_menuitem: "Xóa", + action_failed: "Đã xảy ra lỗi. Vui lòng thử lại.", + }, exporter: { open_file: "Mở tệp", open_video_file: "Mở video", diff --git a/packages/core/src/i18n/locales/zh-tw.ts b/packages/core/src/i18n/locales/zh-tw.ts index 5ac37a80c7..242d5d89b5 100644 --- a/packages/core/src/i18n/locales/zh-tw.ts +++ b/packages/core/src/i18n/locales/zh-tw.ts @@ -455,6 +455,32 @@ export const zhTW: Dictionary = { formatting_change_by: (formats: string, users: string) => `格式變更(${formats}),變更者:${users}`, }, + versioning: { + title: "版本紀錄", + close: "關閉", + save_version: "儲存版本", + show_named_only: "僅顯示已命名的版本", + show_all: "顯示所有版本", + comparison_on: "開啟比較", + comparison_off: "關閉比較", + versions_list: "版本", + loading: "正在載入版本", + empty: "尚無版本", + empty_named_only: "尚無命名版本", + current_version: "目前版本", + unnamed_version: "Unnamed version", + comparing_to: "比較對象", + restored_from: (date: string) => `已從 ${date} 還原`, + more_actions: "更多操作", + version_name_input: "版本名稱", + name_version_menuitem: "為此版本命名", + rename_menuitem: "重新命名", + compare_with_menuitem: "與此版本比較", + compare_since_beginning_menuitem: "從開頭開始比較", + restore_menuitem: "還原", + delete_menuitem: "刪除", + action_failed: "發生錯誤,請再試一次。", + }, exporter: { open_file: "開啟檔案", open_video_file: "開啟影片", diff --git a/packages/core/src/i18n/locales/zh.ts b/packages/core/src/i18n/locales/zh.ts index 3f4c90bb56..be5b078ace 100644 --- a/packages/core/src/i18n/locales/zh.ts +++ b/packages/core/src/i18n/locales/zh.ts @@ -455,6 +455,32 @@ export const zh: Dictionary = { formatting_change_by: (formats: string, users: string) => `格式更改(${formats}),更改者:${users}`, }, + versioning: { + title: "历史记录", + close: "关闭", + save_version: "保存版本", + show_named_only: "仅显示已命名的版本", + show_all: "显示所有版本", + comparison_on: "开启对比", + comparison_off: "关闭对比", + versions_list: "版本", + loading: "正在加载版本", + empty: "暂无版本", + empty_named_only: "暂无命名版本", + current_version: "当前版本", + unnamed_version: "Unnamed version", + comparing_to: "对比对象", + restored_from: (date: string) => `恢复自 ${date}`, + more_actions: "更多操作", + version_name_input: "版本名称", + name_version_menuitem: "命名此版本", + rename_menuitem: "重命名", + compare_with_menuitem: "与此版本对比", + compare_since_beginning_menuitem: "从开头开始对比", + restore_menuitem: "恢复", + delete_menuitem: "删除", + action_failed: "出错了,请重试。", + }, exporter: { open_file: "打开文件", open_video_file: "打开视频", diff --git a/packages/core/src/user/userColors.test.ts b/packages/core/src/user/userColors.test.ts new file mode 100644 index 0000000000..2d130d1257 --- /dev/null +++ b/packages/core/src/user/userColors.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from "vite-plus/test"; + +import type { User } from "./UserStore.js"; +import { createUserStore } from "./UserStore.js"; +import { + colorsForUserIds, + fallbackColorForUserId, + userColorPalette, + userMarkColors, +} from "./userColors.js"; + +describe("userColorPalette", () => { + it("contains no red", () => { + // The palette tints insertions as well as deletions, so a red entry would + // make one author's additions read as errors. Guard the property rather + // than the exact hexes: red is any entry whose hue is near 0°/360°. + for (const { light, dark } of userColorPalette) { + for (const color of [light, dark]) { + const [r, g, b] = [1, 3, 5].map((offset) => + parseInt(color.slice(offset, offset + 2), 16), + ); + const isRed = r > g + 40 && r > b + 40; + expect(isRed, `${color} reads as red`).toBe(false); + } + } + }); + + it("assigns a stable entry per user id", () => { + expect(fallbackColorForUserId("alice")).toEqual( + fallbackColorForUserId("alice"), + ); + expect(userColorPalette).toContainEqual(fallbackColorForUserId("alice")); + }); +}); + +describe("userMarkColors", () => { + it("is undefined for a user with no color", () => { + expect(userMarkColors(undefined)).toBeUndefined(); + expect(userMarkColors({})).toBeUndefined(); + }); + + it("uses both colors when the app supplies both", () => { + expect(userMarkColors({ color: "#123456", colorLight: "#abcdef" })).toEqual( + { + light: "#abcdef", + dark: "#123456", + }, + ); + }); + + it("derives the light tint when only `color` is set", () => { + expect(userMarkColors({ color: "#123456" })).toEqual({ + light: "color-mix(in srgb, #123456 30%, white)", + dark: "#123456", + }); + }); +}); + +describe("colorsForUserIds", () => { + it("falls back to the first palette entry with no ids", () => { + const store = createUserStore(async () => []); + expect(colorsForUserIds(store, undefined)).toEqual(userColorPalette[0]); + expect(colorsForUserIds(store, [])).toEqual(userColorPalette[0]); + }); + + it("falls back to the id's palette entry for an unresolved user", () => { + const store = createUserStore(async () => []); + expect(colorsForUserIds(store, ["alice"])).toEqual( + fallbackColorForUserId("alice"), + ); + }); + + it("uses the resolved user's own colors, deriving the tint when needed", async () => { + const store = createUserStore(async (ids: string[]) => + ids.map((id) => ({ + id, + username: id, + avatarUrl: "", + color: id === "both" ? "#123456" : "#654321", + ...(id === "both" ? { colorLight: "#abcdef" } : {}), + })), + ); + await store.loadUsers(["both", "dark-only"]); + + expect(colorsForUserIds(store, ["both"])).toEqual({ + light: "#abcdef", + dark: "#123456", + }); + expect(colorsForUserIds(store, ["dark-only"])).toEqual({ + light: "color-mix(in srgb, #654321 30%, white)", + dark: "#654321", + }); + }); +}); diff --git a/packages/core/src/user/userColors.ts b/packages/core/src/user/userColors.ts index b63e909238..fdad687cc9 100644 --- a/packages/core/src/user/userColors.ts +++ b/packages/core/src/user/userColors.ts @@ -1,5 +1,5 @@ import { digestString } from "lib0/hash/fnv1a"; -import type { UserStore } from "./UserStore.js"; +import type { User, UserStore } from "./UserStore.js"; /** * Deterministic hash of a string to an unsigned 32-bit integer. @@ -12,13 +12,21 @@ const hashStr = (s: string): number => { return Math.abs(hash); }; -/** Fallback palette used when a user has no resolved color of their own. */ +/** + * Fallback palette used when a user has no resolved color of their own. + * + * Deliberately red-free: these colors tint *insertions* as well as deletions, + * and a red insertion reads as an error rather than as one author's + * contribution. The hues are spread far enough apart to stay distinguishable + * for the most common forms of colour-vision deficiency. + */ export const userColorPalette: Array<{ light: string; dark: string }> = [ - { light: "#fff0c2", dark: "#8a6d1a" }, - { light: "#fcc9c3", dark: "#8a2e24" }, - { light: "#d4e8eb", dark: "#4a7178" }, - { light: "#c2eeff", dark: "#1a6e8a" }, - { light: "#bef3ff", dark: "#0a7a8a" }, + { light: "#fff0c2", dark: "#8a6d1a" }, // amber + { light: "#dcdefc", dark: "#3b3f9c" }, // indigo + { light: "#c9efe9", dark: "#0f6e62" }, // teal + { light: "#c9dcff", dark: "#1e4fb0" }, // blue + { light: "#eadcfb", dark: "#6b2fa3" }, // violet + { light: "#dfe4ea", dark: "#46525f" }, // slate ]; /** The deterministic {@link userColorPalette} entry for a single user id. */ @@ -28,7 +36,28 @@ export const fallbackColorForUserId = ( userColorPalette[hashStr(id) % userColorPalette.length]; /** - * The (first) user's resolved color from the {@link UserStore}, or their + * A user's own mark colors, or `undefined` when they have none. + * + * `color` is the saturated color the app already uses for that user (cursors, + * avatars); `colorLight` is the pale background a mark is highlighted with. Most + * applications only set the former, so derive the latter rather than fall back + * to a palette entry that has nothing to do with the user's actual color — a + * user whose cursor is green shouldn't have amber marks. + */ +export const userMarkColors = ( + user: Pick | undefined, +): { light: string; dark: string } | undefined => { + if (!user?.color) { + return undefined; + } + return { + light: user.colorLight ?? `color-mix(in srgb, ${user.color} 30%, white)`, + dark: user.color, + }; +}; + +/** + * The (first) user's {@link userMarkColors}, or their * {@link fallbackColorForUserId} palette entry. Used where a concrete color * string is needed (the portaled hover tooltip); marks themselves use the * cascaded {@link userColorVarNames} properties instead. @@ -41,11 +70,10 @@ export const colorsForUserIds = ( return userColorPalette[0]; } const firstId = userIds[0]; - const user = userStore.getUser(firstId); - if (user?.color && user.colorLight) { - return { light: user.colorLight, dark: user.color }; - } - return fallbackColorForUserId(firstId); + return ( + userMarkColors(userStore.getUser(firstId)) ?? + fallbackColorForUserId(firstId) + ); }; /** diff --git a/packages/core/src/y/comments/RESTYjsThreadStore.ts b/packages/core/src/y/comments/RESTYjsThreadStore.ts index 7841f453f4..d14d69d13a 100644 --- a/packages/core/src/y/comments/RESTYjsThreadStore.ts +++ b/packages/core/src/y/comments/RESTYjsThreadStore.ts @@ -21,7 +21,7 @@ export class RESTYjsThreadStore extends YjsThreadStoreBase { constructor( private readonly BASE_URL: string, private readonly headers: Record, - threadsYType: Y.Type, + threadsYType: Y.Node, auth: ThreadStoreAuth, ) { super(threadsYType, auth); diff --git a/packages/core/src/y/comments/YjsThreadStore.test.ts b/packages/core/src/y/comments/YjsThreadStore.test.ts index 84ce8c47f4..9683393f55 100644 --- a/packages/core/src/y/comments/YjsThreadStore.test.ts +++ b/packages/core/src/y/comments/YjsThreadStore.test.ts @@ -14,7 +14,7 @@ vi.mock("lib0/random", async (importOriginal) => ({ describe("YjsThreadStore (@y/y v14)", () => { let store: YjsThreadStore; let doc: Y.Doc; - let threadsYType: Y.Type; + let threadsYType: Y.Node; beforeEach(() => { // Reset mocks and create fresh instances diff --git a/packages/core/src/y/comments/YjsThreadStore.ts b/packages/core/src/y/comments/YjsThreadStore.ts index 0a9b09a676..82ab3433a7 100644 --- a/packages/core/src/y/comments/YjsThreadStore.ts +++ b/packages/core/src/y/comments/YjsThreadStore.ts @@ -29,7 +29,7 @@ import { export class YjsThreadStore extends YjsThreadStoreBase { constructor( private readonly userId: string, - threadsYType: Y.Type, + threadsYType: Y.Node, auth: ThreadStoreAuth, ) { super(threadsYType, auth); @@ -98,7 +98,7 @@ export class YjsThreadStore extends YjsThreadStoreBase { threadId: string; }) => { const yThread = this.threadsYType.getAttr(options.threadId) as - | Y.Type + | Y.Node | undefined; if (!yThread) { throw new Error("Thread not found"); @@ -121,7 +121,7 @@ export class YjsThreadStore extends YjsThreadStoreBase { body: options.comment.body, }; - (yThread.getAttr("comments") as Y.Type).push([commentToYType(comment)]); + (yThread.getAttr("comments") as Y.Node).push([commentToYType(comment)]); yThread.setAttr("updatedAt", new Date().getTime()); return comment; @@ -138,23 +138,23 @@ export class YjsThreadStore extends YjsThreadStoreBase { commentId: string; }) => { const yThread = this.threadsYType.getAttr(options.threadId) as - | Y.Type + | Y.Node | undefined; if (!yThread) { throw new Error("Thread not found"); } - const commentsType = yThread.getAttr("comments") as Y.Type; + const commentsType = yThread.getAttr("comments") as Y.Node; const yCommentIndex = yTypeFindIndex( commentsType, - (comment) => (comment as Y.Type).getAttr("id") === options.commentId, + (comment) => (comment as Y.Node).getAttr("id") === options.commentId, ); if (yCommentIndex === -1) { throw new Error("Comment not found"); } - const yComment = commentsType.get(yCommentIndex) as Y.Type; + const yComment = commentsType.get(yCommentIndex) as Y.Node; if (!this.auth.canUpdateComment(yTypeToComment(yComment))) { throw new Error("Not authorized"); @@ -173,23 +173,23 @@ export class YjsThreadStore extends YjsThreadStoreBase { softDelete?: boolean; }) => { const yThread = this.threadsYType.getAttr(options.threadId) as - | Y.Type + | Y.Node | undefined; if (!yThread) { throw new Error("Thread not found"); } - const commentsType = yThread.getAttr("comments") as Y.Type; + const commentsType = yThread.getAttr("comments") as Y.Node; const yCommentIndex = yTypeFindIndex( commentsType, - (comment) => (comment as Y.Type).getAttr("id") === options.commentId, + (comment) => (comment as Y.Node).getAttr("id") === options.commentId, ); if (yCommentIndex === -1) { throw new Error("Comment not found"); } - const yComment = commentsType.get(yCommentIndex) as Y.Type; + const yComment = commentsType.get(yCommentIndex) as Y.Node; if (!this.auth.canDeleteComment(yTypeToComment(yComment))) { throw new Error("Not authorized"); @@ -209,7 +209,7 @@ export class YjsThreadStore extends YjsThreadStoreBase { if ( commentsType .toArray() - .every((comment) => (comment as Y.Type).getAttr("deletedAt")) + .every((comment) => (comment as Y.Node).getAttr("deletedAt")) ) { // all comments deleted if (options.softDelete) { @@ -226,7 +226,7 @@ export class YjsThreadStore extends YjsThreadStoreBase { public deleteThread = this.transact((options: { threadId: string }) => { if ( !this.auth.canDeleteThread( - yTypeToThread(this.threadsYType.getAttr(options.threadId) as Y.Type), + yTypeToThread(this.threadsYType.getAttr(options.threadId) as Y.Node), ) ) { throw new Error("Not authorized"); @@ -237,7 +237,7 @@ export class YjsThreadStore extends YjsThreadStoreBase { public resolveThread = this.transact((options: { threadId: string }) => { const yThread = this.threadsYType.getAttr(options.threadId) as - | Y.Type + | Y.Node | undefined; if (!yThread) { throw new Error("Thread not found"); @@ -254,7 +254,7 @@ export class YjsThreadStore extends YjsThreadStoreBase { public unresolveThread = this.transact((options: { threadId: string }) => { const yThread = this.threadsYType.getAttr(options.threadId) as - | Y.Type + | Y.Node | undefined; if (!yThread) { throw new Error("Thread not found"); @@ -271,23 +271,23 @@ export class YjsThreadStore extends YjsThreadStoreBase { public addReaction = this.transact( (options: { threadId: string; commentId: string; emoji: string }) => { const yThread = this.threadsYType.getAttr(options.threadId) as - | Y.Type + | Y.Node | undefined; if (!yThread) { throw new Error("Thread not found"); } - const commentsType = yThread.getAttr("comments") as Y.Type; + const commentsType = yThread.getAttr("comments") as Y.Node; const yCommentIndex = yTypeFindIndex( commentsType, - (comment) => (comment as Y.Type).getAttr("id") === options.commentId, + (comment) => (comment as Y.Node).getAttr("id") === options.commentId, ); if (yCommentIndex === -1) { throw new Error("Comment not found"); } - const yComment = commentsType.get(yCommentIndex) as Y.Type; + const yComment = commentsType.get(yCommentIndex) as Y.Node; if (!this.auth.canAddReaction(yTypeToComment(yComment), options.emoji)) { throw new Error("Not authorized"); @@ -297,13 +297,13 @@ export class YjsThreadStore extends YjsThreadStoreBase { const key = `${this.userId}-${options.emoji}`; - const reactionsByUser = yComment.getAttr("reactionsByUser") as Y.Type; + const reactionsByUser = yComment.getAttr("reactionsByUser") as Y.Node; if (reactionsByUser.hasAttr(key)) { // already exists return; } else { - const reaction = new Y.Type(); + const reaction = new Y.Node(); reaction.setAttr("emoji", options.emoji); reaction.setAttr("createdAt", date.getTime()); reaction.setAttr("userId", this.userId); @@ -315,23 +315,23 @@ export class YjsThreadStore extends YjsThreadStoreBase { public deleteReaction = this.transact( (options: { threadId: string; commentId: string; emoji: string }) => { const yThread = this.threadsYType.getAttr(options.threadId) as - | Y.Type + | Y.Node | undefined; if (!yThread) { throw new Error("Thread not found"); } - const commentsType = yThread.getAttr("comments") as Y.Type; + const commentsType = yThread.getAttr("comments") as Y.Node; const yCommentIndex = yTypeFindIndex( commentsType, - (comment) => (comment as Y.Type).getAttr("id") === options.commentId, + (comment) => (comment as Y.Node).getAttr("id") === options.commentId, ); if (yCommentIndex === -1) { throw new Error("Comment not found"); } - const yComment = commentsType.get(yCommentIndex) as Y.Type; + const yComment = commentsType.get(yCommentIndex) as Y.Node; if ( !this.auth.canDeleteReaction(yTypeToComment(yComment), options.emoji) @@ -341,14 +341,14 @@ export class YjsThreadStore extends YjsThreadStoreBase { const key = `${this.userId}-${options.emoji}`; - const reactionsByUser = yComment.getAttr("reactionsByUser") as Y.Type; + const reactionsByUser = yComment.getAttr("reactionsByUser") as Y.Node; reactionsByUser.deleteAttr(key); }, ); } -function yTypeFindIndex(yType: Y.Type, predicate: (item: any) => boolean) { +function yTypeFindIndex(yType: Y.Node, predicate: (item: any) => boolean) { for (let i = 0; i < yType.length; i++) { if (predicate(yType.get(i))) { return i; diff --git a/packages/core/src/y/comments/YjsThreadStoreBase.ts b/packages/core/src/y/comments/YjsThreadStoreBase.ts index b62c2e1811..c76c890f18 100644 --- a/packages/core/src/y/comments/YjsThreadStoreBase.ts +++ b/packages/core/src/y/comments/YjsThreadStoreBase.ts @@ -10,7 +10,7 @@ import { yTypeToThread } from "./yjsHelpers.js"; */ export abstract class YjsThreadStoreBase extends ThreadStore { constructor( - protected readonly threadsYType: Y.Type, + protected readonly threadsYType: Y.Node, auth: ThreadStoreAuth, ) { super(auth); @@ -29,7 +29,7 @@ export abstract class YjsThreadStoreBase extends ThreadStore { public getThreads(): Map { const threadMap = new Map(); this.threadsYType.forEachAttr((yThread: any, id: string | number) => { - if (yThread instanceof Y.Type) { + if (yThread instanceof Y.Node) { threadMap.set(String(id), yTypeToThread(yThread)); } }); diff --git a/packages/core/src/y/comments/yjsHelpers.ts b/packages/core/src/y/comments/yjsHelpers.ts index 1ed4ff492f..c485143e8e 100644 --- a/packages/core/src/y/comments/yjsHelpers.ts +++ b/packages/core/src/y/comments/yjsHelpers.ts @@ -6,7 +6,7 @@ import type { } from "../../comments/types.js"; export function commentToYType(comment: CommentData) { - const yType = new Y.Type(); + const yType = new Y.Node(); yType.setAttr("id", comment.id); yType.setAttr("userId", comment.userId); yType.setAttr("createdAt", comment.createdAt.getTime()); @@ -26,18 +26,18 @@ export function commentToYType(comment: CommentData) { * this makes it easy to add / remove reactions and in a way that works local-first. * The cost is that "reading" the reactions is a bit more complex (see yTypeToReactions). */ - yType.setAttr("reactionsByUser", new Y.Type()); + yType.setAttr("reactionsByUser", new Y.Node()); yType.setAttr("metadata", comment.metadata); return yType; } export function threadToYType(thread: ThreadData) { - const yType = new Y.Type(); + const yType = new Y.Node(); yType.setAttr("id", thread.id); yType.setAttr("createdAt", thread.createdAt.getTime()); yType.setAttr("updatedAt", thread.updatedAt.getTime()); - const commentsType = new Y.Type(); + const commentsType = new Y.Node(); commentsType.push(thread.comments.map((comment) => commentToYType(comment))); @@ -55,7 +55,7 @@ type SingleUserCommentReactionData = { userId: string; }; -export function yTypeToReaction(yType: Y.Type): SingleUserCommentReactionData { +export function yTypeToReaction(yType: Y.Node): SingleUserCommentReactionData { return { emoji: yType.getAttr("emoji"), createdAt: new Date(yType.getAttr("createdAt")), @@ -63,8 +63,8 @@ export function yTypeToReaction(yType: Y.Type): SingleUserCommentReactionData { }; } -function yTypeToReactions(yType: Y.Type): CommentReactionData[] { - const flatReactions = [...yType.attrValues()].map((reaction: Y.Type) => +function yTypeToReactions(yType: Y.Node): CommentReactionData[] { + const flatReactions = [...yType.attrValues()].map((reaction: Y.Node) => yTypeToReaction(reaction), ); // combine reactions by the same emoji @@ -92,7 +92,7 @@ function yTypeToReactions(yType: Y.Type): CommentReactionData[] { ); } -export function yTypeToComment(yType: Y.Type): CommentData { +export function yTypeToComment(yType: Y.Node): CommentData { return { type: "comment", id: yType.getAttr("id"), @@ -108,14 +108,14 @@ export function yTypeToComment(yType: Y.Type): CommentData { }; } -export function yTypeToThread(yType: Y.Type): ThreadData { +export function yTypeToThread(yType: Y.Node): ThreadData { return { type: "thread", id: yType.getAttr("id"), createdAt: new Date(yType.getAttr("createdAt")), updatedAt: new Date(yType.getAttr("updatedAt")), - comments: ((yType.getAttr("comments") as Y.Type)?.toArray() || []).map( - (comment) => yTypeToComment(comment as Y.Type), + comments: ((yType.getAttr("comments") as Y.Node)?.toArray() || []).map( + (comment) => yTypeToComment(comment as Y.Node), ), resolved: yType.getAttr("resolved"), resolvedUpdatedAt: new Date(yType.getAttr("resolvedUpdatedAt")), diff --git a/packages/core/src/y/extensions/AttributionExtension.test.ts b/packages/core/src/y/extensions/AttributionExtension.test.ts index f752b48182..ab6fb0a7c2 100644 --- a/packages/core/src/y/extensions/AttributionExtension.test.ts +++ b/packages/core/src/y/extensions/AttributionExtension.test.ts @@ -5,6 +5,7 @@ import { afterEach, describe, expect, it, vi } from "vite-plus/test"; import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; import type { User } from "../../user/index.js"; +import { cssVarUserId } from "../../user/index.js"; import { AttributionExtension } from "./AttributionExtension.js"; // Editors created during a test, destroyed in afterEach: an undestroyed @@ -12,11 +13,12 @@ import { AttributionExtension } from "./AttributionExtension.js"; // the jsdom environment is torn down ("document is not defined" as an // unhandled error - flaky, timing-dependent, mostly on slow CI). const editors: BlockNoteEditor[] = []; +const mounts: HTMLElement[] = []; // A `resolveUsers` spy plus an editor with the AttributionExtension registered. // No Yjs/collaboration needed — the extension's load plugin only cares that a // transaction adds a `y-attributed-*` mark, which we do directly below. -function createEditor() { +function createEditor(user?: Partial) { const resolveUsers = vi.fn(async (ids: string[]): Promise => ids.map((id) => ({ id, @@ -24,18 +26,32 @@ function createEditor() { avatarUrl: "", color: "#123456", colorLight: "#abcdef", + ...user, })), ); const editor = BlockNoteEditor.create({ extensions: [AttributionExtension({ resolveUsers })], }); - editor.mount(document.createElement("div")); + const mount = document.createElement("div"); + document.body.appendChild(mount); + mounts.push(mount); + editor.mount(mount); editors.push(editor); return { editor, resolveUsers }; } +/** The `--user-color--{light,dark}` values on the editor root. */ +function rootColorVars(editor: BlockNoteEditor, userId: string) { + const root = editor.prosemirrorView!.dom as HTMLElement; + const key = cssVarUserId(userId); + return { + light: root.style.getPropertyValue(`--user-color-${key}-light`), + dark: root.style.getPropertyValue(`--user-color-${key}-dark`), + }; +} + // Add a `y-attributed-insert` mark carrying `userIds` over the first block's // text, mirroring how the sync reconcile applies attribution marks. function addInsertMark(editor: BlockNoteEditor, userIds: string[]) { @@ -56,6 +72,9 @@ describe("AttributionExtension user loading", () => { for (const editor of editors.splice(0)) { editor._tiptapEditor.destroy(); } + for (const mount of mounts.splice(0)) { + mount.remove(); + } vi.restoreAllMocks(); }); @@ -92,4 +111,144 @@ describe("AttributionExtension user loading", () => { // The user store dedupes already-cached ids, so `alice` is fetched once. expect(resolveUsers).toHaveBeenCalledTimes(1); }); + + it("writes both of a resolved author's colors to the editor root", async () => { + const { editor } = createEditor(); + editor.replaceBlocks(editor.document, [{ content: "hello" }]); + + addInsertMark(editor, ["alice"]); + await vi.waitFor(() => + expect(rootColorVars(editor, "alice").dark).not.toBe(""), + ); + + expect(rootColorVars(editor, "alice")).toEqual({ + light: "#abcdef", + dark: "#123456", + }); + }); + + it("derives the light tint for an author that only has a `color`", async () => { + const { editor } = createEditor({ colorLight: undefined }); + editor.replaceBlocks(editor.document, [{ content: "hello" }]); + + addInsertMark(editor, ["alice"]); + await vi.waitFor(() => + expect(rootColorVars(editor, "alice").dark).not.toBe(""), + ); + + expect(rootColorVars(editor, "alice")).toEqual({ + light: "color-mix(in srgb, #123456 30%, white)", + dark: "#123456", + }); + }); + + it("loads property authors, replaces stale attribution, and shows the changed keys", async () => { + const { editor, resolveUsers } = createEditor(); + editor.replaceBlocks(editor.document, [ + { type: "paragraph", content: "hello" }, + ]); + const originalBlocks = editor.document; + const markType = editor.pmSchema.marks["y-attributed-attrs"]; + function setChanges( + changes: Record, + ) { + editor.transact((tr) => tr.addNodeMark(2, markType.create({ changes }))); + } + setChanges({ textAlignment: { userIds: ["alice"], timestamp: null } }); + await vi.waitFor(() => + expect(rootColorVars(editor, "alice").dark).toBe("#123456"), + ); + expect(resolveUsers).toHaveBeenCalledWith(["alice"], expect.anything()); + setChanges({ backgroundColor: { userIds: ["bob"], timestamp: null } }); + await vi.waitFor(() => + expect(rootColorVars(editor, "bob").dark).toBe("#123456"), + ); + expect(editor.prosemirrorState.doc.nodeAt(2)!.marks).toHaveLength(1); + expect(editor.document).toEqual(originalBlocks); + const wrapper = + editor.prosemirrorView.dom.querySelector( + "[data-attributes]", + )!; + wrapper.dispatchEvent(new MouseEvent("mouseover", { bubbles: true })); + expect( + editor.getExtension(AttributionExtension)!.store.state, + ).toMatchObject({ + modificationType: "attrs", + attributes: ["backgroundColor"], + users: ["name-bob"], + contentType: "block", + }); + }); + + it("keeps deletion styling directly on the node when attributes are also attributed", () => { + const { editor } = createEditor(); + editor.replaceBlocks(editor.document, [{ content: "hello" }]); + editor.transact((tr) => { + tr.addNodeMark( + 2, + editor.pmSchema.marks["y-attributed-delete"].create({ + userIds: ["alice"], + }), + ); + tr.addNodeMark( + 2, + editor.pmSchema.marks["y-attributed-attrs"].create({ + changes: { textAlignment: { userIds: ["alice"], timestamp: null } }, + }), + ); + }); + expect( + editor.prosemirrorView.dom.querySelector( + "[data-attributes] > span > del > .bn-suggestion-node--delete > .bn-block-content", + ), + ).not.toBeNull(); + }); + + it("only opens a tooltip in the editor containing the hovered mark", () => { + const { editor } = createEditor(); + const { editor: otherEditor } = createEditor(); + editor.replaceBlocks(editor.document, [{ content: "hello" }]); + const mark = editor.pmSchema.marks["y-attributed-attrs"].create({ + changes: { textAlignment: { userIds: [], timestamp: null } }, + }); + editor.transact((tr) => tr.addNodeMark(2, mark)); + editor.prosemirrorView.dom + .querySelector("[data-attributes]")! + .dispatchEvent(new MouseEvent("mouseover", { bubbles: true })); + expect( + editor.getExtension(AttributionExtension)!.store.state, + ).toBeDefined(); + expect( + otherEditor.getExtension(AttributionExtension)!.store.state, + ).toBeUndefined(); + otherEditor.prosemirrorView.dom.dispatchEvent( + new MouseEvent("mouseover", { bubbles: true }), + ); + expect( + editor.getExtension(AttributionExtension)!.store.state, + ).toBeUndefined(); + }); + + it("shows changed properties even when a version diff has no author", () => { + const { editor } = createEditor(); + editor.replaceBlocks(editor.document, [ + { type: "paragraph", content: "hello" }, + ]); + const mark = editor.pmSchema.marks["y-attributed-attrs"].create({ + changes: { textAlignment: { userIds: [], timestamp: null } }, + }); + editor.transact((tr) => tr.addNodeMark(2, mark)); + const wrapper = + editor.prosemirrorView.dom.querySelector( + "[data-attributes]", + )!; + wrapper.dispatchEvent(new MouseEvent("mouseover", { bubbles: true })); + expect( + editor.getExtension(AttributionExtension)!.store.state, + ).toMatchObject({ + modificationType: "attrs", + attributes: ["textAlignment"], + users: [], + }); + }); }); diff --git a/packages/core/src/y/extensions/AttributionExtension.ts b/packages/core/src/y/extensions/AttributionExtension.ts index 10e888830e..71129e6f63 100644 --- a/packages/core/src/y/extensions/AttributionExtension.ts +++ b/packages/core/src/y/extensions/AttributionExtension.ts @@ -1,3 +1,4 @@ +import { AddNodeMarkStep } from "prosemirror-transform"; import { getChangedRanges } from "@tiptap/core"; import { Plugin, PluginKey, type Transaction } from "prosemirror-state"; import { @@ -8,11 +9,13 @@ import { import { colorsForUserIds, userColorVarNames, + userMarkColors, normalizeToUserStore, type UserStoreOrResolver, } from "../../user/index.js"; import { resolveAttributionMarkClassName, + getAttributionUserIds, YAttributionMarksExtension, type GetAttributionMarkClassName, } from "./YAttributionMarks.js"; @@ -22,6 +25,7 @@ const ATTRIBUTION_MARK_TYPES = { "y-attributed-insert": "insert", "y-attributed-delete": "delete", "y-attributed-format": "format", + "y-attributed-attrs": "attrs", } as const; const ATTRIBUTION_LOAD_PLUGIN_KEY = new PluginKey("attributionLoadUsers"); @@ -101,23 +105,24 @@ export const getReferenceClientRects = (wrapper: Element): DOMRectList => * none). The extension computes it; a React controller renders + positions it * (see `AttributionTooltipController`). */ -export type AttributionTooltipState = { +export type AttributionChange = + | { + modificationType: "insert" | "delete"; + format?: never; + attributes?: never; + } + | { modificationType: "format"; format?: string[]; attributes?: never } + | { modificationType: "attrs"; attributes: string[]; format?: never }; + +export type AttributionTooltipState = AttributionChange & { /** The wrapper element the tooltip anchors to (floating-ui reference). */ anchor: HTMLElement; /** Per-user background color, resolved from the user store (default path). */ color: string; - /** The kind of change — `format` is the modification mark. */ - modificationType: "insert" | "delete" | "format"; /** Whether the mark wraps inline content or a whole block. */ contentType: "inline-content" | "block"; /** Resolved usernames (falls back to raw ids), for custom renderers. */ users: string[]; - /** - * The changed format keys (e.g. `["bold", "italic"]`), present only for - * `format` marks. This is the raw change context — the view layer turns it - * into a localized label via its `formatChangeLabel`. - */ - format?: string[]; /** * Class name from the `getAttributionMarkClassName` callback (override path). * When present, the tooltip applies this and skips the inline `color`. @@ -154,9 +159,6 @@ export const AttributionExtension = createExtension( // over existing text and `tr.changedRange()` would miss. const loadChangedUsers = (tr: Transaction) => { const ranges = getChangedRanges(tr); - if (ranges.length === 0) { - return; - } // Most changes are local (often several steps in one small span), so scan a // single range spanning all of them rather than each range individually. let from = Infinity; @@ -167,19 +169,31 @@ export const AttributionExtension = createExtension( } const ids = new Set(); - tr.doc.nodesBetween(from, to, (node) => { - for (const mark of node.marks) { - if ( - ATTRIBUTION_MARK_TYPES[ - mark.type.name as keyof typeof ATTRIBUTION_MARK_TYPES - ] - ) { - const userIds = mark.attrs["userIds"] as string[] | null; - userIds?.forEach((id) => ids.add(id)); - } + // AddNodeMarkStep has an empty position map, so getChangedRanges cannot + // locate its node. Load its authors directly from the added mark. + for (const step of tr.steps) { + if ( + step instanceof AddNodeMarkStep && + step.mark.type.name in ATTRIBUTION_MARK_TYPES + ) { + getAttributionUserIds(step.mark).forEach((id) => ids.add(id)); } - return true; - }); + } + if (ranges.length > 0) { + tr.doc.nodesBetween(from, to, (node) => { + for (const mark of node.marks) { + if ( + ATTRIBUTION_MARK_TYPES[ + mark.type.name as keyof typeof ATTRIBUTION_MARK_TYPES + ] + ) { + const userIds = getAttributionUserIds(mark); + userIds?.forEach((id) => ids.add(id)); + } + } + return true; + }); + } if (ids.size > 0) { void userStore.loadUsers(Array.from(ids)); } @@ -213,9 +227,10 @@ export const AttributionExtension = createExtension( const syncRootVars = () => { for (const [id, user] of userStore.store.state) { const { light, dark } = userColorVarNames(id); - if (user.color && user.colorLight) { - dom.style.setProperty(light, user.colorLight); - dom.style.setProperty(dark, user.color); + const colors = userMarkColors(user); + if (colors) { + dom.style.setProperty(light, colors.light); + dom.style.setProperty(dark, colors.dark); } else { dom.style.removeProperty(light); dom.style.removeProperty(dark); @@ -242,22 +257,31 @@ export const AttributionExtension = createExtension( // and stays free of i18n/username resolution. const attributionIdentity = (wrapper: HTMLElement) => { const ids = parseUserIds(wrapper.dataset["userIds"]); - if (ids.length === 0) { + if (ids.length === 0 && wrapper.dataset["attributes"] === undefined) { return ""; } const format = parseFormatKeys(wrapper.dataset["format"]); - return `${format.join(",")}:${ids.join(",")}`; + return `${wrapper.dataset["attributes"] ?? ""}:${format.join(",")}:${ids.join(",")}`; }; // Build the tooltip state from a wrapper's `data-*` attributes. const buildState = (anchor: HTMLElement): AttributionTooltipState => { - const isModification = anchor.dataset["format"] !== undefined; - const modificationType: AttributionTooltipState["modificationType"] = - isModification - ? "format" - : anchor.tagName === "INS" - ? "insert" - : "delete"; + const change: AttributionChange = + anchor.dataset["attributes"] !== undefined + ? { + modificationType: "attrs", + attributes: parseFormatKeys(anchor.dataset["attributes"]), + } + : anchor.dataset["format"] !== undefined + ? { + modificationType: "format", + format: parseFormatKeys(anchor.dataset["format"]), + } + : { + modificationType: + anchor.tagName === "INS" ? "insert" : "delete", + }; + const { modificationType } = change; const contentType: AttributionTooltipState["contentType"] = anchor.dataset["inline"] === "false" ? "block" : "inline-content"; @@ -269,12 +293,9 @@ export const AttributionExtension = createExtension( userStore, parseUserIds(anchor.dataset["userIds"]), ).dark, - modificationType, + ...change, contentType, users: usersLabelArray(anchor.dataset["userIds"]), - format: isModification - ? parseFormatKeys(anchor.dataset["format"]) - : undefined, className: resolveAttributionMarkClassName( getAttributionMarkClassName?.({ contentType, modificationType }), "tooltip", @@ -310,7 +331,10 @@ export const AttributionExtension = createExtension( const onPointerOver = (event: Event) => { const target = event.target instanceof Element ? event.target : null; - const innermost = innermostAttributed(target); + const innermost = + target && dom.contains(target) + ? innermostAttributed(target) + : undefined; if (!innermost) { // Not over an attributed mark — drop the current tooltip. hideTooltip(); diff --git a/packages/core/src/y/extensions/DiffVersioningExtension.test.ts b/packages/core/src/y/extensions/DiffVersioningExtension.test.ts index 968193b2bd..d40a100287 100644 --- a/packages/core/src/y/extensions/DiffVersioningExtension.test.ts +++ b/packages/core/src/y/extensions/DiffVersioningExtension.test.ts @@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test"; import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; import type { Block } from "../../blocks/defaultBlocks.js"; +import { colorsForUserIds } from "../../user/index.js"; import { AttributionExtension } from "./AttributionExtension.js"; import { DiffVersioningExtension } from "./DiffVersioningExtension.js"; @@ -165,6 +166,25 @@ describe("DiffVersioningExtension", () => { expect(attribution.userStore.getUser(authorId)?.username).toBe("Draft 3"); }); + it("colors the diff author with the palette's blue, tint included", async () => { + const baseline = blocksFromText("hello world"); + const target = blocksFromText("hello brave new world"); + + const diff = editor.getExtension(DiffVersioningExtension)!; + diff.renderDiff(target, baseline, "Draft 3"); + + const attribution = editor.getExtension(AttributionExtension)!; + const authorId = "version:Draft 3"; + await attribution.userStore.loadUsers([authorId]); + + // Both halves are set, so the marks and their tooltip use the tuned pair + // rather than a tint derived from the saturated colour. + expect(colorsForUserIds(attribution.userStore, [authorId])).toEqual({ + light: "#c9dcff", + dark: "#1e4fb0", + }); + }); + it("produces no attribution marks when the docs are identical", () => { const same = blocksFromText("nothing changes here"); @@ -177,7 +197,7 @@ describe("DiffVersioningExtension", () => { ); }); - it("clearDiff restores plain content with no attribution marks", () => { + it("replacing the rendered blocks drops the attribution marks", () => { const baseline = blocksFromText("first version"); const target = blocksFromText("second version"); const restore = blocksFromText("live document"); @@ -186,7 +206,7 @@ describe("DiffVersioningExtension", () => { diff.renderDiff(target, baseline); expect(attributionMarkNames(editor).size).toBeGreaterThan(0); - diff.clearDiff(restore); + editor.replaceBlocks(editor.document, restore); expect(attributionMarkNames(editor).size).toBe(0); expect(editor.prosemirrorState.doc.textContent).toBe("live document"); }); diff --git a/packages/core/src/y/extensions/DiffVersioningExtension.ts b/packages/core/src/y/extensions/DiffVersioningExtension.ts index 651a11a205..918f32a9d1 100644 --- a/packages/core/src/y/extensions/DiffVersioningExtension.ts +++ b/packages/core/src/y/extensions/DiffVersioningExtension.ts @@ -1,4 +1,4 @@ -import { docToDelta } from "@y/prosemirror"; +import { docToDelta, fragmentToTr } from "@y/prosemirror"; import * as Y from "@y/y"; import type { Block } from "../../blocks/defaultBlocks.js"; @@ -9,10 +9,10 @@ import { _blocksToProsemirrorNode, docDiffToDelta, findTypeInOtherYdoc, - getProseMirrorTrFromYFragment, } from "../utils.js"; import { AttributionExtension } from "./AttributionExtension.js"; import type { GetAttributionMarkClassName } from "./YAttributionMarks.js"; +import { mapAttributionToMark } from "./YSync.js"; /** * A version diff has a single "author" — the version that introduced the changes @@ -29,8 +29,9 @@ const diffAuthorId = (label: string) => DIFF_AUTHOR_ID_PREFIX + label; /** Fallback label used when a diff is rendered without a version name. */ const DEFAULT_DIFF_LABEL = "This version"; -/** Color used for the version diff marks. */ -const DIFF_AUTHOR_COLOR = "#4363d8"; +/** Colors used for the version diff marks — the palette's blue. */ +const DIFF_AUTHOR_COLOR = "#1e4fb0"; +const DIFF_AUTHOR_COLOR_LIGHT = "#c9dcff"; export type DiffVersioningExtensionOptions = { /** @@ -46,13 +47,13 @@ export type DiffVersioningExtensionOptions = { /** * Records the author of each transaction on `doc` into a mutable - * {@link Y.Attributions}, so the resulting attribution marks carry a non-empty + * {@link Y.ContentMap}, so the resulting attribution marks carry a non-empty * `userIds` (and therefore resolve to a color/name). The listener must be * attached *before* the attributed transaction runs. Mirrors the store used by * the suggestion gallery example (`createAttributionStore`). */ -function attributeTransactionsTo(doc: Y.Doc, userId: string): Y.Attributions { - const attrs = new Y.Attributions(); +function attributeTransactionsTo(doc: Y.Doc, userId: string): Y.ContentMap { + const attrs = Y.createContentMap(); doc.on("beforeObserverCalls", (tr) => { if (!tr.insertSet.isEmpty()) { Y.insertIntoIdMap( @@ -83,7 +84,7 @@ function attributeTransactionsTo(doc: Y.Doc, userId: string): Y.Attributions { * * It composes {@link AttributionExtension} (which registers the attribution * marks and drives their colors + hover tooltips from a user store), and adds - * the {@link renderDiff} / {@link clearDiff} capability. + * the {@link renderDiff} capability. * * Registering this extension is what makes non-collaborative versioning * (`inMemoryVersioning`) capable of showing diffs: the in-memory preview @@ -112,6 +113,10 @@ export const DiffVersioningExtension = createExtension( editor: BlockNoteEditor; }) => { const color = options?.color ?? DIFF_AUTHOR_COLOR; + // Only the default pairs with a hand-tuned light tint; a caller-supplied + // colour gets the derived one (see `userMarkColors`). + const colorLight = + options?.color === undefined ? DIFF_AUTHOR_COLOR_LIGHT : undefined; // Resolve a synthetic author id back to its version label. The id encodes // the label (`version:

- {children} -
- ); -}); - -export const Snapshot = forwardRef< - HTMLDivElement, - ComponentProps["Versioning"]["Snapshot"] ->((props, ref) => { - const { - className, - selected, - comparing, - onClick, - actions, - children, - ...rest - } = props; - - assertEmpty(rest, false); - - return ( -
- {children} - {actions && ( - // Isolate the actions area so clicks on the menu (trigger and items, - // which render inline rather than in a portal) don't bubble to the - // row's select handler. -
event.stopPropagation()} - > - {actions} -
- )} -
- ); -}); +export { + VersioningSidebarRoot as Sidebar, + VersioningSnapshotRow as Snapshot, +} from "@blocknote/react/versioning"; diff --git a/packages/react/package.json b/packages/react/package.json index 14391fa640..ea9e4ebf02 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -42,6 +42,11 @@ "import": "./dist/blocknote-react.js", "require": "./dist/blocknote-react.cjs" }, + "./versioning": { + "types": "./types/src/versioning.d.ts", + "import": "./dist/versioning.js", + "require": "./dist/versioning.cjs" + }, "./style.css": { "import": "./dist/style.css", "require": "./dist/style.css", diff --git a/packages/react/src/components/AttributionTooltip/AttributionTooltip.tsx b/packages/react/src/components/AttributionTooltip/AttributionTooltip.tsx index 874d62f82a..0bc0fec316 100644 --- a/packages/react/src/components/AttributionTooltip/AttributionTooltip.tsx +++ b/packages/react/src/components/AttributionTooltip/AttributionTooltip.tsx @@ -36,18 +36,25 @@ export const AttributionTooltip = (props: AttributionTooltipProps) => { return changes.deleted_by(users); } + if (props.modificationType === "attrs") { + return users + ? `${changes.formatting_change}: ${users}` + : changes.formatting_change; + } + const formatLabel = props.format ? formatChangeLabel({ format: props.format, dictionary }) : ""; + // When the label falls back to the generic string (unknown/empty formats), + // rendering it inside `formatting_change_by` would duplicate it as + // "Formatting change (Formatting Change) by: ...", so list it once instead. + if (!formatLabel || formatLabel === changes.formatting_change) { + return users + ? `${changes.formatting_change}: ${users}` + : changes.formatting_change; + } return changes.formatting_change_by(formatLabel, users); - }, [ - dictionary, - props.formatChangeLabel, - props.users, - props.modificationType, - props.format, - ]); - + }, [dictionary, props]); return ( state ? { - color: state.color, - className: state.className, - modificationType: state.modificationType, - contentType: state.contentType, - users: state.users, - format: state.format, + ...state, formatChangeLabel: props.formatChangeLabel, } : undefined, diff --git a/packages/react/src/components/AttributionTooltip/AttributionTooltipProps.ts b/packages/react/src/components/AttributionTooltip/AttributionTooltipProps.ts index 9cc3e2e3d9..4c3a323850 100644 --- a/packages/react/src/components/AttributionTooltip/AttributionTooltipProps.ts +++ b/packages/react/src/components/AttributionTooltip/AttributionTooltipProps.ts @@ -1,3 +1,4 @@ +import type { AttributionChange } from "@blocknote/core/y"; import { FormatChangeLabel } from "./formatChangeLabel.js"; /** @@ -9,22 +10,15 @@ import { FormatChangeLabel } from "./formatChangeLabel.js"; * from it, so a custom tooltip can categorize or phrase changes differently * rather than parsing a pre-built string. */ -export type AttributionTooltipProps = { +export type AttributionTooltipProps = AttributionChange & { /** Per-user author color; ignored by the default component when `className` is set. */ color: string; /** App-supplied class from `getAttributionMarkClassName`, when configured. */ className?: string; - /** The kind of change — `format` is the modification mark. */ - modificationType: "insert" | "delete" | "format"; /** Whether the mark wraps inline content or a whole block. */ contentType: "inline-content" | "block"; /** Resolved usernames (falls back to raw ids). */ users: string[]; - /** - * The changed format keys (e.g. `["bold", "italic"]`), present only for - * `format` marks — the raw change context, for custom categorization. - */ - format?: string[]; /** * Turns a modification mark's changed formats into its label (e.g. * `"Bold, Italic"`). Defaults to {@link defaultFormatChangeLabel} when the diff --git a/packages/react/src/components/AttributionTooltip/formatChangeLabel.ts b/packages/react/src/components/AttributionTooltip/formatChangeLabel.ts index 9c4e3d1c07..c52474ed4d 100644 --- a/packages/react/src/components/AttributionTooltip/formatChangeLabel.ts +++ b/packages/react/src/components/AttributionTooltip/formatChangeLabel.ts @@ -41,6 +41,14 @@ export const defaultFormatChangeLabel: FormatChangeLabel = ({ const toolbar = dictionary.formatting_toolbar as Record; const names: string[] = []; for (const key of format) { + // A link attribute may have been changed or removed, so the toolbar's + // "Create link" action is not a description of this change. Fall back to + // the generic localized string — `AttributionTooltip` lists it once + // instead of rendering "Formatting change (Formatting Change) by: ...". + // (No hardcoded "Link" here so all locales stay translated.) + if (key === "link") { + return fallback; + } const entry = toolbar[key]; const tooltip = entry && typeof entry === "object" && "tooltip" in entry diff --git a/packages/react/src/components/FormattingToolbar/DefaultButtons/CreateLinkButton.tsx b/packages/react/src/components/FormattingToolbar/DefaultButtons/CreateLinkButton.tsx index a03eab06ce..6a18824b99 100644 --- a/packages/react/src/components/FormattingToolbar/DefaultButtons/CreateLinkButton.tsx +++ b/packages/react/src/components/FormattingToolbar/DefaultButtons/CreateLinkButton.tsx @@ -94,6 +94,11 @@ export const CreateLinkButton = () => { // Makes Ctrl+K/Meta+K open link creation popover. useEffect(() => { const callback = (event: KeyboardEvent) => { + // A read-only editor (e.g. while a version preview is open) has no link + // creation to offer, so leave the shortcut to the browser. + if (!editor.isEditable) { + return; + } if ((event.ctrlKey || event.metaKey) && event.key === "k") { setShowPopover(true); event.preventDefault(); @@ -105,7 +110,7 @@ export const CreateLinkButton = () => { return () => { editorDOMElement?.removeEventListener("keydown", callback); }; - }, [editorDOMElement]); + }, [editor, editorDOMElement]); if (state === undefined) { return null; diff --git a/packages/react/src/components/LinkToolbar/LinkToolbarController.tsx b/packages/react/src/components/LinkToolbar/LinkToolbarController.tsx index fbe789a544..cb2cd23498 100644 --- a/packages/react/src/components/LinkToolbar/LinkToolbarController.tsx +++ b/packages/react/src/components/LinkToolbar/LinkToolbarController.tsx @@ -5,6 +5,7 @@ import { FC, useEffect, useMemo, useState } from "react"; import { useBlockNoteEditor } from "../../hooks/useBlockNoteEditor.js"; import { useEditorDOMElement } from "../../hooks/useEditorDomElement.js"; +import { useEditorState } from "../../hooks/useEditorState.js"; import { useExtension } from "../../hooks/useExtension.js"; import { FloatingUIOptions } from "../Popovers/FloatingUIOptions.js"; import { @@ -26,6 +27,14 @@ export const LinkToolbarController = (props: { }) => { const editor = useBlockNoteEditor(); + // Reactive, not a one-off read: the editor can be locked and unlocked while + // this component stays mounted (a version preview does exactly that), and a + // read-only editor must not offer link editing. + const isEditable = useEditorState({ + editor, + selector: ({ editor: current }) => current?.isEditable ?? false, + }); + const [toolbarOpen, setToolbarOpen] = useState(false); const [toolbarPositionFrozen, setToolbarPositionFrozen] = useState(false); @@ -49,6 +58,15 @@ export const LinkToolbarController = (props: { // cursor position. If there is none, uses the link hovered by the mouse // cursor. Otherwise, the toolbar remains closed. useEffect(() => { + if (!isEditable) { + // Nothing to open a toolbar for, and any link picked up while the editor + // was still editable has to go — otherwise it would reappear the moment + // editing resumes. + setLink(undefined); + setToolbarOpen(false); + return; + } + const textCursorCallback = () => { const textCursorLink = linkToolbar.getLinkAtSelection(); if (!textCursorLink) { @@ -114,7 +132,14 @@ export const LinkToolbarController = (props: { destroyOnSelectionChangeHandler(); editorDOMElement?.removeEventListener("mouseover", mouseCursorCallback); }; - }, [editor, editorDOMElement, linkToolbar, link, toolbarPositionFrozen]); + }, [ + editor, + editorDOMElement, + isEditable, + linkToolbar, + link, + toolbarPositionFrozen, + ]); const floatingUIOptions = useMemo( () => ({ @@ -176,8 +201,7 @@ export const LinkToolbarController = (props: { [link?.element], ); - // TODO: this should be a hook to be reactive - if (!editor.isEditable) { + if (!isEditable) { return null; } diff --git a/packages/react/src/components/Versioning/CurrentSnapshot.tsx b/packages/react/src/components/Versioning/CurrentSnapshot.tsx deleted file mode 100644 index 139701376f..0000000000 --- a/packages/react/src/components/Versioning/CurrentSnapshot.tsx +++ /dev/null @@ -1,127 +0,0 @@ -import { - CURRENT_VERSION_ID, - VersioningExtension, - VersionSnapshot, -} from "@blocknote/core/extensions"; -import { RiArrowLeftRightLine, RiMoreFill } from "react-icons/ri"; - -import { useComponentsContext } from "../../editor/ComponentsContext.js"; -import { useExtension, useExtensionState } from "../../hooks/useExtension.js"; -import { dateToString } from "./dateToString.js"; -import { useSnapshotLabel } from "./useVersionUsers.js"; -import { useVersioningSidebar } from "./VersioningSidebarContext.js"; - -/** - * The "current version" list row. Unlike {@link Snapshot}, it isn't backed by a - * stored snapshot: clicking it previews the live document (read-only, diffed - * against the most recent snapshot) or returns to live editing. It is rendered - * only when the backend's `list()` emits an entry whose id is - * {@link CURRENT_VERSION_ID} (e.g. YHub when the live doc has edits beyond the - * latest saved version). - * - * The `snapshot` prop carries display metadata (last-edit timestamp + author) - * but is never sent to `getContent`/`getAttributions` — those go through - * `previewCurrentVersion`, which serialises the live document directly. - */ -export const CurrentSnapshot = ({ - snapshot, - previousSnapshot, -}: { - snapshot: VersionSnapshot; - previousSnapshot?: VersionSnapshot; -}) => { - const Components = useComponentsContext()!; - const { canPreviewCurrent, previewCurrentVersion, exitPreview } = - useExtension(VersioningExtension); - const selected = useExtensionState(VersioningExtension, { - selector: (state) => state.previewedSnapshotId === CURRENT_VERSION_ID, - }); - // Exclude the current-version entry itself — it lives in the list too, but - // it's not something to diff against. - const snapshots = useExtensionState(VersioningExtension, { - selector: (state) => - state.snapshots.filter((s) => s.id !== CURRENT_VERSION_ID), - }); - - const { comparisonEnabled, comparisonMode, setComparisonMode } = - useVersioningSidebar(); - - const secondaryLabel = useSnapshotLabel(snapshot); - - // Clicking the current version shows a read-only diff of the live document - // against the most recent snapshot. When comparison mode is off, or there's - // nothing to diff against, return to the live editing view instead. - const handleSelect = () => { - if (comparisonMode && previewCurrentVersion && previousSnapshot) { - void previewCurrentVersion({ compareTo: previousSnapshot.id }); - } else { - exitPreview(); - } - }; - - // "Compare since beginning" diffs the live document against the oldest - // snapshot. Shown only when current-version diffing is supported and there's - // at least one snapshot to compare against. - const oldestSnapshot = snapshots[snapshots.length - 1]; - const actions = - comparisonEnabled && - canPreviewCurrent && - previewCurrentVersion && - oldestSnapshot ? ( - - - - { - event.preventDefault(); - event.stopPropagation(); - }} - > - - - - - } - onClick={() => { - setComparisonMode(true); - void previewCurrentVersion({ compareTo: oldestSnapshot.id }); - }} - > - Compare since beginning - - - - - ) : undefined; - - return ( - -
-
Current version
- {/* The timestamp + author of the last edit are only shown when the - backend stamps them (e.g. YHub). Backends that don't track them - (e.g. in-memory) just get the "Current version" label. */} - {secondaryLabel !== undefined && ( -
- {dateToString(new Date(snapshot.createdAt))} -
- )} - {secondaryLabel !== undefined && ( -
{secondaryLabel}
- )} -
-
- ); -}; diff --git a/packages/react/src/components/Versioning/Snapshot.tsx b/packages/react/src/components/Versioning/Snapshot.tsx index 1a8e03ead3..cb6c0c23b7 100644 --- a/packages/react/src/components/Versioning/Snapshot.tsx +++ b/packages/react/src/components/Versioning/Snapshot.tsx @@ -1,225 +1,269 @@ import { - CURRENT_VERSION_ID, VersioningExtension, - VersionSnapshot, + type VersioningView, + type VersionSnapshot, } from "@blocknote/core/extensions"; -import { useState } from "react"; -import { - RiArrowGoBackFill, - RiArrowLeftRightLine, - RiDeleteBinLine, - RiMoreFill, -} from "react-icons/ri"; +import { useEffect, useRef, type KeyboardEvent } from "react"; +import { GoDiff } from "react-icons/go"; +import { RiMoreFill } from "react-icons/ri"; import { useComponentsContext } from "../../editor/ComponentsContext.js"; import { useExtension, useExtensionState } from "../../hooks/useExtension.js"; +import { useDictionary } from "../../i18n/dictionary.js"; import { dateToString } from "./dateToString.js"; +import { usePreviewRow } from "./usePreviewRow.js"; import { useSnapshotLabel } from "./useVersionUsers.js"; +import { VersionName } from "./VersionName.js"; import { useVersioningSidebar } from "./VersioningSidebarContext.js"; +import { VersionSnapshotProvider } from "./VersionSnapshotContext.js"; + +/** Whether `view` is showing this row's version. */ +function isSelectedRow( + view: VersioningView, + row: VersionSnapshot, + isCurrent: boolean, +): boolean { + switch (view.mode) { + case "live": + return false; + case "current": + return isCurrent; + case "snapshot": + return view.snapshotId === row.id; + } +} + +/** + * Focus the name field and reclaim focus restored by a closing menu. + * Stop after a second or as soon as the user moves focus themselves. + */ +function focusAndReclaim(input: HTMLInputElement) { + input.focus(); + input.select(); + + const row = input.closest('[role="listitem"]'); + if (!row) { + return; + } + let timeout: ReturnType; + const stop = () => { + clearTimeout(timeout); + row.removeEventListener("focusin", reclaim); + document.removeEventListener("pointerdown", stop, true); + document.removeEventListener("keydown", stop, true); + }; + const reclaim = (event: Event) => { + if (event.target === input) { + return; + } + input.focus(); + input.select(); + }; -export const Snapshot = ({ - snapshot, - previousSnapshot, -}: { + timeout = setTimeout(stop, 1000); + row.addEventListener("focusin", reclaim); + document.addEventListener("pointerdown", stop, true); + document.addEventListener("keydown", stop, true); +} + +/** Shared current/stored version row; `isCurrent` controls naming and labels. */ +export function Snapshot(props: { snapshot: VersionSnapshot; + /** The previous visible version in the filtered list. */ previousSnapshot?: VersionSnapshot; -}) => { + isCurrent: boolean; + /** DOM id for this row. */ + id: string; + tabIndex: number; + onKeyDown: (event: KeyboardEvent) => void; + onFocus: () => void; +}) { + const { snapshot, isCurrent } = props; const Components = useComponentsContext()!; - const { - canRestore, - restore, - canRename, - rename, - canRemove, - remove, - previewSnapshot, - previewCurrentVersion, - } = useExtension(VersioningExtension); - const selected = useExtensionState(VersioningExtension, { - selector: (state) => state.previewedSnapshotId === snapshot.id, - }); - const previewedSnapshotId = useExtensionState(VersioningExtension, { - selector: (state) => state.previewedSnapshotId, - }); - const compareToSnapshotId = useExtensionState(VersioningExtension, { - selector: (state) => state.compareToSnapshotId, + const dict = useDictionary(); + const { create, rename, getLoadingState } = useExtension(VersioningExtension); + const { snapshotMenu, run, focusNameFor, setFocusNameFor } = + useVersioningSidebar(); + const previewRow = usePreviewRow(); + + const view = useExtensionState(VersioningExtension, { + selector: (state) => state.view, }); - const revertedSnapshot = useExtensionState(VersioningExtension, { - selector: (state) => - snapshot?.restoredFromSnapshotId !== undefined - ? state.snapshots.find( - (snap) => snap.id === snapshot.restoredFromSnapshotId, - ) - : undefined, + const status = useExtensionState(VersioningExtension, { + selector: getLoadingState, }); - const { comparisonEnabled, comparisonMode, setComparisonMode } = - useVersioningSidebar(); + const nameInput = useRef(null); + const selected = isSelectedRow(view, snapshot, isCurrent); + const comparing = + view.mode !== "live" && view.compareToId === snapshot.id && !selected; const secondaryLabel = useSnapshotLabel(snapshot); + const dateString = dateToString(new Date(snapshot.createdAt)); - const dateString = dateToString(new Date(snapshot?.createdAt || 0)); - const [snapshotName, setSnapshotName] = useState( - snapshot?.name || dateString, - ); + // An unnamed version shows its date instead — a bare timestamp is how an + // automatic version identifies itself — except the current row, which is a + // place in the list rather than a moment. + const placeholder = isCurrent ? dict.versioning.current_version : dateString; + const accessibleLabel = [ + snapshot.name ?? placeholder, + isCurrent && snapshot.name !== undefined + ? dict.versioning.current_version + : undefined, + isCurrent || snapshot.name !== undefined ? dateString : undefined, + comparing ? dict.versioning.comparing_to : undefined, + secondaryLabel, + ] + .filter(Boolean) + .join(", "); + // Naming the current version goes through `create`; every other rename is a + // `rename`. Both are gated on the backend actually supporting them. + const commitsViaCreate = isCurrent && snapshot.name === undefined; + const canEditName = (commitsViaCreate ? create : rename) !== undefined; + // The name is a field on the selected row only; everywhere else it's text, + // and the first click on the row selects it rather than starting a rename. + const editable = selected && canEditName === true; + + // Rename requests focus before selecting the row and mounting its input. + useEffect(() => { + if (focusNameFor !== snapshot.id) { + return; + } + if (editable && nameInput.current) { + setFocusNameFor(undefined); + focusAndReclaim(nameInput.current); + } else if (selected) { + setFocusNameFor(undefined); + } + }, [focusNameFor, setFocusNameFor, snapshot.id, editable, selected]); + + // Only the selected row is loading: `status` carries the view being switched + // to, which is exactly the row the user clicked. Announced on the row only; + // what the eye gets is the editor, which the extension marks for every load + // (see LOADING_PREVIEW_CLASS). + const loading = + status.type === "loading-preview" && + isSelectedRow(status.view, snapshot, isCurrent); - if (snapshot === undefined) { - return null; + function handleSelect() { + void run(() => previewRow(snapshot)); } - // The "Comparing to" badge tracks the actual diff baseline (the store's - // `compareToSnapshotId`), so it always shows which version is being compared - // against. It's hidden on the row currently being viewed (a version is never - // diffed against itself). - const isBaseline = compareToSnapshotId === snapshot.id && !selected; - - // Clicking a version previews it. In comparison mode it's diffed against its - // chronological predecessor — i.e. the baseline always resets to the previous - // version. Otherwise the version is shown on its own with no diff. - const handleSelect = () => { - if (!comparisonMode) { - void previewSnapshot(snapshot.id); + /** Select the row to mount its name field, then focus it. */ + function startRename() { + if (editable && nameInput.current) { + focusAndReclaim(nameInput.current); return; } - void previewSnapshot(snapshot.id, { compareTo: previousSnapshot?.id }); - }; + setFocusNameFor(snapshot.id); + handleSelect(); + } - // "Compare with this version" moves the diff baseline to this version, - // keeping whatever is currently being viewed (the live document when nothing - // — or this same version — was being viewed). - const handleCompareWith = () => { - setComparisonMode(true); - - const viewingOtherSnapshot = - previewedSnapshotId !== undefined && - previewedSnapshotId !== CURRENT_VERSION_ID && - previewedSnapshotId !== snapshot.id; - if (viewingOtherSnapshot) { - void previewSnapshot(previewedSnapshotId, { compareTo: snapshot.id }); - } else if (previewCurrentVersion) { - void previewCurrentVersion({ compareTo: snapshot.id }); + function commitName(name: string | undefined) { + if (commitsViaCreate && create) { + void run(() => create({ name })); + } else if (!commitsViaCreate && rename) { + void run(() => rename(snapshot.id, name)); } - }; + } - // The menu only appears when at least one of its items is available: - // "Compare with this version" (comparison), "Restore", or "Delete". When none - // apply, there's nothing to show, so drop the menu entirely. const actions = - comparisonEnabled || canRestore || canRemove ? ( + snapshotMenu != null && snapshotMenu !== false ? ( { - event.preventDefault(); + // Not `preventDefault`: Ariakit's disclosure bails on a + // default-prevented click, so the menu would never open. + // Stopping propagation is all the row needs — the click must + // not also select the row behind the trigger. event.stopPropagation(); }} > - - {comparisonEnabled && ( - } - onClick={handleCompareWith} - > - Compare with this version - - )} - {canRestore && ( - } - onClick={() => { - void restore?.(snapshot.id); - }} - > - Restore - - )} - {canRemove && ( - } - onClick={() => { - void remove?.(snapshot.id); - }} - > - Delete - - )} - + {snapshotMenu} - ) : undefined; + ) : null; return ( - - {isBaseline && ( -
- - Comparing to -
- )} -
- {canRename ? ( - setSnapshotName(e.target.value)} - onMouseDown={(e) => { - // When this version isn't selected, keep the input from grabbing - // focus so the click falls through to the row and only selects - // the version — a second click (now selected) starts editing. - if (!selected) { - e.preventDefault(); - } - }} - onClick={(e) => { - // Only swallow the click once editable; otherwise let it bubble - // to the row's handler so this version gets selected. - if (selected) { - e.stopPropagation(); - } - }} - onBlur={() => - rename?.( - snapshot.id, - snapshotName === dateString ? undefined : snapshotName, - ) - } - /> - ) : ( -
{snapshotName}
- )} - {snapshot.name && snapshot.name !== dateString && ( -
{dateString}
- )} - {revertedSnapshot && ( -
{`Restored from ${dateToString(new Date(revertedSnapshot.createdAt))}`}
- )} - {secondaryLabel !== undefined && ( -
{secondaryLabel}
+ + {comparing && ( +
+ + {dict.versioning.comparing_to} +
)} -
-
+
+
+ +
+ {/* What the row is, once a name has taken that slot: the date for a + stored version, and "Current version" for the current row — the + row that would otherwise read as just another named version. */} + {isCurrent && snapshot.name !== undefined ? ( +
+ {dict.versioning.current_version} +
+ ) : isCurrent || snapshot.name !== undefined ? ( +
{dateString}
+ ) : null} + {snapshot.restoredFrom !== undefined && ( +
+ {dict.versioning.restored_from( + dateToString(new Date(snapshot.restoredFrom.createdAt)), + )} +
+ )} + {secondaryLabel !== undefined && ( +
{secondaryLabel}
+ )} +
+ + ); -}; +} diff --git a/packages/react/src/components/Versioning/VersionMenu/DefaultItems/CompareSinceBeginningItem.tsx b/packages/react/src/components/Versioning/VersionMenu/DefaultItems/CompareSinceBeginningItem.tsx new file mode 100644 index 0000000000..3974a6be67 --- /dev/null +++ b/packages/react/src/components/Versioning/VersionMenu/DefaultItems/CompareSinceBeginningItem.tsx @@ -0,0 +1,64 @@ +import { VersioningExtension } from "@blocknote/core/extensions"; +import { GoHistory } from "react-icons/go"; + +import { useDictionary } from "../../../../i18n/dictionary.js"; +import { + useExtension, + useExtensionState, +} from "../../../../hooks/useExtension.js"; +import { usePreviewRow } from "../../usePreviewRow.js"; +import { useVersioningSidebar } from "../../VersioningSidebarContext.js"; +import { useVersionSnapshot } from "../../VersionSnapshotContext.js"; +import type { + DefaultVersionMenuItemProps, + VersionMenuAction, +} from "../VersionMenuItem.js"; +import { DefaultVersionMenuItem } from "../DefaultVersionMenuItem.js"; + +/** Compare current against the oldest stored version, when comparisons are supported. */ +export function useCompareSinceBeginningAction(): VersionMenuAction { + const { canCompare } = useExtension(VersioningExtension); + const { setComparisonMode, run } = useVersioningSidebar(); + const previewRow = usePreviewRow(); + const { isCurrent } = useVersionSnapshot(); + + const list = useExtensionState(VersioningExtension, { + selector: (state) => state.list, + }); + const oldest = list.loaded + ? list.snapshots[list.snapshots.length - 1] + : undefined; + + if (!isCurrent || !canCompare || !list.loaded || !oldest) { + return { available: false }; + } + + return { + available: true, + execute: () => { + setComparisonMode(true); + return run(() => + previewRow(list.current, { + compareTo: { type: "snapshot", id: oldest.id }, + }), + ); + }, + }; +} + +/** The default item; customize its behavior with {@link useCompareSinceBeginningAction}. */ +export function CompareSinceBeginningItem( + props: DefaultVersionMenuItemProps = {}, +) { + const dict = useDictionary(); + const action = useCompareSinceBeginningAction(); + + return ( + } + defaultLabel={dict.versioning.compare_since_beginning_menuitem} + /> + ); +} diff --git a/packages/react/src/components/Versioning/VersionMenu/DefaultItems/CompareWithVersionItem.tsx b/packages/react/src/components/Versioning/VersionMenu/DefaultItems/CompareWithVersionItem.tsx new file mode 100644 index 0000000000..e9f13f8058 --- /dev/null +++ b/packages/react/src/components/Versioning/VersionMenu/DefaultItems/CompareWithVersionItem.tsx @@ -0,0 +1,67 @@ +import { VersioningExtension } from "@blocknote/core/extensions"; +import { GoDiff } from "react-icons/go"; + +import { useDictionary } from "../../../../i18n/dictionary.js"; +import { useExtension } from "../../../../hooks/useExtension.js"; +import { usePreviewRow } from "../../usePreviewRow.js"; +import { useVersioningSidebar } from "../../VersioningSidebarContext.js"; +import { useVersionSnapshot } from "../../VersionSnapshotContext.js"; +import type { + DefaultVersionMenuItemProps, + VersionMenuAction, +} from "../VersionMenuItem.js"; +import { DefaultVersionMenuItem } from "../DefaultVersionMenuItem.js"; + +/** + * Use this stored row as the baseline, keeping the shown version. + * Falls back to current when nothing or this same row was shown. + */ +export function useCompareWithVersionAction(): VersionMenuAction { + const versioning = useExtension(VersioningExtension); + const { store, canCompare } = versioning; + const { setComparisonMode, run } = useVersioningSidebar(); + const previewRow = usePreviewRow(); + const { snapshot, isCurrent } = useVersionSnapshot(); + + if (isCurrent || !canCompare) { + return { available: false }; + } + + return { + available: true, + execute: () => { + setComparisonMode(true); + + const { view, list } = store.state; + if (!list.loaded) { + return; + } + const shown = + view.mode === "snapshot" && view.snapshotId !== snapshot.id + ? versioning.getSnapshot(view.snapshotId) + : undefined; + return run(() => + previewRow(shown ?? list.current, { + compareTo: { type: "snapshot", id: snapshot.id }, + }), + ); + }, + }; +} + +/** The default item; customize its behavior with {@link useCompareWithVersionAction}. */ +export function CompareWithVersionItem( + props: DefaultVersionMenuItemProps = {}, +) { + const dict = useDictionary(); + const action = useCompareWithVersionAction(); + + return ( + } + defaultLabel={dict.versioning.compare_with_menuitem} + /> + ); +} diff --git a/packages/react/src/components/Versioning/VersionMenu/DefaultItems/DeleteVersionItem.tsx b/packages/react/src/components/Versioning/VersionMenu/DefaultItems/DeleteVersionItem.tsx new file mode 100644 index 0000000000..d72e1ac0fa --- /dev/null +++ b/packages/react/src/components/Versioning/VersionMenu/DefaultItems/DeleteVersionItem.tsx @@ -0,0 +1,74 @@ +import { VersioningExtension } from "@blocknote/core/extensions"; +import { RiDeleteBinLine } from "react-icons/ri"; + +import { useDictionary } from "../../../../i18n/dictionary.js"; +import { useExtension } from "../../../../hooks/useExtension.js"; +import { usePreviewRow } from "../../usePreviewRow.js"; +import { useVersioningSidebar } from "../../VersioningSidebarContext.js"; +import { useVersionSnapshot } from "../../VersionSnapshotContext.js"; +import type { + DefaultVersionMenuItemProps, + VersionMenuAction, +} from "../VersionMenuItem.js"; +import { DefaultVersionMenuItem } from "../DefaultVersionMenuItem.js"; + +/** + * Delete a named stored version (only its name on continuous-history backends). + * Return to current if the shown version or baseline is removed or filtered out. + */ +export function useDeleteVersionAction(): VersionMenuAction { + const { remove, store } = useExtension(VersioningExtension); + const { run, namedOnly } = useVersioningSidebar(); + const previewRow = usePreviewRow(); + const { snapshot, isCurrent } = useVersionSnapshot(); + + if (isCurrent || !remove || snapshot.name === undefined) { + return { available: false }; + } + + return { + available: true, + execute: () => { + return run( + () => remove(snapshot.id), + async () => { + const { list, view } = store.state; + if (!list.loaded) { + return; + } + const hidden = + namedOnly && + list.snapshots.some( + (row) => row.id === snapshot.id && row.name === undefined, + ); + const deleted = !list.snapshots.some((row) => row.id === snapshot.id); + const usesDeletedVersion = + view.mode !== "live" && + (view.compareToId === snapshot.id || + (view.mode === "snapshot" && view.snapshotId === snapshot.id)); + if ( + view.mode === "live" || + ((hidden || deleted) && usesDeletedVersion) + ) { + await previewRow(list.current); + } + }, + ); + }, + }; +} + +/** The default item; customize its behavior with {@link useDeleteVersionAction}. */ +export function DeleteVersionItem(props: DefaultVersionMenuItemProps = {}) { + const dict = useDictionary(); + const action = useDeleteVersionAction(); + + return ( + } + defaultLabel={dict.versioning.delete_menuitem} + /> + ); +} diff --git a/packages/react/src/components/Versioning/VersionMenu/DefaultItems/NameVersionItem.tsx b/packages/react/src/components/Versioning/VersionMenu/DefaultItems/NameVersionItem.tsx new file mode 100644 index 0000000000..aea2bfbad6 --- /dev/null +++ b/packages/react/src/components/Versioning/VersionMenu/DefaultItems/NameVersionItem.tsx @@ -0,0 +1,46 @@ +import { VersioningExtension } from "@blocknote/core/extensions"; +import { RiPriceTag3Line } from "react-icons/ri"; + +import { useDictionary } from "../../../../i18n/dictionary.js"; +import { useExtension } from "../../../../hooks/useExtension.js"; +import { useVersionSnapshot } from "../../VersionSnapshotContext.js"; +import type { + DefaultVersionMenuItemProps, + VersionMenuAction, +} from "../VersionMenuItem.js"; +import { DefaultVersionMenuItem } from "../DefaultVersionMenuItem.js"; + +/** Start inline naming: create an unnamed current version, otherwise rename. */ +export function useNameVersionAction(): VersionMenuAction { + const { create, rename } = useExtension(VersioningExtension); + const { snapshot, isCurrent, startRename } = useVersionSnapshot(); + + const named = snapshot.name !== undefined; + const available = isCurrent && !named ? create : rename; + if (!available) { + return { available: false }; + } + + return { available: true, execute: startRename }; +} + +/** The default item; customize its behavior with {@link useNameVersionAction}. */ +export function NameVersionItem(props: DefaultVersionMenuItemProps = {}) { + const dict = useDictionary(); + const action = useNameVersionAction(); + const { snapshot } = useVersionSnapshot(); + const named = snapshot.name !== undefined; + + return ( + } + defaultLabel={ + named + ? dict.versioning.rename_menuitem + : dict.versioning.name_version_menuitem + } + /> + ); +} diff --git a/packages/react/src/components/Versioning/VersionMenu/DefaultItems/RestoreVersionItem.tsx b/packages/react/src/components/Versioning/VersionMenu/DefaultItems/RestoreVersionItem.tsx new file mode 100644 index 0000000000..e676322083 --- /dev/null +++ b/packages/react/src/components/Versioning/VersionMenu/DefaultItems/RestoreVersionItem.tsx @@ -0,0 +1,59 @@ +import { VersioningExtension } from "@blocknote/core/extensions"; +import { RiArrowGoBackFill } from "react-icons/ri"; + +import { useDictionary } from "../../../../i18n/dictionary.js"; +import { useExtension } from "../../../../hooks/useExtension.js"; +import { usePreviewRow } from "../../usePreviewRow.js"; +import { useVersioningSidebar } from "../../VersioningSidebarContext.js"; +import { useVersionSnapshot } from "../../VersionSnapshotContext.js"; +import type { + DefaultVersionMenuItemProps, + VersionMenuAction, +} from "../VersionMenuItem.js"; +import { DefaultVersionMenuItem } from "../DefaultVersionMenuItem.js"; + +/** + * Restore this stored version and reselect current. + * Call inside a snapshot row; check `available` before `execute`. Custom items + * can request confirmation before executing the same restore/preview flow. + */ +export function useRestoreVersionAction(): VersionMenuAction { + const { restore, store } = useExtension(VersioningExtension); + const { run } = useVersioningSidebar(); + const previewRow = usePreviewRow(); + const { snapshot, isCurrent } = useVersionSnapshot(); + + if (isCurrent || !restore) { + return { available: false }; + } + + return { + available: true, + execute: () => { + return run( + () => restore(snapshot.id), + async () => { + const { list } = store.state; + if (list.loaded) { + await previewRow(list.current); + } + }, + ); + }, + }; +} + +/** The default item; customize its behavior with {@link useRestoreVersionAction}. */ +export function RestoreVersionItem(props: DefaultVersionMenuItemProps = {}) { + const dict = useDictionary(); + const action = useRestoreVersionAction(); + + return ( + } + defaultLabel={dict.versioning.restore_menuitem} + /> + ); +} diff --git a/packages/react/src/components/Versioning/VersionMenu/DefaultVersionMenuItem.tsx b/packages/react/src/components/Versioning/VersionMenu/DefaultVersionMenuItem.tsx new file mode 100644 index 0000000000..7c18e16bf7 --- /dev/null +++ b/packages/react/src/components/Versioning/VersionMenu/DefaultVersionMenuItem.tsx @@ -0,0 +1,33 @@ +import type { ReactNode } from "react"; +import { + VersionMenuItem, + type DefaultVersionMenuItemProps, + type VersionMenuAction, +} from "./VersionMenuItem.js"; + +/** Shared presentation for the default actions; null overrides hide icon/label. */ +export function DefaultVersionMenuItem({ + action, + defaultIcon, + defaultLabel, + icon = defaultIcon, + children = defaultLabel, + ...props +}: DefaultVersionMenuItemProps & { + action: VersionMenuAction; + defaultIcon: ReactNode; + defaultLabel: ReactNode; +}) { + if (!action.available) { + return null; + } + return ( + void action.execute()} + > + {children} + + ); +} diff --git a/packages/react/src/components/Versioning/VersionMenu/VersionMenu.tsx b/packages/react/src/components/Versioning/VersionMenu/VersionMenu.tsx new file mode 100644 index 0000000000..6658d37049 --- /dev/null +++ b/packages/react/src/components/Versioning/VersionMenu/VersionMenu.tsx @@ -0,0 +1,61 @@ +import { ReactNode } from "react"; + +import { useComponentsContext } from "../../../editor/ComponentsContext.js"; +import { CompareSinceBeginningItem } from "./DefaultItems/CompareSinceBeginningItem.js"; +import { CompareWithVersionItem } from "./DefaultItems/CompareWithVersionItem.js"; +import { DeleteVersionItem } from "./DefaultItems/DeleteVersionItem.js"; +import { NameVersionItem } from "./DefaultItems/NameVersionItem.js"; +import { RestoreVersionItem } from "./DefaultItems/RestoreVersionItem.js"; + +/** + * The "..." menu of a version row in the history sidebar. + * + * By default it renders the default items. Include `DefaultVersionMenuItems` + * among your children to keep all defaults and append or prepend custom items. + * Pass children to override the defaults — + * the children you pass should be: + * + * - Default items: components found within the `/DefaultItems` directory. + * - Custom items: the `VersionMenuItem` component. + * + * Either kind can read the row it's in via `useVersionSnapshot()`, so a custom + * item ("Make a copy", "Download", …) needs no props: + * + * @example + * ```tsx + * + * + * + * + * } + * /> + * ``` + */ +export function VersionMenu(props: { children?: ReactNode }) { + const Components = useComponentsContext()!; + + return ( + + {props.children === undefined ? ( + + ) : ( + props.children + )} + + ); +} + +/** The default actions as a fragment, for composing defaults plus custom items. */ +export function DefaultVersionMenuItems() { + return ( + <> + + + + + + + ); +} diff --git a/packages/react/src/components/Versioning/VersionMenu/VersionMenuItem.tsx b/packages/react/src/components/Versioning/VersionMenu/VersionMenuItem.tsx new file mode 100644 index 0000000000..350c5c6106 --- /dev/null +++ b/packages/react/src/components/Versioning/VersionMenu/VersionMenuItem.tsx @@ -0,0 +1,34 @@ +import { mergeCSSClasses } from "@blocknote/core"; + +import { + type ComponentProps, + useComponentsContext, +} from "../../../editor/ComponentsContext.js"; + +/** Availability and behavior of a version action, independent of its UI. */ +export type VersionMenuAction = + | { available: false } + | { available: true; execute: () => void | Promise }; + +export type VersionMenuItemProps = Omit< + ComponentProps["Generic"]["Menu"]["Item"], + "subTrigger" +>; + +/** Presentation overrides; use the corresponding action hook to customize behavior. */ +export type DefaultVersionMenuItemProps = Omit; + +/** + * A single item in a version row's "..." menu. Use it for application-specific + * actions; the row it belongs to is available via `useVersionSnapshot()`. + */ +export function VersionMenuItem(props: VersionMenuItemProps) { + const Components = useComponentsContext()!; + + return ( + + ); +} diff --git a/packages/react/src/components/Versioning/VersionName.tsx b/packages/react/src/components/Versioning/VersionName.tsx new file mode 100644 index 0000000000..65814a9850 --- /dev/null +++ b/packages/react/src/components/Versioning/VersionName.tsx @@ -0,0 +1,91 @@ +import { useRef, useState, type RefObject } from "react"; + +import { useDictionary } from "../../i18n/dictionary.js"; + +/** + * Show text until the row is selected, then an input sized to its draft. + * Keying by name resets the draft after local or remote renames. + */ +export function VersionName(props: { + name: string | undefined; + /** Shown when the version has no name: its date, or "Current version". */ + placeholder: string; + /** + * Whether the name is a field right now — the row is selected and the + * backend can (re)name it. Otherwise it's rendered as text. + */ + editable: boolean; + inputRef: RefObject; + /** `undefined` when the field was left empty, which clears the name. */ + onCommit: (name: string | undefined) => void; +}) { + if (!props.editable) { + return ( + + {props.name ?? props.placeholder} + + ); + } + return ; +} + +function VersionNameInput(props: { + name: string | undefined; + placeholder: string; + inputRef: RefObject; + onCommit: (name: string | undefined) => void; +}) { + const dict = useDictionary(); + // Mirrored into the sizer, so the field is as wide as what's typed in it. + const [draft, setDraft] = useState(props.name ?? ""); + // Set by Escape, read by the blur it causes: leaving the field commits, so + // the blur has to know the edit was abandoned rather than finished. + const cancelled = useRef(false); + + return ( + + setDraft(event.currentTarget.value)} + // The row this sits in is already selected — clicking its name is a + // rename, not a request to show it again. + onClick={(event) => event.stopPropagation()} + onKeyDown={(event) => { + // An un-stopped key would reach the row's list-navigation handler. + event.stopPropagation(); + if (event.key === "Enter" || event.key === "Escape") { + if (event.key === "Escape") { + cancelled.current = true; + } + // Focusing the row (not blurring to the body) commits or cancels — + // the blur handler runs on the focus change — and keeps keyboard + // navigation in the list afterwards. + event.currentTarget + .closest('[role="listitem"]') + ?.focus(); + } + }} + onBlur={(event) => { + const name = cancelled.current + ? (props.name ?? "") + : event.currentTarget.value.trim(); + cancelled.current = false; + // Show the stored name until the backend confirms the change. + // Naming Current may create a different row instead of renaming it. + setDraft(props.name ?? ""); + if (name !== (props.name ?? "")) { + props.onCommit(name === "" ? undefined : name); + } + }} + /> + + ); +} diff --git a/packages/react/src/components/Versioning/VersionSnapshotContext.tsx b/packages/react/src/components/Versioning/VersionSnapshotContext.tsx new file mode 100644 index 0000000000..9889f26053 --- /dev/null +++ b/packages/react/src/components/Versioning/VersionSnapshotContext.tsx @@ -0,0 +1,60 @@ +import type { VersionSnapshot } from "@blocknote/core/extensions"; +import { createContext, useContext, type ReactNode } from "react"; + +/** + * Everything a version row's menu items need to know about the row they were + * rendered in. Read it with {@link useVersionSnapshot} — that's the seam that + * lets an application drop its own item (e.g. "Make a copy") into + * `snapshotMenu` without threading props through the sidebar. + */ +export type VersionSnapshotContextValue = { + /** The version this row shows. */ + snapshot: VersionSnapshot; + /** + * The previous visible version in the filtered list, i.e. what a + * diff of this row is taken against by default. `undefined` for the oldest + * row. + */ + previousSnapshot?: VersionSnapshot; + /** Whether this row is the current version (the live document). */ + isCurrent: boolean; + /** Whether this row is the version the editor is showing. */ + selected: boolean; + /** Whether this row is the baseline the rendered diff is compared against. */ + comparing: boolean; + /** + * Focus this row's name field. Naming an unnamed current version and renaming + * a stored one both go through here; the row picks the right verb on commit. + */ + startRename: () => void; +}; + +const VersionSnapshotContext = createContext< + VersionSnapshotContextValue | undefined +>(undefined); + +export function VersionSnapshotProvider(props: { + value: VersionSnapshotContextValue; + children: ReactNode; +}) { + return ( + + {props.children} + + ); +} + +/** + * The version row the calling component is rendered in. Only valid inside a + * row's actions menu (or anything else the row renders). + */ +export function useVersionSnapshot(): VersionSnapshotContextValue { + const context = useContext(VersionSnapshotContext); + if (!context) { + throw new Error( + "useVersionSnapshot must be used within a version row (e.g. inside the " + + "sidebar's `snapshotMenu`)", + ); + } + return context; +} diff --git a/packages/react/src/components/Versioning/VersioningPrimitives.tsx b/packages/react/src/components/Versioning/VersioningPrimitives.tsx new file mode 100644 index 0000000000..3e2a22a4ae --- /dev/null +++ b/packages/react/src/components/Versioning/VersioningPrimitives.tsx @@ -0,0 +1,85 @@ +import { assertEmpty, mergeCSSClasses } from "@blocknote/core"; +import type { ComponentProps } from "../../editor/ComponentsContext.js"; +import { + forwardRef, + type ComponentType, + type ComponentPropsWithRef, +} from "react"; + +export const VersioningSidebarRoot = forwardRef< + HTMLDivElement, + ComponentProps["Versioning"]["Sidebar"] +>((props, ref) => { + const { className, children, "aria-label": ariaLabel, ...rest } = props; + + assertEmpty(rest, false); + + return ( +
+ {children} +
+ ); +}); + +export const VersioningSnapshotRow = forwardRef< + HTMLDivElement, + ComponentProps["Versioning"]["Snapshot"] & { + as?: "div" | ComponentType>; + } +>((props, ref) => { + const { + className, + id, + "aria-label": ariaLabel, + as: Root = "div", + selected, + comparing, + tabIndex, + "aria-busy": ariaBusy, + onClick, + onKeyDown, + onFocus, + actions, + children, + ...rest + } = props; + + assertEmpty(rest, false); + + return ( + + {children} + {actions && ( + // Keep menu interactions out of row selection and keyboard navigation. +
event.stopPropagation()} + onKeyDown={(event) => event.stopPropagation()} + > + {actions} +
+ )} +
+ ); +}); diff --git a/packages/react/src/components/Versioning/VersioningSidebar.tsx b/packages/react/src/components/Versioning/VersioningSidebar.tsx index fa55d24d5a..8d67a8e349 100644 --- a/packages/react/src/components/Versioning/VersioningSidebar.tsx +++ b/packages/react/src/components/Versioning/VersioningSidebar.tsx @@ -1,232 +1,117 @@ -import { - CURRENT_VERSION_ID, - VersioningExtension, - type VersionSnapshot, -} from "@blocknote/core/extensions"; -import { useEffect } from "react"; -import { RiArrowLeftRightLine, RiCloseLine, RiSaveLine } from "react-icons/ri"; +import { VersioningExtension } from "@blocknote/core/extensions"; +import { useEffect, useRef, type ReactNode } from "react"; import { useComponentsContext } from "../../editor/ComponentsContext.js"; -import { useExtension, useExtensionState } from "../../hooks/useExtension.js"; -import { CurrentSnapshot } from "./CurrentSnapshot.js"; -import { Snapshot } from "./Snapshot.js"; +import { useExtension } from "../../hooks/useExtension.js"; +import { useDictionary } from "../../i18n/dictionary.js"; +import { usePreviewRow } from "./usePreviewRow.js"; +import { VersionMenu } from "./VersionMenu/VersionMenu.js"; import { - VersioningSidebarProvider, useVersioningSidebar, + VersioningSidebarProvider, } from "./VersioningSidebarContext.js"; +import { VersioningSidebarHeader } from "./VersioningSidebarHeader.js"; +import { VersioningSidebarList } from "./VersioningSidebarList.js"; -const VersioningSidebarHeader = (props: { onClose?: () => void }) => { - const Components = useComponentsContext()!; - const { - exitPreview, - previewSnapshot, - previewCurrentVersion, - create, - canCreate, - } = useExtension(VersioningExtension); - const previewedSnapshotId = useExtensionState(VersioningExtension, { - selector: (state) => state.previewedSnapshotId, - }); - const snapshots = useExtensionState(VersioningExtension, { - selector: (state) => state.snapshots, - }); - const { comparisonEnabled, comparisonMode, setComparisonMode } = - useVersioningSidebar(); - - // Toggling comparison on immediately diffs whatever is currently shown - // against its previous version; toggling off drops the diff and shows the - // viewed version (or the live document) on its own. - const toggleComparison = () => { - const turningOff = comparisonMode; - setComparisonMode((mode) => !mode); - - const viewingSnapshot = - previewedSnapshotId !== undefined && - previewedSnapshotId !== CURRENT_VERSION_ID; +export { VersioningSidebarHeader } from "./VersioningSidebarHeader.js"; +export { VersioningSidebarList } from "./VersioningSidebarList.js"; - if (turningOff) { - if (viewingSnapshot) { - void previewSnapshot(previewedSnapshotId); - } else if (previewedSnapshotId === CURRENT_VERSION_ID) { - exitPreview(); - } - return; - } - - // Turning on: compare against the previous known version. - if (viewingSnapshot) { - const index = snapshots.findIndex((s) => s.id === previewedSnapshotId); - const previous = index >= 0 ? snapshots[index + 1] : undefined; - void previewSnapshot(previewedSnapshotId, { compareTo: previous?.id }); - } else { - // Live / current document → compare against the most recent snapshot. - const latest = snapshots.find((s) => s.id !== CURRENT_VERSION_ID); - if (previewCurrentVersion && latest) { - void previewCurrentVersion({ compareTo: latest.id }); - } - } - }; - - return ( -
-
-

History

- - {/* Save the live document as a new version, prompting for an - optional name. An empty (or whitespace-only) name is saved as - `undefined`; cancelling the prompt aborts the save. */} - {canCreate && ( - { - const input = window.prompt("Name this version (optional):"); - if (input === null) { - return; - } - void create?.({ name: input.trim() || undefined }); - }} - > - - - )} - {comparisonEnabled && ( - - - - )} - -
- {props.onClose && ( - - { - exitPreview(); - props.onClose?.(); - }} - > - - - - )} -
- ); +export type VersioningSidebarProps = { + /** + * Called when the user closes the history panel via the header's close + * button. The host is responsible for hiding the panel; the sidebar exits + * preview mode (restoring editing) before invoking this. When omitted, the + * close button is not rendered. + */ + onClose?: () => void; + /** + * Initial state of the toggles. Both are the user's from then on — pass a + * changing `key` to reset them. + * @default false + */ + defaultNamedOnly?: boolean; + /** + * Off by default: the first thing a reader wants is the document as it was, + * not a marked-up diff. + * @default false + */ + defaultComparisonMode?: boolean; + /** + * The menu rendered in each row's "..." trigger. Compose it from + * `VersionMenu`, the default items and your own `VersionMenuItem`s; every + * item can read the row it's in via `useVersionSnapshot()`. + * Pass `null` or `false` to hide the menu and its trigger. + * @default + */ + snapshotMenu?: ReactNode; + /** + * The spinner shown while the version list loads. + * @default + */ + loadingIndicator?: ReactNode; }; -const VersioningSidebarContent = (props: { onClose?: () => void }) => { +function VersioningSidebarContent(props: { onClose?: () => void }) { const Components = useComponentsContext()!; - const { list } = useExtension(VersioningExtension); - const { snapshots } = useExtensionState(VersioningExtension); - const { activeTab, setActiveTab, showTabs } = useVersioningSidebar(); + const dict = useDictionary(); + const versioning = useExtension(VersioningExtension); + const { run, failed } = useVersioningSidebar(); + const previewRow = usePreviewRow(); - // Load the version list when the sidebar is shown. The list is the source of - // truth for what's rendered — including the "current version" entry that - // backends surface via `list()` — so the sidebar can't rely on the host - // having listed already. - useEffect(() => { - void list(); - }, [list]); + // Read at mount only: the initial selection uses whatever comparison mode the + // panel opened with, and must not re-run when the user toggles it (the header + // re-previews for that). + const latest = useRef({ previewRow, run }); + latest.current = { previewRow, run }; - // The current-version entry is always kept. Otherwise the "named" tab shows - // only user-created named versions, while the "history" tab shows the full - // edit timeline. - // - // A `history-*` snapshot is history-only regardless of any name: renaming - // such a row writes a name into the mutable name store, but it must never - // graduate into the "named" tab. So the named tab keeps only non-history - // snapshots that carry a name. - const keep = (snapshot: VersionSnapshot) => { - if (snapshot.id === CURRENT_VERSION_ID) { - return true; - } - return activeTab === "named" - ? typeof snapshot.id === "string" && - !snapshot.id.startsWith("history-") && - snapshot.name !== undefined - : true; - }; + // Open the panel on the current version, read-only. One `list()` per mount: + // the history is a snapshot of the moment the panel was opened, and closing + // and reopening is what refreshes it. + useEffect(() => { + void latest.current.run( + () => versioning.list(), + async (loaded) => { + if (versioning.store.state.view.mode === "live") { + await latest.current.previewRow(loaded.current); + } + }, + ); + }, [versioning]); return ( - + - {showTabs && ( -
- - + {failed && ( +
+ {dict.versioning.action_failed}
)} - {snapshots.filter(keep).map((snapshot, i, arr) => { - // The current version is driven by the backend's `list()` (it sorts - // newest-first, so it lands at index 0) and is previewed live rather - // than fetched as a stored snapshot. Its id is the CURRENT_VERSION_ID - // symbol, so derive a string React key for it. - if (snapshot.id === CURRENT_VERSION_ID) { - return ( - - ); - } - return ( - - ); - })} + ); -}; +} -export const VersioningSidebar = (props: { - /** - * When set, pins the sidebar to a single view and hides the tab switcher: - * `"named"` shows only user-created named versions, `"all"` shows the full - * edit history. When omitted, both tabs are shown (default active `"named"`). - */ - filter?: "named" | "all"; - /** - * Called when the user closes the history panel via the header's close - * button. The host is responsible for hiding the panel; the sidebar exits - * preview mode (restoring editing) before invoking this. When omitted, the - * close button is not rendered. - */ - onClose?: () => void; -}) => { +/** + * The version-history panel: a list of the document's versions, newest first, + * with the current version at the top. + * + * While it is open the editor is read-only and shows the selected version — + * the panel always has a selection, starting on the current version. + */ +export function VersioningSidebar(props: VersioningSidebarProps) { return ( - + : props.snapshotMenu + } + loadingIndicator={props.loadingIndicator} + > ); -}; +} diff --git a/packages/react/src/components/Versioning/VersioningSidebarContext.tsx b/packages/react/src/components/Versioning/VersioningSidebarContext.tsx index c4af510bcd..5842c93b20 100644 --- a/packages/react/src/components/Versioning/VersioningSidebarContext.tsx +++ b/packages/react/src/components/Versioning/VersioningSidebarContext.tsx @@ -1,104 +1,140 @@ import { VersioningExtension } from "@blocknote/core/extensions"; import { - Dispatch, - ReactNode, - SetStateAction, createContext, + useCallback, useContext, + useEffect, useMemo, + useRef, useState, + type ReactNode, } from "react"; import { useExtension } from "../../hooks/useExtension.js"; /** - * UI-only state shared across the versioning sidebar (the header toggle, - * {@link CurrentSnapshot}, and each {@link Snapshot}). + * The versioning sidebar's own state, shared between its header and its rows. * - * This is intentionally kept out of the core `VersioningExtension` store: it - * describes how the *sidebar* interprets clicks, not the editor's preview - * state. The baseline that drives the rendered diff (and the "Comparing to" - * indicator) lives in the core store as `compareToSnapshotId`. + * Owns UI state and the lifetime of pending actions. The preview and its diff + * baseline live in the editor's `VersioningExtension` store as `view`. */ export type VersioningSidebarContextValue = { /** - * Whether the sidebar exposes version comparison at all. Mirrors the - * extension's {@link VersioningExtension.canCompare} capability: when - * `false`, the comparison toggle and the "Compare with…" actions are hidden - * entirely, and clicking a version only ever views it. Backends that can't - * diff documents (e.g. the Yjs v13 adapter) report this off. - */ - comparisonEnabled: boolean; - /** - * Whether clicking a version shows a diff against another version. When - * `true` (the default), clicking a version diffs it against its chronological - * predecessor, and the baseline can be moved via "Compare with this version". - * When `false`, clicking a version only views it. Always `false` when - * {@link comparisonEnabled} is `false`. + * Whether showing a version diffs it against another one. Always `false` when + * the backend can't diff documents at all (`canCompare`). */ comparisonMode: boolean; - setComparisonMode: Dispatch>; + setComparisonMode: (value: boolean) => void; + /** Whether the list is filtered down to named versions. */ + namedOnly: boolean; + setNamedOnly: (value: boolean) => void; + /** The menu rendered in each row's "..." trigger. */ + snapshotMenu: ReactNode; + /** The spinner rendered while versions load. */ + loadingIndicator: ReactNode; /** - * Which tab of the sidebar is currently active: `"named"` shows only - * user-created named versions, `"history"` shows the full edit timeline. - * - * When a `filter` prop is passed to the sidebar this is *forced* to the - * corresponding tab (`"named"` → named-only, `"all"` → full history) and can - * no longer be changed via {@link setActiveTab} — see {@link showTabs}. + * Run an action and, if it is still the latest action, apply its UI follow-up. + * A newer action or closing the sidebar skips stale follow-ups and notices; + * the mutation itself still completes. Both steps must succeed to clear errors. */ - activeTab: "named" | "history"; - setActiveTab: Dispatch>; + run: ( + action: () => Promise, + onSuccess?: (result: T) => void | Promise, + ) => Promise; + /** Cancel pending UI follow-ups and return the editor to the live document. */ + close: () => void; + /** Whether the last action failed. */ + failed: boolean; /** - * Whether the tab switcher should be rendered. `false` when the sidebar was - * given a `filter` prop, which pins {@link activeTab} to a single view and - * hides the switcher entirely. + * The version whose name field should take focus as soon as its row renders. + * Set by the row's rename action so naming is one keystroke away; + * cleared by the row that takes it. */ - showTabs: boolean; + focusNameFor: string | undefined; + setFocusNameFor: (id: string | undefined) => void; }; const VersioningSidebarContext = createContext< VersioningSidebarContextValue | undefined >(undefined); -export const VersioningSidebarProvider = (props: { - /** - * When set, pins the sidebar to a single view and hides the tab switcher: - * `"named"` shows only user-created named versions, `"all"` shows the full - * edit history. When omitted, both tabs are shown (default active `"named"`). - */ - filter?: "named" | "all"; +export function VersioningSidebarProvider(props: { + defaultNamedOnly?: boolean; + defaultComparisonMode?: boolean; + snapshotMenu: ReactNode; + loadingIndicator: ReactNode; children: ReactNode; -}) => { +}) { // Comparison availability is driven by the extension/adapter, not the host — // backends that can't diff documents report `canCompare: false`. - const { canCompare } = useExtension(VersioningExtension); - const [comparisonMode, setComparisonMode] = useState(true); - const [activeTab, setActiveTab] = useState<"named" | "history">("history"); - const comparisonEnabled = canCompare; + const versioning = useExtension(VersioningExtension); + const { canCompare } = versioning; - // A `filter` prop forces the corresponding tab and hides the switcher; the - // "all" filter maps to the full-history view. Without a filter, the switcher - // is shown and the user's own `activeTab` selection drives the view. - const forcedTab: "named" | "history" | undefined = - props.filter === undefined - ? undefined - : props.filter === "named" - ? "named" - : "history"; - const showTabs = forcedTab === undefined; - const effectiveTab = forcedTab ?? activeTab; + const [namedOnly, setNamedOnly] = useState(props.defaultNamedOnly ?? false); + const [comparisonMode, setComparisonMode] = useState( + props.defaultComparisonMode ?? false, + ); + const [failed, setFailed] = useState(false); + const [focusNameFor, setFocusNameFor] = useState(); + + const actionGeneration = useRef(0); + const close = useCallback(() => { + actionGeneration.current++; + setFocusNameFor(undefined); + versioning.exitPreview(); + }, [versioning]); + useEffect(() => close, [close]); + + const run = useCallback(async function run( + action: () => Promise, + onSuccess?: (result: T) => void | Promise, + ) { + const generation = ++actionGeneration.current; + try { + const result = await action(); + if (generation === actionGeneration.current) { + await onSuccess?.(result); + } + if (generation === actionGeneration.current) { + setFailed(false); + } + } catch (error) { + // Unexpected failures remain visible to developers; never display their + // messages in the sidebar or let an older action overwrite its notice. + // eslint-disable-next-line no-console + console.error(error); + if (generation === actionGeneration.current) { + setFailed(true); + } + } + }, []); const value = useMemo( () => ({ - comparisonEnabled, // Comparison can never be active when it's disabled outright. - comparisonMode: comparisonEnabled && comparisonMode, + comparisonMode: canCompare && comparisonMode, setComparisonMode, - activeTab: effectiveTab, - setActiveTab, - showTabs, + namedOnly, + setNamedOnly, + snapshotMenu: props.snapshotMenu, + loadingIndicator: props.loadingIndicator, + run, + close, + failed, + focusNameFor, + setFocusNameFor, }), - [comparisonEnabled, comparisonMode, effectiveTab, showTabs], + [ + canCompare, + comparisonMode, + namedOnly, + props.snapshotMenu, + props.loadingIndicator, + run, + close, + failed, + focusNameFor, + ], ); return ( @@ -106,9 +142,9 @@ export const VersioningSidebarProvider = (props: { {props.children} ); -}; +} -export const useVersioningSidebar = (): VersioningSidebarContextValue => { +export function useVersioningSidebar(): VersioningSidebarContextValue { const context = useContext(VersioningSidebarContext); if (!context) { throw new Error( @@ -116,4 +152,4 @@ export const useVersioningSidebar = (): VersioningSidebarContextValue => { ); } return context; -}; +} diff --git a/packages/react/src/components/Versioning/VersioningSidebarHeader.tsx b/packages/react/src/components/Versioning/VersioningSidebarHeader.tsx new file mode 100644 index 0000000000..e3a1cbe41a --- /dev/null +++ b/packages/react/src/components/Versioning/VersioningSidebarHeader.tsx @@ -0,0 +1,152 @@ +import { VersioningExtension } from "@blocknote/core/extensions"; +import { type ReactNode } from "react"; +import { GoDiff } from "react-icons/go"; +import { RiBookmarkLine, RiCloseLine } from "react-icons/ri"; + +import { useBlockNoteEditor } from "../../hooks/useBlockNoteEditor.js"; +import { useComponentsContext } from "../../editor/ComponentsContext.js"; +import { useExtension } from "../../hooks/useExtension.js"; +import { useDictionary } from "../../i18n/dictionary.js"; +import { usePreviewRow } from "./usePreviewRow.js"; +import { useVersioningSidebar } from "./VersioningSidebarContext.js"; + +/** A header button, whose tooltip and accessible name are the same string. */ +function HeaderButton(props: { + label: string; + isSelected?: boolean; + onClick: () => void; + children: ReactNode; +}) { + const Components = useComponentsContext()!; + + return ( + + {props.children} + + ); +} + +export function VersioningSidebarHeader(props: { onClose?: () => void }) { + const editor = useBlockNoteEditor(); + const Components = useComponentsContext()!; + const dict = useDictionary(); + const { store, canCompare } = useExtension(VersioningExtension); + const { + comparisonMode, + setComparisonMode, + namedOnly, + setNamedOnly, + run, + close, + } = useVersioningSidebar(); + const previewRow = usePreviewRow(); + // Toggling comparison re-previews whatever is on screen with the new + // baseline, so the toggle takes effect immediately instead of waiting for the + // next row click. + function toggleComparison() { + const next = !comparisonMode; + setComparisonMode(next); + + const { view, list } = store.state; + if (view.mode === "live" || !list.loaded) { + return; + } + const shown = + view.mode === "current" + ? list.current + : list.snapshots.find((s) => s.id === view.snapshotId); + if (shown) { + void run(() => + previewRow(shown, { compareTo: { type: next ? "previous" : "none" } }), + ); + } + } + + function toggleNamedOnly() { + const next = !namedOnly; + setNamedOnly(next); + // A filter change ends the comparison instead of leaving a potentially + // hidden baseline active. Keep the viewed version if it survives the + // filter; otherwise return to Current so the list retains a selection. + setComparisonMode(false); + const { view, list } = store.state; + if (view.mode === "live" || !list.loaded) { + return; + } + const shown = + view.mode === "snapshot" + ? list.snapshots.find((snapshot) => snapshot.id === view.snapshotId) + : list.current; + void run(() => + previewRow( + shown && (!next || shown.name !== undefined) ? shown : list.current, + { + compareTo: { type: "none" }, + }, + ), + ); + } + + return ( +
+
+

{dict.versioning.title}

+ + + + + {canCompare && ( + + + + )} + +
+ {props.onClose && ( + + { + close(); + editor.focus(); + props.onClose?.(); + }} + > + + + + )} +
+ ); +} diff --git a/packages/react/src/components/Versioning/VersioningSidebarList.tsx b/packages/react/src/components/Versioning/VersioningSidebarList.tsx new file mode 100644 index 0000000000..bac2a225e1 --- /dev/null +++ b/packages/react/src/components/Versioning/VersioningSidebarList.tsx @@ -0,0 +1,182 @@ +import { VersioningExtension } from "@blocknote/core/extensions"; +import { + useCallback, + useEffect, + useId, + useLayoutEffect, + useMemo, + useRef, + useState, + type KeyboardEvent, +} from "react"; + +import { useComponentsContext } from "../../editor/ComponentsContext.js"; +import { useExtension, useExtensionState } from "../../hooks/useExtension.js"; +import { useDictionary } from "../../i18n/dictionary.js"; +import { Snapshot } from "./Snapshot.js"; +import { usePreviewRow } from "./usePreviewRow.js"; +import { useVersioningSidebar } from "./VersioningSidebarContext.js"; + +const useIsomorphicLayoutEffect = + typeof window !== "undefined" ? useLayoutEffect : useEffect; + +/** + * The sidebar's list of versions: a list whose items are {@link Snapshot} rows, + * newest first, with the current version pinned at the top. + * + * A plain list rather than a listbox: the rows carry their own interactive + * content (the "..." menu, the inline name field), which an `option` may not. + */ +export function VersioningSidebarList() { + const Components = useComponentsContext()!; + const dict = useDictionary(); + const { namedOnly, loadingIndicator, run } = useVersioningSidebar(); + const previewRow = usePreviewRow(); + const { getLoadingState } = useExtension(VersioningExtension); + + const list = useExtensionState(VersioningExtension, { + selector: (state) => state.list, + }); + const listing = useExtensionState(VersioningExtension, { + selector: (state) => getLoadingState(state).type === "listing", + }); + + const listId = useId(); + const focusedRowId = useRef(undefined); + const listRef = useRef(null); + const [activeIndex, setActiveIndex] = useState(0); + + const focusRow = useCallback((index: number) => { + const items = + listRef.current?.querySelectorAll('[role="listitem"]'); + if (!items || items.length === 0) { + return; + } + const clamped = Math.max(0, Math.min(index, items.length - 1)); + setActiveIndex(clamped); + // The header sits outside the list's scroll container, so native focus + // scrolling reveals the whole row on its own. + items[clamped]!.focus(); + }, []); + + // Current stays pinned even when unnamed versions are filtered out. + const rows = useMemo( + () => + list.loaded + ? [ + list.current, + ...list.snapshots.filter( + (snapshot) => !namedOnly || snapshot.name !== undefined, + ), + ] + : [], + [list, namedOnly], + ); + + useIsomorphicLayoutEffect(() => { + if (!focusedRowId.current) { + return; + } + // Removing the focused DOM node drops focus onto body. Return it to the + // nearest remaining row without stealing focus from another control. + if ( + !rows.some((row) => row.id === focusedRowId.current) && + document.activeElement === document.body + ) { + focusRow(activeIndex); + } + }, [rows, activeIndex, focusRow]); + + if (!list.loaded) { + return ( +
+ {loadingIndicator ?? ( + + )} + {dict.versioning.loading} +
+ ); + } + + function handleKeyDown(event: KeyboardEvent, index: number) { + // Text inputs (the inline rename) and the row menu handle their own keys. + const target = event.target as HTMLElement; + if (target.tagName === "INPUT" || target.closest(".bn-snapshot-menu")) { + return; + } + + switch (event.key) { + case "ArrowDown": + event.preventDefault(); + focusRow(index + 1); + break; + case "ArrowUp": + event.preventDefault(); + focusRow(index - 1); + break; + case "Home": + event.preventDefault(); + focusRow(0); + break; + case "End": + event.preventDefault(); + focusRow(rows.length - 1); + break; + case "Enter": + case " ": + event.preventDefault(); + void run(() => previewRow(rows[index]!)); + break; + default: + break; + } + } + + return ( + <> +
{ + if (!event.currentTarget.contains(event.relatedTarget)) { + focusedRowId.current = undefined; + } + }} + > + {rows.map((row, index) => ( + = rows.length && index === 0) + ? 0 + : -1 + } + onKeyDown={(event) => handleKeyDown(event, index)} + onFocus={() => { + focusedRowId.current = row.id; + setActiveIndex(index); + }} + /> + ))} +
+ {rows.length === 1 && ( +
+ {/* Only the current row is rendered: either nothing is stored, or + * the named-only filter is hiding every unnamed version. */} + {list.snapshots.length > 0 + ? dict.versioning.empty_named_only + : dict.versioning.empty} +
+ )} + + ); +} diff --git a/packages/react/src/components/Versioning/usePreviewRow.ts b/packages/react/src/components/Versioning/usePreviewRow.ts new file mode 100644 index 0000000000..0357ad56b2 --- /dev/null +++ b/packages/react/src/components/Versioning/usePreviewRow.ts @@ -0,0 +1,92 @@ +import { + VersioningExtension, + type VersionSnapshot, +} from "@blocknote/core/extensions"; +import { useCallback } from "react"; + +import { useExtension } from "../../hooks/useExtension.js"; +import { useVersioningSidebar } from "./VersioningSidebarContext.js"; + +/** + * What showing a row should diff against: + * + * - `previous` — the previous visible version (the row below this one + * after applying the named-only filter). + * - `none` — show the version on its own, no diff. + * - `snapshot` — a specific baseline ("Compare with this version"). + */ +export type CompareTarget = + | { type: "previous" } + | { type: "none" } + | { type: "snapshot"; id: string }; + +/** + * Preview a row using the sidebar's comparison and filter settings, unless + * overridden. Current falls back to the live view if it cannot be previewed. + * Rejects on failure; wrap the complete user action in the sidebar's `run`. + */ +export function usePreviewRow(): ( + row: VersionSnapshot, + options?: { compareTo?: CompareTarget; namedOnly?: boolean }, +) => Promise { + const { previewSnapshot, previewCurrentVersion, exitPreview, store } = + useExtension(VersioningExtension); + const { comparisonMode, namedOnly } = useVersioningSidebar(); + + return useCallback( + async ( + row: VersionSnapshot, + options?: { compareTo?: CompareTarget; namedOnly?: boolean }, + ) => { + const { list } = store.state; + if (!list.loaded) { + return; + } + + const isCurrent = row.id === list.current.id; + const compareTo = options?.compareTo ?? { + type: comparisonMode ? "previous" : "none", + }; + let compareToId: string | undefined; + switch (compareTo.type) { + case "previous": { + const snapshots = list.snapshots.filter( + (snapshot) => + !(options?.namedOnly ?? namedOnly) || snapshot.name !== undefined, + ); + const rowIndex = snapshots.findIndex((s) => s.id === row.id); + compareToId = isCurrent + ? snapshots[0]?.id + : rowIndex === -1 + ? undefined + : snapshots[rowIndex + 1]?.id; + break; + } + case "snapshot": + compareToId = compareTo.id; + break; + case "none": + compareToId = undefined; + break; + default: + compareTo satisfies never; + } + + if (!isCurrent) { + await previewSnapshot(row.id, { compareTo: compareToId }); + } else if (previewCurrentVersion) { + await previewCurrentVersion({ compareTo: compareToId }); + } else { + exitPreview(); + } + }, + [ + store, + comparisonMode, + namedOnly, + previewCurrentVersion, + previewSnapshot, + exitPreview, + ], + ); +} diff --git a/packages/react/src/editor/ComponentsContext.tsx b/packages/react/src/editor/ComponentsContext.tsx index 35d8a1ee3c..20e7378a27 100644 --- a/packages/react/src/editor/ComponentsContext.tsx +++ b/packages/react/src/editor/ComponentsContext.tsx @@ -16,6 +16,9 @@ import { DefaultReactGridSuggestionItem } from "../components/SuggestionMenu/Gri import { DefaultReactSuggestionItem } from "../components/SuggestionMenu/types.js"; type ToolbarRootType = { + "aria-label"?: string; + /** Keep Tab within floating toolbars. Disable for toolbars embedded in a panel. */ + trapFocus?: boolean; className?: string; children?: ReactNode; onMouseEnter?: () => void; @@ -242,24 +245,38 @@ export type ComponentProps = { * snapshot rows). */ Sidebar: { + "aria-label"?: string; className?: string; children?: ReactNode; }; /** - * A single row in the version-history sidebar — the live "current version" - * entry or a stored snapshot. + * A single row in the version-history sidebar — the current version or a + * stored one. Rendered as a `role="listitem"` inside the sidebar's list, + * which is why it takes the roving-tabindex and focus props below. */ Snapshot: { + "aria-label"?: string; className?: string; + id?: string; /** Whether this row is the version currently shown in the editor. */ selected?: boolean; /** Whether this row is the baseline the current diff is compared against. */ comparing?: boolean; + /** `0` for the active row of the roving tabindex, `-1` for the rest. */ + tabIndex?: number; + /** Whether this row's content is still loading. */ + "aria-busy"?: boolean; onClick?: () => void; + onKeyDown?: (event: KeyboardEvent) => void; + onFocus?: () => void; /** Row actions (e.g. the "..." menu), revealed on hover. */ actions?: ReactNode; children?: ReactNode; }; + /** The spinner shown while versions (or a preview) are loading. */ + Loader: { + className?: string; + }; }; AttributionTooltip: { /** @@ -347,6 +364,7 @@ export type ComponentProps = { className?: string; children?: ReactNode; + disabled?: boolean; subTrigger?: boolean; icon?: ReactNode; checked?: boolean; diff --git a/packages/react/src/editor/styles.css b/packages/react/src/editor/styles.css index 507f2cd46f..3dd7a7547f 100644 --- a/packages/react/src/editor/styles.css +++ b/packages/react/src/editor/styles.css @@ -299,8 +299,8 @@ SideMenuController offsets its position to keep it centered on the line. */ * `.bn-threads-sidebar` override for the pattern). * * Resembles `.bn-threads-sidebar` above, but shares almost nothing with it - * concretely: the shells have only `overflow: auto` in common (this one is a - * flex *item* whose rows self-space via `margin-bottom`, not a flex column), + * concretely: this one is a fixed header over a scrolling `role="list"` + * whose rows self-space via `margin-bottom`, not a single scrolling column, * and the two selected states are deliberately different designs — a filled * accent with inverted text here, a pale tint with a saturated border there. * The row boxes look closer, but only against `.bn-mantine .bn-thread`: @@ -311,14 +311,18 @@ SideMenuController offsets its position to keep it centered on the line. */ */ .bn-versioning-sidebar { + display: flex; + flex-direction: column; flex: 1; - overflow: auto; + min-height: 0; + overflow: hidden; padding-inline: 16px; } .bn-versioning-sidebar-header { align-items: center; display: flex; + flex-shrink: 0; justify-content: space-between; padding-block: 16px 8px; } @@ -342,33 +346,60 @@ SideMenuController offsets its position to keep it centered on the line. */ gap: 4px; } -.bn-versioning-sidebar-tabs { - border-bottom: 1px solid var(--bn-colors-border); +.bn-versioning-sidebar-list { display: flex; - gap: 4px; - margin-bottom: 8px; + flex: 1; + flex-direction: column; + margin: -4px -4px 0; + min-height: 0; + overflow-y: auto; + /* 4px of ring room on every side without shifting the rows out of line + with the header; the extra bottom padding keeps the last row off the + panel's edge. */ + padding: 4px 4px 16px; } -.bn-versioning-sidebar-tab { - background: transparent; - border: none; - border-bottom: 2px solid transparent; - color: var(--bn-colors-menu-text); - cursor: pointer; +.bn-versioning-sidebar-loading { + align-items: center; + display: flex; + flex-shrink: 0; + justify-content: center; + padding-block: 24px; +} + +.bn-versioning-sidebar-empty { + color: #6b7280; + flex-shrink: 0; font-size: 13px; - font-weight: 500; - margin-bottom: -1px; - opacity: 0.6; - padding: 8px 4px; + padding-block: 12px; + text-align: center; } -.bn-versioning-sidebar-tab:hover { - opacity: 0.85; +.dark .bn-versioning-sidebar-empty { + color: #9ca3af; } -.bn-versioning-sidebar-tab[aria-selected="true"] { - border-bottom-color: var(--bn-colors-menu-text); - opacity: 1; +/* The generic "something failed" notice, shown until the next action succeeds. */ +.bn-versioning-sidebar-error { + color: #b42318; + flex-shrink: 0; + font-size: 13px; + padding-block: 8px; +} + +.dark .bn-versioning-sidebar-error { + color: #f97066; +} + +/* Screen-reader-only text (the loading announcement). */ +.bn-visually-hidden { + clip: rect(0 0 0 0); + clip-path: inset(50%); + height: 1px; + overflow: hidden; + position: absolute; + white-space: nowrap; + width: 1px; } .bn-snapshot { @@ -394,18 +425,100 @@ SideMenuController offsets its position to keep it centered on the line. */ background-color: var(--bn-colors-hovered-background); } +/* The row's title: text on every row, a field on the selected one. */ .bn-snapshot-name { - background: transparent; - border: none; color: inherit; font-size: 14px; font-weight: 700; - padding: 0; + line-height: 20px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Rows are reached with the arrow keys, so the focus ring is + the only thing telling a keyboard user where they are. */ +.bn-snapshot:focus-visible { + outline: 2px solid #3e5de7; + outline-offset: 2px; +} + +/* The selected row is already filled with that indigo, so the ring goes inside + it in white instead of disappearing into the background. */ +.bn-versioning-sidebar .bn-snapshot.selected:focus-visible { + outline: 2px solid #fff; + outline-offset: -4px; +} + +/* Padded on the right so neither the title nor its field runs under the "..." + trigger, which sits in the row's top-right corner. */ +.bn-snapshot-title-row { + align-items: center; + display: flex; + gap: 6px; + min-height: 20px; + min-width: 0; + padding-right: 20px; +} + +/* Sizes the field to its text: the hidden mirror of the draft sets the width, + and the field fills it. Capped at the row, so a long name truncates rather + than pushing out. The negative margin is the field's own padding and border, + so its text lines up with the rest of the row. */ +.bn-snapshot-name-sizer { + display: inline-grid; + grid-template-columns: minmax(0, max-content); + margin: -2px -5px; + max-width: calc(100% + 10px); + min-width: 0; +} + +.bn-snapshot-name-sizer::after { + border: 1px solid transparent; + content: attr(data-value) " "; + font-size: 14px; + font-weight: 700; + grid-area: 1 / 1; + line-height: 20px; + padding: 1px 4px; + visibility: hidden; + white-space: pre; +} + +.bn-snapshot-name-sizer > .bn-snapshot-name { + background: transparent; + border: 1px solid transparent; + border-radius: 4px; + grid-area: 1 / 1; + margin: 0; + min-width: 0; + padding: 1px 4px; width: 100%; } -.bn-snapshot-name:focus { +/* An unnamed version shows its date (or "Current version") here, so the + placeholder has to read as the row's title, not as a hint. */ +.bn-snapshot-name::placeholder { + color: inherit; + opacity: 1; +} + +/* Reads as plain text until you click it — the dotted underline and the caret + are what say it can be renamed at all. */ +.bn-snapshot-name-sizer > .bn-snapshot-name:hover { + text-decoration: underline dotted; + text-underline-offset: 3px; +} + +.bn-snapshot-name-sizer > .bn-snapshot-name:focus { + border-color: #3e5de7; outline: none; + text-decoration: none; +} + +.bn-versioning-sidebar .bn-snapshot.selected .bn-snapshot-name:focus { + background-color: rgba(255, 255, 255, 0.15); + border-color: rgba(255, 255, 255, 0.6); } .bn-snapshot-body { @@ -445,6 +558,7 @@ SideMenuController offsets its position to keep it centered on the line. */ } .bn-snapshot:hover .bn-snapshot-menu, +.bn-snapshot:focus-visible .bn-snapshot-menu, .bn-snapshot:focus-within .bn-snapshot-menu { opacity: 1; } @@ -484,13 +598,21 @@ SideMenuController offsets its position to keep it centered on the line. */ .bn-versioning-sidebar .bn-snapshot.selected .bn-snapshot-date, .bn-versioning-sidebar .bn-snapshot.selected .bn-snapshot-original-date, .bn-versioning-sidebar .bn-snapshot.selected .bn-snapshot-secondary-label { - color: rgba(255, 255, 255, 0.8); + color: rgba(255, 255, 255, 0.9); } .bn-versioning-sidebar .bn-snapshot.selected .bn-snapshot-menu-trigger { color: #fff; } +/* Both ends of a comparison share a colored border. A shadow thickens it + without changing the row's dimensions or replacing its keyboard focus ring. */ +.bn-versioning-sidebar .bn-snapshot-comparison-source, +.bn-versioning-sidebar .bn-snapshot.comparing { + border-color: #3e5de7; + box-shadow: 0 0 0 1px #3e5de7; +} + /* Comparing-to (the diff baseline) — a subtle tint of the selected indigo. */ .bn-versioning-sidebar .bn-snapshot.comparing { background-color: color-mix( @@ -509,6 +631,10 @@ SideMenuController offsets its position to keep it centered on the line. */ gap: 4px; } +.bn-root[data-color-scheme="dark"] .bn-snapshot-comparing-to { + color: #9baeff; +} + /* Mobile formatting toolbar positioning */ .bn-mobile-formatting-toolbar { display: flex; diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 0553f8a30d..f4c2c67ebe 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -124,9 +124,6 @@ export * from "./components/Comments/Thread.js"; export * from "./components/Comments/ThreadsSidebar.js"; export * from "./components/Comments/useThreads.js"; -export * from "./components/Versioning/useVersionUsers.js"; -export * from "./components/Versioning/VersioningSidebar.js"; - export * from "./hooks/useActiveStyles.js"; export * from "./hooks/useBlockNoteEditor.js"; export * from "./hooks/useCreateBlockNote.js"; diff --git a/packages/react/src/versioning.ts b/packages/react/src/versioning.ts new file mode 100644 index 0000000000..26353880b6 --- /dev/null +++ b/packages/react/src/versioning.ts @@ -0,0 +1,14 @@ +export * from "./components/Versioning/useVersionUsers.js"; +export * from "./components/Versioning/usePreviewRow.js"; +export * from "./components/Versioning/VersioningSidebar.js"; +export * from "./components/Versioning/VersioningSidebarContext.js"; +export * from "./components/Versioning/VersionName.js"; +export * from "./components/Versioning/VersioningPrimitives.js"; +export * from "./components/Versioning/VersionSnapshotContext.js"; +export * from "./components/Versioning/VersionMenu/VersionMenu.js"; +export * from "./components/Versioning/VersionMenu/VersionMenuItem.js"; +export * from "./components/Versioning/VersionMenu/DefaultItems/CompareSinceBeginningItem.js"; +export * from "./components/Versioning/VersionMenu/DefaultItems/CompareWithVersionItem.js"; +export * from "./components/Versioning/VersionMenu/DefaultItems/DeleteVersionItem.js"; +export * from "./components/Versioning/VersionMenu/DefaultItems/NameVersionItem.js"; +export * from "./components/Versioning/VersionMenu/DefaultItems/RestoreVersionItem.js"; diff --git a/packages/react/vite.config.ts b/packages/react/vite.config.ts index d4e59a60b4..f46cd3fb46 100644 --- a/packages/react/vite.config.ts +++ b/packages/react/vite.config.ts @@ -49,6 +49,7 @@ export default defineConfig( lib: { entry: { "blocknote-react": path.resolve(__dirname, "src/index.ts"), + versioning: path.resolve(__dirname, "src/versioning.ts"), }, name: "blocknote-react", cssFileName: "style", diff --git a/packages/shadcn/src/components.ts b/packages/shadcn/src/components.ts index eee0bc70e4..997464ca44 100644 --- a/packages/shadcn/src/components.ts +++ b/packages/shadcn/src/components.ts @@ -91,6 +91,9 @@ export const components: Components = { Versioning: { Sidebar: VersioningSidebar, Snapshot: VersioningSnapshot, + // The sidebar's loader is the same dots/spinner as the suggestion menu's — + // one spinner per UI package, not one per feature. + Loader: SuggestionMenuLoader, }, AttributionTooltip: { Root: AttributionTooltip, diff --git a/packages/shadcn/src/menu/Menu.tsx b/packages/shadcn/src/menu/Menu.tsx index 1e5eb6ea54..1c5a50fe47 100644 --- a/packages/shadcn/src/menu/Menu.tsx +++ b/packages/shadcn/src/menu/Menu.tsx @@ -105,8 +105,16 @@ export const MenuItem = forwardRef< HTMLDivElement, ComponentProps["Generic"]["Menu"]["Item"] >((props, ref) => { - const { className, children, icon, checked, subTrigger, onClick, ...rest } = - props; + const { + className, + children, + icon, + checked, + disabled, + subTrigger, + onClick, + ...rest + } = props; assertEmpty(rest); @@ -128,6 +136,7 @@ export const MenuItem = forwardRef< ref={ref} checked={checked} onClick={onClick} + disabled={disabled} {...rest} > {icon} @@ -141,6 +150,7 @@ export const MenuItem = forwardRef< className={className} ref={ref} onClick={onClick} + disabled={disabled} {...rest} > {icon} diff --git a/packages/shadcn/src/toolbar/Toolbar.tsx b/packages/shadcn/src/toolbar/Toolbar.tsx index 6ac937ee7c..0affeb57d6 100644 --- a/packages/shadcn/src/toolbar/Toolbar.tsx +++ b/packages/shadcn/src/toolbar/Toolbar.tsx @@ -11,9 +11,11 @@ export const Toolbar = forwardRef( (props, ref) => { const { className, + "aria-label": ariaLabel, children, onMouseEnter, onMouseLeave, + trapFocus: _trapFocus, variant, ...rest } = props; @@ -30,6 +32,8 @@ export const Toolbar = forwardRef( "bg-popover text-popover-foreground flex h-fit gap-1 rounded-lg border p-1 shadow-md", variant === "action-toolbar" ? "w-fit" : "", )} + role="toolbar" + aria-label={ariaLabel} ref={ref} onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave} diff --git a/packages/shadcn/src/versioning/Versioning.tsx b/packages/shadcn/src/versioning/Versioning.tsx index dbbe1cac2f..fb37e9ec7e 100644 --- a/packages/shadcn/src/versioning/Versioning.tsx +++ b/packages/shadcn/src/versioning/Versioning.tsx @@ -1,5 +1,8 @@ -import { assertEmpty } from "@blocknote/core"; -import { ComponentProps } from "@blocknote/react"; +import { type ComponentProps } from "@blocknote/react"; +import { + VersioningSidebarRoot, + VersioningSnapshotRow, +} from "@blocknote/react/versioning"; import { forwardRef } from "react"; import { cn } from "../lib/utils.js"; @@ -8,64 +11,22 @@ import { useShadCNComponentsContext } from "../ShadCNComponentsContext.js"; export const Sidebar = forwardRef< HTMLDivElement, ComponentProps["Versioning"]["Sidebar"] ->((props, ref) => { - const { className, children, ...rest } = props; - - assertEmpty(rest, false); - - return ( -
- {children} -
- ); -}); +>((props, ref) => ( + +)); export const Snapshot = forwardRef< HTMLDivElement, ComponentProps["Versioning"]["Snapshot"] >((props, ref) => { - const { - className, - selected, - comparing, - onClick, - actions, - children, - ...rest - } = props; - - assertEmpty(rest, false); - const ShadCNComponents = useShadCNComponentsContext()!; - return ( - - {children} - {actions && ( - // Isolate the actions area so clicks on the menu (trigger and items, - // which render inline rather than in a portal) don't bubble to the - // row's select handler. -
event.stopPropagation()} - > - {actions} -
- )} -
+ /> ); }); diff --git a/patches/@y__prosemirror@2.0.0-11.patch b/patches/@y__prosemirror@2.0.0-11.patch new file mode 100644 index 0000000000..ae7f149ac1 --- /dev/null +++ b/patches/@y__prosemirror@2.0.0-11.patch @@ -0,0 +1,334 @@ +# Includes https://github.com/yjs/y-prosemirror/pull/277 (`initialContentCompare`: +# integrator-overridable initial-content gate in ProsemirrorRdt/syncPlugin — +# both at bind time AND on every gated `pull`, so a re-minted empty skeleton +# (new id, same shape) re-anchors the gate instead of seeding Y). +# Also carries BlockNote-side additions: dist type declarations for the new +# option (incl. the InitialContentCompare typedef in global.d.ts), needed +# because consumers type against dist while upstream regenerates it via +# `npm run dist`. Drop those hunks once the PR lands upstream. +diff --git a/dist/src/commands.d.ts b/dist/src/commands.d.ts +index 88b7e6cfc784787991ae62fdea084d24cc5c778a..10295afff66e55a2c3cfa4f4f8862c5e02edb075 100644 +--- a/dist/src/commands.d.ts ++++ b/dist/src/commands.d.ts +@@ -1,10 +1,4 @@ +-/** +- * Switch to pause mode (stop synchronization between prosemirror and ytype) +- * @param {import('prosemirror-state').EditorState} state +- * @param {((tr: import('prosemirror-state').Transaction) => void)?} dispatch +- * @returns {boolean} +- */ +-export function pauseSync(state: import("prosemirror-state").EditorState, dispatch: ((tr: import("prosemirror-state").Transaction) => void) | null): boolean; ++export function pauseSync(state: import("prosemirror-state").EditorState, dispatch?: (tr: import("prosemirror-state").Transaction) => void, view?: import("prosemirror-view").EditorView): boolean; + export function configureYProsemirror(opts?: { + ytype?: YType | null; + renderer?: Y.AbstractRenderer | null | undefined; +diff --git a/dist/src/commands.d.ts.map b/dist/src/commands.d.ts.map +index e24a8fc6c44a975779320d47950d9467040fc7fc..c26869bf6cd69680c772976d7d76f067f2d5438f 100644 +--- a/dist/src/commands.d.ts.map ++++ b/dist/src/commands.d.ts.map +@@ -1 +1 @@ +-{"version":3,"file":"commands.d.ts","sourceRoot":"","sources":["../../src/commands.js"],"names":[],"mappings":"AAIA;;;;;GAKG;AACH,iCAJW,OAAO,mBAAmB,EAAE,WAAW,YACvC,CAAC,CAAC,EAAE,EAAE,OAAO,mBAAmB,EAAE,WAAW,KAAK,IAAI,CAAC,OAAC,GACtD,OAAO,CAanB;AAkBM,6CAJJ;IAAsB,KAAK,GAAnB,KAAK,OAAC;IACW,QAAQ;CACjC,GAAU,OAAO,mBAAmB,EAAE,OAAO,CAa/C;AAQM,4BAHI,OAAO,mBAAmB,EAAE,WAAW,GACtC,OAAO,CAEqE;AAQjF,4BAHI,OAAO,mBAAmB,EAAE,WAAW,GACtC,OAAO,CAEqE;AAExF;;GAEG;AACH,0BAFU,OAAO,mBAAmB,EAAE,OAAO,CAEqG;AAElJ;;GAEG;AACH,0BAFU,OAAO,mBAAmB,EAAE,OAAO,CAEqG;AAQ3I,qCAJI,MAAM,QACN,MAAM,GACJ,OAAO,mBAAmB,EAAE,OAAO,CAmB/C;AAQM,qCAJI,MAAM,QACN,MAAM,GACJ,OAAO,mBAAmB,EAAE,OAAO,CAmB/C;AAMM,oCAFM,OAAO,mBAAmB,EAAE,OAAO,CAW/C;AAMM,oCAFM,OAAO,mBAAmB,EAAE,OAAO,CAW/C;mBA3JkB,MAAM"} +\ No newline at end of file ++{"version":3,"file":"commands.d.ts","sourceRoot":"","sources":["../../src/commands.js"],"names":[],"mappings":";AAqCO,6CAJJ;IAAsB,KAAK,GAAnB,KAAK,OAAC;IACW,QAAQ;CACjC,GAAU,OAAO,mBAAmB,EAAE,OAAO,CAa/C;AAQM,4BAHI,OAAO,mBAAmB,EAAE,WAAW,GACtC,OAAO,CAEqE;AAQjF,4BAHI,OAAO,mBAAmB,EAAE,WAAW,GACtC,OAAO,CAEqE;AAExF;;GAEG;AACH,0BAFU,OAAO,mBAAmB,EAAE,OAAO,CAEqG;AAElJ;;GAEG;AACH,0BAFU,OAAO,mBAAmB,EAAE,OAAO,CAEqG;AAQ3I,qCAJI,MAAM,QACN,MAAM,GACJ,OAAO,mBAAmB,EAAE,OAAO,CAmB/C;AAQM,qCAJI,MAAM,QACN,MAAM,GACJ,OAAO,mBAAmB,EAAE,OAAO,CAmB/C;AAMM,oCAFM,OAAO,mBAAmB,EAAE,OAAO,CAW/C;AAMM,oCAFM,OAAO,mBAAmB,EAAE,OAAO,CAW/C;mBAzJkB,MAAM"} +\ No newline at end of file +diff --git a/dist/src/index.d.ts b/dist/src/index.d.ts +index 27874d7065554179b122e5fb444f06cb24afcc2b..c14432944b43fa370f86e7c19d9709b157a51021 100644 +--- a/dist/src/index.d.ts ++++ b/dist/src/index.d.ts +@@ -6,5 +6,5 @@ export * from "./cursor-plugin.js"; + export { YSyncRdt } from "./rdt/y-sync.js"; + export { ProsemirrorRdt } from "./rdt/prosemirror.js"; + export { resolvedPositionToDeltaPosition, deltaPositionToResolvedPosition, resolvedPositionToRelativePosition, relativePositionToResolvedPosition, resolvedPositionsToRelativePositions, relativePositionsToResolvedPositions, relativePositionStore, relativePositionStoreMapping } from "./positions.js"; +-export { docToDelta, nodeToDelta, nodeToDeltaCached, deltaToPNode, deltaToPSteps, deltaAttributionToFormat, $prosemirrorDelta, defaultMapAttributionToMark, defaultMapAttrAttribution, defaultAttributionConf, attributionMapperToConf, yattr2markname, pmToFragment, fragmentToPm } from "./sync-utils.js"; ++export { docToDelta, nodeToDelta, nodeToDeltaCached, deltaToPNode, deltaToPSteps, deltaAttributionToFormat, $prosemirrorDelta, defaultMapAttributionToMark, defaultMapAttrAttribution, defaultAttributionConf, attributionMapperToConf, yattr2markname, pmToFragment, fragmentToTr, fragmentToPm } from "./sync-utils.js"; + //# sourceMappingURL=index.d.ts.map +\ No newline at end of file +diff --git a/dist/src/rdt/prosemirror.d.ts b/dist/src/rdt/prosemirror.d.ts +index f47ac0f5b3bb263414320b212c10de5eaedf58f7..bba1039fb3e7de2610714116f6c33cfe6f55c0ff 100644 +--- a/dist/src/rdt/prosemirror.d.ts ++++ b/dist/src/rdt/prosemirror.d.ts +@@ -87,17 +87,23 @@ export class ProsemirrorRdt extends ObservableV2<{ + * @param {boolean} [opts.gateInitialContent] the counterpart ytype has no + * children — gate the schema-default document instead of treating it as + * content (see "Initial-content gate" in the class doc) ++ * @param {InitialContentCompare?} [opts.initialContentCompare] Optional ++ * predicate `(doc) => boolean` deciding whether the current document is ++ * the integrator's initial (empty) state that must not be written into ++ * the empty ytype. `null` keeps the default check (the document's ++ * fingerprint equals the schema's `createAndFill()` default). + * @param {null|((err:Error,errCode:number)=>any)} [opts.onInternalError] + * Listen to internal errors for debugging purposes. This API is unstable + * and can be changed/removed at any time! (errCode 2: the `applyDelta` + * reconcile diff failed — see the fail-safe there) + */ +- constructor({ view, attributedNodes, compare, getMeta, gateInitialContent, onInternalError }: { ++ constructor({ view, attributedNodes, compare, getMeta, gateInitialContent, initialContentCompare, onInternalError }: { + view: import("prosemirror-view").EditorView; + attributedNodes?: AttributedNodesPredicate | undefined; + compare?: NodeCompare | null | undefined; + getMeta: () => any; + gateInitialContent?: boolean | undefined; ++ initialContentCompare?: InitialContentCompare | null | undefined; + onInternalError?: ((err: Error, errCode: number) => any) | null | undefined; + }); + view: import("prosemirror-view").EditorView; +diff --git a/dist/src/sync-plugin.d.ts b/dist/src/sync-plugin.d.ts +index b9755f153c93a702cb85c62dbba7046a7347ff5d..22c1b34367136f76525f2158d085fa5a7c69f5ae 100644 +--- a/dist/src/sync-plugin.d.ts ++++ b/dist/src/sync-plugin.d.ts +@@ -8,6 +8,7 @@ export const $syncPluginState: s.Schema<{ + attributionMapper: AttributionMapper; + attributedNodes: AttributedNodesPredicate; + customCompare: NodeCompare | null; ++ initialContentCompare: InitialContentCompare | null; + binding: Binding | null; + }>; + export const $syncPluginStateUpdate: s.Schema<{ +@@ -16,6 +17,7 @@ export const $syncPluginStateUpdate: s.Schema<{ + attributionMapper?: AttributionMapper | null | undefined; + attributedNodes?: AttributedNodesPredicate | null | undefined; + customCompare?: NodeCompare | null | undefined; ++ initialContentCompare?: InitialContentCompare | null | undefined; + binding?: Binding | null | undefined; + change?: Y.YEvent | null | undefined; + }>; +@@ -29,6 +31,7 @@ export function syncPlugin(opts?: { + mapAttributionToMark?: AttributionMapper | undefined; + attributedNodes?: AttributedNodesPredicate | undefined; + customCompare?: NodeCompare | undefined; ++ initialContentCompare?: InitialContentCompare | undefined; + transformers?: (($d: s.Schema) => dt.Template)[] | undefined; + onInternalError?: ((err: Error, errCode: number) => any) | null | undefined; + }): Plugin; +diff --git a/dist/src/sync-utils.d.ts b/dist/src/sync-utils.d.ts +index d7fbaec602a70c4973f8ce476ced054226f65aa6..6e971c2520e9cebe9137ff05cc185de72f85da9e 100644 +--- a/dist/src/sync-utils.d.ts ++++ b/dist/src/sync-utils.d.ts +@@ -30,9 +30,10 @@ export function fragmentToTr(fragment: Y.Node, tr: import("prosemirror-state").T + * Transforms a {@link Y.XmlFragment} into a {@link Node} + * @param {Y.Node} fragment + * @param {import('prosemirror-state').Transaction} tr ++ * @param {Parameters[2]} [ctx] + * @return {Node} + */ +-export function fragmentToPm(fragment: Y.Node, tr: import("prosemirror-state").Transaction): Node; ++export function fragmentToPm(fragment: Y.Node, tr: import("prosemirror-state").Transaction, ctx?: Parameters[2]): Node; + /** @import { Node } from 'prosemirror-model' */ + export const $prosemirrorDelta: s.Schema boolean ++/** ++ * Decides whether a ProseMirror document is the integrator's initial (empty) ++ * state that must not be written into an empty ytype at bind time (see ++ * "Initial-content gate" in `ProsemirrorRdt`'s doc). Return `true` to arm the ++ * gate for the document, `false` to sync it immediately. ++ */ ++declare type InitialContentCompare = (doc: import('prosemirror-model').Node) => boolean + declare type SyncPluginState = import('lib0/schema').Unwrap + declare type SyncPluginStateUpdate = import('lib0/schema').Unwrap + declare type ProsemirrorDelta = import('lib0/schema').Unwrap +diff --git a/src/commands.js b/src/commands.js +index f83c4b84df920c48890bd5ec106cf1775cb8a721..2e07b566d733703f12639d3d4368868632ff0ba4 100644 +--- a/src/commands.js ++++ b/src/commands.js +@@ -4,9 +4,7 @@ import { mapResolvedPositionsToRelativePositions } from './positions.js' + + /** + * Switch to pause mode (stop synchronization between prosemirror and ytype) +- * @param {import('prosemirror-state').EditorState} state +- * @param {((tr: import('prosemirror-state').Transaction) => void)?} dispatch +- * @returns {boolean} ++ * @type {import('prosemirror-state').Command} + */ + export function pauseSync (state, dispatch) { + const pluginState = ySyncPluginKey.getState(state) +diff --git a/src/index.js b/src/index.js +index bceb24895d6fafae5644213d469ad74ace072e5d..811646bbdad9fff215af57c6718efdf78a6cd8c4 100644 +--- a/src/index.js ++++ b/src/index.js +@@ -12,7 +12,7 @@ export { + relativePositionStore, + relativePositionStoreMapping + } from './positions.js' +-export { docToDelta, nodeToDelta, nodeToDeltaCached, deltaToPNode, deltaToPSteps, deltaAttributionToFormat, $prosemirrorDelta, defaultMapAttributionToMark, defaultMapAttrAttribution, defaultAttributionConf, attributionMapperToConf, yattr2markname, pmToFragment, fragmentToPm } from './sync-utils.js' ++export { docToDelta, nodeToDelta, nodeToDeltaCached, deltaToPNode, deltaToPSteps, deltaAttributionToFormat, $prosemirrorDelta, defaultMapAttributionToMark, defaultMapAttrAttribution, defaultAttributionConf, attributionMapperToConf, yattr2markname, pmToFragment, fragmentToTr, fragmentToPm } from './sync-utils.js' + export * from './commands.js' + export * from './undo-plugin.js' + export * from './cursor-plugin.js' +diff --git a/src/rdt/prosemirror.js b/src/rdt/prosemirror.js +index 69c7007c4fa8a8f25ed09450d93d324c706d584b..4bfae43b79bfe7ea79d297dfdc4a5b4773027dd1 100644 +--- a/src/rdt/prosemirror.js ++++ b/src/rdt/prosemirror.js +@@ -147,8 +147,9 @@ const touchesAttributionSpace = format => { + * always materialized. Binding that default to an *empty* ytype must not write + * it into Y — every fresh client would seed its own copy and merging two such + * docs duplicates the content (the init race). When the sync plugin signals +- * that the ytype has no children and the document fingerprints equal to the +- * schema default, `_state` starts as the **empty** delta instead of a document ++ * that the ytype has no children and the document is the integrator's ++ * initial state (by default: the document fingerprint equals the schema ++ * default; overridable via `initialContentCompare`), `_state` starts as the **empty** delta instead of a document + * snapshot, with {@link ProsemirrorRdt#_defaultFingerprint} set. The + * binding's initial sync then diffs empty against empty — nothing is rendered + * or written — while the schema-default skeleton stays visible in the editor, +@@ -182,30 +183,46 @@ export class ProsemirrorRdt extends ObservableV2 { + * @param {boolean} [opts.gateInitialContent] the counterpart ytype has no + * children — gate the schema-default document instead of treating it as + * content (see "Initial-content gate" in the class doc) ++ * @param {InitialContentCompare?} [opts.initialContentCompare] Optional ++ * predicate `(doc) => boolean` deciding whether the current document is ++ * the integrator's initial (empty) state that must not be written into ++ * the empty ytype. `null` keeps the default check (the document's ++ * fingerprint equals the schema's `createAndFill()` default). + * @param {null|((err:Error,errCode:number)=>any)} [opts.onInternalError] + * Listen to internal errors for debugging purposes. This API is unstable + * and can be changed/removed at any time! (errCode 2: the `applyDelta` + * reconcile diff failed — see the fail-safe there) + */ +- constructor ({ view, attributedNodes = defaultAttributedNodes, compare = null, getMeta, gateInitialContent = false, onInternalError = null }) { ++ constructor ({ view, attributedNodes = defaultAttributedNodes, compare = null, getMeta, gateInitialContent = false, initialContentCompare = null, onInternalError = null }) { + super() + this.view = view + this.attributedNodes = attributedNodes + this.compare = compare ?? undefined + this.getMeta = getMeta + this._onInternalError = onInternalError ++ /** ++ * Integrator override for the initial-content gate (see class doc). ++ * Consulted by `pull` while the gate holds: a diverged fingerprint ++ * alone must not seed Y when the document is still initial content ++ * (e.g. the empty skeleton re-minted with a new id). ++ * ++ * @type {InitialContentCompare?} ++ */ ++ this._initialContentCompare = initialContentCompare + this.$delta = $prosemirrorDelta + const snapshot = nodeToDeltaCached(view.state.doc) +- const dflt = gateInitialContent ? view.state.doc.type.createAndFill() : null ++ const dflt = gateInitialContent && initialContentCompare == null ? view.state.doc.type.createAndFill() : null + const dfltFingerprint = dflt != null ? nodeToDeltaCached(dflt).fingerprint : null ++ const isDefaultDoc = dfltFingerprint != null && snapshot.fingerprint === dfltFingerprint ++ const isInitial = gateInitialContent && (initialContentCompare != null ? initialContentCompare(view.state.doc) : isDefaultDoc) + /** + * Non-null while the initial content is gated (see class doc): the +- * fingerprint of the schema-default document, which `pull` must not emit. ++ * fingerprint of the gated initial document, which `pull` must not emit. + * The first render in either direction resets this to `null`. + * + * @type {string?} + */ +- this._defaultFingerprint = dfltFingerprint != null && snapshot.fingerprint === dfltFingerprint ? dfltFingerprint : null ++ this._defaultFingerprint = isInitial ? snapshot.fingerprint : null + /** + * @type {ProsemirrorDelta} + */ +@@ -399,6 +416,14 @@ export class ProsemirrorRdt extends ObservableV2 { + // the skeleton must not leak into Y — not even via a transaction that + // changed the doc and changed it back (see class doc) + if (next.fingerprint === this._defaultFingerprint) return ++ // Integrator override: the fingerprint may have diverged while the ++ // document is still initial content (e.g. the empty skeleton ++ // re-minted with a new id after the user deleted all blocks). ++ // Re-anchor the gate instead of seeding Y with the skeleton. ++ if (this._initialContentCompare != null && this._initialContentCompare(doc)) { ++ this._defaultFingerprint = next.fingerprint ++ return ++ } + this._defaultFingerprint = null + } + // The cursor as a diff placement hint: within a run of identical +diff --git a/src/sync-plugin.js b/src/sync-plugin.js +index bf5043c92264dcc01face8b49451ab6352aa510c..0e51ae5ecf9269175ebe188f7842e3fad526891e 100644 +--- a/src/sync-plugin.js ++++ b/src/sync-plugin.js +@@ -39,6 +39,12 @@ export const $syncPluginState = s.$object({ + * default. See {@link NodeCompare} and {@link syncPlugin}. + */ + customCompare: /** @type {s.Schema} */ (s.$function).nullable, ++ /** ++ * Predicate deciding whether the current ProseMirror document is the ++ * integrator's initial (empty) state. `null` keeps the schema-default ++ * fingerprint check. See {@link InitialContentCompare} and {@link syncPlugin}. ++ */ ++ initialContentCompare: /** @type {s.Schema} */ (s.$function).nullable, + /** + * The live RDT binding (null while paused / before the first setup). `binding.t` is the + * data(Y render)⇄view(PM doc) transformer that cursor positions are mapped through. +@@ -52,6 +58,7 @@ export const $syncPluginStateUpdate = s.$object({ + attributionMapper: /** @type {s.Schema} */ (s.$function).nullable.optional, + attributedNodes: /** @type {s.Schema} */ (s.$function).nullable.optional, + customCompare: /** @type {s.Schema} */ (s.$function).nullable.optional, ++ initialContentCompare: /** @type {s.Schema} */ (s.$function).nullable.optional, + binding: /** @type {s.Schema>} */ (s.$instanceOf(Binding)).nullable.optional, + change: /** @type {s.Schema>} */ (s.$any).nullable.optional + }) +@@ -182,7 +189,8 @@ const warnUnsupportedAttributionMarks = (schema) => { + * @param {Y.Doc} [opts.suggestionDoc] A {@link Y.Doc} to use for suggestion tracking + * @param {AttributionMapper} [opts.mapAttributionToMark] A function to map the {@link Y.ContentAttribute} to a {@link import('prosemirror-model').Mark} - the mark names *must* be one of: `y-attributed-insert`, `y-attributed-delete`, `y-attributed-format`, `y-attributed-attrs`. No other mark names are permitted. `y-attributed-attrs` is the node-level mark for *attribute* changes (e.g. a suggested heading-level change): it is materialized automatically when the schema declares it (declare `attrs: { changes: { default: null } }` and — unlike the other three — keep the DEFAULT `excludes`, so a re-render *replaces* the mark instead of stacking instances). Its payload is not routed through the mapper by default; a mapper may take control by emitting the `y-attributed-attrs` key. + * @param {AttributedNodesPredicate} [opts.attributedNodes] Optional predicate `(nodeName, kinds) => boolean`. When it returns `true` for an attributed node *and* a `{nodeName}--attributed` type exists in the schema, that node is rendered under the variant type (the `y-attributed-*` marks are still applied). `kinds` is `{ insert?, delete?, format? }`. The variant is a pure rendering concern - the canonical name is what is stored in the Y document. The predicate must be deterministic in `(nodeName, kinds)`. +- * @param {NodeCompare} [opts.customCompare] Optional predicate `(a, b) => boolean` that shifts the *diffing boundary*. To sync, y-prosemirror diffs the ProseMirror doc against the Y document as `lib0/delta` trees; lib0's `diff` decides for each candidate node pair whether to pair them (diff *in place* via a `modify` op) or to **replace the old subtree wholesale** (delete + insert). By default a pair is matched purely on node name (`a.name === b.name`). Supply this to move the boundary - e.g. make a `blockContainer` only pair when its first child type also matches (`(a, b) => a.name === b.name && (a.name !== 'blockContainer' || firstChildName(a) === firstChildName(b))`), so changing the first child replaces the whole container instead of editing it in place. Receives the raw `lib0/delta` nodes `(fromNode, toNode)` (each exposing `.name`, `.attrs`, `.children`) and is forwarded to `lib0/delta.diff` as its `compare` option, applied recursively down the tree. Generally keep the `a.name === b.name` check; omit the option to keep lib0's name-only default. ++ * @param {NodeCompare} [opts.customCompare] Optional predicate `(a, b) => boolean` that shifts the *diffing boundary*. To sync, y-prosemirror diffs the ProseMirror doc against the Y document as `lib0/delta` trees; lib0's `diff` decides for each candidate node pair whether to pair them (diff *in place* via a `modify` op) or to **replace the old subtree wholesale** (delete + insert). By default a pair is matched purely on node name (`a.name === b.name`). Supply this to move the boundary - e.g. make a `blockContainer` only pair when its first child type also matches (`(a, b) => a.name === b.name && (a.name !== 'blockContainer' || firstChildName(a) === firstChildName(b))`), so changing the first child replaces the whole container instead of editing it in place. Receives the raw `lib0/delta` nodes `(fromNode, toNode)` (each exposing `.name`, `.attrs`, `.children`) and is forwarded to `lib0/delta.diff` as its `compare` option, applied recursively down the tree. Generally keep the `a.name === b.name` check; omit the option to keep lib0's name-only default. ++ * @param {InitialContentCompare} [opts.initialContentCompare] Optional predicate `(doc) => boolean` deciding whether the current ProseMirror document is the integrator's initial (empty) state that must not be written into an empty ytype at bind time (see "Initial-content gate" in {@link ProsemirrorRdt}'s doc). Return `true` to arm the gate for this document, `false` to sync it immediately. Omit the option to keep the default check (document fingerprint equals the schema's `createAndFill()` default). Only consulted when the ytype has no children. + * @param {Array<(($d: s.Schema) => dt.Template)>} [opts.transformers] Optional custom transformer stages, slotted into the pipeline **between** the built-in compat flattening stage ({@link inlineAnonymousNodes}) and `attributionToFormat`, in data→view (`applyA`) order (i.e. before the closing `attributionToFormat` / {@link swallowFormats} pair). Each is a `$d => Template` factory (see `lib0/delta/transformer`); the input schema is threaded left to right. Custom transformers see changes in the flattened document space (old-representation anonymous text containers already spliced into their parents), with the complete accumulated attribution on every attribution-bearing op. + * @param {null|((err:Error,errCode:number)=>any)} [opts.onInternalError] Listen to internal + * errors for debugging purposes. This API is unstable and can be changed/removed at any time! +@@ -214,6 +222,7 @@ export const syncPlugin = (opts = {}) => { + attributionMapper: opts.mapAttributionToMark || defaultMapAttributionToMark, + attributedNodes: opts.attributedNodes || defaultAttributedNodes, + customCompare: opts.customCompare || null, ++ initialContentCompare: opts.initialContentCompare || null, + binding: null + }) + }, +@@ -296,6 +305,7 @@ export const syncPlugin = (opts = {}) => { + // an empty ytype must not receive the editor's schema-default + // content — see "Initial-content gate" in ProsemirrorRdt's doc + gateInitialContent: ytype.length === 0, ++ initialContentCompare: pluginState.initialContentCompare, + getMeta: () => $syncPluginStateUpdate.expect({ + change: null, + renderer: pluginState.renderer, +@@ -351,7 +361,8 @@ export const syncPlugin = (opts = {}) => { + prevPluginState?.renderer !== pluginState.renderer || + prevPluginState?.attributionMapper !== pluginState.attributionMapper || + prevPluginState?.attributedNodes !== pluginState.attributedNodes || +- prevPluginState?.customCompare !== pluginState.customCompare ++ prevPluginState?.customCompare !== pluginState.customCompare || ++ prevPluginState?.initialContentCompare !== pluginState.initialContentCompare + ) { + setup(view, pluginState) + } +diff --git a/src/sync-utils.js b/src/sync-utils.js +index 40ebaec7ae1a4fa51f30af5eeca43228bf6579fb..b6f25b11d74821393cb4c4e3d145b1d2d90b762a 100644 +--- a/src/sync-utils.js ++++ b/src/sync-utils.js +@@ -575,10 +575,11 @@ export function fragmentToTr (fragment, tr, { + * Transforms a {@link Y.XmlFragment} into a {@link Node} + * @param {Y.Node} fragment + * @param {import('prosemirror-state').Transaction} tr ++ * @param {Parameters[2]} [ctx] + * @return {Node} + */ +-export function fragmentToPm (fragment, tr) { +- return fragmentToTr(fragment, tr).doc ++export function fragmentToPm (fragment, tr, ctx) { ++ return fragmentToTr(fragment, tr, ctx).doc + } + + /** diff --git a/patches/@y__prosemirror@2.0.0-6.patch b/patches/@y__prosemirror@2.0.0-6.patch deleted file mode 100644 index b1685b7218..0000000000 --- a/patches/@y__prosemirror@2.0.0-6.patch +++ /dev/null @@ -1,351 +0,0 @@ -diff --git a/dist/demo/prosemirror.d.ts b/dist/demo/prosemirror.d.ts -deleted file mode 100644 -index c9b8da026e73cfa5b83aeed606cf289c6da79667..0000000000000000000000000000000000000000 -diff --git a/dist/demo/prosemirror.d.ts.map b/dist/demo/prosemirror.d.ts.map -deleted file mode 100644 -index 60f5203a9f44de836b05155064898b3709836949..0000000000000000000000000000000000000000 -diff --git a/dist/demo/schema.d.ts b/dist/demo/schema.d.ts -deleted file mode 100644 -index 579716a4a0af3c62efed3fdd6f5d2a24704e617c..0000000000000000000000000000000000000000 -diff --git a/dist/demo/schema.d.ts.map b/dist/demo/schema.d.ts.map -deleted file mode 100644 -index f7879c19424714d1c0314eadd81aee0d3047f84d..0000000000000000000000000000000000000000 -diff --git a/dist/src/commands.d.ts b/dist/src/commands.d.ts -index 62f626eb65c508c8e7adcf43d2018ff1d1bf6efd..667ddbb86d379f6229f8c1e2f4645ccbd2b6397a 100644 ---- a/dist/src/commands.d.ts -+++ b/dist/src/commands.d.ts -@@ -1,10 +1,4 @@ --/** -- * Switch to pause mode (stop synchronization between prosemirror and ytype) -- * @param {import('prosemirror-state').EditorState} state -- * @param {((tr: import('prosemirror-state').Transaction) => void)?} dispatch -- * @returns {boolean} -- */ --export function pauseSync(state: import("prosemirror-state").EditorState, dispatch: ((tr: import("prosemirror-state").Transaction) => void) | null): boolean; -+export function pauseSync(state: import("prosemirror-state").EditorState, dispatch?: (tr: import("prosemirror-state").Transaction) => void, view?: import("prosemirror-view").EditorView): boolean; - export function configureYProsemirror(opts?: { - ytype?: Y.Type | null | undefined; - renderer?: Y.AbstractRenderer | null | undefined; -diff --git a/dist/src/commands.d.ts.map b/dist/src/commands.d.ts.map -index b3d7c4d9867667dd121690a6879d9886eb31db77..8e7d9a04e4988d661a0cc8a083761d2a8a16cf47 100644 ---- a/dist/src/commands.d.ts.map -+++ b/dist/src/commands.d.ts.map -@@ -1 +1 @@ --{"version":3,"file":"commands.d.ts","sourceRoot":"","sources":["../../src/commands.js"],"names":[],"mappings":"AAIA;;;;;GAKG;AACH,iCAJW,OAAO,mBAAmB,EAAE,WAAW,YACvC,CAAC,CAAC,EAAE,EAAE,OAAO,mBAAmB,EAAE,WAAW,KAAK,IAAI,CAAC,OAAC,GACtD,OAAO,CAanB;AAkBM,6CAJJ;IAAsB,KAAK;IACF,QAAQ;CACjC,GAAU,OAAO,mBAAmB,EAAE,OAAO,CAa/C;AAQM,4BAHI,OAAO,mBAAmB,EAAE,WAAW,GACtC,OAAO,CAEqE;AAQjF,4BAHI,OAAO,mBAAmB,EAAE,WAAW,GACtC,OAAO,CAEqE;AAExF;;GAEG;AACH,0BAFU,OAAO,mBAAmB,EAAE,OAAO,CAEqG;AAElJ;;GAEG;AACH,0BAFU,OAAO,mBAAmB,EAAE,OAAO,CAEqG;AAQ3I,qCAJI,MAAM,QACN,MAAM,GACJ,OAAO,mBAAmB,EAAE,OAAO,CAc/C;AAQM,qCAJI,MAAM,QACN,MAAM,GACJ,OAAO,mBAAmB,EAAE,OAAO,CAc/C;AAMM,oCAFM,OAAO,mBAAmB,EAAE,OAAO,CAW/C;AAMM,oCAFM,OAAO,mBAAmB,EAAE,OAAO,CAW/C;mBAjJkB,MAAM"} -\ No newline at end of file -+{"version":3,"file":"commands.d.ts","sourceRoot":"","sources":["../../src/commands.js"],"names":[],"mappings":";AAqCO,6CAJJ;IAAsB,KAAK;IACF,QAAQ;CACjC,GAAU,OAAO,mBAAmB,EAAE,OAAO,CAa/C;AAQM,4BAHI,OAAO,mBAAmB,EAAE,WAAW,GACtC,OAAO,CAEqE;AAQjF,4BAHI,OAAO,mBAAmB,EAAE,WAAW,GACtC,OAAO,CAEqE;AAExF;;GAEG;AACH,0BAFU,OAAO,mBAAmB,EAAE,OAAO,CAEqG;AAElJ;;GAEG;AACH,0BAFU,OAAO,mBAAmB,EAAE,OAAO,CAEqG;AAQ3I,qCAJI,MAAM,QACN,MAAM,GACJ,OAAO,mBAAmB,EAAE,OAAO,CAc/C;AAQM,qCAJI,MAAM,QACN,MAAM,GACJ,OAAO,mBAAmB,EAAE,OAAO,CAc/C;AAMM,oCAFM,OAAO,mBAAmB,EAAE,OAAO,CAW/C;AAMM,oCAFM,OAAO,mBAAmB,EAAE,OAAO,CAW/C;mBA/IkB,MAAM"} -\ No newline at end of file -diff --git a/dist/src/index.d.ts b/dist/src/index.d.ts -index d4c634df59f3695525e1be3b1fc89e3598e6edca..1a26ef98745535025578d9e9369cfcffad2dd3c8 100644 ---- a/dist/src/index.d.ts -+++ b/dist/src/index.d.ts -@@ -6,5 +6,5 @@ export * from "./undo-plugin.js"; - export * from "./cursor-plugin.js"; - export { YSyncRdt } from "./rdt/y-sync.js"; - export { ProsemirrorRdt } from "./rdt/prosemirror.js"; --export { docToDelta, $prosemirrorDelta, defaultMapAttributionToMark, defaultAttributionConf, attributionMapperToConf, yattr2markname, pmToFragment, fragmentToPm } from "./sync-utils.js"; -+export { docToDelta, $prosemirrorDelta, defaultMapAttributionToMark, defaultAttributionConf, attributionMapperToConf, yattr2markname, pmToFragment, fragmentToPm, deltaAttributionToFormat, deltaToPNode, deltaToPSteps, nodeToDelta } from "./sync-utils.js"; - //# sourceMappingURL=index.d.ts.map -\ No newline at end of file -diff --git a/dist/src/rdt/prosemirror.d.ts.map b/dist/src/rdt/prosemirror.d.ts.map -index 7656d2d019bcfc3ac8e587a54186e2510c60cb73..a99625f58988dea8f7e220cabbd0c46ac0055aa0 100644 ---- a/dist/src/rdt/prosemirror.d.ts.map -+++ b/dist/src/rdt/prosemirror.d.ts.map -@@ -1 +1 @@ --{"version":3,"file":"prosemirror.d.ts","sourceRoot":"","sources":["../../../src/rdt/prosemirror.js"],"names":[],"mappings":"AAsCA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAuDG;AACH;WAFmC,CAAC,CAAC,EAAE,gBAAgB,EAAE,MAAM,EAAE,GAAG,KAAK,IAAI;aAAW,CAAC,GAAG,EAAE,cAAc,KAAK,IAAI;;IAGnH;;;;;;;;;;OAUG;IACH,6EATG;QAAoD,IAAI,EAAhD,OAAO,kBAAkB,EAAE,UAAU;QACL,eAAe;QAC3B,OAAO;QACX,OAAO,EAAvB,MAAM,GAAG;QAEM,kBAAkB;KAG3C,EAgCA;IA7BC,4CAAgB;IAChB,0CAAsC;IACtC,iCAAmC;IACnC,eAXe,GAAG,CAWI;IACtB;;;;;;;QAA+B;IAI/B;;;;;;OAMG;IACH,qBAFU,MAAM,OAAC,CAEsG;IACvH;;OAEG;IACH,QAFU,gBAAgB,CAE+D;IACzF,mBAAsB;IACtB;;;;;;OAMG;IACH,mBAAsB;IAGxB;;;OAGG;IACH,0BAEC;IAED;;;;;OAKG;IACH,aAFY,gBAAgB,CAI3B;IAED;;;OAGG;IACH,cAHW,OAAO,mBAAmB,EAAE,WAAW,GACtC,OAAO,CAalB;IAED;;;;OAIG;IACH,YAFY,OAAO,CAoBlB;IAED;;;;;OAKG;IACH,aA2BC;IAED;;;;;;;;;;;;;;;;OAgBG;IACH,cAJW,gBAAgB,UAChB,GAAG,GACF,KAAK,CAAC,YAAY,CAAC,GAAG,CAAC,GAAG,IAAI,CA4CzC;CAMF;6BA3S4B,iBAAiB;uBACvB,YAAY"} -\ No newline at end of file -+{"version":3,"file":"prosemirror.d.ts","sourceRoot":"","sources":["../../../src/rdt/prosemirror.js"],"names":[],"mappings":"AAsCA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAuDG;AACH;WAFmC,CAAC,CAAC,EAAE,gBAAgB,EAAE,MAAM,EAAE,GAAG,KAAK,IAAI;aAAW,CAAC,GAAG,EAAE,cAAc,KAAK,IAAI;;IAGnH;;;;;;;;;;OAUG;IACH,6EATG;QAAoD,IAAI,EAAhD,OAAO,kBAAkB,EAAE,UAAU;QACL,eAAe;QAC3B,OAAO;QACX,OAAO,EAAvB,MAAM,GAAG;QAEM,kBAAkB;KAG3C,EAgCA;IA7BC,4CAAgB;IAChB,0CAAsC;IACtC,iCAAmC;IACnC,eAXe,GAAG,CAWI;IACtB;;;;;;;QAA+B;IAI/B;;;;;;OAMG;IACH,qBAFU,MAAM,OAAC,CAEsG;IACvH;;OAEG;IACH,QAFU,gBAAgB,CAE+D;IACzF,mBAAsB;IACtB;;;;;;OAMG;IACH,mBAAsB;IAGxB;;;OAGG;IACH,0BAEC;IAED;;;;;OAKG;IACH,aAFY,gBAAgB,CAI3B;IAED;;;OAGG;IACH,cAHW,OAAO,mBAAmB,EAAE,WAAW,GACtC,OAAO,CAalB;IAED;;;;OAIG;IACH,YAFY,OAAO,CAoBlB;IAED;;;;;OAKG;IACH,aA2BC;IAED;;;;;;;;;;;;;;;;OAgBG;IACH,cAJW,gBAAgB,UAChB,GAAG,GACF,KAAK,CAAC,YAAY,CAAC,GAAG,CAAC,GAAG,IAAI,CAsDzC;CAMF;6BArT4B,iBAAiB;uBACvB,YAAY"} -\ No newline at end of file -diff --git a/dist/src/sync-utils.d.ts b/dist/src/sync-utils.d.ts -index 539b2a70ae5d41575fa0f03c85e95d5d0f98165d..3fbb973027104938642e3c88ae588261323ec206 100644 ---- a/dist/src/sync-utils.d.ts -+++ b/dist/src/sync-utils.d.ts -@@ -111,7 +111,7 @@ export function docToDelta(doc: Node): delta.Delta<{ - export function deltaToPSteps(tr: import("prosemirror-state").Transaction, d: ProsemirrorDelta, pnode?: Node, currPos?: { - i: number; - }, attributedNodes?: AttributedNodesPredicate): import("prosemirror-state").Transaction; --export function deltaToPNode(d: ProsemirrorDelta, schema: import("prosemirror-model").Schema, dformat: delta.Formats | null, attributedNodes?: AttributedNodesPredicate): Node; -+export function deltaToPNode(d: ProsemirrorDelta, schema: import("prosemirror-model").Schema, dformat: delta.Formats | null, attributedNodes?: AttributedNodesPredicate): Node | null; - export function docDiffToDelta(beforeDoc: Node, afterDoc: Node): delta.Delta<{ - name: string; - attrs: { -diff --git a/dist/src/sync-utils.d.ts.map b/dist/src/sync-utils.d.ts.map -index 29b2331b710abffb5bdc9e070738eefde9e297c2..0530725291c7b9c26ea34cf9d696321afeb4e0d9 100644 ---- a/dist/src/sync-utils.d.ts.map -+++ b/dist/src/sync-utils.d.ts.map -@@ -1 +1 @@ --{"version":3,"file":"sync-utils.d.ts","sourceRoot":"","sources":["../../src/sync-utils.js"],"names":[],"mappings":"AA0WA;;;;;;;GAOG;AACH,mCANW,IAAI,YACJ,CAAC,CAAC,IAAI,iBAEd;IAAmC,QAAQ;CAC3C,GAAU,CAAC,CAAC,IAAI,CASlB;AAED;;;;;;;;;GASG;AACH,uCARW,CAAC,CAAC,IAAI,MACN,OAAO,mBAAmB,EAAE,WAAW,wDAE/C;IAAkC,QAAQ;IACO,oBAAoB,KA3RxB,CAAC,SAApC,OAAQ,YAAY,EAAE,WAAY,UACpC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,eAC9B,CAAC,KACC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI;IAyRD,eAAe;CACtD,GAAU,OAAO,mBAAmB,EAAE,WAAW,CAiBnD;AAED;;;;;GAKG;AACH,uCAJW,CAAC,CAAC,IAAI,MACN,OAAO,mBAAmB,EAAE,WAAW,GACtC,IAAI,CAIf;AAgZD;;;;;;;;;;;;;;;;;GAiBG;AACH,oCAJW,IAAI,mBACJ,MAAM,GACL,MAAM,EAAE,CAwBnB;AAED;;;;;GAKG;AACH,yCAJW,MAAM,EAAE,QACR,IAAI,GACH,MAAM,CAgCjB;AAx2BD;;;;;;;IAA4I;AAE5I;;;;;;;GAOG;AACH,gCAAiC,cAAc,CAAA;AAE/C;;;;;GAKG;AACH,qCAFU,wBAAwB,CAEe;AAS1C,wCAHI,MAAM,GACL,MAAM,CAKR;AAcH,iDANI,MAAM,UACN,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,GAAG,SAAS,mBAC1C,wBAAwB,UACxB,OAAO,mBAAmB,EAAE,MAAM,GACjC,MAAM,CAajB;AAgCM,4CALyC,CAAC,SAApC,OAAQ,YAAY,EAAE,WAAY,UACpC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,eAC9B,CAAC,GACC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAiC1C;AAwEM,gDAHI,iBAAiB,GAChB,eAAe,CAMzB;AAEF;;;;;;GAMG;AACH,qCAFU,eAAe,CAEiE;AAOnF,4CAHI,KAAK,CAAC,QAAQ,mCA4CL,gBAAgB,CACnC;AAqBM,yCAHI,MAAM,GACL,MAAM,CAE2E;AAmDtF,wDAHI;IAAC,CAAC,GAAG,EAAC,MAAM,GAAE,GAAG,CAAA;CAAC,GAAC,IAAI,UACvB,OAAO,mBAAmB,EAAE,MAAM,sCAGwE;AAM9G,iCAHI,KAAK,CAAC,IAAI,CAAC,GACV,gBAAgB,CAW3B;AAgEM,+BAPI,IAAI,aACJ,MAAM,OAAC,iBACP,OAAO,GAGN,gBAAgB,CAoB3B;AAKM,gCAFI,IAAI;;;;;;;GAEwC;AAyEhD,kCAPI,OAAO,mBAAmB,EAAE,WAAW,KACvC,gBAAgB,UAChB,IAAI,YACJ;IAAE,CAAC,EAAE,MAAM,CAAA;CAAE,oBACb,wBAAwB,GACvB,OAAO,mBAAmB,EAAE,WAAW,CA6JlD;AASM,gCANI,gBAAgB,UAChB,OAAO,mBAAmB,EAAE,MAAM,WAClC,KAAK,CAAC,OAAO,GAAC,IAAI,oBAClB,wBAAwB,GACvB,IAAI,CAkCf;AAMM,0CAHI,IAAI,YACJ,IAAI;;;;;;;GAMd;AAKM,8BAFI,OAAO,mBAAmB,EAAE,WAAW;;;;;;;GAkBjD;AA+CM,kCAJI,OAAO,uBAAuB,EAAE,IAAI,aACpC,OAAO,mBAAmB,EAAE,IAAI,GAC/B,gBAAgB,CAQ3B;AAoGM,wCALI,IAAI,YACJ,MAAM,OACN,CAAC,CAAC,EAAC,KAAK,CAAC,eAAe,KAAG,GAAG,GAC7B,gBAAgB,CAa3B;;;;;;;;;;;;;qCA7sBY,CAAC,CAAC,EAAE,OAAO,YAAY,EAAE,WAAW,KAAK,GAAG;;;;;;;;;;;;;8BAC5C;IAAE,MAAM,CAAC,EAAE,sBAAsB,CAAC;IAAC,MAAM,CAAC,EAAE,sBAAsB,CAAC;IAAC,MAAM,CAAC,EAAE,sBAAsB,CAAC;IAAC,KAAK,CAAC,EAAE,sBAAsB,CAAA;CAAE;;;;;iCAiTrI,KAAK,CAAC,aAAa;;;;;;;;;aAQlB,KAAK,CAAC,KAAK,CAAC,QAAQ,CAAC,GAAG,CAAC,GAAC,KAAK,CAAC,MAAM,CAAC;;;;aACvC,KAAK,CAAC,KAAK,CAAC,QAAQ,CAAC;;qBAtfG,mBAAmB;mBAPtC,MAAM;uBAEF,YAAY;mBAIhB,aAAa"} -\ No newline at end of file -+{"version":3,"file":"sync-utils.d.ts","sourceRoot":"","sources":["../../src/sync-utils.js"],"names":[],"mappings":"AA0WA;;;;;;;GAOG;AACH,mCANW,IAAI,YACJ,CAAC,CAAC,IAAI,iBAEd;IAAmC,QAAQ;CAC3C,GAAU,CAAC,CAAC,IAAI,CASlB;AAED;;;;;;;;;GASG;AACH,uCARW,CAAC,CAAC,IAAI,MACN,OAAO,mBAAmB,EAAE,WAAW,wDAE/C;IAAkC,QAAQ;IACO,oBAAoB,KA3RxB,CAAC,SAApC,OAAQ,YAAY,EAAE,WAAY,UACpC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,eAC9B,CAAC,KACC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI;IAyRD,eAAe;CACtD,GAAU,OAAO,mBAAmB,EAAE,WAAW,CAiBnD;AAED;;;;;GAKG;AACH,uCAJW,CAAC,CAAC,IAAI,MACN,OAAO,mBAAmB,EAAE,WAAW,GACtC,IAAI,CAIf;AA4bD;;;;;;;;;;;;;;;;;GAiBG;AACH,oCAJW,IAAI,mBACJ,MAAM,GACL,MAAM,EAAE,CAwBnB;AAED;;;;;GAKG;AACH,yCAJW,MAAM,EAAE,QACR,IAAI,GACH,MAAM,CAgCjB;AAp5BD;;;;;;;IAA4I;AAE5I;;;;;;;GAOG;AACH,gCAAiC,cAAc,CAAA;AAE/C;;;;;GAKG;AACH,qCAFU,wBAAwB,CAEe;AAS1C,wCAHI,MAAM,GACL,MAAM,CAKR;AAcH,iDANI,MAAM,UACN,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,GAAG,SAAS,mBAC1C,wBAAwB,UACxB,OAAO,mBAAmB,EAAE,MAAM,GACjC,MAAM,CAajB;AAgCM,4CALyC,CAAC,SAApC,OAAQ,YAAY,EAAE,WAAY,UACpC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,eAC9B,CAAC,GACC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAiC1C;AAwEM,gDAHI,iBAAiB,GAChB,eAAe,CAMzB;AAEF;;;;;;GAMG;AACH,qCAFU,eAAe,CAEiE;AAOnF,4CAHI,KAAK,CAAC,QAAQ,mCA4CL,gBAAgB,CACnC;AAqBM,yCAHI,MAAM,GACL,MAAM,CAE2E;AAmDtF,wDAHI;IAAC,CAAC,GAAG,EAAC,MAAM,GAAE,GAAG,CAAA;CAAC,GAAC,IAAI,UACvB,OAAO,mBAAmB,EAAE,MAAM,sCAGwE;AAM9G,iCAHI,KAAK,CAAC,IAAI,CAAC,GACV,gBAAgB,CAW3B;AAgEM,+BAPI,IAAI,aACJ,MAAM,OAAC,iBACP,OAAO,GAGN,gBAAgB,CAoB3B;AAKM,gCAFI,IAAI;;;;;;;GAEwC;AAyEhD,kCAPI,OAAO,mBAAmB,EAAE,WAAW,KACvC,gBAAgB,UAChB,IAAI,YACJ;IAAE,CAAC,EAAE,MAAM,CAAA;CAAE,oBACb,wBAAwB,GACvB,OAAO,mBAAmB,EAAE,WAAW,CAoKlD;AAsBM,gCANI,gBAAgB,UAChB,OAAO,mBAAmB,EAAE,MAAM,WAClC,KAAK,CAAC,OAAO,GAAC,IAAI,oBAClB,wBAAwB,GACvB,IAAI,GAAC,IAAI,CA0DpB;AAMM,0CAHI,IAAI,YACJ,IAAI;;;;;;;GAMd;AAKM,8BAFI,OAAO,mBAAmB,EAAE,WAAW;;;;;;;GAkBjD;AA+CM,kCAJI,OAAO,uBAAuB,EAAE,IAAI,aACpC,OAAO,mBAAmB,EAAE,IAAI,GAC/B,gBAAgB,CAQ3B;AAoGM,wCALI,IAAI,YACJ,MAAM,OACN,CAAC,CAAC,EAAC,KAAK,CAAC,eAAe,KAAG,GAAG,GAC7B,gBAAgB,CAa3B;;;;;;;;;;;;;qCAzvBY,CAAC,CAAC,EAAE,OAAO,YAAY,EAAE,WAAW,KAAK,GAAG;;;;;;;;;;;;;8BAC5C;IAAE,MAAM,CAAC,EAAE,sBAAsB,CAAC;IAAC,MAAM,CAAC,EAAE,sBAAsB,CAAC;IAAC,MAAM,CAAC,EAAE,sBAAsB,CAAC;IAAC,KAAK,CAAC,EAAE,sBAAsB,CAAA;CAAE;;;;;iCAiTrI,KAAK,CAAC,aAAa;;;;;;;;;aAQlB,KAAK,CAAC,KAAK,CAAC,QAAQ,CAAC,GAAG,CAAC,GAAC,KAAK,CAAC,MAAM,CAAC;;;;aACvC,KAAK,CAAC,KAAK,CAAC,QAAQ,CAAC;;qBAtfG,mBAAmB;mBAPtC,MAAM;uBAEF,YAAY;mBAIhB,aAAa"} -\ No newline at end of file -diff --git a/dist/tests/attributed-nodes.test.d.ts b/dist/tests/attributed-nodes.test.d.ts -deleted file mode 100644 -index e6935d6a014cf43be563ee160c9f74f47abec7b8..0000000000000000000000000000000000000000 -diff --git a/dist/tests/attributed-nodes.test.d.ts.map b/dist/tests/attributed-nodes.test.d.ts.map -deleted file mode 100644 -index 6ffb87ae68ef83567536bcae750eb39810a8ac75..0000000000000000000000000000000000000000 -diff --git a/dist/tests/cohort.d.ts b/dist/tests/cohort.d.ts -deleted file mode 100644 -index 98770d13fccc4e0f1bf6607717004e1acfc4a7b5..0000000000000000000000000000000000000000 -diff --git a/dist/tests/cohort.d.ts.map b/dist/tests/cohort.d.ts.map -deleted file mode 100644 -index 7c2422c1660824964d415d0eb161c027af7a1b65..0000000000000000000000000000000000000000 -diff --git a/dist/tests/commands.test.d.ts b/dist/tests/commands.test.d.ts -deleted file mode 100644 -index a146af7a8e362fb103c61286c6702e8c1fbd82ca..0000000000000000000000000000000000000000 -diff --git a/dist/tests/commands.test.d.ts.map b/dist/tests/commands.test.d.ts.map -deleted file mode 100644 -index 157cbef2ebd45c39253cb502934622d0e05e1abe..0000000000000000000000000000000000000000 -diff --git a/dist/tests/complexSchema.d.ts b/dist/tests/complexSchema.d.ts -deleted file mode 100644 -index d515c309d65bf0cb25eb2f5d0f17ba6c580a9966..0000000000000000000000000000000000000000 -diff --git a/dist/tests/complexSchema.d.ts.map b/dist/tests/complexSchema.d.ts.map -deleted file mode 100644 -index 6100c0e504b30b3bd55253dfaa8be562a3f95d6c..0000000000000000000000000000000000000000 -diff --git a/dist/tests/cursor.test.d.ts b/dist/tests/cursor.test.d.ts -deleted file mode 100644 -index 2fcbb1cad1c80056bcd6bbad5836e5fdec5ee3f9..0000000000000000000000000000000000000000 -diff --git a/dist/tests/cursor.test.d.ts.map b/dist/tests/cursor.test.d.ts.map -deleted file mode 100644 -index 24b239543f8c36e897282c130cc6941924f18a5b..0000000000000000000000000000000000000000 -diff --git a/dist/tests/custom-compare.test.d.ts b/dist/tests/custom-compare.test.d.ts -deleted file mode 100644 -index f203e46a5f37505b7098d9e960ab73f7e8abc44c..0000000000000000000000000000000000000000 -diff --git a/dist/tests/custom-compare.test.d.ts.map b/dist/tests/custom-compare.test.d.ts.map -deleted file mode 100644 -index ca5fae799c4636d7992604e86dbe710326556d5d..0000000000000000000000000000000000000000 -diff --git a/dist/tests/delta.test.d.ts b/dist/tests/delta.test.d.ts -deleted file mode 100644 -index ec16d0836b3b5f1b9bc48b6ae1193eba09dd050b..0000000000000000000000000000000000000000 -diff --git a/dist/tests/delta.test.d.ts.map b/dist/tests/delta.test.d.ts.map -deleted file mode 100644 -index a9b33d6a6fc09f298f7cba094e233930a8980763..0000000000000000000000000000000000000000 -diff --git a/dist/tests/index.d.ts b/dist/tests/index.d.ts -deleted file mode 100644 -index e26a57a8ca84c682b2b77b57b9d6e340ffd33436..0000000000000000000000000000000000000000 -diff --git a/dist/tests/index.d.ts.map b/dist/tests/index.d.ts.map -deleted file mode 100644 -index fe3992828209916ff4b2412cee13d0f522d1a1e5..0000000000000000000000000000000000000000 -diff --git a/dist/tests/index.node.d.ts b/dist/tests/index.node.d.ts -deleted file mode 100644 -index 95867294f443b797ca7f2ae869106fa46ca530ab..0000000000000000000000000000000000000000 -diff --git a/dist/tests/index.node.d.ts.map b/dist/tests/index.node.d.ts.map -deleted file mode 100644 -index 0b2bb0a8c721e902506511717d4d4f052932ddb5..0000000000000000000000000000000000000000 -diff --git a/dist/tests/overlapping-marks.test.d.ts b/dist/tests/overlapping-marks.test.d.ts -deleted file mode 100644 -index e10fa41bcbbe47a8bd2836844d58d2d9e5ce8605..0000000000000000000000000000000000000000 -diff --git a/dist/tests/overlapping-marks.test.d.ts.map b/dist/tests/overlapping-marks.test.d.ts.map -deleted file mode 100644 -index 3721a2a35a0232f4d25294432662297290edf81e..0000000000000000000000000000000000000000 -diff --git a/dist/tests/positions.test.d.ts b/dist/tests/positions.test.d.ts -deleted file mode 100644 -index bb857eb74e21b89f3fc69516b051bcd0b545d449..0000000000000000000000000000000000000000 -diff --git a/dist/tests/positions.test.d.ts.map b/dist/tests/positions.test.d.ts.map -deleted file mode 100644 -index ec25283e531e4a23b448f978695f31e3dd44f1db..0000000000000000000000000000000000000000 -diff --git a/dist/tests/suggestion-simulation.test.d.ts b/dist/tests/suggestion-simulation.test.d.ts -deleted file mode 100644 -index 540700ae85d4d6e30c29fd26ee4ff6ddba0ade84..0000000000000000000000000000000000000000 -diff --git a/dist/tests/suggestion-simulation.test.d.ts.map b/dist/tests/suggestion-simulation.test.d.ts.map -deleted file mode 100644 -index c30a4996afb1de798df52e01848b1ffed1ef1a30..0000000000000000000000000000000000000000 -diff --git a/dist/tests/suggestions.test.d.ts b/dist/tests/suggestions.test.d.ts -deleted file mode 100644 -index 6d8f00814d8604e8a30eb07ae3c825fb40188d31..0000000000000000000000000000000000000000 -diff --git a/dist/tests/suggestions.test.d.ts.map b/dist/tests/suggestions.test.d.ts.map -deleted file mode 100644 -index 437a8e751a2d2fb89cde921c267d23b40e3be834..0000000000000000000000000000000000000000 -diff --git a/dist/tests/tr.test.d.ts b/dist/tests/tr.test.d.ts -deleted file mode 100644 -index 00781bfbf6cdda67b9a832291fef255c1365396e..0000000000000000000000000000000000000000 -diff --git a/dist/tests/tr.test.d.ts.map b/dist/tests/tr.test.d.ts.map -deleted file mode 100644 -index 64d56446779ef951b09d0c5dc5a1a1da7c6ccefc..0000000000000000000000000000000000000000 -diff --git a/dist/tests/undo.test.d.ts b/dist/tests/undo.test.d.ts -deleted file mode 100644 -index 73304221437551cc5e959abe3868f1ebcfe2acad..0000000000000000000000000000000000000000 -diff --git a/dist/tests/undo.test.d.ts.map b/dist/tests/undo.test.d.ts.map -deleted file mode 100644 -index e275eb3b866b96b6bb2d1c54290466606a2e65e9..0000000000000000000000000000000000000000 -diff --git a/dist/tests/y-prosemirror.test.d.ts b/dist/tests/y-prosemirror.test.d.ts -deleted file mode 100644 -index a619f8f45b3375c101877bb30fef85676e1ec753..0000000000000000000000000000000000000000 -diff --git a/dist/tests/y-prosemirror.test.d.ts.map b/dist/tests/y-prosemirror.test.d.ts.map -deleted file mode 100644 -index e589c0a78b58b66c071d3cc3e97d32e81bec6643..0000000000000000000000000000000000000000 -diff --git a/dist/tests/y-sync-rdt.test.d.ts b/dist/tests/y-sync-rdt.test.d.ts -deleted file mode 100644 -index 35c4ce6671060e933a0a54c3d94150215bda95c6..0000000000000000000000000000000000000000 -diff --git a/dist/tests/y-sync-rdt.test.d.ts.map b/dist/tests/y-sync-rdt.test.d.ts.map -deleted file mode 100644 -index d1b36fbe2f871825ae9d96abdaf6ddbb47476ecd..0000000000000000000000000000000000000000 -diff --git a/src/commands.js b/src/commands.js -index 3b7ec2ccb88d7863a770c1e0cc98fd72cf2781b5..46755a2009149c221040109b5ecdca28a8578ce8 100644 ---- a/src/commands.js -+++ b/src/commands.js -@@ -4,9 +4,7 @@ import { absolutePositionToRelativePosition } from './positions.js' - - /** - * Switch to pause mode (stop synchronization between prosemirror and ytype) -- * @param {import('prosemirror-state').EditorState} state -- * @param {((tr: import('prosemirror-state').Transaction) => void)?} dispatch -- * @returns {boolean} -+ * @type {import('prosemirror-state').Command} - */ - export function pauseSync (state, dispatch) { - const pluginState = ySyncPluginKey.getState(state) -diff --git a/src/index.js b/src/index.js -index 2e75b5a2dd1674ea88aeb13cde1d43513c353bed..2763e7f5dfb39e6c9323ce76cbb86d12515f2b9a 100644 ---- a/src/index.js -+++ b/src/index.js -@@ -1,7 +1,7 @@ - export * from './sync-plugin.js' - export * from './keys.js' - export * from './positions.js' --export { docToDelta, $prosemirrorDelta, defaultMapAttributionToMark, defaultAttributionConf, attributionMapperToConf, yattr2markname, pmToFragment, fragmentToPm } from './sync-utils.js' -+export { docToDelta, $prosemirrorDelta, defaultMapAttributionToMark, defaultAttributionConf, attributionMapperToConf, yattr2markname, pmToFragment, fragmentToPm, deltaAttributionToFormat, deltaToPNode, deltaToPSteps, nodeToDelta } from './sync-utils.js' - export * from './commands.js' - export * from './undo-plugin.js' - export * from './cursor-plugin.js' -diff --git a/src/rdt/prosemirror.js b/src/rdt/prosemirror.js -index cfc76909965fec6749b92170223bca0bcad537e9..f666671ab1a28d9d660daa78ac2a26348364b6dc 100644 ---- a/src/rdt/prosemirror.js -+++ b/src/rdt/prosemirror.js -@@ -270,7 +270,12 @@ export class ProsemirrorRdt extends ObservableV2 { - // skeleton, and the fix below would write the skeleton into Y. - this._defaultFingerprint = null - tr = this.view.state.tr -- tr.replaceWith(0, tr.doc.content.size, deltaToPNode(/** @type {any} */ (expected), tr.doc.type.schema, null, this.attributedNodes)) -+ const pDoc = deltaToPNode(/** @type {any} */ (expected), tr.doc.type.schema, null, this.attributedNodes) -+ if (pDoc === null) { -+ // The root document itself can't be materialized — nothing to recover. -+ throw new Error('[y/prosemirror]: failed to create document node') -+ } -+ tr.replaceWith(0, tr.doc.content.size, pDoc) - } else { - try { - tr = deltaToPSteps(this.view.state.tr, /** @type {any} */ (d), undefined, undefined, this.attributedNodes) -@@ -278,7 +283,12 @@ export class ProsemirrorRdt extends ObservableV2 { - // Raw steps could not express the change against the schema — replace - // the whole document through ProseMirror's fitting `replaceWith`. - tr = this.view.state.tr -- tr.replaceWith(0, tr.doc.content.size, deltaToPNode(/** @type {any} */ (expected), tr.doc.type.schema, null, this.attributedNodes)) -+ const pDoc = deltaToPNode(/** @type {any} */ (expected), tr.doc.type.schema, null, this.attributedNodes) -+ if (pDoc === null) { -+ // The root document itself can't be materialized — nothing to recover. -+ throw new Error('[y/prosemirror]: failed to create document node') -+ } -+ tr.replaceWith(0, tr.doc.content.size, pDoc) - } - } - if (tr.docChanged && !this._dispatch(tr)) { -diff --git a/src/sync-utils.js b/src/sync-utils.js -index 834e75955b4ff0e0209fcc543893b6a6a401d64b..e62e6c8bfeb970491e28adca4dec4e1a0d613fa2 100644 ---- a/src/sync-utils.js -+++ b/src/sync-utils.js -@@ -638,7 +638,14 @@ export const deltaToPSteps = (tr, d, pnode = tr.doc, currPos = { i: 0 }, attribu - for (const ins of bundle.inserts) { - if (delta.$insertOp.check(ins)) { - for (const n of ins.insert) { -- newPChildren.push(deltaToPNode(n, schema, ins.format, attributedNodes)) -+ const pn = deltaToPNode(n, schema, ins.format, attributedNodes) -+ if (pn === null) { -+ // Can't materialize this node incrementally — bail out so the -+ // caller falls back to the whole-document path, which drops -+ // invalid nodes coherently (#258). -+ throw new Error('[y/prosemirror]: failed to create node: ' + n.name) -+ } -+ newPChildren.push(pn) - } - } else { // text op - newPChildren.push(schema.text(ins.insert, formattingAttributesToMarks(ins.format, schema))) -@@ -676,11 +683,24 @@ export const deltaToPSteps = (tr, d, pnode = tr.doc, currPos = { i: 0 }, attribu - } - - /** -+ * Materialize a delta node as a ProseMirror node, or return null if the local -+ * schema can't represent it (yjs/y-prosemirror#258 — "delete the invalid -+ * node"). A null child is dropped by its parent; the dropped content surfaces -+ * as a "fix" diff in `ProsemirrorRdt.applyDelta`, which the binding -+ * propagates back into the ytype so all peers converge on the same -+ * schema-valid document. Callers that cannot recover from a null root must -+ * throw themselves. Null is returned when: -+ * - the node type doesn't exist in the schema (e.g. a peer runs a newer -+ * schema version) -+ * - the node is missing a required attribute without a default -+ * - the children violate the content expression and dropping the rejected -+ * ones still doesn't produce a fillable node -+ * - * @param {ProsemirrorDelta} d - * @param {import('prosemirror-model').Schema} schema - * @param {delta.Formats|null} dformat - * @param {AttributedNodesPredicate} [attributedNodes] -- * @return {Node} -+ * @return {Node|null} - */ - export const deltaToPNode = (d, schema, dformat, attributedNodes = defaultAttributedNodes) => { - /** -@@ -690,13 +710,12 @@ export const deltaToPNode = (d, schema, dformat, attributedNodes = defaultAttrib - for (const attr of d.attrs) { - attrs[attr.key] = attr.value - } -- const dc = d.children.map(c => delta.$insertOp.check(c) ? c.insert.map(cn => deltaToPNode(cn, schema, c.format, attributedNodes)) : (delta.$textOp.check(c) ? [schema.text(c.insert, formattingAttributesToMarks(c.format, schema))] : [])) -+ const dc = d.children.map(c => delta.$insertOp.check(c) ? c.insert.map(cn => deltaToPNode(cn, schema, c.format, attributedNodes)).filter(cn => cn !== null) : (delta.$textOp.check(c) ? [schema.text(c.insert, formattingAttributesToMarks(c.format, schema))] : [])) - const canonical = d.name == null ? 'doc' : canonicalNodeName(d.name) - const nodeType = schema.nodes[attributedVariant(canonical, dformat, attributedNodes, schema)] - if (!nodeType) { -- throw new Error( -- '[y/prosemirror]: node type does not exist in the schema: ' + d.name -- ) -+ // unknown node type — drop the node (#258) -+ return null - } - const inputChildren = dc.flat(1) - const inputMarks = formattingAttributesToMarks(dformat, schema) -@@ -705,13 +724,38 @@ export const deltaToPNode = (d, schema, dformat, attributedNodes = defaultAttrib - 'y-attributed': true - }, attrs) - : attrs -- const pNode = nodeType.createAndFill( -- finalAttrs, -- inputChildren, -- inputMarks -- ) -- if (pNode === null) { -- throw new Error('[y/prosemirror]: failed to create node: ' + d.name) -+ /** -+ * @type {Node|null} -+ */ -+ let pNode = null -+ try { -+ pNode = nodeType.createAndFill( -+ finalAttrs, -+ inputChildren, -+ inputMarks -+ ) -+ if (pNode === null) { -+ // The children don't satisfy the node's content expression — e.g. two -+ // concurrently-initialized documents were merged, concatenating content -+ // that the schema only allows once (`doc { content: 'blockquote' }` -+ // holding two blockquotes). Greedily keep the children the content -+ // expression accepts and drop the ones it rejects — deterministic, so -+ // every peer materializes the same schema-valid document. -+ const fitted = [] -+ let match = nodeType.contentMatch -+ for (const child of inputChildren) { -+ const nextMatch = match.matchType(child.type) -+ if (nextMatch != null) { -+ match = nextMatch -+ fitted.push(child) -+ } -+ } -+ pNode = nodeType.createAndFill(finalAttrs, fitted, inputMarks) -+ } -+ } catch (_e) { -+ // createAndFill throws e.g. for a missing required attribute without a -+ // default — the node can't be materialized at all; drop it (#258) -+ return null - } - return pNode - } diff --git a/playground/src/examples.gen.tsx b/playground/src/examples.gen.tsx index 2d32e8c524..181bb2ed86 100644 --- a/playground/src/examples.gen.tsx +++ b/playground/src/examples.gen.tsx @@ -1848,8 +1848,8 @@ export const examples = { tags: ["Advanced", "Saving/Loading", "Collaboration"], dependencies: { "@y/protocols": "^1.0.6-rc.1", - "@y/y": "^14.0.0-rc.23", - "@y/prosemirror": "^2.0.0-6", + "@y/y": "^14.0.0-rc.26", + "@y/prosemirror": "^2.0.0-11", "@y/websocket": "^4.0.0-rc.2", } as any, }, @@ -1874,6 +1874,7 @@ export const examples = { "y-websocket": "^2.1.0", yjs: "^13.6.27", lib0: "^0.2.99", + "y-prosemirror": "^1.3.7", } as any, }, title: "Local Storage Versioning (yjs v13)", @@ -1882,7 +1883,7 @@ export const examples = { slug: "collaboration", }, readme: - 'This example shows how to use the `VersioningExtension` with collaborative editing using `yjs` (v13). Snapshots are stored in localStorage using Yjs state updates.\n\n**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.\n\n**Relevant Docs:**\n\n- [Editor Setup](/docs/getting-started/editor-setup)\n- [Real-time collaboration](/docs/features/collaboration)', + 'This example shows how to use the `VersioningExtension` with collaborative editing using `yjs` (v13). Snapshots are stored in localStorage using Yjs state updates.\n\nThe 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.\n\n**Relevant Docs:**\n\n- [Editor Setup](/docs/getting-started/editor-setup)\n- [Real-time collaboration](/docs/features/collaboration)', }, { projectSlug: "multi-doc-versioning", @@ -1896,8 +1897,9 @@ export const examples = { dependencies: { "@y/protocols": "^1.0.6-rc.1", "@y/websocket": "^4.0.0-3", - "@y/y": "^14.0.0-rc.23", - lib0: "1.0.0-rc.22", + "@y/y": "^14.0.0-rc.26", + lib0: "1.0.0-rc.32", + "@y/prosemirror": "^2.0.0-11", } as any, }, title: "YHub Multi-Doc", @@ -1906,7 +1908,7 @@ export const examples = { slug: "collaboration", }, readme: - "This example shows a multi-document collaborative editor with per-document version history, using BlockNote's `VersioningExtension` and Y.js v14.\n\n**Features:**\n\n- User picker (per-tab identity via `sessionStorage`)\n- Left sidebar with document list (create, rename, delete)\n- Collaborative editing with Y.js (including suggestion mode)\n- Right sidebar with version history powered by `VersioningSidebar`\n- Per-document versioning backed by `localStorage`\n- Open multiple tabs with different users via the `?as=` URL param\n\n**Relevant Docs:**\n\n- [Versioning](https://www.blocknotejs.org/docs/collaboration/versioning)\n- [Y.js Collaboration](https://www.blocknotejs.org/docs/collaboration)", + 'This example shows a multi-document collaborative editor with per-document version history, using BlockNote\'s `VersioningExtension` and Y.js v14. Sync and history both come from [YHub](https://github.com/yjs/yhub), which records every edit and groups them into versions.\n\nA first visit creates a sample document whose history already has several versions by several users, so the history sidebar has something to show right away. The editor is read-only while the sidebar is open: close it to edit, then reopen it with the "History" button.\n\n**Features:**\n\n- User picker (per-tab identity via `sessionStorage`)\n- Left sidebar with document list (create, rename, delete)\n- Collaborative editing with Y.js (including suggestion mode)\n- Right sidebar with version history powered by `VersioningSidebar`\n- Per-document version history backed by YHub\n- Open multiple tabs with different users via the `?as=` URL param\n\n**Relevant Docs:**\n\n- [Versioning](https://www.blocknotejs.org/docs/collaboration/versioning)\n- [Y.js Collaboration](https://www.blocknotejs.org/docs/collaboration)', }, { projectSlug: "versioning-yjs14", @@ -1918,11 +1920,11 @@ export const examples = { author: "yousefed", tags: ["Advanced", "Development", "Collaboration"], dependencies: { - "@y/prosemirror": "^2.0.0-6", + "@y/prosemirror": "^2.0.0-11", "@y/protocols": "^1.0.6-rc.1", "@y/websocket": "^4.0.0-3", - "@y/y": "^14.0.0-rc.23", - lib0: "1.0.0-rc.22", + "@y/y": "^14.0.0-rc.26", + lib0: "1.0.0-rc.32", } as any, }, title: "YHub Versioning (@y/y v14)", @@ -1931,7 +1933,7 @@ export const examples = { slug: "collaboration", }, readme: - 'This example shows how to use the `VersioningExtension` with collaborative editing using `@y/y` (v14). Snapshots are stored in localStorage using Yjs v2 state updates.\n\n**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.\n\n**Relevant Docs:**\n\n- [Editor Setup](/docs/getting-started/editor-setup)\n- [Real-time collaboration](/docs/features/collaboration)', + 'This example shows how to use the `VersioningExtension` with collaborative editing using `@y/y` (v14). Version history comes from [YHub](https://github.com/yjs/yhub), which records every edit and groups them into versions.\n\nThe sidebar opens on a document seeded with several versions by several users, so you can preview them, compare 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.\n\n**Relevant Docs:**\n\n- [Editor Setup](/docs/getting-started/editor-setup)\n- [Real-time collaboration](/docs/features/collaboration)', }, { projectSlug: "suggestion-gallery", @@ -1945,7 +1947,7 @@ export const examples = { dependencies: { "@blocknote/xl-multi-column": "latest", "@y/protocols": "^1.0.6-rc.1", - "@y/y": "^14.0.0-rc.23", + "@y/y": "^14.0.0-rc.26", } as any, }, title: "Suggestion Scenarios Gallery", @@ -1994,8 +1996,9 @@ export const examples = { author: "yousefed", tags: ["Extension"], dependencies: { - "@y/y": "^14.0.0-rc.23", - "@y/prosemirror": "^2.0.0-6", + "@y/y": "^14.0.0-rc.26", + "@y/prosemirror": "^2.0.0-11", + "react-icons": "^5.5.0", } as any, }, title: "In-Memory Versioning", @@ -2004,7 +2007,7 @@ export const examples = { slug: "extensions", }, readme: - 'This example shows how to use the `VersioningExtension` without any collaboration layer (no Yjs required). Snapshots are stored in memory using ProseMirror JSON.\n\n**Try it out:** Edit the document, then use the Version History sidebar to save snapshots, preview older versions, rename them, and restore them. You can hide the sidebar with the close button and reopen it with the "History" button.', + 'This example shows how to use the `VersioningExtension` without any collaboration layer (no Yjs required). Snapshots are stored in memory using ProseMirror JSON.\n\nThe sidebar opens on a document with a few versions already in its history, so you can preview them, compare 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.', }, ], }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c704abb1be..10f280db79 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -19,18 +19,18 @@ overrides: '@tiptap/pm': ^3.29.2 prosemirror-transform: 1.12.1 '@types/node': 25.9.5 - jsdom: ^29.0.2 + jsdom: 29.0.2 vitest: 4.1.10 '@vitest/runner': 4.1.10 '@vitest/mocker': 4.1.10 - '@y/y': 14.0.0-rc.23 - '@y/prosemirror': 2.0.0-6 - lib0: 1.0.0-rc.22 + '@y/y': 14.0.0-rc.26 + '@y/prosemirror': 2.0.0-11 + lib0: 1.0.0-rc.32 packageExtensionsChecksum: sha256-RBsr8H6XmGjVk3a5IXktWPY+vN2mX4m0Q/uTlfMsVxo= patchedDependencies: - '@y/prosemirror@2.0.0-6': e49b17b47e301dd138d7e383a779a0e2125bf7f038e10e3c740e26b43d988776 + '@y/prosemirror@2.0.0-11': f933bc43aec3fbf2e4cf7d58f76646d1d95d7b02ab98e4cacbe48bfcfb0bfc9a katex@0.16.47: cbfb6fe178282ddb73b753dcb27f891295e4f9ed85f63bc1466b4e4b1e4ef6e7 importers: @@ -248,17 +248,17 @@ importers: specifier: ^0.6.3 version: 0.6.4(react@19.2.5)(yjs@13.6.30) '@y/prosemirror': - specifier: 2.0.0-6 - version: 2.0.0-6(patch_hash=e49b17b47e301dd138d7e383a779a0e2125bf7f038e10e3c740e26b43d988776)(@y/protocols@1.0.6-rc.1(@y/y@14.0.0-rc.23))(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-view@1.42.2) + specifier: 2.0.0-11 + version: 2.0.0-11(patch_hash=f933bc43aec3fbf2e4cf7d58f76646d1d95d7b02ab98e4cacbe48bfcfb0bfc9a)(@y/protocols@1.0.6-rc.1(@y/y@14.0.0-rc.26))(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-view@1.42.2) '@y/protocols': specifier: ^1.0.6-rc.1 - version: 1.0.6-rc.1(@y/y@14.0.0-rc.23) + version: 1.0.6-rc.1(@y/y@14.0.0-rc.26) '@y/websocket': specifier: ^4.0.0-3 - version: 4.0.0-rc.2(@y/y@14.0.0-rc.23) + version: 4.0.0-rc.2(@y/y@14.0.0-rc.26) '@y/y': - specifier: 14.0.0-rc.23 - version: 14.0.0-rc.23 + specifier: 14.0.0-rc.26 + version: 14.0.0-rc.26 ai: specifier: ^6.0.5 version: 6.0.5(zod@4.3.6) @@ -296,8 +296,8 @@ importers: specifier: ^0.16.11 version: 0.16.47(patch_hash=cbfb6fe178282ddb73b753dcb27f891295e4f9ed85f63bc1466b4e4b1e4ef6e7) lib0: - specifier: 1.0.0-rc.22 - version: 1.0.0-rc.22 + specifier: 1.0.0-rc.32 + version: 1.0.0-rc.32 lucide-react: specifier: ^0.562.0 version: 0.562.0(react@19.2.5) @@ -355,6 +355,9 @@ importers: y-partykit: specifier: ^0.0.25 version: 0.0.25 + y-prosemirror: + specifier: ^1.3.7 + version: 1.3.7(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-view@1.42.2)(y-protocols@1.0.7(yjs@13.6.30))(yjs@13.6.30) y-websocket: specifier: ^2.1.0 version: 2.1.0(yjs@13.6.30) @@ -4296,17 +4299,17 @@ importers: specifier: ^9.0.2 version: 9.1.1(react@19.2.5) '@y/prosemirror': - specifier: 2.0.0-6 - version: 2.0.0-6(patch_hash=e49b17b47e301dd138d7e383a779a0e2125bf7f038e10e3c740e26b43d988776)(@y/protocols@1.0.6-rc.1(@y/y@14.0.0-rc.23))(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-view@1.42.2) + specifier: 2.0.0-11 + version: 2.0.0-11(patch_hash=f933bc43aec3fbf2e4cf7d58f76646d1d95d7b02ab98e4cacbe48bfcfb0bfc9a)(@y/protocols@1.0.6-rc.1(@y/y@14.0.0-rc.26))(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-view@1.42.2) '@y/protocols': specifier: ^1.0.6-rc.1 - version: 1.0.6-rc.1(@y/y@14.0.0-rc.23) + version: 1.0.6-rc.1(@y/y@14.0.0-rc.26) '@y/websocket': specifier: ^4.0.0-rc.2 - version: 4.0.0-rc.2(@y/y@14.0.0-rc.23) + version: 4.0.0-rc.2(@y/y@14.0.0-rc.26) '@y/y': - specifier: 14.0.0-rc.23 - version: 14.0.0-rc.23 + specifier: 14.0.0-rc.26 + version: 14.0.0-rc.26 react: specifier: ^19.2.3 version: 19.2.5 @@ -4351,14 +4354,17 @@ importers: specifier: ^9.0.2 version: 9.1.1(react@19.2.5) lib0: - specifier: 1.0.0-rc.22 - version: 1.0.0-rc.22 + specifier: 1.0.0-rc.32 + version: 1.0.0-rc.32 react: specifier: ^19.2.3 version: 19.2.5 react-dom: specifier: ^19.2.3 version: 19.2.5(react@19.2.5) + y-prosemirror: + specifier: ^1.3.7 + version: 1.3.7(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-view@1.42.2)(y-protocols@1.0.7(yjs@13.6.30))(yjs@13.6.30) y-websocket: specifier: ^2.1.0 version: 2.1.0(yjs@13.6.30) @@ -4402,18 +4408,21 @@ importers: '@mantine/hooks': specifier: ^9.0.2 version: 9.1.1(react@19.2.5) + '@y/prosemirror': + specifier: 2.0.0-11 + version: 2.0.0-11(patch_hash=f933bc43aec3fbf2e4cf7d58f76646d1d95d7b02ab98e4cacbe48bfcfb0bfc9a)(@y/protocols@1.0.6-rc.1(@y/y@14.0.0-rc.26))(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-view@1.42.2) '@y/protocols': specifier: ^1.0.6-rc.1 - version: 1.0.6-rc.1(@y/y@14.0.0-rc.23) + version: 1.0.6-rc.1(@y/y@14.0.0-rc.26) '@y/websocket': specifier: ^4.0.0-3 - version: 4.0.0-rc.2(@y/y@14.0.0-rc.23) + version: 4.0.0-rc.2(@y/y@14.0.0-rc.26) '@y/y': - specifier: 14.0.0-rc.23 - version: 14.0.0-rc.23 + specifier: 14.0.0-rc.26 + version: 14.0.0-rc.26 lib0: - specifier: 1.0.0-rc.22 - version: 1.0.0-rc.22 + specifier: 1.0.0-rc.32 + version: 1.0.0-rc.32 react: specifier: ^19.2.3 version: 19.2.5 @@ -4458,20 +4467,20 @@ importers: specifier: ^9.0.2 version: 9.1.1(react@19.2.5) '@y/prosemirror': - specifier: 2.0.0-6 - version: 2.0.0-6(patch_hash=e49b17b47e301dd138d7e383a779a0e2125bf7f038e10e3c740e26b43d988776)(@y/protocols@1.0.6-rc.1(@y/y@14.0.0-rc.23))(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-view@1.42.2) + specifier: 2.0.0-11 + version: 2.0.0-11(patch_hash=f933bc43aec3fbf2e4cf7d58f76646d1d95d7b02ab98e4cacbe48bfcfb0bfc9a)(@y/protocols@1.0.6-rc.1(@y/y@14.0.0-rc.26))(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-view@1.42.2) '@y/protocols': specifier: ^1.0.6-rc.1 - version: 1.0.6-rc.1(@y/y@14.0.0-rc.23) + version: 1.0.6-rc.1(@y/y@14.0.0-rc.26) '@y/websocket': specifier: ^4.0.0-3 - version: 4.0.0-rc.2(@y/y@14.0.0-rc.23) + version: 4.0.0-rc.2(@y/y@14.0.0-rc.26) '@y/y': - specifier: 14.0.0-rc.23 - version: 14.0.0-rc.23 + specifier: 14.0.0-rc.26 + version: 14.0.0-rc.26 lib0: - specifier: 1.0.0-rc.22 - version: 1.0.0-rc.22 + specifier: 1.0.0-rc.32 + version: 1.0.0-rc.32 react: specifier: ^19.2.3 version: 19.2.5 @@ -4520,10 +4529,10 @@ importers: version: 9.1.1(react@19.2.5) '@y/protocols': specifier: ^1.0.6-rc.1 - version: 1.0.6-rc.1(@y/y@14.0.0-rc.23) + version: 1.0.6-rc.1(@y/y@14.0.0-rc.26) '@y/y': - specifier: 14.0.0-rc.23 - version: 14.0.0-rc.23 + specifier: 14.0.0-rc.26 + version: 14.0.0-rc.26 react: specifier: ^19.2.3 version: 19.2.5 @@ -4614,17 +4623,20 @@ importers: specifier: ^9.0.2 version: 9.1.1(react@19.2.5) '@y/prosemirror': - specifier: 2.0.0-6 - version: 2.0.0-6(patch_hash=e49b17b47e301dd138d7e383a779a0e2125bf7f038e10e3c740e26b43d988776)(@y/protocols@1.0.6-rc.1(@y/y@14.0.0-rc.23))(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-view@1.42.2) + specifier: 2.0.0-11 + version: 2.0.0-11(patch_hash=f933bc43aec3fbf2e4cf7d58f76646d1d95d7b02ab98e4cacbe48bfcfb0bfc9a)(@y/protocols@1.0.6-rc.1(@y/y@14.0.0-rc.26))(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-view@1.42.2) '@y/y': - specifier: 14.0.0-rc.23 - version: 14.0.0-rc.23 + specifier: 14.0.0-rc.26 + version: 14.0.0-rc.26 react: specifier: ^19.2.3 version: 19.2.5 react-dom: specifier: ^19.2.3 version: 19.2.5(react@19.2.5) + react-icons: + specifier: ^5.5.0 + version: 5.6.0(react@19.2.5) devDependencies: '@types/react': specifier: ^19.2.3 @@ -5289,14 +5301,14 @@ importers: specifier: ^3.29.2 version: 3.29.2 '@y/prosemirror': - specifier: 2.0.0-6 - version: 2.0.0-6(patch_hash=e49b17b47e301dd138d7e383a779a0e2125bf7f038e10e3c740e26b43d988776)(@y/protocols@1.0.6-rc.1(@y/y@14.0.0-rc.23))(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-view@1.42.2) + specifier: 2.0.0-11 + version: 2.0.0-11(patch_hash=f933bc43aec3fbf2e4cf7d58f76646d1d95d7b02ab98e4cacbe48bfcfb0bfc9a)(@y/protocols@1.0.6-rc.1(@y/y@14.0.0-rc.26))(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-view@1.42.2) '@y/protocols': specifier: ^1.0.6-rc.1 - version: 1.0.6-rc.1(@y/y@14.0.0-rc.23) + version: 1.0.6-rc.1(@y/y@14.0.0-rc.26) '@y/y': - specifier: 14.0.0-rc.23 - version: 14.0.0-rc.23 + specifier: 14.0.0-rc.26 + version: 14.0.0-rc.26 emoji-mart: specifier: ^5.6.0 version: 5.6.0 @@ -5304,8 +5316,8 @@ importers: specifier: ^3.1.3 version: 3.1.3 lib0: - specifier: 1.0.0-rc.22 - version: 1.0.0-rc.22 + specifier: 1.0.0-rc.32 + version: 1.0.0-rc.32 prosemirror-highlight: specifier: ^0.15.3 version: 0.15.3(@shikijs/types@4.4.3)(@types/hast@3.0.5)(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-transform@1.12.1)(prosemirror-view@1.42.2) @@ -5326,7 +5338,7 @@ importers: version: 1.42.2 devDependencies: jsdom: - specifier: ^29.0.2 + specifier: 29.0.2 version: 29.0.2(@noble/hashes@2.0.1)(canvas@3.1.0) rimraf: specifier: ^5.0.10 @@ -5676,7 +5688,7 @@ importers: specifier: ^3.29.2 version: 3.29.2 jsdom: - specifier: ^29.0.2 + specifier: 29.0.2 version: 29.0.2(@noble/hashes@2.0.1)(canvas@3.1.0) yjs: specifier: ^13.6.27 @@ -6115,7 +6127,7 @@ importers: specifier: ^19.2.3 version: 19.2.3(@types/react@19.2.14) jsdom: - specifier: ^29.0.2 + specifier: 29.0.2 version: 29.0.2(@noble/hashes@2.0.1)(canvas@3.1.0) react: specifier: ^19.2.5 @@ -6576,6 +6588,12 @@ importers: '@tailwindcss/vite': specifier: ^4.1.14 version: 4.2.2(vite@8.0.8(@types/node@25.9.5)(esbuild@0.27.5)(jiti@2.6.1)(terser@5.51.2)(tsx@4.21.0)(yaml@2.9.0)) + '@testing-library/dom': + specifier: ^10.4.0 + version: 10.4.1 + '@testing-library/react': + specifier: ^16.3.0 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) '@tiptap/pm': specifier: ^3.29.2 version: 3.29.2 @@ -6596,10 +6614,10 @@ importers: version: 4.1.5(vitest@4.1.10) '@y/protocols': specifier: ^1.0.6-rc.1 - version: 1.0.6-rc.1(@y/y@14.0.0-rc.23) + version: 1.0.6-rc.1(@y/y@14.0.0-rc.26) '@y/y': - specifier: 14.0.0-rc.23 - version: 14.0.0-rc.23 + specifier: 14.0.0-rc.26 + version: 14.0.0-rc.26 htmlfy: specifier: ^0.6.7 version: 0.6.7 @@ -11641,8 +11659,8 @@ packages: '@y-sweet/sdk@0.6.4': resolution: {integrity: sha512-px51qSbckGrucN83BM9jJyaBLLdYFT+zhvsootK+WW9t/9rQSQHQX54gdtF6M1kUktA4jOGfSiAXDzuTY0zYVg==} - '@y/prosemirror@2.0.0-6': - resolution: {integrity: sha512-SRXxliKc2Q0EBoN3bayP+5PgFNzkPW0xG7PsFAeRJYn/d0kYKpJcdCPhH2awjO34P8CZXCD0eC8hDkujYFyHgg==} + '@y/prosemirror@2.0.0-11': + resolution: {integrity: sha512-9eWCYfXUNd/Xn6HpQ0wi4EiRFOOKiYoMcYtfbNTaVtMz4zfIJt8mCGsIsUWAfWNm4ha9VyksCMuwheHWc5a1kw==} engines: {node: '>=16.0.0', npm: '>=8.0.0'} peerDependencies: '@y/protocols': ^1.0.6-rc.1 @@ -11654,16 +11672,16 @@ packages: resolution: {integrity: sha512-e/qs7hXcLk/SeNitxMXv2ymozyWFTULwbJEi7cAf/K/iXw9nGwGXHrR5TNluQ/bMwOX1cwuUT0hjEojkfH0gsA==} engines: {node: '>=16.0.0', npm: '>=8.0.0'} peerDependencies: - '@y/y': 14.0.0-rc.23 + '@y/y': 14.0.0-rc.26 '@y/websocket@4.0.0-rc.2': resolution: {integrity: sha512-QhF3ehjAvrlTMwR16dKVLdFrq+8+rhfndvqHjx+83BpxRvgTuseg0ckq4hQ6tuEFA31VRos2x+cm9fyxlix7Nw==} engines: {node: '>=16.0.0', npm: '>=8.0.0'} peerDependencies: - '@y/y': 14.0.0-rc.23 + '@y/y': 14.0.0-rc.26 - '@y/y@14.0.0-rc.23': - resolution: {integrity: sha512-5IBr9puk4BL0ke09Yqa0uFNIpCreUocCPxHMQkF1uJvQfwDx60kRm0sYptHLTUcnNtxiCG3wqkogzQbQFszXgQ==} + '@y/y@14.0.0-rc.26': + resolution: {integrity: sha512-sJ0N5qkYFtFTIdHWV77Fx+JU7bPqNXUmoFC807FscFtsxMSXp6zjPwHQ1PdUCYv8PJlNcPjsuGGyVpOT+FF5SQ==} engines: {node: '>=22.0.0', npm: '>=8.0.0'} '@yuku-codegen/binding-darwin-arm64@0.5.48': @@ -14017,8 +14035,8 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} - lib0@1.0.0-rc.22: - resolution: {integrity: sha512-KNefJloRQIsWncTF2tIcRqQXSQ7bDRYHwVSUhf1lY2P65Rej4WWFnen6L8L+odJQIo1ZNJGVVjK2WzqB9a+B/g==} + lib0@1.0.0-rc.32: + resolution: {integrity: sha512-AaFxUR1ta0Zf3LmryubLTfYWmZCDfPjy5kAkDjURszy1RYJ7Rtt07xums81FLfj9Oa/6XLx7dOypiWXcSWJWyQ==} engines: {node: '>=22'} hasBin: true @@ -14221,10 +14239,6 @@ packages: lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} - lru-cache@11.2.7: - resolution: {integrity: sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==} - engines: {node: 20 || >=22} - lru-cache@11.5.2: resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} engines: {node: 20 || >=22} @@ -16458,7 +16472,7 @@ packages: '@vitest/coverage-v8': 4.1.10 '@vitest/ui': 4.1.10 happy-dom: '*' - jsdom: ^29.0.2 + jsdom: 29.0.2 vite: ^6.0.0 || ^7.0.0 || ^8.0.0 peerDependenciesMeta: '@edge-runtime/vm': @@ -18346,7 +18360,7 @@ snapshots: '@liveblocks/core': 3.19.5(@types/json-schema@7.0.15) '@noble/hashes': 1.8.0 js-base64: 3.7.8 - lib0: 1.0.0-rc.22 + lib0: 1.0.0-rc.32 y-indexeddb: 9.0.12(yjs@13.6.30) yjs: 13.6.30 transitivePeerDependencies: @@ -22068,29 +22082,29 @@ snapshots: dependencies: '@types/node': 25.9.5 - '@y/prosemirror@2.0.0-6(patch_hash=e49b17b47e301dd138d7e383a779a0e2125bf7f038e10e3c740e26b43d988776)(@y/protocols@1.0.6-rc.1(@y/y@14.0.0-rc.23))(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-view@1.42.2)': + '@y/prosemirror@2.0.0-11(patch_hash=f933bc43aec3fbf2e4cf7d58f76646d1d95d7b02ab98e4cacbe48bfcfb0bfc9a)(@y/protocols@1.0.6-rc.1(@y/y@14.0.0-rc.26))(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-view@1.42.2)': dependencies: - '@y/protocols': 1.0.6-rc.1(@y/y@14.0.0-rc.23) - '@y/y': 14.0.0-rc.23 - lib0: 1.0.0-rc.22 + '@y/protocols': 1.0.6-rc.1(@y/y@14.0.0-rc.26) + '@y/y': 14.0.0-rc.26 + lib0: 1.0.0-rc.32 prosemirror-model: 1.25.11 prosemirror-state: 1.4.4 prosemirror-view: 1.42.2 - '@y/protocols@1.0.6-rc.1(@y/y@14.0.0-rc.23)': + '@y/protocols@1.0.6-rc.1(@y/y@14.0.0-rc.26)': dependencies: - '@y/y': 14.0.0-rc.23 - lib0: 1.0.0-rc.22 + '@y/y': 14.0.0-rc.26 + lib0: 1.0.0-rc.32 - '@y/websocket@4.0.0-rc.2(@y/y@14.0.0-rc.23)': + '@y/websocket@4.0.0-rc.2(@y/y@14.0.0-rc.26)': dependencies: - '@y/protocols': 1.0.6-rc.1(@y/y@14.0.0-rc.23) - '@y/y': 14.0.0-rc.23 - lib0: 1.0.0-rc.22 + '@y/protocols': 1.0.6-rc.1(@y/y@14.0.0-rc.26) + '@y/y': 14.0.0-rc.26 + lib0: 1.0.0-rc.32 - '@y/y@14.0.0-rc.23': + '@y/y@14.0.0-rc.26': dependencies: - lib0: 1.0.0-rc.22 + lib0: 1.0.0-rc.32 '@yuku-codegen/binding-darwin-arm64@0.5.48': optional: true @@ -24554,7 +24568,7 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 - lib0@1.0.0-rc.22: {} + lib0@1.0.0-rc.32: {} lie@3.3.0: dependencies: @@ -24699,8 +24713,6 @@ snapshots: lru-cache@10.4.3: {} - lru-cache@11.2.7: {} - lru-cache@11.5.2: {} lru-cache@5.1.1: @@ -25724,7 +25736,7 @@ snapshots: path-scurry@2.0.2: dependencies: - lru-cache: 11.2.7 + lru-cache: 11.5.2 minipass: 7.1.3 path-to-regexp@3.3.0: {} @@ -27745,19 +27757,19 @@ snapshots: y-indexeddb@9.0.12(yjs@13.6.30): dependencies: - lib0: 1.0.0-rc.22 + lib0: 1.0.0-rc.32 yjs: 13.6.30 y-leveldb@0.1.2(yjs@13.6.30): dependencies: level: 6.0.1 - lib0: 1.0.0-rc.22 + lib0: 1.0.0-rc.32 yjs: 13.6.30 optional: true y-partykit@0.0.25: dependencies: - lib0: 1.0.0-rc.22 + lib0: 1.0.0-rc.32 lodash.debounce: 4.0.8 react: 18.3.1 y-protocols: 1.0.7(yjs@13.6.30) @@ -27765,7 +27777,7 @@ snapshots: y-prosemirror@1.3.7(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-view@1.42.2)(y-protocols@1.0.7(yjs@13.6.30))(yjs@13.6.30): dependencies: - lib0: 1.0.0-rc.22 + lib0: 1.0.0-rc.32 prosemirror-model: 1.25.11 prosemirror-state: 1.4.4 prosemirror-view: 1.42.2 @@ -27774,12 +27786,12 @@ snapshots: y-protocols@1.0.7(yjs@13.6.30): dependencies: - lib0: 1.0.0-rc.22 + lib0: 1.0.0-rc.32 yjs: 13.6.30 y-websocket@2.1.0(yjs@13.6.30): dependencies: - lib0: 1.0.0-rc.22 + lib0: 1.0.0-rc.32 lodash.debounce: 4.0.8 y-protocols: 1.0.7(yjs@13.6.30) yjs: 13.6.30 @@ -27824,7 +27836,7 @@ snapshots: yjs@13.6.30: dependencies: - lib0: 1.0.0-rc.22 + lib0: 1.0.0-rc.32 yocto-queue@0.1.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 246b1c616d..821624dca5 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -36,16 +36,16 @@ overrides: # `toMatchSnapshot` fails with "The snapshot state for '...' is not found". # Pin both so the whole workspace shares one instance. "@types/node": "25.9.5" - "jsdom": "^29.0.2" + "jsdom": "29.0.2" "vitest": "4.1.10" "@vitest/runner": "4.1.10" # @vitest/mocker must stay >=4.1.10: earlier builds only recognize `vitest` # as the mocks-API source, so `vi.mock` imported from `vite-plus/test` fails # to hoist ("problems in resolving the mocks API"). 4.1.10 adds vite-plus/test. "@vitest/mocker": "4.1.10" - "@y/y": "14.0.0-rc.23" - "@y/prosemirror": "2.0.0-6" - "lib0": "1.0.0-rc.22" + "@y/y": "14.0.0-rc.26" + "@y/prosemirror": "2.0.0-11" + "lib0": "1.0.0-rc.32" packageExtensions: # `@vitest/ui` is an *optional peer* of vitest, which vite-plus re-exposes as # an optional peer of its own. Only `tests` declares it (its browser config @@ -75,7 +75,7 @@ allowBuilds: wasm-pack: false patchedDependencies: { - "@y/prosemirror@2.0.0-6": patches/@y__prosemirror@2.0.0-6.patch, + "@y/prosemirror@2.0.0-11": patches/@y__prosemirror@2.0.0-11.patch, katex@0.16.47: patches/katex@0.16.47.patch, } catalog: diff --git a/scripts/patch-y-prosemirror.sh b/scripts/patch-y-prosemirror.sh index 9a4287096a..fb18e0061b 100755 --- a/scripts/patch-y-prosemirror.sh +++ b/scripts/patch-y-prosemirror.sh @@ -15,7 +15,7 @@ LOCAL_YPM="${1:-$(cd "$BLOCKNOTE_ROOT/../y-prosemirror" && pwd)}" # Version of @y/prosemirror to patch. Must match the version pinned in # pnpm-workspace.yaml (overrides + patchedDependencies) and package.json files. -YPM_VERSION="2.0.0-6" +YPM_VERSION="2.0.0-11" if [[ ! -d "$LOCAL_YPM/src" ]]; then echo "ERROR: Cannot find y-prosemirror at $LOCAL_YPM" @@ -27,8 +27,8 @@ echo "==> Using local y-prosemirror at: $LOCAL_YPM" echo "==> BlockNote root: $BLOCKNOTE_ROOT" # 0. Build y-prosemirror so dist/ is up to date -echo "==> Building y-prosemirror (npm run dist) ..." -(cd "$LOCAL_YPM" && npm run dist) +echo "==> Building y-prosemirror (pnpm run dist) ..." +(cd "$LOCAL_YPM" && pnpm run dist) # Best-effort cleanup of any leftover patch dir (case-insensitive FS resolves this fine). STALE_PATCH_DIR="$BLOCKNOTE_ROOT/node_modules/.pnpm_patches/@y/prosemirror@$YPM_VERSION" @@ -63,9 +63,9 @@ echo "==> Replacing src/ ..." rm -rf "$PATCH_DIR/src" cp -R "$LOCAL_YPM/src" "$PATCH_DIR/src" -# 3. Replace dist/ with local build (only dist/src/ with .d.ts files) +# 3. Replace library declarations, preserving unrelated published artifacts. echo "==> Replacing dist/ ..." -rm -rf "$PATCH_DIR/dist" +rm -rf "$PATCH_DIR/dist/src" mkdir -p "$PATCH_DIR/dist/src" cp -R "$LOCAL_YPM/dist/src/" "$PATCH_DIR/dist/src/" diff --git a/tests/package.json b/tests/package.json index 68c8844a79..cfc1f45ca7 100644 --- a/tests/package.json +++ b/tests/package.json @@ -11,17 +11,19 @@ "devDependencies": { "@blocknote/ariakit": "workspace:^", "@blocknote/core": "workspace:^", - "@blocknote/mantine": "workspace:^", "@blocknote/diagram-block": "workspace:^", - "@blocknote/xl-email-exporter": "workspace:^", - "@blocknote/xl-pdf-exporter": "workspace:^", - "pdfjs-dist": "^4.10.38", + "@blocknote/mantine": "workspace:^", "@blocknote/math-block": "workspace:^", "@blocknote/react": "workspace:^", "@blocknote/shadcn": "workspace:^", + "@blocknote/xl-email-exporter": "workspace:^", "@blocknote/xl-multi-column": "workspace:^", + "@blocknote/xl-pdf-exporter": "workspace:^", + "@blocknote/xl-typst-exporter": "workspace:^", "@playwright/test": "1.60.0", "@tailwindcss/vite": "^4.1.14", + "@testing-library/dom": "^10.4.0", + "@testing-library/react": "^16.3.0", "@tiptap/pm": "^3.29.2", "@types/node": "^20.19.22", "@types/react": "^19.2.3", @@ -29,15 +31,15 @@ "@vitest/browser-playwright": "4.1.10", "@vitest/ui": "4.1.5", "@y/protocols": "^1.0.6-rc.1", - "@y/y": "^14.0.0-rc.23", + "@y/y": "^14.0.0-rc.26", "htmlfy": "^0.6.7", + "pdfjs-dist": "^4.10.38", "react": "^19.2.5", "react-dom": "^19.2.5", "react-icons": "^5.5.0", "rimraf": "^5.0.10", "vite-plus": "catalog:", - "vitest-browser-react": "^2.2.0", - "@blocknote/xl-typst-exporter": "workspace:^" + "vitest-browser-react": "^2.2.0" }, "dependencies": { "get-port-please": "3.2.0", diff --git a/tests/src/end-to-end/ai/__screenshots__/ai.test.tsx/ai_menu_scroll_position-chromium-linux.png b/tests/src/end-to-end/ai/__screenshots__/ai.test.tsx/ai_menu_scroll_position-chromium-linux.png index c5c7d6b558..f1adf2a6e6 100644 Binary files a/tests/src/end-to-end/ai/__screenshots__/ai.test.tsx/ai_menu_scroll_position-chromium-linux.png and b/tests/src/end-to-end/ai/__screenshots__/ai.test.tsx/ai_menu_scroll_position-chromium-linux.png differ diff --git a/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-add-bullet-to-empty-chromium-linux.png b/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-add-bullet-to-empty-chromium-linux.png index 30fd839ee4..d49e6acd04 100644 Binary files a/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-add-bullet-to-empty-chromium-linux.png and b/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-add-bullet-to-empty-chromium-linux.png differ diff --git a/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-add-bullet-to-empty-firefox-linux.png b/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-add-bullet-to-empty-firefox-linux.png index 6a82839356..f6e8a5a592 100644 Binary files a/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-add-bullet-to-empty-firefox-linux.png and b/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-add-bullet-to-empty-firefox-linux.png differ diff --git a/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-add-bullet-to-empty-webkit-linux.png b/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-add-bullet-to-empty-webkit-linux.png index 8cccb0e945..1d8d4f6d8b 100644 Binary files a/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-add-bullet-to-empty-webkit-linux.png and b/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-add-bullet-to-empty-webkit-linux.png differ diff --git a/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-add-colored-block-to-empty-chromium-linux.png b/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-add-colored-block-to-empty-chromium-linux.png index cdc139b59e..4b6fc9fc71 100644 Binary files a/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-add-colored-block-to-empty-chromium-linux.png and b/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-add-colored-block-to-empty-chromium-linux.png differ diff --git a/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-add-colored-block-to-empty-firefox-linux.png b/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-add-colored-block-to-empty-firefox-linux.png index 5618efbeab..7d8f2dae6a 100644 Binary files a/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-add-colored-block-to-empty-firefox-linux.png and b/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-add-colored-block-to-empty-firefox-linux.png differ diff --git a/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-add-colored-block-to-empty-webkit-linux.png b/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-add-colored-block-to-empty-webkit-linux.png index e288acaf57..c274f030b1 100644 Binary files a/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-add-colored-block-to-empty-webkit-linux.png and b/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-add-colored-block-to-empty-webkit-linux.png differ diff --git a/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-add-heading-to-empty-chromium-linux.png b/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-add-heading-to-empty-chromium-linux.png index ffb3f4ef07..4189437256 100644 Binary files a/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-add-heading-to-empty-chromium-linux.png and b/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-add-heading-to-empty-chromium-linux.png differ diff --git a/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-add-heading-to-empty-firefox-linux.png b/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-add-heading-to-empty-firefox-linux.png index 0413a5d729..9511f3afb1 100644 Binary files a/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-add-heading-to-empty-firefox-linux.png and b/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-add-heading-to-empty-firefox-linux.png differ diff --git a/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-add-heading-to-empty-webkit-linux.png b/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-add-heading-to-empty-webkit-linux.png index c8c46bab63..d931fb80ac 100644 Binary files a/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-add-heading-to-empty-webkit-linux.png and b/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-add-heading-to-empty-webkit-linux.png differ diff --git a/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-add-nested-bullets-to-empty-chromium-linux.png b/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-add-nested-bullets-to-empty-chromium-linux.png index 49e4a14c82..5ba293748a 100644 Binary files a/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-add-nested-bullets-to-empty-chromium-linux.png and b/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-add-nested-bullets-to-empty-chromium-linux.png differ diff --git a/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-add-nested-bullets-to-empty-firefox-linux.png b/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-add-nested-bullets-to-empty-firefox-linux.png index 93b53c06c3..338707e039 100644 Binary files a/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-add-nested-bullets-to-empty-firefox-linux.png and b/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-add-nested-bullets-to-empty-firefox-linux.png differ diff --git a/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-add-nested-bullets-to-empty-webkit-linux.png b/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-add-nested-bullets-to-empty-webkit-linux.png index 5890672450..d4e3d125bf 100644 Binary files a/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-add-nested-bullets-to-empty-webkit-linux.png and b/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-add-nested-bullets-to-empty-webkit-linux.png differ diff --git a/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-add-numbered-to-empty-chromium-linux.png b/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-add-numbered-to-empty-chromium-linux.png index 2de4d1d161..1ae9842909 100644 Binary files a/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-add-numbered-to-empty-chromium-linux.png and b/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-add-numbered-to-empty-chromium-linux.png differ diff --git a/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-add-numbered-to-empty-firefox-linux.png b/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-add-numbered-to-empty-firefox-linux.png index 8b2f5d77a8..6cf17212c9 100644 Binary files a/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-add-numbered-to-empty-firefox-linux.png and b/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-add-numbered-to-empty-firefox-linux.png differ diff --git a/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-add-numbered-to-empty-webkit-linux.png b/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-add-numbered-to-empty-webkit-linux.png index c6da03ca77..88cacd3f3c 100644 Binary files a/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-add-numbered-to-empty-webkit-linux.png and b/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-add-numbered-to-empty-webkit-linux.png differ diff --git a/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-delete-divider-chromium-linux.png b/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-delete-divider-chromium-linux.png index b6236bfa7c..61addfcfe3 100644 Binary files a/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-delete-divider-chromium-linux.png and b/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-delete-divider-chromium-linux.png differ diff --git a/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-delete-divider-firefox-linux.png b/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-delete-divider-firefox-linux.png index 3ec96ecb1b..5771b495ec 100644 Binary files a/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-delete-divider-firefox-linux.png and b/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-delete-divider-firefox-linux.png differ diff --git a/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-delete-divider-webkit-linux.png b/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-delete-divider-webkit-linux.png index 18da779999..4ac04ff5f4 100644 Binary files a/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-delete-divider-webkit-linux.png and b/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-delete-divider-webkit-linux.png differ diff --git a/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-delete-image-chromium-linux.png b/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-delete-image-chromium-linux.png index a6d32cf4c9..246560ea48 100644 Binary files a/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-delete-image-chromium-linux.png and b/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-delete-image-chromium-linux.png differ diff --git a/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-delete-image-firefox-linux.png b/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-delete-image-firefox-linux.png index d10fc7603c..01bfbf417a 100644 Binary files a/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-delete-image-firefox-linux.png and b/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-delete-image-firefox-linux.png differ diff --git a/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-delete-image-webkit-linux.png b/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-delete-image-webkit-linux.png index 087a27afeb..82e1802a00 100644 Binary files a/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-delete-image-webkit-linux.png and b/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-delete-image-webkit-linux.png differ diff --git a/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-delete-mixed-parent-chromium-linux.png b/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-delete-mixed-parent-chromium-linux.png index e25c25b06a..a8323de626 100644 Binary files a/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-delete-mixed-parent-chromium-linux.png and b/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-delete-mixed-parent-chromium-linux.png differ diff --git a/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-delete-mixed-parent-firefox-linux.png b/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-delete-mixed-parent-firefox-linux.png index 7b4021c14a..6ebf2cde9d 100644 Binary files a/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-delete-mixed-parent-firefox-linux.png and b/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-delete-mixed-parent-firefox-linux.png differ diff --git a/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-delete-mixed-parent-webkit-linux.png b/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-delete-mixed-parent-webkit-linux.png index 1cf3c25a9a..4b56e9a3b8 100644 Binary files a/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-delete-mixed-parent-webkit-linux.png and b/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-delete-mixed-parent-webkit-linux.png differ diff --git a/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-insert-divider-chromium-linux.png b/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-insert-divider-chromium-linux.png index b6e5b1276f..3ef89b73ed 100644 Binary files a/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-insert-divider-chromium-linux.png and b/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-insert-divider-chromium-linux.png differ diff --git a/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-insert-divider-firefox-linux.png b/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-insert-divider-firefox-linux.png index 8c4f4be6a2..7af1b39817 100644 Binary files a/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-insert-divider-firefox-linux.png and b/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-insert-divider-firefox-linux.png differ diff --git a/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-insert-divider-webkit-linux.png b/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-insert-divider-webkit-linux.png index 29d746a046..92307d67f0 100644 Binary files a/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-insert-divider-webkit-linux.png and b/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-insert-divider-webkit-linux.png differ diff --git a/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-insert-image-chromium-linux.png b/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-insert-image-chromium-linux.png index dc9553fdb5..c57c6f9200 100644 Binary files a/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-insert-image-chromium-linux.png and b/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-insert-image-chromium-linux.png differ diff --git a/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-insert-image-firefox-linux.png b/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-insert-image-firefox-linux.png index e4700ac3dc..a1f821173a 100644 Binary files a/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-insert-image-firefox-linux.png and b/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-insert-image-firefox-linux.png differ diff --git a/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-insert-image-webkit-linux.png b/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-insert-image-webkit-linux.png index 1b9e4c42b3..c5a1c82499 100644 Binary files a/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-insert-image-webkit-linux.png and b/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-insert-image-webkit-linux.png differ diff --git a/tests/src/end-to-end/y-prosemirror/__screenshots__/propChanges.test.tsx/prop-change-heading-level-chromium-linux.png b/tests/src/end-to-end/y-prosemirror/__screenshots__/propChanges.test.tsx/prop-change-heading-level-chromium-linux.png index df2585616a..de47612961 100644 Binary files a/tests/src/end-to-end/y-prosemirror/__screenshots__/propChanges.test.tsx/prop-change-heading-level-chromium-linux.png and b/tests/src/end-to-end/y-prosemirror/__screenshots__/propChanges.test.tsx/prop-change-heading-level-chromium-linux.png differ diff --git a/tests/src/end-to-end/y-prosemirror/__screenshots__/propChanges.test.tsx/prop-change-heading-level-firefox-linux.png b/tests/src/end-to-end/y-prosemirror/__screenshots__/propChanges.test.tsx/prop-change-heading-level-firefox-linux.png index d59ea815a9..da4e9fa691 100644 Binary files a/tests/src/end-to-end/y-prosemirror/__screenshots__/propChanges.test.tsx/prop-change-heading-level-firefox-linux.png and b/tests/src/end-to-end/y-prosemirror/__screenshots__/propChanges.test.tsx/prop-change-heading-level-firefox-linux.png differ diff --git a/tests/src/end-to-end/y-prosemirror/__screenshots__/propChanges.test.tsx/prop-change-heading-level-webkit-linux.png b/tests/src/end-to-end/y-prosemirror/__screenshots__/propChanges.test.tsx/prop-change-heading-level-webkit-linux.png index 14f27ba56f..d0c89a86f3 100644 Binary files a/tests/src/end-to-end/y-prosemirror/__screenshots__/propChanges.test.tsx/prop-change-heading-level-webkit-linux.png and b/tests/src/end-to-end/y-prosemirror/__screenshots__/propChanges.test.tsx/prop-change-heading-level-webkit-linux.png differ diff --git a/tests/src/end-to-end/y-prosemirror/__screenshots__/propChanges.test.tsx/prop-change-image-source-chromium-linux.png b/tests/src/end-to-end/y-prosemirror/__screenshots__/propChanges.test.tsx/prop-change-image-source-chromium-linux.png index 7dc690ad9e..b099959c38 100644 Binary files a/tests/src/end-to-end/y-prosemirror/__screenshots__/propChanges.test.tsx/prop-change-image-source-chromium-linux.png and b/tests/src/end-to-end/y-prosemirror/__screenshots__/propChanges.test.tsx/prop-change-image-source-chromium-linux.png differ diff --git a/tests/src/end-to-end/y-prosemirror/__screenshots__/propChanges.test.tsx/prop-change-image-source-firefox-linux.png b/tests/src/end-to-end/y-prosemirror/__screenshots__/propChanges.test.tsx/prop-change-image-source-firefox-linux.png index 57717574e2..bcd1bad9c5 100644 Binary files a/tests/src/end-to-end/y-prosemirror/__screenshots__/propChanges.test.tsx/prop-change-image-source-firefox-linux.png and b/tests/src/end-to-end/y-prosemirror/__screenshots__/propChanges.test.tsx/prop-change-image-source-firefox-linux.png differ diff --git a/tests/src/end-to-end/y-prosemirror/__screenshots__/propChanges.test.tsx/prop-change-image-source-webkit-linux.png b/tests/src/end-to-end/y-prosemirror/__screenshots__/propChanges.test.tsx/prop-change-image-source-webkit-linux.png index 18dfb205e8..12d01f4938 100644 Binary files a/tests/src/end-to-end/y-prosemirror/__screenshots__/propChanges.test.tsx/prop-change-image-source-webkit-linux.png and b/tests/src/end-to-end/y-prosemirror/__screenshots__/propChanges.test.tsx/prop-change-image-source-webkit-linux.png differ diff --git a/tests/src/end-to-end/y-prosemirror/__screenshots__/propChanges.test.tsx/prop-change-image-width-chromium-linux.png b/tests/src/end-to-end/y-prosemirror/__screenshots__/propChanges.test.tsx/prop-change-image-width-chromium-linux.png index 71dbb3fe33..0a94d1fa4a 100644 Binary files a/tests/src/end-to-end/y-prosemirror/__screenshots__/propChanges.test.tsx/prop-change-image-width-chromium-linux.png and b/tests/src/end-to-end/y-prosemirror/__screenshots__/propChanges.test.tsx/prop-change-image-width-chromium-linux.png differ diff --git a/tests/src/end-to-end/y-prosemirror/__screenshots__/propChanges.test.tsx/prop-change-image-width-firefox-linux.png b/tests/src/end-to-end/y-prosemirror/__screenshots__/propChanges.test.tsx/prop-change-image-width-firefox-linux.png index 1e43f2ed63..f83fcd1ed3 100644 Binary files a/tests/src/end-to-end/y-prosemirror/__screenshots__/propChanges.test.tsx/prop-change-image-width-firefox-linux.png and b/tests/src/end-to-end/y-prosemirror/__screenshots__/propChanges.test.tsx/prop-change-image-width-firefox-linux.png differ diff --git a/tests/src/end-to-end/y-prosemirror/__screenshots__/propChanges.test.tsx/prop-change-image-width-webkit-linux.png b/tests/src/end-to-end/y-prosemirror/__screenshots__/propChanges.test.tsx/prop-change-image-width-webkit-linux.png index b6c5e22092..5c213215e8 100644 Binary files a/tests/src/end-to-end/y-prosemirror/__screenshots__/propChanges.test.tsx/prop-change-image-width-webkit-linux.png and b/tests/src/end-to-end/y-prosemirror/__screenshots__/propChanges.test.tsx/prop-change-image-width-webkit-linux.png differ diff --git a/tests/src/end-to-end/y-prosemirror/__screenshots__/propChanges.test.tsx/prop-change-text-alignment-chromium-linux.png b/tests/src/end-to-end/y-prosemirror/__screenshots__/propChanges.test.tsx/prop-change-text-alignment-chromium-linux.png index d3dfddb760..53064c498c 100644 Binary files a/tests/src/end-to-end/y-prosemirror/__screenshots__/propChanges.test.tsx/prop-change-text-alignment-chromium-linux.png and b/tests/src/end-to-end/y-prosemirror/__screenshots__/propChanges.test.tsx/prop-change-text-alignment-chromium-linux.png differ diff --git a/tests/src/end-to-end/y-prosemirror/__screenshots__/propChanges.test.tsx/prop-change-text-alignment-firefox-linux.png b/tests/src/end-to-end/y-prosemirror/__screenshots__/propChanges.test.tsx/prop-change-text-alignment-firefox-linux.png index 77c9e3cf52..7ae344ffb6 100644 Binary files a/tests/src/end-to-end/y-prosemirror/__screenshots__/propChanges.test.tsx/prop-change-text-alignment-firefox-linux.png and b/tests/src/end-to-end/y-prosemirror/__screenshots__/propChanges.test.tsx/prop-change-text-alignment-firefox-linux.png differ diff --git a/tests/src/end-to-end/y-prosemirror/__screenshots__/propChanges.test.tsx/prop-change-text-alignment-webkit-linux.png b/tests/src/end-to-end/y-prosemirror/__screenshots__/propChanges.test.tsx/prop-change-text-alignment-webkit-linux.png index f6d1a8b938..e963f9d0f7 100644 Binary files a/tests/src/end-to-end/y-prosemirror/__screenshots__/propChanges.test.tsx/prop-change-text-alignment-webkit-linux.png and b/tests/src/end-to-end/y-prosemirror/__screenshots__/propChanges.test.tsx/prop-change-text-alignment-webkit-linux.png differ diff --git a/tests/src/end-to-end/y-prosemirror/__snapshots__/addRemoveBlocks.test.tsx.snap b/tests/src/end-to-end/y-prosemirror/__snapshots__/addRemoveBlocks.test.tsx.snap index 6c2bebcac8..182ace0380 100644 --- a/tests/src/end-to-end/y-prosemirror/__snapshots__/addRemoveBlocks.test.tsx.snap +++ b/tests/src/end-to-end/y-prosemirror/__snapshots__/addRemoveBlocks.test.tsx.snap @@ -13,7 +13,7 @@ exports[`suggestion mode: add an empty block 2`] = ` A paragraph - + " @@ -26,23 +26,25 @@ exports[`suggestion mode: add an empty block 3`] = ` A paragraph - - - - - + + + + + + + + + " `; -exports[`suggestion mode: add bullet list item to empty doc 1`] = ` -" - - - -" -`; +exports[`suggestion mode: add bullet list item to empty doc 1`] = `""`; exports[`suggestion mode: add bullet list item to empty doc 2`] = ` " @@ -58,38 +60,35 @@ exports[`suggestion mode: add bullet list item to empty doc 2`] = ` exports[`suggestion mode: add bullet list item to empty doc 3`] = ` " - - - - - - - - - - - - - New bullet - - - - - + + + + + + + + + New bullet + + + + + + + + " `; -exports[`suggestion mode: add colored block with child to empty doc 1`] = ` -" - - - -" -`; +exports[`suggestion mode: add colored block with child to empty doc 1`] = `""`; exports[`suggestion mode: add colored block with child to empty doc 2`] = ` " @@ -106,47 +105,52 @@ exports[`suggestion mode: add colored block with child to empty doc 2`] = ` exports[`suggestion mode: add colored block with child to empty doc 3`] = ` " - - - - - - - - - - - - - Colored parent - - - - + + + + + - + + + Colored parent + + + + + - - Child block - + + + + + + Child block + + + + + - + - - - - - + + + + + " `; -exports[`suggestion mode: add heading to empty doc 1`] = ` -" - - - -" -`; +exports[`suggestion mode: add heading to empty doc 1`] = `""`; exports[`suggestion mode: add heading to empty doc 2`] = ` " @@ -164,40 +168,37 @@ exports[`suggestion mode: add heading to empty doc 2`] = ` exports[`suggestion mode: add heading to empty doc 3`] = ` " - - - - - - - - - - - - - New heading - - - - - + + + + + + + + + New heading + + + + + + + + " `; -exports[`suggestion mode: add nested bullet list to empty doc 1`] = ` -" - - - -" -`; +exports[`suggestion mode: add nested bullet list to empty doc 1`] = `""`; exports[`suggestion mode: add nested bullet list to empty doc 2`] = ` " @@ -231,72 +232,85 @@ exports[`suggestion mode: add nested bullet list to empty doc 2`] = ` exports[`suggestion mode: add nested bullet list to empty doc 3`] = ` " - - - - - - - - - - - - - Level 0 - - - - + + + + + + + + + Level 0 + + + - + - - Level 1 - - - - - - - + + + - Level 2 + Level 1 - - - - + + + + + + + + + + + Level 2 + + + + + + + + + + - + - - - - - + + + + + " `; -exports[`suggestion mode: add numbered list item to empty doc 1`] = ` -" - - - -" -`; +exports[`suggestion mode: add numbered list item to empty doc 1`] = `""`; exports[`suggestion mode: add numbered list item to empty doc 2`] = ` " @@ -313,28 +327,31 @@ exports[`suggestion mode: add numbered list item to empty doc 2`] = ` exports[`suggestion mode: add numbered list item to empty doc 3`] = ` " - - - - - - - - - - - - - New numbered - - - - - + + + + + + + + + New numbered + + + + + + + + " `; @@ -382,13 +399,21 @@ exports[`suggestion mode: add paragraph after existing block 3`] = ` >Title - - - - Body text - - - + + + + + + Body text + + + + + " @@ -404,7 +429,7 @@ exports[`suggestion mode: delete code block 1`] = ` exports[`suggestion mode: delete code block 2`] = ` " - + " @@ -414,20 +439,36 @@ exports[`suggestion mode: delete code block 3`] = ` " - - - - const x = 1; - - - + + + + + + const x = 1; + + + + + - - - - - + + + + + + + + + " @@ -443,7 +484,7 @@ exports[`suggestion mode: delete divider 1`] = ` exports[`suggestion mode: delete divider 2`] = ` " - + " @@ -453,18 +494,30 @@ exports[`suggestion mode: delete divider 3`] = ` " - - - - - + + + + + + + - - - - - + + + + + + + + + " @@ -488,7 +541,7 @@ exports[`suggestion mode: delete image block 1`] = ` exports[`suggestion mode: delete image block 2`] = ` " - + " @@ -498,26 +551,42 @@ exports[`suggestion mode: delete image block 3`] = ` " - - - - - + + + + + + + + + - - - - - + + + + + + + + + " @@ -548,35 +617,59 @@ exports[`suggestion mode: delete nested block 3`] = ` " - - - - Parent - - - - - - - - - Child - - - - - - - + + + + + + Parent + + + + + + + + + + + + Child + + + + + + + + + + - - - - Parent - - - + + + + + + Parent + + + + + " @@ -608,11 +701,19 @@ exports[`suggestion mode: delete one of two empty blocks 3`] = ` - - - - - + + + + + + + + + " @@ -633,7 +734,7 @@ exports[`suggestion mode: delete parent block (with children) 1`] = ` exports[`suggestion mode: delete parent block (with children) 2`] = ` " - + " @@ -643,33 +744,57 @@ exports[`suggestion mode: delete parent block (with children) 3`] = ` " - - - - Parent - - - - - - - - - Child - - - - - - - + + + + + + Parent + + + + + + + + + + + + Child + + + + + + + + + + - - - - - + + + + + + + + + " @@ -701,7 +826,7 @@ exports[`suggestion mode: delete parent with nested paragraph and image 1`] = ` exports[`suggestion mode: delete parent with nested paragraph and image 2`] = ` " - + " @@ -711,48 +836,80 @@ exports[`suggestion mode: delete parent with nested paragraph and image 3`] = ` " - - - - Parent - - - - - - - - - Nested paragraph - - - - - - - - - - - - - - + + + + + + Parent + + + + + + + + + + + + Nested paragraph + + + + + + + + + + + + + + + + + + + + + - - - - - + + + + + + + + + " @@ -774,7 +931,7 @@ exports[`suggestion mode: insert a divider between paragraphs 2`] = ` Above - + @@ -790,11 +947,15 @@ exports[`suggestion mode: insert a divider between paragraphs 3`] = ` Above - - - - - + + + + + + + Below @@ -803,13 +964,7 @@ exports[`suggestion mode: insert a divider between paragraphs 3`] = ` " `; -exports[`suggestion mode: insert image block 1`] = ` -" - - - -" -`; +exports[`suggestion mode: insert image block 1`] = `""`; exports[`suggestion mode: insert image block 2`] = ` " @@ -829,30 +984,33 @@ exports[`suggestion mode: insert image block 2`] = ` exports[`suggestion mode: insert image block 3`] = ` " - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + " `; @@ -900,60 +1058,92 @@ exports[`suggestion mode: nest a bullet under an existing bullet 3`] = ` " - - - - Parent - - - + + + + + + Parent + + + + + - - - - Child - - - + + + + + + Child + + + + + - - - - Parent - - - - - - - - - Child - - - - - - - + + + + + + Parent + + + + + + + + + + + + Child + + + + + + + + + + " @@ -969,7 +1159,7 @@ exports[`suggestion mode: remove all blocks 1`] = ` exports[`suggestion mode: remove all blocks 2`] = ` " - + " @@ -978,11 +1168,15 @@ exports[`suggestion mode: remove all blocks 2`] = ` exports[`suggestion mode: remove all blocks 3`] = ` " - - - Only block - - + + + + Only block + + + " `; @@ -1031,13 +1225,21 @@ exports[`suggestion mode: remove paragraph from heading+paragraph 3`] = ` >Title - - - - Body text - - - + + + + + + Body text + + + + + " diff --git a/tests/src/end-to-end/y-prosemirror/__snapshots__/mergeSplit.concurrent.test.tsx.snap b/tests/src/end-to-end/y-prosemirror/__snapshots__/mergeSplit.concurrent.test.tsx.snap index 586789e9ae..7bb4df5b68 100644 --- a/tests/src/end-to-end/y-prosemirror/__snapshots__/mergeSplit.concurrent.test.tsx.snap +++ b/tests/src/end-to-end/y-prosemirror/__snapshots__/mergeSplit.concurrent.test.tsx.snap @@ -48,13 +48,21 @@ exports[`concurrent: A merges B into A, B edits block B 5`] = ` - - - - Second - - - + + + + + + Second + + + + + " @@ -81,7 +89,7 @@ exports[`concurrent: B splits the block, A types at the end 3`] = ` Hello - + world " @@ -92,7 +100,7 @@ exports[`concurrent: B splits the block, A types at the end 4`] = ` Hello ! - + world " @@ -109,13 +117,21 @@ exports[`concurrent: B splits the block, A types at the end 5`] = ` - - - - world - - - + + + + + + world + + + + + " diff --git a/tests/src/end-to-end/y-prosemirror/__snapshots__/moveBlocks.test.tsx.snap b/tests/src/end-to-end/y-prosemirror/__snapshots__/moveBlocks.test.tsx.snap index 035b96fab2..70ce03a0f5 100644 --- a/tests/src/end-to-end/y-prosemirror/__snapshots__/moveBlocks.test.tsx.snap +++ b/tests/src/end-to-end/y-prosemirror/__snapshots__/moveBlocks.test.tsx.snap @@ -32,25 +32,41 @@ exports[`suggestion mode: move paragraph up 3`] = ` " - - - - Middle - - - + + + + + + Middle + + + + + First - - - - Middle - - - + + + + + + Middle + + + + + Last @@ -95,51 +111,83 @@ exports[`suggestion mode: move paragraph with children 3`] = ` " - - - - Parent - - - - - - - - - Child - - - - - - - + + + + + + Parent + + + + + + + + + + + + Child + + + + + + + + + + First - - - - Parent - - - - - - - - - Child - - - - - - - + + + + + + Parent + + + + + + + + + + + + Child + + + + + + + + + + " diff --git a/tests/src/end-to-end/y-prosemirror/__snapshots__/multiColumn.test.tsx.snap b/tests/src/end-to-end/y-prosemirror/__snapshots__/multiColumn.test.tsx.snap index fb3539d7ef..49af5e421e 100644 --- a/tests/src/end-to-end/y-prosemirror/__snapshots__/multiColumn.test.tsx.snap +++ b/tests/src/end-to-end/y-prosemirror/__snapshots__/multiColumn.test.tsx.snap @@ -9,7 +9,7 @@ exports[`suggestion mode: add a block to a column 1`] = ` - + Right column @@ -24,12 +24,12 @@ exports[`suggestion mode: add a block to a column 2`] = ` Left column - + Added to the left column - + Right column @@ -46,17 +46,25 @@ exports[`suggestion mode: add a block to a column 3`] = ` Left column - - - - Added to the left column - - - + + + + + + Added to the left column + + + + + - + Right column @@ -78,14 +86,14 @@ exports[`suggestion mode: create two columns 2`] = ` Intro paragraph - - - + + + Left column - - + + Right column @@ -100,34 +108,62 @@ exports[`suggestion mode: create two columns 3`] = ` Intro paragraph - - - - - + + + + + - - Left column - + + + + + + Left column + + + + + - - - - - - - - + + + + + + - - Right column - + + + + + + Right column + + + + + - - - - - + + + + + " @@ -137,12 +173,12 @@ exports[`suggestion mode: remove a column 1`] = ` " - + Left column - + Right column @@ -152,7 +188,7 @@ exports[`suggestion mode: remove a column 1`] = ` exports[`suggestion mode: remove a column 2`] = ` " - + Left column " @@ -162,43 +198,79 @@ exports[`suggestion mode: remove a column 3`] = ` " - - - - - + + + + + - - Left column - + + + + + + Left column + + + + + - - - - - - - - + + + + + + - - Right column - + + + + + + Right column + + + + + - - - - - + + + + + - - - - Left column - - - + + + + + + Left column + + + + + " @@ -208,17 +280,17 @@ exports[`suggestion mode: remove a column from three 1`] = ` " - + Left column - + Middle column - + Right column @@ -230,12 +302,12 @@ exports[`suggestion mode: remove a column from three 2`] = ` " - + Left column - + Middle column @@ -248,27 +320,39 @@ exports[`suggestion mode: remove a column from three 3`] = ` - + Left column - + Middle column - - - - - - Right column - - - - - + + + + + + + + + Right column + + + + + + + + diff --git a/tests/src/end-to-end/y-prosemirror/__snapshots__/nesting.concurrent.test.tsx.snap b/tests/src/end-to-end/y-prosemirror/__snapshots__/nesting.concurrent.test.tsx.snap index e0fba76cab..958d39c987 100644 --- a/tests/src/end-to-end/y-prosemirror/__snapshots__/nesting.concurrent.test.tsx.snap +++ b/tests/src/end-to-end/y-prosemirror/__snapshots__/nesting.concurrent.test.tsx.snap @@ -71,75 +71,131 @@ exports[`concurrent: A indents N1, B indents N2 below N1 5`] = ` " - - - - N0 - - - + + + + + + N0 + + + + + - - - - N1 - - - + + + + + + N1 + + + + + - - - - N0 - - - - - - - - - N1 - - - - - - - + + + + + + N0 + + + + + + + + + + + + N1 + + + + + + + + + + - - - - N2 - - - + + + + + + N2 + + + + + - - - - N1 - - - - - - - - - N2 - - - - - - - + + + + + + N1 + + + + + + + + + + + + N2 + + + + + + + + + + " @@ -204,57 +260,97 @@ exports[`concurrent: A nests N1 under N0, B nests N2 under N0 5`] = ` " - - - - N0 - - - + + + + + + N0 + + + + + - - - - N0 - - - - - - - - - N1 - - - - - - - + + + + + + N0 + + + + + + + + + + + + N1 + + + + + + + + + + - - - - N0 - - - - - - - - - N2 - - - - - - - + + + + + + N0 + + + + + + + + + + + + N2 + + + + + + + + + + " diff --git a/tests/src/end-to-end/y-prosemirror/__snapshots__/nesting.test.tsx.snap b/tests/src/end-to-end/y-prosemirror/__snapshots__/nesting.test.tsx.snap index 898549de01..6e4bd785a6 100644 --- a/tests/src/end-to-end/y-prosemirror/__snapshots__/nesting.test.tsx.snap +++ b/tests/src/end-to-end/y-prosemirror/__snapshots__/nesting.test.tsx.snap @@ -36,54 +36,86 @@ exports[`suggestion mode: change block type of a block with children 3`] = ` " - - - - N0 - - - - - - - - - N1 - - - - - - - + + + + + + N0 + + + + + + + + + + + + N1 + + + + + + + + + + - - - - N0 - - - - - - - - - N1 - - - - - - - + + + + + + N0 + + + + + + + + + + + + N1 + + + + + + + + + + " @@ -117,44 +149,76 @@ exports[`suggestion mode: indent a block 3`] = ` " - - - - N0 - - - + + + + + + N0 + + + + + - - - - N1 - - - + + + + + + N1 + + + + + - - - - N0 - - - - - - - - - N1 - - - - - - - + + + + + + N0 + + + + + + + + + + + + N1 + + + + + + + + + + " @@ -188,44 +252,76 @@ exports[`suggestion mode: unindent a block 3`] = ` " - - - - N0 - - - - - - - - - N1 - - - - - - - + + + + + + N0 + + + + + + + + + + + + N1 + + + + + + + + + + - - - - N0 - - - + + + + + + N0 + + + + + - - - - N1 - - - + + + + + + N1 + + + + + " diff --git a/tests/src/end-to-end/y-prosemirror/__snapshots__/propChanges.concurrent.test.tsx.snap b/tests/src/end-to-end/y-prosemirror/__snapshots__/propChanges.concurrent.test.tsx.snap index 3caaba42e1..5661dbde3c 100644 --- a/tests/src/end-to-end/y-prosemirror/__snapshots__/propChanges.concurrent.test.tsx.snap +++ b/tests/src/end-to-end/y-prosemirror/__snapshots__/propChanges.concurrent.test.tsx.snap @@ -36,7 +36,11 @@ exports[`concurrent: A changes textColor, B changes backgroundColor 5`] = ` " - hello world + + hello world + " diff --git a/tests/src/end-to-end/y-prosemirror/__snapshots__/propChanges.test.tsx.snap b/tests/src/end-to-end/y-prosemirror/__snapshots__/propChanges.test.tsx.snap index b328bc3395..c9745f492f 100644 --- a/tests/src/end-to-end/y-prosemirror/__snapshots__/propChanges.test.tsx.snap +++ b/tests/src/end-to-end/y-prosemirror/__snapshots__/propChanges.test.tsx.snap @@ -32,13 +32,17 @@ exports[`suggestion mode: change heading level from 1 to 2 3`] = ` " - hello world + + hello world + " @@ -80,15 +84,19 @@ exports[`suggestion mode: change image source 3`] = ` " - + + + " @@ -114,7 +122,11 @@ exports[`suggestion mode: change text alignment to center 3`] = ` " - hello world + + hello world + " @@ -156,15 +168,19 @@ exports[`suggestion mode: resize image (previewWidth) 3`] = ` " - + + + " diff --git a/tests/src/end-to-end/y-prosemirror/__snapshots__/tables.concurrent.test.tsx.snap b/tests/src/end-to-end/y-prosemirror/__snapshots__/tables.concurrent.test.tsx.snap index 09afa5be55..b2554346ed 100644 --- a/tests/src/end-to-end/y-prosemirror/__snapshots__/tables.concurrent.test.tsx.snap +++ b/tests/src/end-to-end/y-prosemirror/__snapshots__/tables.concurrent.test.tsx.snap @@ -331,19 +331,23 @@ exports[`concurrent: A adds a column, B adds a row 5`] = ` B1 - - - - C1 - - - + + + + C1 + + + + @@ -366,24 +370,9 @@ exports[`concurrent: A adds a column, B adds a row 5`] = ` B2 - - - - C2 - - - - - - - - - + - A3 + C2 + + + + + + + + + + + A3 + + + + - - - - B3 - - - + + + + B3 + + + + + + + + + + + + + - - - @@ -761,19 +785,23 @@ exports[`concurrent: A adds a row, B adds a column 5`] = ` B1 - - - - C1 - - - + + + + C1 + + + + @@ -796,24 +824,9 @@ exports[`concurrent: A adds a row, B adds a column 5`] = ` B2 - - - - C2 - - - - - - - - - + - A3 + C2 + + + + + + + + + + + A3 + + + + - - - - B3 - - - + + + + B3 + + + + + + + + + + + + + - - - @@ -1092,19 +1140,23 @@ exports[`concurrent: A deletes a column, B adds a row 5`] = ` A1 - - - - B1 - - - + + + + B1 + + + + @@ -1118,24 +1170,9 @@ exports[`concurrent: A deletes a column, B adds a row 5`] = ` A2 - - - - B2 - - - - - - - - - + - A3 + B2 - + + + + + + + + + + + + A3 + + + + - - - - B3 - - - + + + + B3 + + + + @@ -1564,34 +1628,42 @@ exports[`sequential: A adds a column then a row, B adds a column 5`] = ` B1 - - - - C1 - - - + + + + C1 + + + + - - - - D1 - - - + + + + D1 + + + + @@ -1614,39 +1686,9 @@ exports[`sequential: A adds a column then a row, B adds a column 5`] = ` B2 - - - - C2 - - - - - - - - - D2 - - - - - - - - - A3 + C2 - - + + + + - + - B3 + D2 + + + + + + + + + + + A3 + + + + - - - - C3 - - - + + + + B3 + + + + + + + + + + + C3 + + + + + + + + + + + + + - - - @@ -2103,19 +2203,23 @@ exports[`sequential: A adds a row then a column, B adds a row 5`] = ` B1 - - - - C1 - - - + + + + C1 + + + + @@ -2138,24 +2242,9 @@ exports[`sequential: A adds a row then a column, B adds a row 5`] = ` B2 - - - - C2 - - - - - - - - - A3 + C2 + + + + + + + + + + + A3 + + + + - - - - B3 - - - + + + + B3 + + + + - - - - C3 - - - + + + + C3 + + + + - - - - D1 - - - + + + + D1 + + + + - - - - D2 - - - + + + + D2 + + + + + + + + + + + + + - - - diff --git a/tests/src/end-to-end/y-prosemirror/__snapshots__/tables.test.tsx.snap b/tests/src/end-to-end/y-prosemirror/__snapshots__/tables.test.tsx.snap index acf08bea8f..f42f72e713 100644 --- a/tests/src/end-to-end/y-prosemirror/__snapshots__/tables.test.tsx.snap +++ b/tests/src/end-to-end/y-prosemirror/__snapshots__/tables.test.tsx.snap @@ -151,19 +151,23 @@ exports[`suggestion mode: add column 3`] = ` B1 - - - - C1 - - - + + + + C1 + + + + @@ -186,19 +190,23 @@ exports[`suggestion mode: add column 3`] = ` B2 - - - - C2 - - - + + + + C2 + + + + @@ -383,34 +391,42 @@ exports[`suggestion mode: add row 3`] = ` - - - - A3 - - - + + + + A3 + + + + - - - - B3 - - - + + + + B3 + + + + @@ -532,15 +548,19 @@ exports[`suggestion mode: change column background color 3`] = ` - - A1 - + + A1 + + - - A2 - + + A2 + +
- - - A1 - +B1 - - - - - - B1 - - + + A1 + +B1 + + + + + + + + B1 + + + + @@ -738,17 +770,21 @@ exports[`suggestion mode: merge two cells 3`] = ` B2 - - - - - + + + + + +
@@ -859,19 +895,23 @@ exports[`suggestion mode: remove column 3`] = ` A1 - - - - B1 - - - + + + + B1 + + + + @@ -885,19 +925,23 @@ exports[`suggestion mode: remove column 3`] = ` A2 - - - - B2 - - - + + + + B2 + + + + @@ -1018,34 +1062,42 @@ exports[`suggestion mode: remove row 3`] = ` - - - - A2 - - - + + + + A2 + + + + - - - - B2 - - - + + + + B2 + + + + @@ -1157,19 +1209,9 @@ exports[`suggestion mode: split a merged cell 3`] = ` - - - A1 - +B1 - - - - - - B1 - - + + A1 + +B1 + + + + + + + + B1 + + + + diff --git a/tests/src/end-to-end/y-prosemirror/__snapshots__/typeChanges.concurrent.test.tsx.snap b/tests/src/end-to-end/y-prosemirror/__snapshots__/typeChanges.concurrent.test.tsx.snap index 272ba6b782..97f65b1bb2 100644 --- a/tests/src/end-to-end/y-prosemirror/__snapshots__/typeChanges.concurrent.test.tsx.snap +++ b/tests/src/end-to-end/y-prosemirror/__snapshots__/typeChanges.concurrent.test.tsx.snap @@ -48,31 +48,47 @@ exports[`concurrent: A edits text, B → heading 5`] = ` " - - - - hello - wo - r - ld - - - + + + + + + hello + wo + r + ld + + + + + - - - - hello world - - - + + + + + + hello world + + + + + " @@ -137,41 +153,65 @@ exports[`concurrent: A → heading, B → list item 5`] = ` " - - - - hello world - - - + + + + + + hello world + + + + + - - - - hello world - - - + + + + + + hello world + + + + + - - - - hello world - - - + + + + + + hello world + + + + + " diff --git a/tests/src/end-to-end/y-prosemirror/__snapshots__/typeChanges.test.tsx.snap b/tests/src/end-to-end/y-prosemirror/__snapshots__/typeChanges.test.tsx.snap index 8b40e097d7..8d5b6a3e97 100644 --- a/tests/src/end-to-end/y-prosemirror/__snapshots__/typeChanges.test.tsx.snap +++ b/tests/src/end-to-end/y-prosemirror/__snapshots__/typeChanges.test.tsx.snap @@ -24,26 +24,42 @@ exports[`suggestion mode: change list item to paragraph 3`] = ` " - - - - hello world - - - + + + + + + hello world + + + + + - - - - hello world - - - + + + + + + hello world + + + + + " @@ -75,28 +91,44 @@ exports[`suggestion mode: change paragraph to heading 3`] = ` " - - - - hello world - - - + + + + + + hello world + + + + + - - - - hello world - - - + + + + + + hello world + + + + + " diff --git a/tests/src/end-to-end/y-prosemirror/addRemoveBlocks.test.tsx b/tests/src/end-to-end/y-prosemirror/addRemoveBlocks.test.tsx index c66d385459..ca3687a650 100644 --- a/tests/src/end-to-end/y-prosemirror/addRemoveBlocks.test.tsx +++ b/tests/src/end-to-end/y-prosemirror/addRemoveBlocks.test.tsx @@ -170,8 +170,7 @@ test("suggestion mode: add numbered list item to empty doc", async () => { // Empty doc gets a 3-level nested bullet list inserted as a suggestion. // -// Known issue — tracked in the suggestion gallery ("add-nested-bullets"). -// This baseline intentionally captures all three rows as `•`. +// Nesting markers must survive attribution wrappers: •, ◦, then ▪. test("suggestion mode: add nested bullet list to empty doc", async () => { const { editor, screen, baseDoc, suggestionDoc, sync } = await setupSuggestionTest({ userAction: "add nested bullets" }); @@ -230,8 +229,7 @@ test("suggestion mode: add colored block with child to empty doc", async () => { // nested under the first (`nestBlock`). Unlike the all-new subtree above, the // parent bullet already exists – only the newly-nested child is the suggestion. // -// Known issue — tracked in the suggestion gallery ("nest-bullet-existing"): -// the nested child shows `•` instead of `◦`. Baseline captures `•`. +// The nested child should retain its hollow bullet and nesting guide. test("suggestion mode: nest a bullet under an existing bullet", async () => { const { editor, screen, baseDoc, suggestionDoc, sync } = await setupSuggestionTest({ userAction: "nest bullet under existing" }); diff --git a/tests/src/end-to-end/y-prosemirror/basicText.concurrent.test.tsx b/tests/src/end-to-end/y-prosemirror/basicText.concurrent.test.tsx index 6837727f0e..c41442dc4a 100644 --- a/tests/src/end-to-end/y-prosemirror/basicText.concurrent.test.tsx +++ b/tests/src/end-to-end/y-prosemirror/basicText.concurrent.test.tsx @@ -8,10 +8,10 @@ * * TODO: BlockNote's `mapAttributionToMark` (YSync.ts) hashes user IDs * from the attribution data to pick a color from a fixed palette, but - * `Y.Attributions()` ships empty and nothing in the editor pipeline + * `Y.ContentMap()` ships empty and nothing in the editor pipeline * populates it from the editor's `user` / awareness. Result: every - * mark in every test renders as `userColorPalette[0]` (#30bced), - * regardless of which user actually made the edit. In the merged + * mark in every test renders as `userColorPalette[0]` (the amber entry + * in `userColors.ts`), regardless of which user actually made the edit. In the merged * snapshots below we therefore cannot tell A's marks from B's. Decide * whether the attribution layer should automatically tag writes with * the local awareness user, or whether tests should construct an diff --git a/tests/src/end-to-end/y-prosemirror/fixtures/concurrentSuggestionFixture.tsx b/tests/src/end-to-end/y-prosemirror/fixtures/concurrentSuggestionFixture.tsx index fc0557cfa3..14d2af97ea 100644 --- a/tests/src/end-to-end/y-prosemirror/fixtures/concurrentSuggestionFixture.tsx +++ b/tests/src/end-to-end/y-prosemirror/fixtures/concurrentSuggestionFixture.tsx @@ -114,7 +114,7 @@ export async function setupConcurrentSuggestionTest({ // reliable to snapshot. // Each editor's attribution manager reads its `attrs` (a mutable - // `Y.Attributions`) on every transaction. We back each `attrs` with an + // `Y.ContentMap`) on every transaction. We back each `attrs` with an // in-memory store that records the author of each change (see // `createInMemoryAttributionStore` below) so suggestions render in their // author's color instead of all sharing the default. A and B are single-user @@ -125,7 +125,7 @@ export async function setupConcurrentSuggestionTest({ tr.local ? "A" : null, ); const managerA = Y.createDiffRenderer(baseDoc, suggestionDocA, { - attrs: attrsA, + attributions: attrsA, }); managerA.suggestionMode = true; @@ -133,7 +133,7 @@ export async function setupConcurrentSuggestionTest({ tr.local ? "B" : null, ); const managerB = Y.createDiffRenderer(baseDoc, suggestionDocB, { - attrs: attrsB, + attributions: attrsB, }); managerB.suggestionMode = true; @@ -144,7 +144,7 @@ export async function setupConcurrentSuggestionTest({ (tr) => (tr.origin === "A" || tr.origin === "B" ? tr.origin : null), ); const managerMerged = Y.createDiffRenderer(baseDoc, suggestionDocMerged, { - attrs: attrsMerged, + attributions: attrsMerged, }); managerMerged.suggestionMode = false; @@ -292,10 +292,11 @@ function makeAwareness( * attribution store (YHub) that real deployments use. * * It observes the doc and, for every transaction, records the author of that - * transaction's inserts/deletes into a mutable `Y.Attributions`. A + * transaction's inserts/deletes into a mutable `Y.ContentMap`. A * `DiffRenderer` re-reads that same `attrs` object on each transaction * (via its own `beforeObserverCalls` handler), so the suggestion marks pick up - * the author and render in their color (`colorsForUserIds` in YSync.ts). + * the author and render in their color (`colorsForUserIds` / + * `userMarkColors` in `user/userColors.ts`). * * Crucially this store's handler must run BEFORE the manager's, so it is * registered here and the caller creates the manager immediately afterwards @@ -309,8 +310,8 @@ function makeAwareness( function createInMemoryAttributionStore( doc: Y.Doc, resolveUserId: (tr: any) => string | null, -): Y.Attributions { - const attrs = new Y.Attributions(); +): Y.ContentMap { + const attrs = Y.createContentMap(); doc.on("beforeObserverCalls", (tr: any) => { const userId = resolveUserId(tr); if (userId == null) { diff --git a/tests/src/end-to-end/y-prosemirror/fixtures/suggestionFixture.tsx b/tests/src/end-to-end/y-prosemirror/fixtures/suggestionFixture.tsx index 2c768cff6c..aad6c56882 100644 --- a/tests/src/end-to-end/y-prosemirror/fixtures/suggestionFixture.tsx +++ b/tests/src/end-to-end/y-prosemirror/fixtures/suggestionFixture.tsx @@ -91,7 +91,7 @@ export async function setupSuggestionTest({ const suggestionDoc = new Y.Doc({ isSuggestionDoc: true }); suggestionDoc.clientID = 2; const renderer = Y.createDiffRenderer(baseDoc, suggestionDoc, { - attrs: new Y.Attributions(), + attributions: Y.createContentMap(), }); renderer.suggestionMode = true; @@ -163,6 +163,38 @@ export async function setupSuggestionTest({ }; } +/** + * Whether `editor` is showing the pristine-empty skeleton: a single empty + * paragraph with no children. + * + * The empty-doc binding keeps that skeleton local instead of committing it to + * Y — `blocksToYDoc([])` seeds a fragment with no children — so a pristine-empty + * editor corresponds to a base fragment with zero block nodes. Waiting for the + * skeleton to show up in `baseDoc` would never succeed. + * + * Ids are deliberately ignored: the skeleton carries whatever id the editor + * minted (`initialBlockId` at mount, a fresh random id after deleting all + * blocks). The sync layer's initial-content gate (`isInitialBlockNoteDoc` in + * `packages/core/src/y/extensions/YSync.ts`) treats any single empty + * paragraph as initial content, so the id never reaches Y either way. + */ +function isPristineEmptyEditor(editor: GalleryEditor): boolean { + const blocks = editor.document as { + type?: string; + content?: { length?: number }; + children?: unknown[]; + }[]; + if (blocks.length !== 1) { + return false; + } + const [block] = blocks; + return ( + block.type === "paragraph" && + (block.content?.length ?? 0) === 0 && + (block.children?.length ?? 0) === 0 + ); +} + /** * Count every block in a (possibly nested) BlockNote document tree. */ @@ -189,13 +221,19 @@ function countBlocks(blocks: { children?: unknown[] }[]): number { * multi-column `columnList` / `column` nodes, which serialise as their own * elements rather than `blockContainer`s (the ` { - const expected = countBlocks(editor.document as { children?: unknown[] }[]); + // The pristine-empty skeleton never reaches Y (see `isPristineEmptyEditor`), + // so it is excluded from the expected block count: an empty editor document + // maps to a fragment with zero block nodes. + const expected = + countBlocks(editor.document as { children?: unknown[] }[]) - + (isPristineEmptyEditor(editor) ? 1 : 0); await expect .poll(() => { // `XmlFragment` isn't exported from `@y/y` v14's types, so cast to @@ -275,17 +313,17 @@ export async function waitForSuggestion(editor: GalleryEditor): Promise { * nested tags (`world`) and attribution as an * `attribution="..."` attribute so the snapshots actually differ. * - * We pass an explicit, stable `renderer` (`Y.baseRenderer`) rather than + * We pass an explicit, stable `renderer` (`null`) rather than * relying on `toDeltaDeep()`'s default. As of @y/prosemirror v2.0.0-6 the * default renderer is ambient/mutable, so a no-arg call serialises the * *same* Y.Doc differently from run to run (attribution-rich vs. plain), * which makes these inline snapshots flip-flop and never converge. Passing - * `Y.baseRenderer` renders each doc's own intrinsic content + stored + * `null` renders each doc's own intrinsic content + stored * attribution deterministically, independent of any live DiffRenderer. */ export function ydocXml( doc: Y.Doc, - renderer: Y.AbstractRenderer | null = Y.baseRenderer, + renderer: Y.AbstractRenderer | null = null, ): string { const delta = (doc.get("doc") as any).toDeltaDeep({ renderer }).toJSON(); return prettify(deltaToXml(delta), { tag_wrap: true }); @@ -443,7 +481,10 @@ function formatAttrs(attrs: Record): string { return Object.entries(attrs) .filter(([, v]) => v !== null && v !== undefined) .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) - .map(([k, v]) => ` ${k}="${escapeXml(String(v))}"`) + .map( + ([k, v]) => + ` ${k}="${escapeXml(k === "changes" ? JSON.stringify(v) : String(v))}"`, + ) .join(""); } diff --git a/tests/src/end-to-end/y-prosemirror/propChanges.concurrent.test.tsx b/tests/src/end-to-end/y-prosemirror/propChanges.concurrent.test.tsx index 592355c7bb..7fe9069f67 100644 --- a/tests/src/end-to-end/y-prosemirror/propChanges.concurrent.test.tsx +++ b/tests/src/end-to-end/y-prosemirror/propChanges.concurrent.test.tsx @@ -3,9 +3,6 @@ * Vitest browser-mode tests for two-user concurrent prop-change * suggestions. Same shape as `basicText.concurrent.test.tsx` but the * edits are block-level prop changes rather than content edits. - * - * The "no `y-attributed-*` mark for block-prop changes" known issue (tracked in - * the suggestion gallery's "Prop changes" scenarios) applies here too. */ import { expect, test } from "vite-plus/test"; import { expectScreenshot, expectVisible } from "./fixtures/browserExpect.js"; @@ -58,8 +55,8 @@ test("concurrent: A changes textColor, B changes backgroundColor", async () => { // B: change backgroundColor to yellow. textColorVsBgColor.applyB(userB.editor); - // Prop changes don't generate y-attributed marks, so we poll on the - // individual editor doc states instead. + // Poll on the individual editor doc states to confirm the merge, + // then snapshot the rendered diff below. type ColorProps = { textColor?: string; backgroundColor?: string }; await expect .poll(() => (userA.editor.document[0]?.props as ColorProps)?.textColor) diff --git a/tests/src/end-to-end/y-prosemirror/propChanges.test.tsx b/tests/src/end-to-end/y-prosemirror/propChanges.test.tsx index 72b7dfaaa6..5a50a13f0e 100644 --- a/tests/src/end-to-end/y-prosemirror/propChanges.test.tsx +++ b/tests/src/end-to-end/y-prosemirror/propChanges.test.tsx @@ -6,7 +6,7 @@ * shape as `basicText.test.tsx`: seed, enable suggestions, edit, then * screenshot + inline snapshots of base/suggestion docs + PM doc. */ -import { SuggestionsExtension } from "@blocknote/core/y"; +import { AttributionExtension, SuggestionsExtension } from "@blocknote/core/y"; import { expect, test } from "vite-plus/test"; import { expectScreenshot, expectVisible } from "./fixtures/browserExpect.js"; @@ -14,6 +14,7 @@ import { editorHtml, setupSuggestionTest, ydocXml, + waitForSuggestion, } from "./fixtures/suggestionFixture.js"; // Scenario data (the `initial` seed + the `apply` change) is shared with the @@ -40,10 +41,6 @@ const imageSource = scenarios.find( (s) => s.id === "prop-image-source", ) as SingleScenario; -// Known issue — tracked in the suggestion gallery (the "Prop changes" scenarios, -// e.g. "prop-text-alignment"): block-level prop changes generate no -// `y-attributed-*` mark, so the pending change is invisible in the diff. -// // Block-level prop change: paragraph's `textAlignment` flips from // "left" to "center". Text content is unchanged. test("suggestion mode: change text alignment to center", async () => { @@ -58,9 +55,6 @@ test("suggestion mode: change text alignment to center", async () => { textAlignment.apply(editor); - // Prop changes don't generate `y-attributed-*` marks, so the - // `waitForSuggestion` helper used elsewhere is too narrow here. - // Poll on the editor's view of the prop instead. await expect .poll( () => @@ -69,18 +63,47 @@ test("suggestion mode: change text alignment to center", async () => { ) .toBe("center"); + await waitForSuggestion(editor); + const attributeMarks = + editor.prosemirrorView.dom.querySelectorAll( + "[data-attributes]", + ); + expect(attributeMarks.length).toBe(1); + expect(JSON.parse(attributeMarks[0].dataset["attributes"]!)).toEqual({ + textAlignment: { userIds: [], timestamp: null }, + }); await expectScreenshot( screen.getByTestId("editor-root"), "prop-change-text-alignment", ); + attributeMarks[0].dispatchEvent( + new MouseEvent("mouseover", { bubbles: true }), + ); + expect(editor.getExtension(AttributionExtension)!.store.state).toMatchObject({ + modificationType: "attrs", + attributes: ["textAlignment"], + }); + await expectVisible( + screen.getByText(editor.dictionary.suggestion_changes.formatting_change, { + exact: true, + }), + ); + expect( + getComputedStyle(attributeMarks[0].firstElementChild!.firstElementChild!) + .backgroundColor, + ).not.toBe("rgba(0, 0, 0, 0)"); + editor.prosemirrorView.dom.dispatchEvent( + new MouseEvent("mouseover", { bubbles: true }), + ); + expect(ydocXml(baseDoc)).toMatchSnapshot(); expect(ydocXml(suggestionDoc)).toMatchSnapshot(); expect(editorHtml(editor)).toMatchSnapshot(); }); // Block-level prop change on a heading: bump `level` from 1 to 2. -// Same lack of attribution as the alignment case. +// The changed block is highlighted as a formatting change. test("suggestion mode: change heading level from 1 to 2", async () => { const { editor, screen, baseDoc, suggestionDoc, sync } = await setupSuggestionTest({ userAction: "demote heading" }); @@ -97,11 +120,40 @@ test("suggestion mode: change heading level from 1 to 2", async () => { .poll(() => (editor.document[0]?.props as { level?: number })?.level) .toBe(2); + await waitForSuggestion(editor); + const attributeMarks = + editor.prosemirrorView.dom.querySelectorAll( + "[data-attributes]", + ); + expect(attributeMarks.length).toBe(1); + expect(JSON.parse(attributeMarks[0].dataset["attributes"]!)).toEqual({ + level: { userIds: [], timestamp: null }, + }); await expectScreenshot( screen.getByTestId("editor-root"), "prop-change-heading-level", ); + attributeMarks[0].dispatchEvent( + new MouseEvent("mouseover", { bubbles: true }), + ); + expect(editor.getExtension(AttributionExtension)!.store.state).toMatchObject({ + modificationType: "attrs", + attributes: ["level"], + }); + await expectVisible( + screen.getByText(editor.dictionary.suggestion_changes.formatting_change, { + exact: true, + }), + ); + expect( + getComputedStyle(attributeMarks[0].firstElementChild!.firstElementChild!) + .backgroundColor, + ).not.toBe("rgba(0, 0, 0, 0)"); + editor.prosemirrorView.dom.dispatchEvent( + new MouseEvent("mouseover", { bubbles: true }), + ); + expect(ydocXml(baseDoc)).toMatchSnapshot(); expect(ydocXml(suggestionDoc)).toMatchSnapshot(); expect(editorHtml(editor)).toMatchSnapshot(); @@ -133,11 +185,40 @@ test("suggestion mode: resize image (previewWidth)", async () => { ) .toBe(400); + await waitForSuggestion(editor); + const attributeMarks = + editor.prosemirrorView.dom.querySelectorAll( + "[data-attributes]", + ); + expect(attributeMarks.length).toBe(1); + expect(JSON.parse(attributeMarks[0].dataset["attributes"]!)).toEqual({ + previewWidth: { userIds: [], timestamp: null }, + }); await expectScreenshot( screen.getByTestId("editor-root"), "prop-change-image-width", ); + attributeMarks[0].dispatchEvent( + new MouseEvent("mouseover", { bubbles: true }), + ); + expect(editor.getExtension(AttributionExtension)!.store.state).toMatchObject({ + modificationType: "attrs", + attributes: ["previewWidth"], + }); + await expectVisible( + screen.getByText(editor.dictionary.suggestion_changes.formatting_change, { + exact: true, + }), + ); + expect( + getComputedStyle(attributeMarks[0].firstElementChild!.firstElementChild!) + .backgroundColor, + ).not.toBe("rgba(0, 0, 0, 0)"); + editor.prosemirrorView.dom.dispatchEvent( + new MouseEvent("mouseover", { bubbles: true }), + ); + expect(ydocXml(baseDoc)).toMatchSnapshot(); expect(ydocXml(suggestionDoc)).toMatchSnapshot(); expect(editorHtml(editor)).toMatchSnapshot(); @@ -165,11 +246,40 @@ test("suggestion mode: change image source", async () => { .poll(() => (editor.document[0]?.props as { url?: string })?.url) .toBe(IMG_SRC_NEW); + await waitForSuggestion(editor); + const attributeMarks = + editor.prosemirrorView.dom.querySelectorAll( + "[data-attributes]", + ); + expect(attributeMarks.length).toBe(1); + expect(JSON.parse(attributeMarks[0].dataset["attributes"]!)).toEqual({ + url: { userIds: [], timestamp: null }, + }); await expectScreenshot( screen.getByTestId("editor-root"), "prop-change-image-source", ); + attributeMarks[0].dispatchEvent( + new MouseEvent("mouseover", { bubbles: true }), + ); + expect(editor.getExtension(AttributionExtension)!.store.state).toMatchObject({ + modificationType: "attrs", + attributes: ["url"], + }); + await expectVisible( + screen.getByText(editor.dictionary.suggestion_changes.formatting_change, { + exact: true, + }), + ); + expect( + getComputedStyle(attributeMarks[0].firstElementChild!.firstElementChild!) + .backgroundColor, + ).not.toBe("rgba(0, 0, 0, 0)"); + editor.prosemirrorView.dom.dispatchEvent( + new MouseEvent("mouseover", { bubbles: true }), + ); + expect(ydocXml(baseDoc)).toMatchSnapshot(); expect(ydocXml(suggestionDoc)).toMatchSnapshot(); expect(editorHtml(editor)).toMatchSnapshot(); diff --git a/tests/src/end-to-end/y-prosemirror/versioning.test.tsx b/tests/src/end-to-end/y-prosemirror/versioning.test.tsx index d5f832a643..1030a3c2e4 100644 --- a/tests/src/end-to-end/y-prosemirror/versioning.test.tsx +++ b/tests/src/end-to-end/y-prosemirror/versioning.test.tsx @@ -15,6 +15,7 @@ import { BlockNoteEditor } from "@blocknote/core"; import { blocksToYDoc, + getAttributeChanges, createYjsVersioningAdapter, withCollaboration, } from "@blocknote/core/y"; @@ -83,6 +84,13 @@ function mountEditor(doc: Y.Doc): { // retried into a runaway warning loop that never lets the suite finish. const VERSIONING_CRASHES = new Set(["large-diff-delete-all"]); +const propertyChanges = new Map([ + ["prop-text-alignment", "textAlignment"], + ["prop-heading-level", "level"], + ["prop-image-width", "previewWidth"], + ["prop-image-source", "url"], +]); + for (const scenario of scenarios) { const applies = scenario.kind === "single" @@ -134,6 +142,22 @@ for (const scenario of scenarios) { // Reached only when enterPreview didn't throw: the diff is now showing. expect(diffEditor.prosemirrorState.doc.childCount).toBeGreaterThan(0); + const property = propertyChanges.get(scenario.id); + if (property) { + const changedProperties: string[] = []; + diffEditor.prosemirrorState.doc.descendants((node) => { + for (const mark of node.marks) { + if (mark.type.name === "y-attributed-attrs") { + changedProperties.push(...Object.keys(getAttributeChanges(mark))); + } + } + }); + expect(changedProperties).toEqual([property]); + adapter.preview.exitPreview(); + expect( + diffEditor.prosemirrorView.dom.querySelector("[data-attributes]"), + ).toBeNull(); + } } finally { teardown.reverse().forEach((fn) => fn()); } diff --git a/tests/src/unit/react/LinkToolbarReadOnly.test.tsx b/tests/src/unit/react/LinkToolbarReadOnly.test.tsx new file mode 100644 index 0000000000..44858db29f --- /dev/null +++ b/tests/src/unit/react/LinkToolbarReadOnly.test.tsx @@ -0,0 +1,112 @@ +import { BlockNoteEditor } from "@blocknote/core"; +import { BlockNoteView } from "@blocknote/mantine"; +import "@blocknote/mantine/style.css"; +import { LinkToolbarController } from "@blocknote/react"; +import { TextSelection } from "@tiptap/pm/state"; +import { act, cleanup, fireEvent, render } from "@testing-library/react"; +import { afterEach, describe, expect, it } from "vite-plus/test"; + +/** + * A read-only editor must not open the link toolbar: it only offers editing + * actions, and a version preview keeps the editor read-only for as long as the + * history panel is open. + */ +describe("LinkToolbarController in a read-only editor", () => { + afterEach(() => { + cleanup(); + }); + + function setup(editable: boolean) { + const editor = BlockNoteEditor.create({ + initialContent: [ + { + type: "paragraph", + content: [ + { + type: "link", + href: "https://example.com", + content: "a link", + }, + ], + }, + ], + }); + + render( + + + , + ); + + return editor; + } + + /** The toolbar's own root, rendered only while it's open. */ + function toolbar() { + return document.querySelector(".bn-link-toolbar"); + } + + /** + * Put the text cursor inside the link. That's the deterministic half of the + * controller's two open paths; the mouse path shares the same `isEditable` + * gate (and the same effect), it just also needs floating-ui's hover delay. + */ + async function putCursorInLink(editor: BlockNoteEditor) { + let linkPos: number | undefined; + editor.prosemirrorState.doc.descendants((node, pos) => { + if (linkPos === undefined && node.isText && node.marks.length > 0) { + linkPos = pos + 1; + } + return linkPos === undefined; + }); + + await act(async () => { + editor.transact((tr) => + tr.setSelection(TextSelection.create(tr.doc, linkPos!)), + ); + }); + } + + it("opens the toolbar when the editor is editable", async () => { + const editor = setup(true); + + await putCursorInLink(editor); + + expect(toolbar()).not.toBeNull(); + }); + + it("does not open the toolbar when the editor is read-only", async () => { + const editor = setup(false); + + await putCursorInLink(editor); + + expect(toolbar()).toBeNull(); + }); + + it("ignores a hovered link when the editor is read-only", async () => { + const editor = setup(false); + const link = editor.domElement!.querySelector("a")!; + + fireEvent.mouseOver(link, { bubbles: true }); + fireEvent.pointerEnter(link, { pointerType: "mouse", bubbles: true }); + fireEvent.mouseEnter(link); + // Past floating-ui's 250ms open delay. + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 400)); + }); + + expect(toolbar()).toBeNull(); + }); + + it("closes the toolbar when the editor becomes read-only", async () => { + const editor = setup(true); + await putCursorInLink(editor); + expect(toolbar()).not.toBeNull(); + + await act(async () => { + editor.isEditable = false; + }); + + expect(toolbar()).toBeNull(); + }); +}); diff --git a/tests/src/unit/react/versioning/VersioningSidebar.test.tsx b/tests/src/unit/react/versioning/VersioningSidebar.test.tsx new file mode 100644 index 0000000000..fd1d54adaf --- /dev/null +++ b/tests/src/unit/react/versioning/VersioningSidebar.test.tsx @@ -0,0 +1,1353 @@ +import { StrictMode, type ComponentType, type ReactNode } from "react"; +import { BlockNoteView as AriakitBlockNoteView } from "@blocknote/ariakit"; +import { BlockNoteView as ShadcnBlockNoteView } from "@blocknote/shadcn"; +import { BlockNoteEditor } from "@blocknote/core"; +import { + VersioningExtension, + type VersioningEndpoints, + type VersionSnapshot, +} from "@blocknote/core/extensions"; +import { BlockNoteView } from "@blocknote/mantine"; +import "@blocknote/mantine/style.css"; +import { + DefaultVersionMenuItems, + RestoreVersionItem, + useRestoreVersionAction, + usePreviewRow, + useVersionSnapshot, + VersioningSidebar, + VersionMenu, + VersionMenuItem, +} from "@blocknote/react/versioning"; +import { + act, + cleanup, + fireEvent, + render, + screen, + within, +} from "@testing-library/react"; +import { + afterEach, + beforeEach, + describe, + expect, + it, + vi, +} from "vite-plus/test"; + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +const CURRENT: VersionSnapshot = { id: "now", createdAt: 3000 }; +const NAMED: VersionSnapshot = { id: "b", createdAt: 2000, name: "Draft" }; +const AUTOMATIC: VersionSnapshot = { id: "a", createdAt: 1000 }; + +/** + * Fake endpoints with spies on every verb, plus a gate that holds `list` and + * `getContent` open so the loading states can be observed. + */ +function createFakeEndpoints() { + let current = CURRENT; + let snapshots = [NAMED, AUTOMATIC]; + + let gate: { promise: Promise; release: () => void } | undefined; + + const endpoints = { + list: vi.fn(async () => { + await gate?.promise; + return { current, snapshots }; + }), + getContent: vi.fn(async () => { + await gate?.promise; + return []; + }), + getAttributions: vi.fn(async () => undefined), + create: vi.fn(async (_doc: unknown, options: { name?: string }) => { + current = { ...current, name: options.name }; + return current; + }), + rename: vi.fn(async (snapshot: VersionSnapshot, name?: string) => { + snapshots = snapshots.map((s) => + s.id === snapshot.id ? { ...s, name } : s, + ); + if (snapshot.id === current.id) { + current = { ...current, name }; + } + }), + remove: vi.fn(async (snapshot: VersionSnapshot) => { + snapshots = snapshots.filter((s) => s.id !== snapshot.id); + }), + restore: vi.fn(async () => []), + } satisfies VersioningEndpoints; + + return { + endpoints, + /** Hold every async endpoint open until the returned callback is called. */ + block() { + let release!: () => void; + const promise = new Promise((resolve) => { + release = resolve; + }); + gate = { promise, release }; + return () => { + gate = undefined; + release(); + }; + }, + /** Mutate the backend behind the sidebar's back (as a peer would). */ + setSnapshots(next: VersionSnapshot[]) { + snapshots = next; + }, + }; +} + +function createEditor(endpoints: VersioningEndpoints) { + return BlockNoteEditor.create({ + extensions: [ + VersioningExtension({ + endpoints, + preview: { + enterPreview: () => {}, + exitPreview: () => {}, + applyRestore: () => {}, + }, + getCurrentDocument: () => [], + serializeCurrentContent: () => [], + }), + ], + }); +} + +/** Render the sidebar inside a real editor and wait for its initial list. */ +async function setup( + props: Parameters[0] = {}, + fake = createFakeEndpoints(), + View: ComponentType<{ + editor: ReturnType; + children: ReactNode; + }> = BlockNoteView, +) { + const editor = createEditor(fake.endpoints); + const view = render( + + + , + ); + // Let the mount effect's `list()` + initial preview settle. + await act(async () => {}); + return { editor, fake, view }; +} + +function rows() { + return screen.getAllByRole("listitem"); +} + +/** + * A row's name field. Only the selected row has one — every other row shows + * its name as text (see {@link nameText}). + */ +function nameInput(row: HTMLElement) { + return within(row).getByRole("textbox", { + name: "Version name", + }) as HTMLInputElement; +} + +/** What a row shows as its name: the field's value, or the text. */ +function nameText(row: HTMLElement) { + const input = within(row).queryByRole("textbox", { name: "Version name" }); + if (input) { + return (input as HTMLInputElement).value; + } + return row.querySelector(".bn-snapshot-name")!.textContent; +} + +/** + * Click, then let everything the click kicked off settle — including the + * timer-driven mount of a Mantine menu dropdown, which a microtask flush alone + * doesn't cover. + */ +async function click(element: Element) { + fireEvent.click(element); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 20)); + }); +} + +/** + * Retry `get` until it stops throwing. Menus and their contents mount on + * timers, so a single flush is not always enough. + */ +async function eventually(get: () => T): Promise { + for (let attempt = 0; attempt < 20; attempt++) { + try { + return get(); + } catch { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 20)); + }); + } + } + return get(); +} + +/** + * Open the row's "..." menu and return the item matching `label`, retrying + * until the dropdown is there — it mounts on a timer, so under load a click can + * land while the menu is still opening and do nothing at all. + */ +async function openMenuItem(row: HTMLElement, label: RegExp) { + // Scoped to this row: the dropdown isn't portaled, and a document-wide + // query could pick up another row's menu if one were still open. + const item = () => within(row).queryAllByText(label)[0]; + + for (let attempt = 0; attempt < 10 && !item(); attempt++) { + const trigger = within(row).getByRole("button", { + name: "More actions", + }); + if (trigger.getAttribute("aria-expanded") !== "true") { + await click(trigger); + } else { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 20)); + }); + } + } + + const found = item(); + if (!found) { + throw new Error(`the row's ${label} menu item never appeared`); + } + return found; +} + +/** Type `value` into a name field and end the edit with `key`. */ +async function commit(input: HTMLInputElement, value: string, key: string) { + input.focus(); + fireEvent.change(input, { target: { value } }); + // Both keys leave the field, and leaving it is what commits. + fireEvent.keyDown(input, { key }); + await act(async () => {}); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("VersioningSidebar", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + cleanup(); + }); + + it("opens on the current version with the editor locked, and unlocks on unmount", async () => { + const { editor, view } = await setup(); + + expect(rows()[0]!.getAttribute("aria-current")).toBe("true"); + expect(nameInput(rows()[0]!).placeholder).toBe("Current version"); + expect(editor.isEditable).toBe(false); + + view.unmount(); + expect(editor.isEditable).toBe(true); + }); + + it("opens and closes preview through Strict Mode effect cleanup", async () => { + const editor = createEditor(createFakeEndpoints().endpoints); + const view = render( + + + + + , + ); + await act(async () => {}); + expect( + editor.getExtension(VersioningExtension)!.store.state.view.mode, + ).toBe("current"); + expect(editor.isEditable).toBe(false); + view.unmount(); + expect( + editor.getExtension(VersioningExtension)!.store.state.view.mode, + ).toBe("live"); + expect(editor.isEditable).toBe(true); + }); + + it("lists the current version first, then stored versions newest-first", async () => { + await setup(); + + expect(rows()).toHaveLength(3); + expect(nameText(rows()[1]!)).toBe("Draft"); + }); + + it("shows a name field on the selected row only", async () => { + await setup(); + + // Opens on the current row, so that's the one with a field. + expect(nameInput(rows()[0]!)).toBeDefined(); + expect( + within(rows()[1]!).queryByRole("textbox", { name: "Version name" }), + ).toBeNull(); + expect(nameText(rows()[1]!)).toBe("Draft"); + + // The first click selects; the name is text until then. + await click(rows()[1]!); + expect(rows()[1]!.getAttribute("aria-current")).toBe("true"); + expect(nameInput(rows()[1]!).value).toBe("Draft"); + expect( + within(rows()[0]!).queryByRole("textbox", { name: "Version name" }), + ).toBeNull(); + }); + + it("doesn't reselect the row when its name field is clicked", async () => { + const { fake } = await setup(); + + await click(rows()[2]!); + fake.endpoints.getContent.mockClear(); + + await click(nameInput(rows()[2]!)); + + expect(fake.endpoints.getContent).not.toHaveBeenCalled(); + }); + + it("filters to named versions, keeping the current row", async () => { + await setup(); + + await click( + screen.getByRole("button", { name: "Show named versions only" }), + ); + + // The current row survives the filter; the unnamed one doesn't. + expect(rows()).toHaveLength(2); + expect(nameText(rows()[1]!)).toBe("Draft"); + + await click(screen.getByRole("button", { name: "Show all versions" })); + expect(rows()).toHaveLength(3); + }); + + it("starts filtered when asked to", async () => { + await setup({ defaultNamedOnly: true }); + + expect(rows()).toHaveLength(2); + }); + + it("says no named versions when the filter hides everything", async () => { + const fake = createFakeEndpoints(); + fake.setSnapshots([AUTOMATIC]); + await setup({}, fake); + + await click( + screen.getByRole("button", { name: "Show named versions only" }), + ); + + expect(screen.getByText("No named versions")).toBeDefined(); + }); + + it("says no versions yet when nothing is stored", async () => { + const fake = createFakeEndpoints(); + fake.setSnapshots([]); + await setup({}, fake); + + expect(screen.getByText("No versions yet")).toBeDefined(); + }); + + it("starts with comparison off and turns it on with a baseline", async () => { + const { editor } = await setup(); + + const versioning = editor.getExtension(VersioningExtension)!; + expect(versioning.store.state.view).toEqual({ + mode: "current", + compareToId: undefined, + }); + + await click(screen.getByRole("button", { name: "Turn on comparison" })); + await act(async () => {}); + + // The current version is diffed against the newest stored version. + expect(versioning.store.state.view).toEqual({ + mode: "current", + compareToId: NAMED.id, + }); + }); + + it("does not compare a row excluded from the requested filter against the newest snapshot", async () => { + function PreviewNamedHistoryItem() { + const { snapshot } = useVersionSnapshot(); + const previewRow = usePreviewRow(); + return ( + + previewRow(snapshot, { + namedOnly: true, + compareTo: { type: "previous" }, + }) + } + > + Preview named history + + ); + } + const { editor } = await setup({ + snapshotMenu: ( + + + + ), + }); + await click(await openMenuItem(rows()[2]!, /^Preview named history$/)); + expect( + editor.getExtension(VersioningExtension)!.store.state.view, + ).toMatchObject({ + mode: "snapshot", + snapshotId: AUTOMATIC.id, + compareToId: undefined, + }); + }); + + it.each([false, true])( + "compares visible named versions (initial comparison: %s)", + async (initialComparison) => { + const fake = createFakeEndpoints(); + const olderNamed = { id: "older", createdAt: 500, name: "First draft" }; + fake.setSnapshots([ + { id: "recent-auto", createdAt: 2500 }, + NAMED, + AUTOMATIC, + olderNamed, + { id: "oldest-auto", createdAt: 100 }, + ]); + const { editor } = await setup( + { + defaultNamedOnly: initialComparison, + defaultComparisonMode: initialComparison, + }, + fake, + ); + if (!initialComparison) { + await click( + screen.getByRole("button", { name: "Show named versions only" }), + ); + await click(screen.getByRole("button", { name: "Turn on comparison" })); + } + const versioning = editor.getExtension(VersioningExtension)!; + expect(versioning.store.state.view).toEqual({ + mode: "current", + compareToId: NAMED.id, + }); + expect(rows()).toHaveLength(3); + expect(rows()[1]!.classList.contains("comparing")).toBe(true); + await click(rows()[1]!); + expect(versioning.store.state.view).toMatchObject({ + mode: "snapshot", + snapshotId: NAMED.id, + compareToId: olderNamed.id, + }); + expect(rows()[2]!.classList.contains("comparing")).toBe(true); + await click(rows()[2]!); + expect(versioning.store.state.view).toMatchObject({ + mode: "snapshot", + snapshotId: olderNamed.id, + compareToId: undefined, + }); + expect(document.querySelector(".bn-snapshot.comparing")).toBeNull(); + }, + ); + + it.each([0, 1])( + "clears comparison when showing all versions and keeps selection %s", + async (selectedIndex) => { + const fake = createFakeEndpoints(); + const olderNamed = { id: "older", createdAt: 500, name: "First draft" }; + fake.setSnapshots([ + { id: "recent-auto", createdAt: 2500 }, + NAMED, + AUTOMATIC, + olderNamed, + ]); + const { editor } = await setup( + { defaultNamedOnly: true, defaultComparisonMode: true }, + fake, + ); + await click(rows()[selectedIndex]!); + await click(screen.getByRole("button", { name: "Show all versions" })); + + expect( + editor.getExtension(VersioningExtension)!.store.state.view, + ).toEqual( + selectedIndex === 0 + ? { mode: "current", compareToId: undefined } + : { mode: "snapshot", snapshotId: NAMED.id, compareToId: undefined }, + ); + expect(rows()).toHaveLength(5); + expect(screen.queryByText("Comparing to")).toBeNull(); + expect( + screen.getByRole("button", { name: "Turn on comparison" }), + ).toBeDefined(); + expect(editor.isEditable).toBe(false); + }, + ); + + it("reports a failed initial preview", async () => { + const fake = createFakeEndpoints(); + fake.endpoints.getContent.mockRejectedValueOnce( + new Error("preview offline"), + ); + + await setup({ defaultComparisonMode: true }, fake); + + expect(screen.getByRole("alert").textContent).toBe( + "Something went wrong. Please try again.", + ); + }); + + it("clears comparison when filtering to named versions and keeps a named selection", async () => { + const { editor } = await setup({ defaultComparisonMode: true }); + await click(rows()[1]!); + const versioning = editor.getExtension(VersioningExtension)!; + expect(versioning.store.state.view).toEqual({ + mode: "snapshot", + snapshotId: NAMED.id, + compareToId: AUTOMATIC.id, + }); + expect(rows()[1]!.classList.contains("bn-snapshot-comparison-source")).toBe( + true, + ); + expect(rows()[2]!.classList.contains("comparing")).toBe(true); + + await click( + screen.getByRole("button", { name: "Show named versions only" }), + ); + + expect(versioning.store.state.view).toEqual({ + mode: "snapshot", + snapshotId: NAMED.id, + compareToId: undefined, + }); + expect(rows()).toHaveLength(2); + expect(rows()[1]!.getAttribute("aria-current")).toBe("true"); + expect(rows()[1]!.classList.contains("bn-snapshot-comparison-source")).toBe( + false, + ); + expect(screen.queryByText("Comparing to")).toBeNull(); + expect( + screen.getByRole("button", { name: "Turn on comparison" }), + ).toBeDefined(); + + await click(screen.getByRole("button", { name: "Show all versions" })); + expect(versioning.store.state.view).toEqual({ + mode: "snapshot", + snapshotId: NAMED.id, + compareToId: undefined, + }); + }); + + it("clears an explicit named baseline when entering named-only history", async () => { + const { editor } = await setup(); + await click(await openMenuItem(rows()[1]!, /^Compare with this version$/)); + await click( + screen.getByRole("button", { name: "Show named versions only" }), + ); + expect(editor.getExtension(VersioningExtension)!.store.state.view).toEqual({ + mode: "current", + compareToId: undefined, + }); + expect(screen.queryByText("Comparing to")).toBeNull(); + }); + + it("returns to Current when named-only history hides the viewed version", async () => { + const { editor } = await setup({ defaultComparisonMode: true }); + await click(rows()[2]!); + await click( + screen.getByRole("button", { name: "Show named versions only" }), + ); + expect(editor.getExtension(VersioningExtension)!.store.state.view).toEqual({ + mode: "current", + compareToId: undefined, + }); + expect(rows()[0]!.getAttribute("aria-current")).toBe("true"); + expect(editor.isEditable).toBe(false); + }); + + it.each(["none", "previous"])( + "compares against a snapshot whose id is %s", + async (id) => { + const fake = createFakeEndpoints(); + fake.setSnapshots([NAMED, { ...AUTOMATIC, id }]); + const { editor } = await setup({}, fake); + + await click( + await openMenuItem(rows()[2]!, /^Compare with this version$/), + ); + + expect( + editor.getExtension(VersioningExtension)!.store.state.view, + ).toEqual({ + mode: "current", + compareToId: id, + }); + expect(fake.endpoints.getContent).toHaveBeenCalledWith( + expect.objectContaining({ id }), + ); + }, + ); + + it("reports a failed comparison toggle", async () => { + const { fake } = await setup(); + fake.endpoints.getContent.mockRejectedValueOnce( + new Error("preview offline"), + ); + + await click(screen.getByRole("button", { name: "Turn on comparison" })); + + expect(screen.getByRole("alert")).toBeDefined(); + }); + + it("starts comparing when asked to", async () => { + const { editor } = await setup({ defaultComparisonMode: true }); + + expect(editor.getExtension(VersioningExtension)!.store.state.view).toEqual({ + mode: "current", + compareToId: NAMED.id, + }); + }); + + it("selects a version when its row is clicked", async () => { + const { editor } = await setup(); + + await click(rows()[2]!); + await act(async () => {}); + + expect(editor.getExtension(VersioningExtension)!.store.state.view).toEqual({ + mode: "snapshot", + snapshotId: AUTOMATIC.id, + compareToId: undefined, + }); + }); + + // ------------------------------------------------------------------------- + // Renaming + // ------------------------------------------------------------------------- + + describe("naming and renaming", () => { + const openRenameItem = (row: HTMLElement) => + openMenuItem(row, /^(Name this version|Rename)$/); + + it("focuses the name field when started from the menu", async () => { + await setup(); + // Selected already, so the field is there to focus. + await click(rows()[1]!); + const row = rows()[1]!; + + await click(await openRenameItem(row)); + // A tick later than the click: the menu hands focus back to its trigger + // as it closes, so the row asks for it only once that has happened. + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 20)); + }); + + expect(document.activeElement).toBe(nameInput(row)); + }); + + it("selects the row first when started from an unselected row's menu", async () => { + await setup(); + const row = rows()[2]!; + expect(row.getAttribute("aria-current")).toBeNull(); + + await click(await openRenameItem(row)); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 20)); + }); + + expect(rows()[2]!.getAttribute("aria-current")).toBe("true"); + expect(document.activeElement).toBe(nameInput(rows()[2]!)); + }); + + it("names the current version through `create`", async () => { + const { fake } = await setup(); + + await commit(nameInput(rows()[0]!), "Milestone", "Enter"); + + expect(fake.endpoints.create).toHaveBeenCalledWith([], { + name: "Milestone", + }); + expect(fake.endpoints.rename).not.toHaveBeenCalled(); + }); + + it("labels a named current row as the current version", async () => { + await setup(); + + // This backend names the current row in place, so the name takes the + // title slot and "Current version" moves to where the date was. + await commit(nameInput(rows()[0]!), "Milestone", "Enter"); + + const row = rows()[0]!; + expect(nameInput(row).value).toBe("Milestone"); + expect(within(row).getByText("Current version")).toBeDefined(); + expect(within(row).queryByText(/2026|1970/)).toBeNull(); + }); + + it("clears the field when the name lands on a new version", async () => { + const fake = createFakeEndpoints(); + // What the in-memory backend does: naming the current version stores a + // *new* named version and leaves the current row itself unnamed. + fake.endpoints.create.mockImplementation(async (_doc, options) => { + const created = { id: "new", createdAt: 2500, name: options.name }; + fake.setSnapshots([created, NAMED, AUTOMATIC]); + return created; + }); + + await setup({}, fake); + await commit(nameInput(rows()[0]!), "Milestone", "Enter"); + + expect(nameInput(rows()[0]!).value).toBe(""); + expect(nameText(rows()[1]!)).toBe("Milestone"); + }); + + it("renames a stored version through `rename`", async () => { + const { fake } = await setup(); + + await click(rows()[1]!); + await commit(nameInput(rows()[1]!), "Final", "Enter"); + + expect(fake.endpoints.rename).toHaveBeenCalledWith( + expect.objectContaining({ id: NAMED.id }), + "Final", + ); + expect(fake.endpoints.create).not.toHaveBeenCalled(); + // Enter commits by moving focus to the row, keeping keyboard + // navigation in the list rather than dropping focus to the body. + expect(document.activeElement).toBe(rows()[1]); + }); + + it("cancels on Escape without renaming", async () => { + const { fake } = await setup(); + + await click(rows()[1]!); + await commit(nameInput(rows()[1]!), "Discarded", "Escape"); + + expect(fake.endpoints.rename).not.toHaveBeenCalled(); + expect(nameInput(rows()[1]!).value).toBe("Draft"); + }); + + it("clears the name when committed empty", async () => { + const { fake } = await setup(); + + await click(rows()[1]!); + await commit(nameInput(rows()[1]!), " ", "Enter"); + + expect(fake.endpoints.rename).toHaveBeenCalledWith( + expect.objectContaining({ id: NAMED.id }), + undefined, + ); + }); + + it("re-renders when a version is renamed from outside the row", async () => { + const { editor } = await setup(); + const versioning = editor.getExtension(VersioningExtension)!; + + await act(async () => { + await versioning.rename!(NAMED.id, "Renamed elsewhere"); + }); + + expect(nameText(rows()[1]!)).toBe("Renamed elsewhere"); + }); + }); + + // ------------------------------------------------------------------------- + // Menu composition + // ------------------------------------------------------------------------- + + it.each([null, false])( + "hides the menu and trigger for snapshotMenu=%s", + async (snapshotMenu) => { + await setup({ snapshotMenu }); + expect(rows()).toHaveLength(3); + expect(screen.queryByRole("button", { name: "More actions" })).toBeNull(); + await click(rows()[1]!); + expect(rows()[1]!.getAttribute("aria-current")).toBe("true"); + }, + ); + + it.each([ + ["Mantine", BlockNoteView], + ["Ariakit", AriakitBlockNoteView], + ["Shadcn", ShadcnBlockNoteView], + ] as const)("prevents disabled custom actions in %s", async (_name, View) => { + const onClick = vi.fn(); + await setup( + { + snapshotMenu: ( + + + Disabled action + + + Disabled checked action + + + ), + }, + createFakeEndpoints(), + View, + ); + await click( + within(rows()[1]!).getByRole("button", { name: "More actions" }), + ); + for (const label of ["Disabled action", "Disabled checked action"]) { + const item = await eventually(() => + screen.getByText(label).closest('[role^="menuitem"]'), + ); + if (!item) { + throw new Error("Missing menu item"); + } + expect( + item.hasAttribute("disabled") || + item.getAttribute("aria-disabled") === "true", + ).toBe(true); + await click(item); + } + expect(onClick).not.toHaveBeenCalled(); + }); + + it("composes the default fragment with extra items", async () => { + const onClick = vi.fn(); + await setup({ + snapshotMenu: ( + + + Download + + ), + }); + await click( + within(rows()[1]!).getByRole("button", { name: "More actions" }), + ); + const download = await openMenuItem(rows()[1]!, /^Download$/); + expect(screen.getByText("Restore")).toBeDefined(); + expect(screen.getByText("Delete")).toBeDefined(); + await click(download); + expect(onClick).toHaveBeenCalledOnce(); + }); + + it("reuses restore behavior from a custom item and exposes availability", async () => { + function CustomRestoreItem() { + const action = useRestoreVersionAction(); + if (!action.available) { + return Cannot restore; + } + return ( + { + void action.execute(); + }} + > + Roll back + + ); + } + const { editor, fake } = await setup({ + snapshotMenu: ( + + + + ), + }); + await click( + within(rows()[0]!).getByRole("button", { name: "More actions" }), + ); + const unavailable = await openMenuItem(rows()[0]!, /^Cannot restore$/); + await click(unavailable); + expect(fake.endpoints.restore).not.toHaveBeenCalled(); + // Close the current menu before opening the stored version's menu. + await click( + within(rows()[0]!).getByRole("button", { name: "More actions" }), + ); + await click(rows()[1]!); + await click( + within(rows()[1]!).getByRole("button", { name: "More actions" }), + ); + await click(await openMenuItem(rows()[1]!, /^Roll back$/)); + expect(fake.endpoints.restore).toHaveBeenCalledWith([], NAMED); + expect( + editor.getExtension(VersioningExtension)!.store.state.view.mode, + ).toBe("current"); + expect(editor.isEditable).toBe(false); + }); + + it("customizes a default item's presentation and disables its action", async () => { + const { fake } = await setup({ + snapshotMenu: ( + + Custom icon} + > + Roll back + + + ), + }); + await click( + within(rows()[1]!).getByRole("button", { name: "More actions" }), + ); + const label = await openMenuItem(rows()[1]!, /^Roll back$/); + const item = label.closest('[role="menuitem"]'); + if (!item) { + throw new Error("Missing restore item"); + } + expect(within(item).getByText("Custom icon")).toBeDefined(); + expect(item.classList.contains("bn-menu-item")).toBe(true); + expect(item.classList.contains("custom-restore")).toBe(true); + expect( + item.hasAttribute("disabled") || + item.getAttribute("aria-disabled") === "true", + ).toBe(true); + await click(item); + expect(fake.endpoints.restore).not.toHaveBeenCalled(); + }); + + it("gives a custom snapshotMenu the row it was rendered in, and drops the defaults", async () => { + function MakeCopyItem() { + const { snapshot, isCurrent } = useVersionSnapshot(); + return ( + + {isCurrent ? "Copy current" : `Copy ${snapshot.name ?? snapshot.id}`} + + ); + } + + await setup({ + snapshotMenu: ( + + + + ), + }); + + await click( + within(rows()[1]!).getByRole("button", { name: "More actions" }), + ); + + expect( + await eventually(() => screen.getByText("Copy Draft")), + ).toBeDefined(); + expect(screen.queryByText("Restore")).toBeNull(); + expect(screen.queryByText("Delete")).toBeNull(); + }); + + // ------------------------------------------------------------------------- + // Keyboard + // ------------------------------------------------------------------------- + + it("moves through the list with the arrow keys and selects with Enter", async () => { + const { editor } = await setup(); + + rows()[0]!.focus(); + fireEvent.keyDown(document.activeElement!, { key: "ArrowDown" }); + expect(document.activeElement).toBe(rows()[1]); + + fireEvent.keyDown(document.activeElement!, { key: "End" }); + expect(document.activeElement).toBe(rows()[2]); + + fireEvent.keyDown(document.activeElement!, { key: "Home" }); + expect(document.activeElement).toBe(rows()[0]); + + fireEvent.keyDown(document.activeElement!, { key: "ArrowDown" }); + fireEvent.keyDown(document.activeElement!, { key: "Enter" }); + await act(async () => {}); + + expect(editor.getExtension(VersioningExtension)!.store.state.view).toEqual({ + mode: "snapshot", + snapshotId: NAMED.id, + compareToId: undefined, + }); + }); + + it("keeps a single tab stop for the whole list", async () => { + await setup(); + + expect(rows().map((row) => row.getAttribute("tabindex"))).toEqual([ + "0", + "-1", + "-1", + ]); + }); + + // ------------------------------------------------------------------------- + // Loading + // ------------------------------------------------------------------------- + + it("shows a status region while the list loads, then the rows", async () => { + const fake = createFakeEndpoints(); + const release = fake.block(); + const editor = createEditor(fake.endpoints); + + render( + + + , + ); + + expect(screen.getByRole("status")).toBeDefined(); + expect(screen.getByText("Loading versions")).toBeDefined(); + expect(screen.queryAllByRole("listitem")).toHaveLength(0); + + await act(async () => { + release(); + }); + + expect(screen.queryByRole("status")).toBeNull(); + expect(rows()).toHaveLength(3); + }); + + it("keeps the existing rows and selection visible during a refresh", async () => { + const { editor, fake } = await setup(); + await click(rows()[1]!); + const selectedRow = rows()[1]!; + const release = fake.block(); + const versioning = editor.getExtension(VersioningExtension)!; + let refresh!: ReturnType; + act(() => { + refresh = versioning.list(); + }); + + expect(screen.getByRole("list").getAttribute("aria-busy")).toBe("true"); + expect(screen.queryByRole("status")).toBeNull(); + expect(rows()).toHaveLength(3); + expect(rows()[1]).toBe(selectedRow); + expect(selectedRow.getAttribute("aria-current")).toBe("true"); + expect(editor.isEditable).toBe(false); + + fake.setSnapshots([NAMED]); + await act(async () => { + release(); + await refresh; + }); + expect(screen.getByRole("list").getAttribute("aria-busy")).toBeNull(); + expect(rows()).toHaveLength(2); + expect(rows()[1]).toBe(selectedRow); + expect(selectedRow.getAttribute("aria-current")).toBe("true"); + }); + + it.each(["close", "unmount"])( + "does not enter preview when the initial list finishes after %s", + async (exit) => { + const fake = createFakeEndpoints(); + const release = fake.block(); + const onClose = vi.fn(); + const { editor, view } = await setup({ onClose }, fake); + expect(screen.getByRole("status")).toBeDefined(); + + if (exit === "close") { + await click(screen.getByRole("button", { name: "Close" })); + expect(onClose).toHaveBeenCalledOnce(); + } else { + view.rerender(); + } + await act(async () => release()); + expect( + editor.getExtension(VersioningExtension)!.store.state.view, + ).toEqual({ mode: "live" }); + expect(editor.isEditable).toBe(true); + expect(fake.endpoints.getContent).not.toHaveBeenCalled(); + }, + ); + + it("moves the busy marker to the latest selection while both previews load", async () => { + const { editor, fake } = await setup(); + const release = fake.block(); + await click(rows()[1]!); + expect(rows()[1]!.getAttribute("aria-busy")).toBe("true"); + await click(rows()[2]!); + expect(rows()[1]!.getAttribute("aria-busy")).toBeNull(); + expect(rows()[2]!.getAttribute("aria-busy")).toBe("true"); + expect(rows()[2]!.getAttribute("aria-current")).toBe("true"); + expect(editor.isEditable).toBe(false); + + await act(async () => release()); + expect(rows().every((row) => !row.hasAttribute("aria-busy"))).toBe(true); + expect(rows()[2]!.getAttribute("aria-current")).toBe("true"); + expect(screen.queryByRole("alert")).toBeNull(); + }); + + it("clears a failed row's busy marker, restores selection, and allows retry", async () => { + const { fake } = await setup(); + let rejectContent!: (error: Error) => void; + fake.endpoints.getContent.mockImplementationOnce( + () => + new Promise((_, reject) => { + rejectContent = reject; + }), + ); + const logged = vi.spyOn(console, "error").mockImplementation(() => {}); + try { + await click(rows()[1]!); + expect(rows()[1]!.getAttribute("aria-busy")).toBe("true"); + await act(async () => rejectContent(new Error("private backend detail"))); + expect(rows()[1]!.getAttribute("aria-busy")).toBeNull(); + expect(rows()[0]!.getAttribute("aria-current")).toBe("true"); + expect(screen.getByRole("alert").textContent).toBe( + "Something went wrong. Please try again.", + ); + + await click(rows()[1]!); + expect(rows()[1]!.getAttribute("aria-current")).toBe("true"); + expect(rows()[1]!.getAttribute("aria-busy")).toBeNull(); + expect(screen.queryByRole("alert")).toBeNull(); + } finally { + logged.mockRestore(); + } + }); + + it("marks the row being switched to as busy", async () => { + const { fake } = await setup(); + + const release = fake.block(); + fireEvent.click(rows()[2]!); + await act(async () => {}); + + expect(rows()[2]!.getAttribute("aria-busy")).toBe("true"); + expect(rows()[1]!.getAttribute("aria-busy")).toBeNull(); + + await act(async () => { + release(); + }); + + expect(rows()[2]!.getAttribute("aria-busy")).toBeNull(); + }); + + // ------------------------------------------------------------------------- + // Row actions + // ------------------------------------------------------------------------- + + describe("row actions", () => { + it.each([ + { namedOnly: true, selection: "deleted" }, + { namedOnly: true, selection: "baseline" }, + { namedOnly: true, selection: "unrelated" }, + { namedOnly: false, selection: "deleted" }, + ] as const)( + "keeps a visible selection when deletion only clears a name ($namedOnly, $selection)", + async ({ namedOnly, selection }) => { + const fake = createFakeEndpoints(); + const older = { id: "older", createdAt: 500, name: "First draft" }; + fake.setSnapshots([NAMED, older]); + fake.endpoints.remove.mockImplementation(async (snapshot) => { + await fake.endpoints.rename(snapshot, undefined); + }); + const { editor } = await setup( + { + defaultNamedOnly: namedOnly, + defaultComparisonMode: selection === "baseline", + }, + fake, + ); + const ext = editor.getExtension(VersioningExtension)!; + if (selection === "deleted") { + await click(rows()[1]!); + } + fake.endpoints.getContent.mockClear(); + + await click(await openMenuItem(rows()[1]!, /^Delete$/)); + + expect(ext.getSnapshot(NAMED.id)?.name).toBeUndefined(); + expect(rows()).toHaveLength(namedOnly ? 2 : 3); + expect(ext.store.state.view).toEqual( + !namedOnly && selection === "deleted" + ? { mode: "snapshot", snapshotId: NAMED.id, compareToId: undefined } + : { + mode: "current", + compareToId: selection === "baseline" ? older.id : undefined, + }, + ); + expect( + rows().filter((row) => row.hasAttribute("aria-current")), + ).toHaveLength(1); + expect(editor.isEditable).toBe(false); + if (selection === "unrelated" || !namedOnly) { + expect(fake.endpoints.getContent).not.toHaveBeenCalled(); + } + }, + ); + + it("re-selects the current version after deleting the one on screen", async () => { + const { editor } = await setup(); + const ext = editor.getExtension(VersioningExtension)!; + + await click(rows()[1]!); + expect(ext.store.state.view).toEqual({ + mode: "snapshot", + snapshotId: NAMED.id, + compareToId: undefined, + }); + + await click(await openMenuItem(rows()[1]!, /^Delete$/)); + await act(async () => {}); + + // The panel always has a selection, and the editor stays read-only for + // as long as it is open. + expect(ext.store.state.view).toEqual({ + mode: "current", + compareToId: undefined, + }); + expect(editor.isEditable).toBe(false); + expect(rows()).toHaveLength(2); + }); + + it.each([ + ["Restore", "close"], + ["Restore", "unmount"], + ["Delete", "close"], + ["Delete", "unmount"], + ])( + "does not reopen preview when %s finishes after %s", + async (action, exit) => { + const { editor, fake, view } = await setup({ onClose: vi.fn() }); + await click(rows()[1]!); + const item = await openMenuItem(rows()[1]!, new RegExp(`^${action}$`)); + // Hold the refresh after the mutation, before its follow-up selection. + const release = fake.block(); + await click(item); + if (exit === "close") { + await click(screen.getByRole("button", { name: "Close" })); + } else { + view.rerender(); + } + await act(async () => release()); + + expect( + editor.getExtension(VersioningExtension)!.store.state.view, + ).toEqual({ + mode: "live", + }); + expect(editor.isEditable).toBe(true); + }, + ); + + it("keeps the selection and reports it when a restore fails", async () => { + const fake = createFakeEndpoints(); + fake.endpoints.restore.mockRejectedValueOnce(new Error("network")); + const { editor } = await setup({}, fake); + const ext = editor.getExtension(VersioningExtension)!; + + await click(rows()[1]!); + await click(await openMenuItem(rows()[1]!, /^Restore$/)); + await act(async () => {}); + + expect(screen.getByRole("alert").textContent).toBe( + "Something went wrong. Please try again.", + ); + expect(ext.store.state.view).toEqual({ + mode: "snapshot", + snapshotId: NAMED.id, + compareToId: undefined, + }); + expect(editor.isEditable).toBe(false); + + // The next successful action clears the notice. + await click(rows()[2]!); + await act(async () => {}); + expect(screen.queryByRole("alert")).toBeNull(); + }); + + it("reports a name that the backend rejects", async () => { + const fake = createFakeEndpoints(); + fake.endpoints.create.mockRejectedValueOnce(new Error("no activity")); + await setup({}, fake); + + await commit(nameInput(rows()[0]!), "First draft", "Enter"); + + expect(screen.getByRole("alert")).toBeDefined(); + expect(nameInput(rows()[0]!).value).toBe(""); + }); + }); + + // ------------------------------------------------------------------------- + // Accessibility baseline + // ------------------------------------------------------------------------- + + it("ignores a superseded naming action's failure notice", async () => { + const { fake } = await setup(); + let rejectName!: (error: Error) => void; + fake.endpoints.create.mockImplementationOnce( + () => + new Promise((_, reject) => { + rejectName = reject; + }), + ); + await commit(nameInput(rows()[0]!), "Draft", "Enter"); + await click(rows()[1]!); + await act(async () => rejectName(new Error("old naming failed"))); + expect(screen.queryByRole("alert")).toBeNull(); + }); + + it.each([BlockNoteView, AriakitBlockNoteView, ShadcnBlockNoteView])( + "names the panel, toolbars and focused rows across skins", + async (View) => { + await setup({}, createFakeEndpoints(), View); + expect(screen.getByRole("region", { name: "History" })).toBeDefined(); + expect(screen.getByRole("toolbar", { name: "History" })).toBeDefined(); + expect(rows()[0]!.getAttribute("aria-label")).toContain( + "Current version", + ); + expect(rows()[1]!.getAttribute("aria-label")).toContain("Draft"); + }, + ); + + it("keeps naming in the tab order when row menus are hidden", async () => { + await setup({ snapshotMenu: null }); + expect(nameInput(rows()[0]!).tabIndex).toBe(0); + }); + + it("gives multiple sidebars unique row IDs", async () => { + await setup(); + await setup(); + const ids = rows().map((row) => row.id); + expect(new Set(ids).size).toBe(ids.length); + }); + + it("returns keyboard focus to the editor when closing", async () => { + const { editor } = await setup({ onClose: () => {} }); + await click(screen.getByRole("button", { name: "Close" })); + expect(editor.domElement!.contains(document.activeElement)).toBe(true); + }); + + it("focuses a remaining row when the focused version is removed", async () => { + const { editor } = await setup(); + act(() => rows()[1]!.focus()); + await act(async () => { + await editor.getExtension(VersioningExtension)!.remove!(NAMED.id); + }); + expect(document.activeElement).toBe(rows()[1]); + }); + + it("does not steal focus after a removed version has been left", async () => { + const { editor } = await setup({ onClose: () => {} }); + const control = screen.getByRole("button", { name: "Turn on comparison" }); + act(() => { + rows()[1]!.focus(); + control.focus(); + }); + await act(async () => { + await editor.getExtension(VersioningExtension)!.remove!(NAMED.id); + }); + expect(document.activeElement).toBe(control); + }); + + it("gives every header control an accessible name", async () => { + await setup({ onClose: () => {} }); + + for (const name of [ + "Show named versions only", + "Turn on comparison", + "Close", + ]) { + expect(screen.getByRole("button", { name })).toBeDefined(); + } + expect(screen.getByRole("list", { name: "Versions" })).toBeDefined(); + }); +}); diff --git a/tests/vite.config.ts b/tests/vite.config.ts index bdbf19262c..3791e19aeb 100644 --- a/tests/vite.config.ts +++ b/tests/vite.config.ts @@ -30,7 +30,11 @@ export default defineConfig( test: { environment: "jsdom", setupFiles: ["./vitestSetup.ts"], - include: ["./src/unit/**/*.test.ts", "./src/unit/**/*.test.tsx"], + include: [ + "./src/unit/**/*.test.ts", + "./src/unit/**/*.test.tsx", + "../examples/07-collaboration/12-multi-doc-versioning/src/*.test.ts", + ], }, resolve: { alias: @@ -49,6 +53,14 @@ export default defineConfig( __dirname, "../packages/react/src/", ), + "@blocknote/ariakit": path.resolve( + __dirname, + "../packages/ariakit/src/", + ), + "@blocknote/shadcn": path.resolve( + __dirname, + "../packages/shadcn/src/", + ), "@blocknote/mantine": path.resolve( __dirname, "../packages/mantine/src/",