Skip to content

Commit

Permalink
feat: add blocksuite provider (#27)
Browse files Browse the repository at this point in the history
* feat: add blocksuite doc source

* feat: wrap `WebsocketProvider` to `WebSocketConnectProvider`

* refactor: move provider to connect dialog

* chore: add @blocksuite/sync to dependencies

* chore: lint

* chore: do nothing if doc no change
  • Loading branch information
lawvs authored Aug 16, 2024
1 parent 390f4ae commit 39f8f09
Show file tree
Hide file tree
Showing 11 changed files with 371 additions and 49 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"typeCheck": "tsc --noEmit"
},
"dependencies": {
"@blocksuite/sync": "^0.16.0",
"@emotion/react": "^11.13.0",
"@emotion/styled": "^11.13.0",
"@fn-sphere/filter": "^0.4.0",
Expand Down
43 changes: 43 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

33 changes: 11 additions & 22 deletions src/components/connect-button.tsx
Original file line number Diff line number Diff line change
@@ -1,29 +1,27 @@
import { Cable, RotateCw, Unplug } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { WebsocketProvider } from "y-websocket";
import * as Y from "yjs";
import { ConnectProvider } from "../providers/types";
import { useYDoc } from "../state";
import { ConnectDialog } from "./connect-dialog";
import { Button } from "./ui/button";
import { Dialog } from "./ui/dialog";

export function ConnectButton() {
const [yDoc, setYDoc] = useYDoc();
const [yDoc] = useYDoc();
const [open, setOpen] = useState(false);
const [provider, setProvider] = useState<WebsocketProvider>();
const [provider, setProvider] = useState<ConnectProvider>();
const [connectState, setConnectState] = useState<
"connecting" | "connected" | "disconnected"
>("disconnected");

const disconnect = useCallback(() => {
if (connectState === "disconnected") return;
provider?.disconnect();
provider?.destroy();
setProvider(undefined);
setConnectState("disconnected");
}, [connectState, provider]);

// This effect is for convenience, it is evil.
// This effect is for convenience, it is evil. We should add the connect logic to global state and handle it there.
useEffect(() => {
// Disconnect when the yDoc changes
if (connectState === "disconnected") return;
Expand All @@ -41,28 +39,19 @@ export function ConnectButton() {
}, [yDoc, disconnect, provider, connectState]);

const onConnect = useCallback(
({ doc, url, room }: { doc: Y.Doc; url: string; room: string }) => {
(provider: ConnectProvider) => {
if (connectState !== "disconnected") {
throw new Error("Should not be able to connect when already connected");
}
provider?.disconnect();
const wsProvider = new WebsocketProvider(url, room, doc);
wsProvider.on("sync", (isSynced: boolean) => {
if (isSynced) {
setConnectState("connected");
}
});
// wsProvider.on(
// "status",
// ({ status }: { status: "connected" | "disconnected" | string }) => {},
// );
wsProvider.connect();
provider.connect();
setConnectState("connecting");
setYDoc(doc);
setProvider(wsProvider);
provider.waitForSynced().then(() => {
setConnectState("connected");
});
setProvider(provider);
setOpen(false);
},
[connectState, provider, setYDoc],
[connectState],
);

const handleClick = () => {
Expand Down
99 changes: 72 additions & 27 deletions src/components/connect-dialog.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { BlocksuiteWebsocketProvider } from "@/providers/blocksuite/provider";
import { WebSocketConnectProvider } from "@/providers/websocket";
import { RocketIcon } from "lucide-react";
import { useState } from "react";
import * as Y from "yjs";
import { ConnectProvider } from "../providers/types";
import { useYDoc } from "../state";
import { Alert, AlertDescription, AlertTitle } from "./ui/alert";
import { Button } from "./ui/button";
Expand All @@ -24,50 +27,69 @@ import {
} from "./ui/select";
import { Switch } from "./ui/switch";

// Hardcoded in the playground of blocksuite
// See https://github.com/toeverything/blocksuite/blob/9203e1c39651e40d33b1d724ef0261bdcabf6ca8/packages/playground/apps/default/utils/collection.ts#L65
const BLOCKSUITE_PLAYGROUND_DOC_GUID = "quickEdgeless";
const BLOCKSUITE_NAME = "Blocksuite Playground";

const officialDemos = [
{
name: "ProseMirror",
room: "prosemirror-demo-2024/06",
url: "https://demos.yjs.dev/prosemirror/prosemirror.html",
url: "wss://demos.yjs.dev/ws",
demoUrl: "https://demos.yjs.dev/prosemirror/prosemirror.html",
},
{
name: "ProseMirror with Version History",
room: "prosemirror-versions-demo-2024/06",
url: "https://demos.yjs.dev/prosemirror-versions/prosemirror-versions.html",
url: "wss://demos.yjs.dev/ws",
demoUrl:
"https://demos.yjs.dev/prosemirror-versions/prosemirror-versions.html",
},
{
name: "Quill",
room: "quill-demo-2024/06",
url: "https://demos.yjs.dev/quill/quill.html",
url: "wss://demos.yjs.dev/ws",
demoUrl: "https://demos.yjs.dev/quill/quill.html",
},
{
name: "Monaco",
room: "monaco-demo-2024/06",
url: "https://demos.yjs.dev/monaco/monaco.html",
url: "wss://demos.yjs.dev/ws",
demoUrl: "https://demos.yjs.dev/monaco/monaco.html",
},
{
name: "CodeMirror",
room: "codemirror-demo-2024/06",
url: "https://demos.yjs.dev/codemirror/codemirror.html",
url: "wss://demos.yjs.dev/ws",
demoUrl: "https://demos.yjs.dev/codemirror/codemirror.html",
},
{
name: "CodeMirror 6",
room: "codemirror.next-demo-2024/06",
url: "https://demos.yjs.dev/codemirror.next/codemirror.next.html",
url: "wss://demos.yjs.dev/ws",
demoUrl: "https://demos.yjs.dev/codemirror.next/codemirror.next.html",
},
{
name: BLOCKSUITE_NAME,
room: "",
url: "wss://blocksuite-playground.toeverything.workers.dev",
demoUrl: "https://try-blocksuite.vercel.app",
custom: true,
},
] as const;
];

export function ConnectDialog({
onConnect,
}: {
onConnect: (data: { doc: Y.Doc; url: string; room: string }) => void;
onConnect: (provider: ConnectProvider) => void;
}) {
const [yDoc] = useYDoc();
const [yDoc, setYDoc] = useYDoc();
const [url, setUrl] = useState("wss://demos.yjs.dev/ws");
const [room, setRoom] = useState("quill-demo-2024/06");
const [provider, setProvider] = useState("quill-demo-2024/06");
const [provider, setProvider] = useState("Quill");
const [needCreateNewDoc, setNeedCreateNewDoc] = useState(true);
const officialDemo = officialDemos.find((demo) => demo.room === provider);
const officialDemo = officialDemos.find((demo) => demo.name === provider);

return (
<DialogContent>
Expand All @@ -87,9 +109,9 @@ export function ConnectDialog({
value={provider}
onValueChange={(value) => {
setProvider(value);
const demo = officialDemos.find((demo) => demo.room === provider);
const demo = officialDemos.find((demo) => demo.name === value);
if (demo) {
setUrl("wss://demos.yjs.dev/ws");
setUrl(demo.url);
setRoom(demo.room);
return;
}
Expand All @@ -101,11 +123,16 @@ export function ConnectDialog({
<SelectContent>
<SelectGroup>
<SelectLabel>Official Demos</SelectLabel>
{officialDemos.map((demo) => (
<SelectItem key={demo.room} value={demo.room}>
{demo.name}
</SelectItem>
))}
{
// Ad-hoc remove the blocksuite playground from the official demos
officialDemos
.filter((i) => i.name !== BLOCKSUITE_NAME)
.map((demo) => (
<SelectItem key={demo.name} value={demo.name}>
{demo.name}
</SelectItem>
))
}
</SelectGroup>

<SelectGroup>
Expand All @@ -114,8 +141,8 @@ export function ConnectDialog({
<SelectItem value="y-webrtc" disabled>
y-webrtc (coming soon)
</SelectItem>
<SelectItem value="blocksuite">
Blocksuite Playground
<SelectItem value={BLOCKSUITE_NAME}>
{BLOCKSUITE_NAME}
</SelectItem>
<SelectItem value="liveblocks" disabled>
LiveblocksProvider (coming soon)
Expand Down Expand Up @@ -146,10 +173,10 @@ export function ConnectDialog({
<Input
id="room-input"
className="col-span-3"
disabled={!!officialDemo}
disabled={!!officialDemo && !officialDemo.custom}
value={room}
onInput={(e) => setRoom(e.currentTarget.value)}
placeholder="room-name"
placeholder="Please enter a room name"
/>
</div>

Expand All @@ -173,7 +200,7 @@ export function ConnectDialog({
Click here to access the&nbsp;
<a
className="text-primary underline"
href={officialDemo.url}
href={officialDemo.demoUrl}
target="_blank"
rel="noopener noreferrer"
>
Expand All @@ -186,12 +213,30 @@ export function ConnectDialog({
</div>
<DialogFooter>
<Button
onClick={() => {
if (needCreateNewDoc) {
onConnect({ url, room, doc: new Y.Doc() });
onClick={async () => {
const doc = needCreateNewDoc
? new Y.Doc({ guid: BLOCKSUITE_PLAYGROUND_DOC_GUID })
: yDoc;
setYDoc(doc);
if (provider === BLOCKSUITE_NAME) {
const ws = new WebSocket(new URL(`/room/${room}`, url));
// Fix Uncaught (in promise) DOMException: Failed to execute 'send' on 'WebSocket': Still in CONNECTING state.
await new Promise((resolve, reject) => {
ws.addEventListener("open", resolve);
ws.addEventListener("error", reject);
});
const connectProvider = new BlocksuiteWebsocketProvider(ws, doc);
onConnect(connectProvider);
return;
}
onConnect({ url, room, doc: yDoc });

const connectProvider = new WebSocketConnectProvider(
url,
room,
doc,
);

onConnect(connectProvider);
}}
>
Connect
Expand Down
33 changes: 33 additions & 0 deletions src/providers/blocksuite/provider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { DocEngine } from "@blocksuite/sync";
import * as Y from "yjs";
import { ConnectProvider } from "../types";
import { NoopLogger } from "./utils";
import { WebSocketDocSource } from "./web-socket-doc-source";

export class BlocksuiteWebsocketProvider implements ConnectProvider {
doc: Y.Doc;
private docEngine: DocEngine;

constructor(ws: WebSocket, doc: Y.Doc) {
this.doc = doc;
const docSource = new WebSocketDocSource(ws);
this.docEngine = new DocEngine(doc, docSource, [], new NoopLogger());
}

connect() {
this.docEngine.start();
this.docEngine;
}

disconnect() {
this.docEngine.forceStop();
}

destroy() {
this.disconnect();
}

async waitForSynced() {
await this.docEngine.waitForLoadedRootDoc();
}
}
Loading

0 comments on commit 39f8f09

Please sign in to comment.