Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions examples/07-collaboration/15-yhub/.bnexample.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"playground": true,
"docs": true,
"author": "",
"tags": ["Advanced", "Saving/Loading", "Collaboration"],
"dependencies": {
"@y/prosemirror": "^2.0.0-15",
"@y/protocols": "^1.0.6-rc.1",
"@y/websocket": "^4.0.0-3",
"@y/y": "^14.0.0-rc.26"
}
}
11 changes: 11 additions & 0 deletions examples/07-collaboration/15-yhub/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Collaborative Editing with YHub

This example uses [YHub](https://github.com/yjs/yhub) and Yjs v14 to sync a BlockNote document and share user cursors over WebSocket. It connects to a public demo server, so don't enter private information.

**Try it out:** Open this page in another browser tab or window to edit the same document together.

**Relevant Docs:**

- [Editor Setup](/docs/getting-started/editor-setup)
- [Collaboration](/docs/features/collaboration)
- [YHub](https://github.com/yjs/yhub)
17 changes: 17 additions & 0 deletions examples/07-collaboration/15-yhub/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, interactive-widget=resizes-content"
/>
<title>Collaborative Editing with YHub</title>
<script>
<!-- AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY -->
</script>
</head>
<body>
<div id="root"></div>
<script type="module" src="./main.tsx"></script>
</body>
</html>
11 changes: 11 additions & 0 deletions examples/07-collaboration/15-yhub/main.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
// AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY
import React from "react";
import { createRoot } from "react-dom/client";
import App from "./src/App.jsx";

const root = createRoot(document.getElementById("root")!);
root.render(
<React.StrictMode>
<App />
</React.StrictMode>,
);
34 changes: 34 additions & 0 deletions examples/07-collaboration/15-yhub/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
{
"name": "@blocknote/example-collaboration-yhub",
"description": "AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY",
"type": "module",
"private": true,
"version": "0.12.4",
"scripts": {
"start": "vite",
"dev": "vite",
"build:prod": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"@blocknote/ariakit": "latest",
"@blocknote/core": "latest",
"@blocknote/mantine": "latest",
"@blocknote/react": "latest",
"@blocknote/shadcn": "latest",
"@mantine/core": "^9.0.2",
"@mantine/hooks": "^9.0.2",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"@y/prosemirror": "^2.0.0-15",
"@y/protocols": "^1.0.6-rc.1",
"@y/websocket": "^4.0.0-3",
"@y/y": "^14.0.0-rc.26"
},
"devDependencies": {
"@types/react": "^19.2.3",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"vite": "^8.0.0"
}
}
36 changes: 36 additions & 0 deletions examples/07-collaboration/15-yhub/src/App.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import "@blocknote/core/fonts/inter.css";
import { withCollaboration } from "@blocknote/core/y";
import { BlockNoteView } from "@blocknote/mantine";
import "@blocknote/mantine/style.css";
import { useCreateBlockNote } from "@blocknote/react";
import { WebsocketProvider } from "@y/websocket";
import * as Y from "@y/y";

const user = {
id: crypto.randomUUID(),
name: "My Username",
color: "#ff0000",
};

const doc = new Y.Doc();
const provider = new WebsocketProvider(
"wss://yhub.teleportal.tools/api/ws/v1",
// YHub rooms use "organization/document". Use your own names in production.
"blocknote/blocknote-yhub-example",
doc,
{ params: { userid: user.id } },
);

export default function App() {
const editor = useCreateBlockNote(
withCollaboration({
collaboration: {
provider,
fragment: doc.get("bn"),
user,
},
}),
);

return <BlockNoteView editor={editor} />;
}
32 changes: 32 additions & 0 deletions examples/07-collaboration/15-yhub/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
{
"__comment": "AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY",
"compilerOptions": {
"target": "ESNext",
"useDefineForClassFields": true,
"lib": ["DOM", "DOM.Iterable", "ESNext"],
"allowJs": false,
"skipLibCheck": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"composite": true,
"paths": {
"@shared/*": ["../../../shared/*"]
}
},
"include": ["."],
"__ADD_FOR_LOCAL_DEV_references": [
{
"path": "../../../packages/core/"
},
{
"path": "../../../packages/react/"
}
]
}
1 change: 1 addition & 0 deletions examples/07-collaboration/15-yhub/vite-env.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
/// <reference types="vite/client" />
35 changes: 35 additions & 0 deletions examples/07-collaboration/15-yhub/vite.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
// AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY
import react from "@vitejs/plugin-react";
import * as fs from "fs";
import * as path from "path";
import { defineConfig } from "vite";
// https://vitejs.dev/config/
export default defineConfig(((conf: { command: string }) => ({
plugins: [react()],
optimizeDeps: {},
build: {
sourcemap: true,
},
resolve: {
alias:
conf.command === "build" ||
!fs.existsSync(path.resolve(__dirname, "../../../packages/core/src"))
? {}
: ({
// The repo-wide alias for the shared test-utils directory (private,
// so it only resolves inside the monorepo). Harmless for examples
// that don't use it.
"@shared": path.resolve(__dirname, "../../../shared/"),
// Comment out the lines below to load a built version of blocknote
// or, keep as is to load live from sources with live reload working
"@blocknote/core": path.resolve(
__dirname,
"../../../packages/core/src/",
),
"@blocknote/react": path.resolve(
__dirname,
"../../../packages/react/src/",
),
} as any),
},
})) as Parameters<typeof defineConfig>[0]);
37 changes: 31 additions & 6 deletions packages/ariakit/src/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -452,18 +452,16 @@

.bn-ariakit .bn-snapshot-date,
.bn-ariakit .bn-snapshot-original-date,
.bn-ariakit .bn-snapshot-secondary-label {
.bn-ariakit .bn-snapshot-users {
font-size: 13px;
line-height: 1.3;
}

.bn-ariakit .bn-snapshot-original-date,
.bn-ariakit .bn-snapshot-secondary-label {
.bn-ariakit .bn-snapshot-original-date {
color: hsl(204 4% 40%);
}

.bn-ariakit .bn-snapshot-original-date:where(.dark, .dark *),
.bn-ariakit .bn-snapshot-secondary-label:where(.dark, .dark *) {
.bn-ariakit .bn-snapshot-original-date:where(.dark, .dark *) {
color: hsl(204 8% 70%);
}

Expand All @@ -475,6 +473,34 @@
transition: opacity 0.12s ease;
}

.bn-ariakit .bn-snapshot-users {
display: flex;
flex-wrap: wrap;
gap: 4px;
overflow: visible;
white-space: normal;
}

.bn-ariakit .bn-snapshot-user {
display: inline-flex;
flex: none;
align-items: center;
gap: 4px;
white-space: nowrap;
}

.bn-ariakit .bn-snapshot-user-color {
background-color: currentColor;
border-radius: 50%;
flex: none;
height: 8px;
width: 8px;
}

.bn-ariakit .bn-snapshot-user-color:where(.dark, .dark *) {
background-color: var(--bn-user-color-light);
}

.bn-ariakit .bn-snapshot:hover .bn-snapshot-menu,
.bn-ariakit .bn-snapshot:focus-visible .bn-snapshot-menu,
.bn-ariakit .bn-snapshot:focus-within .bn-snapshot-menu {
Expand Down Expand Up @@ -512,7 +538,6 @@

.bn-ariakit .bn-snapshot.selected .bn-snapshot-date,
.bn-ariakit .bn-snapshot.selected .bn-snapshot-original-date,
.bn-ariakit .bn-snapshot.selected .bn-snapshot-secondary-label,
.bn-ariakit .bn-snapshot.selected .bn-snapshot-menu-trigger {
color: rgb(255 255 255 / 0.9);
}
Expand Down
27 changes: 23 additions & 4 deletions packages/ariakit/src/versioning/Versioning.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ export const Snapshot = forwardRef<
name,
date,
restoredFrom,
secondaryLabel,
users,
comparingLabel,
comparingIcon,
...rest
Expand Down Expand Up @@ -131,9 +131,28 @@ export const Snapshot = forwardRef<
{restoredFrom && (
<div className="bn-snapshot-original-date">{restoredFrom}</div>
)}
{secondaryLabel && (
<div className="bn-snapshot-secondary-label">{secondaryLabel}</div>
)}
{users?.length ? (
<div className="bn-snapshot-users">
{users.map((user, index) => (
<span className="bn-snapshot-user" key={index}>
{index > 0 && <span aria-hidden="true">·</span>}
{typeof user !== "string" && user.color && (
<span
className="bn-snapshot-user-color"
aria-hidden="true"
style={{
color: user.color,
...{
"--bn-user-color-light": user.colorLight ?? user.color,
},
}}
/>
)}
<span>{typeof user === "string" ? user : user.username}</span>
</span>
))}
</div>
) : null}
</div>
{actions && (
<div
Expand Down
7 changes: 5 additions & 2 deletions packages/core/src/editor/managers/EventManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,8 +177,11 @@ export class EventManager<
includeUpdatesFromRemote = true,
): Unsubscribe {
return this.on("onChange", ({ transaction, appendedTransactions }) => {
if (!includeUpdatesFromRemote && isRemoteTransaction(transaction)) {
// don't trigger the callback if the changes are caused by a remote user
if (
transaction.getMeta("blocknoteVersionPreview") ||
(!includeUpdatesFromRemote && isRemoteTransaction(transaction))
) {
// Don't trigger the callback for previews or excluded remote changes.
return;
}
callback(this.editor, {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
HistoryExtension,
InlineContentBoundaryEditExtension,
LinkToolbarExtension,
LoadingExtension,
NodeSelectionKeyboardExtension,
PlaceholderExtension,
PositionMappingExtension,
Expand Down Expand Up @@ -174,6 +175,7 @@ export function getDefaultExtensions(
: {}),
}),
LinkToolbarExtension(options),
LoadingExtension(),
NodeSelectionKeyboardExtension(),
PlaceholderExtension(options),
ReadOnlyExtension({ editable: options._tiptapOptions?.editable }),
Expand Down
56 changes: 56 additions & 0 deletions packages/core/src/extensions/Loading/Loading.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
// @vitest-environment node
import { afterEach, expect, it } from "vite-plus/test";
import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js";
import { ReadOnlyExtension } from "../ReadOnly/ReadOnly.js";
import { LoadingExtension } from "./Loading.js";

const editor = BlockNoteEditor.create();
const loading = editor.getExtension(LoadingExtension)!;
const readOnly = editor.getExtension(ReadOnlyExtension)!;

afterEach(() => {
editor.isEditable = true;
readOnly.setReadOnly(false, "preview");
});

it("blocks editing until every loading token is released", () => {
const release = loading.acquire();
const releaseOther = loading.acquire();
try {
expect(editor.isEditable).toBe(false);
release();
release();
expect(editor.isEditable).toBe(false);
releaseOther();
expect(editor.isEditable).toBe(true);
} finally {
release();
releaseOther();
}
});

it("preserves application and independent feature restrictions", () => {
const release = loading.acquire();
editor.isEditable = false;
readOnly.setReadOnly(true, "preview");
release();
expect(editor.isEditable).toBe(false);
editor.isEditable = true;
expect(editor.isEditable).toBe(false);
readOnly.setReadOnly(false, "preview");
expect(editor.isEditable).toBe(true);
});

it("tracks tokens without a ReadOnly extension", () => {
const editor = BlockNoteEditor.create({ disableExtensions: ["readOnly"] });
try {
const loading = editor.getExtension(LoadingExtension)!;
expect(editor.getExtension(ReadOnlyExtension)).toBeUndefined();
const release = loading.acquire();
expect(loading.store.state.size).toBe(1);
release();
expect(loading.store.state.size).toBe(0);
} finally {
editor._tiptapEditor.destroy();
}
});
Loading
Loading