John Q
John Q

Reputation: 1

Issue with Duplicated Text When Reopening Notes in a Collaborative Setup with Yjs, Quill, and SQLite

I'm facing an issue with my collaborative text editor built using Yjs, y-websocket, and Quill. The editor allows users to work on notes collaboratively in real time. However, I'm encountering text duplication under the following conditions:

Issue Description

  1. When does the duplication occur?
  1. What works as expected?

Code Implementation

Here is the relevant implementation for the editor (TextEditor.jsx) and the Yjs WebSocket server (yjs-server.cjs).

TextEditor.jsx

import React, { useEffect, useRef, useState } from "react";
import Quill from "quill";
import "quill/dist/quill.snow.css";
import "./TextEditor.css";
import { useParams, useNavigate } from "react-router-dom";
import * as Y from "yjs";
import { WebsocketProvider } from "y-websocket";
import { QuillBinding } from "y-quill";
import { useAuth0 } from "@auth0/auth0-react";
import QuillCursors from "quill-cursors";

Quill.register("modules/cursors", QuillCursors);

const BACKEND_URL = import.meta.env.VITE_BACKEND_URL;
const YJS_URL = import.meta.env.VITE_YJS_URL || "ws://localhost:1234";

const SAVE_INTERVAL_MS = 2000;
const TOOLBAR_OPTIONS = [
  [{ header: [1, 2, 3, 4, 5, 6, false] }],
  [{ font: [] }],
  [{ list: "ordered" }, { list: "bullet" }],
  ["bold", "italic", "underline"],
  [{ color: [] }, { background: [] }],
  [{ script: "sub" }, { script: "super" }],
  [{ align: [] }],
  ["image", "blockquote", "code-block"],
  ["clean"],
];

export default function TextEditor() {
  const { id: documentId } = useParams();
  const navigate = useNavigate();
  const editorRef = useRef();
  const quillRef = useRef();
  const ydocRef = useRef();
  const ytextRef = useRef();
  const [noteName, setNoteName] = useState("Untitled Note");
  const [isCollaborative, setIsCollaborative] = useState(false);
  const [resourceId, setResourceId] = useState(null);

  const { user } = useAuth0();

  useEffect(() => {
    const fetchInitialContent = async () => {
      try {
        const response = await fetch(`${BACKEND_URL}/get_note?note_id=${documentId}`);
        const data = await response.json();
        if (response.ok) {
          const content = data.content ? JSON.parse(data.content) : null;

          if (ytextRef.current) {
            ytextRef.current.delete(0, ytextRef.current.length); // Clear existing content
          }

          if (content && ytextRef.current) {
            ytextRef.current.applyDelta(content.ops); // Apply new content
          }

          setNoteName(data.name || "Untitled Note");
          setResourceId(data.resource_id);

          const isUUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(
            documentId
          );
          setIsCollaborative(isUUID);
        } else {
          if (data.error === "Unauthorized access") navigate("/");
        }
      } catch (error) {
        console.error("Error fetching initial content:", error);
        navigate("/");
      }
    };

    fetchInitialContent();
  }, [documentId, BACKEND_URL, navigate]);

  useEffect(() => {
    if (editorRef.current && !quillRef.current && resourceId) {
      editorRef.current.innerHTML = "";

      const ydoc = new Y.Doc();
      ydocRef.current = ydoc;

      const provider = new WebsocketProvider(YJS_URL, resourceId.toString(), ydoc);

      const ytext = ydoc.getText("quill");
      ytextRef.current = ytext;

      const quill = new Quill(editorRef.current, {
        theme: "snow",
        modules: {
          toolbar: TOOLBAR_OPTIONS,
          cursors: true,
          history: { userOnly: true },
        },
      });
      quill.disable();
      quillRef.current = quill;

      const binding = new QuillBinding(ytext, quill, provider.awareness);

      provider.on("status", (event) => {
        if (event.status === "connected") quill.enable();
        else quill.disable();
      });

      return () => {
        binding.destroy();
        provider.destroy();
        ydoc.destroy();
        quill.off();
        quillRef.current = null;
      };
    }
  }, [resourceId, YJS_URL]);

  return (
    <div>
      <input
        type="text"
        value={noteName}
        onChange={(e) => setNoteName(e.target.value)}
        placeholder="Enter note title"
      />
      <div ref={editorRef}></div>
    </div>
  );
}

yjs-server.cjs

const http = require("http");
const express = require("express");
const WebSocket = require("ws");
const { setupWSConnection } = require("y-websocket/utils");

const app = express();
const server = http.createServer(app);
const wss = new WebSocket.Server({ server });

const port = process.env.PORT || 1234;

wss.on("connection", (ws, req) => {
  const ip = req.socket.remoteAddress;
  console.log(`New connection from ${ip}`);
  setupWSConnection(ws, req);
});

server.listen(port, () => {
  console.log(`Yjs WebSocket server running on port ${port}`);
});

Environment Details

What I've Tried

  1. I attempted to avoid applying the backend content when the Yjs document already has content, but this caused inconsistencies in real-time collaboration.
  2. I suspect that the Yjs WebSocket server isn't persisting the document state correctly, but I would appreciate guidance on how to confirm this or configure it if necessary.

Expected Behavior

Thanks to anyone who can help me resolve this duplication issue.

Upvotes: 0

Views: 38

Answers (0)

Related Questions