Skip to content

Commit 3d34a69

Browse files
authored
test: add a ghost writer demo (#1642)
1 parent 163f8c1 commit 3d34a69

File tree

12 files changed

+353
-1
lines changed

12 files changed

+353
-1
lines changed
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"playground": true,
3+
"docs": false,
4+
"author": "nperez0111",
5+
"tags": ["Advanced", "Development", "Collaboration"],
6+
"dependencies": {
7+
"y-partykit": "^0.0.25",
8+
"yjs": "^13.6.15"
9+
}
10+
}
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import "@blocknote/core/fonts/inter.css";
2+
import "@blocknote/mantine/style.css";
3+
import { BlockNoteView } from "@blocknote/mantine";
4+
import { useCreateBlockNote } from "@blocknote/react";
5+
6+
import YPartyKitProvider from "y-partykit/provider";
7+
import * as Y from "yjs";
8+
import "./styles.css";
9+
import { useEffect, useState } from "react";
10+
// eslint-disable-next-line import/no-extraneous-dependencies
11+
import { EditorView } from "prosemirror-view";
12+
13+
const params = new URLSearchParams(window.location.search);
14+
const ghostWritingRoom = params.get("room");
15+
const ghostWriterIndex = parseInt(params.get("index") || "1");
16+
const isGhostWriting = Boolean(ghostWritingRoom);
17+
const roomName = ghostWritingRoom || `ghost-writer-${Date.now()}`;
18+
// Sets up Yjs document and PartyKit Yjs provider.
19+
const doc = new Y.Doc();
20+
const provider = new YPartyKitProvider(
21+
"blocknote-dev.yousefed.partykit.dev",
22+
// Use a unique name as a "room" for your application.
23+
roomName,
24+
doc,
25+
);
26+
27+
/**
28+
* Y-prosemirror has an optimization, where it doesn't send awareness updates unless the editor is currently focused.
29+
* So, for the ghost writers, we override the hasFocus method to always return true.
30+
*/
31+
if (isGhostWriting) {
32+
EditorView.prototype.hasFocus = () => true;
33+
}
34+
35+
const ghostContent =
36+
"This demo shows a two-way sync of documents. It allows you to test collaboration features, and see how stable the editor is. ";
37+
38+
export default function App() {
39+
const [numGhostWriters, setNumGhostWriters] = useState(1);
40+
const [isPaused, setIsPaused] = useState(false);
41+
const editor = useCreateBlockNote({
42+
collaboration: {
43+
// The Yjs Provider responsible for transporting updates:
44+
provider,
45+
// Where to store BlockNote data in the Y.Doc:
46+
fragment: doc.getXmlFragment("document-store"),
47+
// Information (name and color) for this user:
48+
user: {
49+
name: isGhostWriting
50+
? `Ghost Writer #${ghostWriterIndex}`
51+
: "My Username",
52+
color: isGhostWriting ? "#CCCCCC" : "#00ff00",
53+
},
54+
},
55+
});
56+
57+
useEffect(() => {
58+
if (!isGhostWriting || isPaused) {
59+
return;
60+
}
61+
let index = 0;
62+
let timeout: NodeJS.Timeout;
63+
64+
const scheduleNextChar = () => {
65+
const jitter = Math.random() * 200; // Random delay between 0-200ms
66+
timeout = setTimeout(() => {
67+
const firstBlock = editor.document?.[0];
68+
if (firstBlock) {
69+
editor.insertInlineContent(ghostContent[index], {
70+
updateSelection: true,
71+
});
72+
index = (index + 1) % ghostContent.length;
73+
}
74+
scheduleNextChar();
75+
}, 50 + jitter);
76+
};
77+
78+
scheduleNextChar();
79+
80+
return () => clearTimeout(timeout);
81+
}, [editor, isPaused]);
82+
83+
// Renders the editor instance.
84+
return (
85+
<>
86+
{isGhostWriting ? (
87+
<button onClick={() => setIsPaused((a) => !a)}>
88+
{isPaused ? "Resume Ghost Writer" : "Pause Ghost Writer"}
89+
</button>
90+
) : (
91+
<>
92+
<button onClick={() => setNumGhostWriters((a) => a + 1)}>
93+
Add a Ghost Writer
94+
</button>
95+
<button onClick={() => setNumGhostWriters((a) => a - 1)}>
96+
Remove a Ghost Writer
97+
</button>
98+
<button
99+
onClick={() => {
100+
window.open(
101+
`${window.location.origin}${window.location.pathname}?room=${roomName}&index=-1`,
102+
"_blank",
103+
);
104+
}}>
105+
Ghost Writer in a new window
106+
</button>
107+
</>
108+
)}
109+
<BlockNoteView editor={editor} />
110+
111+
{!isGhostWriting && (
112+
<div className="two-way-sync">
113+
{Array.from({ length: numGhostWriters }).map((_, index) => (
114+
<iframe
115+
src={`${window.location.origin}${
116+
window.location.pathname
117+
}?room=${roomName}&index=${index + 1}&hideMenu=true`}
118+
title="ghost writer"
119+
className="ghost-writer"
120+
/>
121+
))}
122+
</div>
123+
)}
124+
</>
125+
);
126+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# Ghost Writer
2+
3+
In this example, we use a local Yjs document to store the document state, and have a ghost writer that edits the document in real-time.
4+
5+
**Try it out:** Open this page in a new browser tab or window to see it in action!
6+
7+
**Relevant Docs:**
8+
9+
- [Editor Setup](/docs/editor-basics/setup)
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<html lang="en">
2+
<head>
3+
<script>
4+
<!-- AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY -->
5+
</script>
6+
<meta charset="UTF-8" />
7+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
8+
<title>Ghost Writer</title>
9+
</head>
10+
<body>
11+
<div id="root"></div>
12+
<script type="module" src="./main.tsx"></script>
13+
</body>
14+
</html>
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
// AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY
2+
import React from "react";
3+
import { createRoot } from "react-dom/client";
4+
import App from "./App.jsx";
5+
6+
const root = createRoot(document.getElementById("root")!);
7+
root.render(
8+
<React.StrictMode>
9+
<App />
10+
</React.StrictMode>
11+
);
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
{
2+
"name": "@blocknote/example-ghost-writer",
3+
"description": "AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY",
4+
"private": true,
5+
"version": "0.12.4",
6+
"scripts": {
7+
"start": "vite",
8+
"dev": "vite",
9+
"build:prod": "tsc && vite build",
10+
"preview": "vite preview"
11+
},
12+
"dependencies": {
13+
"@blocknote/core": "latest",
14+
"@blocknote/react": "latest",
15+
"@blocknote/ariakit": "latest",
16+
"@blocknote/mantine": "latest",
17+
"@blocknote/shadcn": "latest",
18+
"react": "^18.3.1",
19+
"react-dom": "^18.3.1",
20+
"y-partykit": "^0.0.25",
21+
"yjs": "^13.6.15"
22+
},
23+
"devDependencies": {
24+
"@types/react": "^18.0.25",
25+
"@types/react-dom": "^18.0.9",
26+
"@vitejs/plugin-react": "^4.3.1",
27+
"vite": "^5.3.4"
28+
}
29+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
.two-way-sync {
2+
display: flex;
3+
flex-direction: row;
4+
height: 100%;
5+
margin-top: 10px;
6+
gap: 8px;
7+
}
8+
9+
.ghost-writer {
10+
flex: 1;
11+
border: 1px solid #ccc;
12+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
{
2+
"__comment": "AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY",
3+
"compilerOptions": {
4+
"target": "ESNext",
5+
"useDefineForClassFields": true,
6+
"lib": [
7+
"DOM",
8+
"DOM.Iterable",
9+
"ESNext"
10+
],
11+
"allowJs": false,
12+
"skipLibCheck": true,
13+
"esModuleInterop": false,
14+
"allowSyntheticDefaultImports": true,
15+
"strict": true,
16+
"forceConsistentCasingInFileNames": true,
17+
"module": "ESNext",
18+
"moduleResolution": "bundler",
19+
"resolveJsonModule": true,
20+
"isolatedModules": true,
21+
"noEmit": true,
22+
"jsx": "react-jsx",
23+
"composite": true
24+
},
25+
"include": [
26+
"."
27+
],
28+
"__ADD_FOR_LOCAL_DEV_references": [
29+
{
30+
"path": "../../../packages/core/"
31+
},
32+
{
33+
"path": "../../../packages/react/"
34+
}
35+
]
36+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
// AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY
2+
import react from "@vitejs/plugin-react";
3+
import * as fs from "fs";
4+
import * as path from "path";
5+
import { defineConfig } from "vite";
6+
// import eslintPlugin from "vite-plugin-eslint";
7+
// https://vitejs.dev/config/
8+
export default defineConfig((conf) => ({
9+
plugins: [react()],
10+
optimizeDeps: {},
11+
build: {
12+
sourcemap: true,
13+
},
14+
resolve: {
15+
alias:
16+
conf.command === "build" ||
17+
!fs.existsSync(path.resolve(__dirname, "../../packages/core/src"))
18+
? {}
19+
: ({
20+
// Comment out the lines below to load a built version of blocknote
21+
// or, keep as is to load live from sources with live reload working
22+
"@blocknote/core": path.resolve(
23+
__dirname,
24+
"../../packages/core/src/"
25+
),
26+
"@blocknote/react": path.resolve(
27+
__dirname,
28+
"../../packages/react/src/"
29+
),
30+
} as any),
31+
},
32+
}));

packages/core/src/editor/BlockNoteEditor.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1231,7 +1231,10 @@ export class BlockNoteEditor<
12311231
*
12321232
* @param content can be a string, or array of partial inline content elements
12331233
*/
1234-
public insertInlineContent(content: PartialInlineContent<ISchema, SSchema>) {
1234+
public insertInlineContent(
1235+
content: PartialInlineContent<ISchema, SSchema>,
1236+
{ updateSelection = false }: { updateSelection?: boolean } = {},
1237+
) {
12351238
const nodes = inlineContentToNodes(content, this.pmSchema);
12361239

12371240
this.transact((tr) => {
@@ -1242,6 +1245,9 @@ export class BlockNoteEditor<
12421245
to: tr.selection.to,
12431246
},
12441247
nodes,
1248+
{
1249+
updateSelection,
1250+
},
12451251
);
12461252
});
12471253
}

playground/src/examples.gen.tsx

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1326,6 +1326,30 @@
13261326
"pathFromRoot": "examples/07-collaboration",
13271327
"slug": "collaboration"
13281328
}
1329+
},
1330+
{
1331+
"projectSlug": "ghost-writer",
1332+
"fullSlug": "collaboration/ghost-writer",
1333+
"pathFromRoot": "examples/07-collaboration/06-ghost-writer",
1334+
"config": {
1335+
"playground": true,
1336+
"docs": false,
1337+
"author": "nperez0111",
1338+
"tags": [
1339+
"Advanced",
1340+
"Development",
1341+
"Collaboration"
1342+
],
1343+
"dependencies": {
1344+
"y-partykit": "^0.0.25",
1345+
"yjs": "^13.6.15"
1346+
} as any
1347+
},
1348+
"title": "Ghost Writer",
1349+
"group": {
1350+
"pathFromRoot": "examples/07-collaboration",
1351+
"slug": "collaboration"
1352+
}
13291353
}
13301354
]
13311355
},

pnpm-lock.yaml

Lines changed: 43 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)