用React和Automerge建立一个Google Docs的克隆

550 阅读10分钟

协作式在线文件在各行各业都很常见,让具有不同专长的人走到一起,把他们的工作写在(虚拟)纸上。他们提高了参与度,增加了生产力,鼓励解决问题,并允许参与者相互学习。

Automerge是一个流行的库,用于在JavaScriptReact生态系统中构建协作应用程序。它很容易设置,并使用类似JSON的数据结构,可以由不同的用户并发地修改和合并。

在本教程中,我们将建立一个Google Docs的克隆,用户可以创建、查看、编辑和删除文本文档。编辑界面将支持所见即所得,所有的编辑将在应用程序的不同实例之间自动同步。

Google Docs Clone Output

作为参考,我们将建立的项目部署在这里

初始化一个React应用程序

首先,打开你的终端,运行命令npx create-react-app@latest google-docs-clone 。该命令将创建一个功能齐全的React应用程序。

然后通过cd google-docs-clone ,改变你在新创建的项目文件夹中的工作目录,运行npm start ,启动React开发者服务器。

现在,打开你的网络浏览器,导航到http://localhost:3000,看看你的应用程序。它应该看起来像这样。

Localhost React Output

切换回你的代码编辑器,删除src 文件夹中的所有文件,因为我们将从头开始构建一切。

安装依赖项

为了安装所有必要的依赖,在终端运行以下命令:npm install automerge react-quill react-router-dom uuid localforage

  • automerge包将为应用程序提供核心功能,允许创建、编辑、删除和同步所有文件的数据。
  • react-quill将被用于所见即所得的编辑器,以编辑每个文档的内容。它将允许用户创建诸如标题、段落、列表和链接等元素。
  • react-router-dom将提供主仪表板和各个文档之间的路由,允许用户打开它们并切换回主页。
  • uuid包将为每个文档生成唯一的指标,这些指标将被传递到URL中,以使它们独一无二。
  • localforage包将被用来在用户机器上存储创建的数据。你也可以使用原生的本地存储,但这个包将简化与它的交互。

创建基础

我们首先需要创建将渲染我们的应用程序的主文件,并定义一些全局风格规则,这些规则将在整个应用程序中使用。

src 文件夹内,创建一个新的文件index.js ,并包括以下代码。

import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router-dom";

import App from "./App";
import  "./styles.css";

const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(
  <BrowserRouter>
    <App />
  </BrowserRouter>
);

我们导入了ReactDOM ,并创建了根元素,后来我们用它来渲染应用程序。为了使用react-router-dom ,我们首先导入了它,然后将整个应用程序包裹在其中。

创建一个新文件,styles.css ,并包括以下样式规则。

* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

body {
  background-color: #eeeeee;
  min-height: 100vh;
}

.wrapper {
  font-family: sans-serif;
}

.ql-editor {
  min-height: calc(100vh - 100px);
  background-color: white;
}

我们首先删除了margin、padding和box-sizing的所有默认样式,只是为了让我们的应用程序的布局在不同的浏览器上看起来是一样的。然后,我们将主体背景设置为非常浅的灰色,并确保它始终至少是视口的高度。

我们还将应用程序设置为使用无衬线字体,以及为编辑器窗口设置自定义规则。我们确保编辑器总是填满视口的高度,以及将文本的背景颜色设置为白色,以便与文本形成更好的对比。

创建组件

在终端运行以下命令。

cd src && mkdir components && cd components && touch ContentWrapper.js ContentWrapper.module.css Header.js Header.module.css DocumentCard.js DocumentCard.module.css AddButton.js AddButton.module.css

这将为该应用程序创建所有必要的组件。

打开ContentWrapper.js ,包括以下代码。

lang=javascript
import styles from "./ContentWrapper.module.css";

export default function ({ children }) {
  return <div className={styles.wrapper}>{children}</div>;
}

这将是主仪表板上所有文件卡的一个包装组件。一旦我们实现应用程序的逻辑,我们将传递给孩子们的组件。

打开ContentWrapper.module.css ,包括以下样式规则。

.wrapper {
  max-width: 1200px;
  margin: 20px auto;
  padding: 0 20px;
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
  gap: 20px;
}

我们确保包装器的宽度是有限的,将其居中,并在顶部和底部添加一些边距。我们还在包装器的两侧添加了一些填充物,这样内容在较小的屏幕上看起来也能很好地定位。

然后,我们将包装器设置为使用网格布局系统,即每一列不超过一定的宽度,并对不同的屏幕做出响应。为了改善布局,我们还在各列之间添加了一些间隙。

打开Header.js ,包括以下代码。

lang=javascript
import styles from "./Header.module.css";

export default function ({ onClick }) {
  return (
    <div className={styles.wrapper}>
      <p className={styles.title} onClick={onClick}>
        Docs
      </p>
    </div>
  );
}

头部组件将显示标题元素以及接收onClick ,这将把用户从应用程序的任何状态带到主仪表板。

打开Header.module.css ,包括以下样式规则。

.wrapper {
  background-color: #4480f7;
  padding: 20px;
  color: white;
}

.title {
  cursor: pointer;
}

我们将背景颜色设置为蓝色,添加一些填充物,并将文本颜色设置为白色。为了改善用户体验,我们设置光标在悬停在页眉的标题元素上时变为指针。

打开DocumentCard.js ,包括以下代码。

import styles from "./DocumentCard.module.css";

export default function ({ text, onClick, deleteHandler }) {
  const createTitle = (text) => {
    if (text.replace(/<\/?[^>]+(>|$)/g, "")) {
      let parser = new DOMParser();
      const doc = parser.parseFromString(text, "text/html");
      const title =
        doc.body.childNodes[0].lastChild.innerHTML ||
        doc.body.childNodes[0].innerHTML;
      return title.length > 10 ? `${title.slice(0, 10)}...` : title;
    }
    return "Untitled doc";
  };
  return (
    <div className={styles.wrapper} onClick={onClick}>
      <div
        className={styles.preview}
        dangerouslySetInnerHTML={{ __html: text }}
      ></div>
      <div className={styles.footer}>
        <div className={styles.title}>{createTitle(text)}</div>
        <div className={styles.delete} onClick={deleteHandler}>
          <span role="img" aria-label="bin">
            

该文档卡将由两个主要块组成;预览区和页脚。

预览区将接收text 道具,这将是一串原始的HTML代码,我们将使用dangerouslySetInnerHTML 来生成它的预览。

页脚将包括卡片的标题,它将从text 道具中的第一个节点生成,并通过createTitle 功能限制为10个字符。它还将包括删除按钮,这将允许用户使用deleteHandler 道具删除该卡片。

该卡还将收到onClick 道具,它将打开该卡并显示编辑器。

打开DocumentCard.module.css ,包括以下样式规则。

.wrapper {
  background-color: white;
  padding: 10px;
  border: 1px solid rgb(223, 223, 223);
  border-radius: 5px;
}

.wrapper:hover {
  border: 1px solid #4480f7;
  cursor: pointer;
}

.preview {
  height: 200px;
  overflow: hidden;
  font-size: 50%;
  word-wrap: break-word;
}

.footer {
  display: grid;
  grid-template-columns: auto 20px;
  min-height: 40px;
  border-top: 1px solid rgb(223, 223, 223);
  padding-top: 10px;
}

.title {
  color: #494949;
  font-weight: bold;
}

.delete {
  font-size: 12px;
}

我们让卡片的主包装是白色的,添加了一些填充物,设置了一个灰色的边框,并为一些平滑的边缘添加了一个边框半径。我们还确保卡片的边框颜色变为蓝色,以及光标在悬停时变为指针的颜色。

对于预览区,我们定义了一个特定的高度,通过将其正常大小减半来确保更多的文本被包含在内,并确保较长的字被分割开来。

对于页脚区域,我们设置了一个特定的高度,在上面添加了一些边距和填充,并使用网格布局将宽度分成两列。

第一列将包括标题,它将使用深灰色并被加粗。第二列将包括删除按钮,为此我们减小了字体大小。

打开AddButton.js ,包括以下代码。

import styles from "./AddButton.module.css";

export default function AddButton({ onClick }) {
  return (
    <div className={styles.wrapper} onClick={onClick}>
      <p className={styles.sign}>+</p>
    </div>
  );
}

添加按钮将包括加号并接受onClick ,当用户点击它时,它将允许用户创建一个新文档。

打开AddButton.module.css ,并包括以下样式规则。

.wrapper {
  display: grid;
  place-items: center;
  height: 60px;
  width: 60px;
  border-radius: 50%;
  background-color: #4480f7;
  position: fixed;
  bottom: 20px;
  right: 20px;
}

.wrapper:hover {
  cursor: pointer;
  box-shadow: rgba(0, 0, 0, 0.1) 0px 20px 25px -5px,
    rgba(0, 0, 0, 0.04) 0px 10px 10px -5px;
}

.sign {
  font-size: 28px;
  color: white;
}

我们使用了网格布局,并将包装器的内容居中,设置元素使用特定的宽度和高度,使其成为一个圆形,设置蓝色背景颜色,并确保它总是显示在屏幕的右下角。

为了改善用户体验,我们还将光标设置为指针,以及在悬停时显示一些盒状阴影。为了改善用户界面,我们还增加了加号的字体大小,并将其显示为白色。

实现逻辑

src 文件夹中,创建一个新文件App.js ,并包括以下代码。

import { useState, useEffect, useCallback, useMemo } from "react";
import { useNavigate } from "react-router-dom";

import * as Automerge from "automerge";
import localforage from "localforage";
import Header from "./components/Header";
import ContentWrapper from "./components/ContentWrapper";
import DocumentCard from "./components/DocumentCard";
import AddButton from "./components/AddButton";

import ReactQuill from "react-quill";
import "react-quill/dist/quill.snow.css";

import { v4 as uuidv4 } from "uuid";

let doc = Automerge.init();

export default function App() {
  const navigate = useNavigate();

  const [editorVisible, setEditorVisible] = useState(false);
  const [editorValue, setEditorValue] = useState("");

  let docId = window.location.pathname.split("/").pop();
  let channel = useMemo(() => {
    return new BroadcastChannel(docId);
  }, [docId]);

  const initDocuments = useCallback(() => {
    if (localforage.getItem("automerge-data") && !docId) {
      setEditorVisible(false);
      async function getItem() {
        return await localforage.getItem("automerge-data");
      }

      getItem()
        .then((item) => {
          if (item) {
            doc = Automerge.load(item);
            navigate(`/`);
          }
        })
        .catch((err) => console.log(err));
    }
  }, [navigate, docId]);

  useEffect(() => {
    initDocuments();
  }, [initDocuments]);

  const addDocument = () => {
    const id = uuidv4();
    let newDoc = Automerge.change(doc, (doc) => {
      setEditorValue("");
      if (!doc.documents) doc.documents = [];
      doc.documents.push({
        id,
        text: editorValue,
        done: false
      });
      navigate(`/${id}`);
    });

    let binary = Automerge.save(newDoc);
    localforage.clear();
    localforage
      .setItem("automerge-data", binary)
      .catch((err) => console.log(err));
    doc = newDoc;
  };

  const loadDocument = useCallback(() => {
    if (docId) {
      setEditorVisible(true);
      async function getItem() {
        return await localforage.getItem("automerge-data");
      }

      getItem()
        .then((item) => {
          if (item) {
            doc = Automerge.load(item);

            const itemIndex = doc.documents.findIndex(
              (item) => item.id === docId
            );
            if (itemIndex !== -1) {
              setEditorValue(doc.documents[itemIndex].text);
            } else {
              navigate("/");
              setEditorVisible(false);
            }
          }
        })
        .catch((err) => console.log(err));
    }
  }, [docId, navigate]);

  useEffect(() => {
    loadDocument();
  }, [loadDocument]);

  const updateDocument = useCallback(() => {
    if (Object.keys(doc).length !== 0) {
      const itemIndex = doc.documents.findIndex((item) => item.id === docId);

      if (itemIndex !== -1) {
        let newDoc = Automerge.change(doc, (doc) => {
          doc.documents[itemIndex].text = editorValue;
        });

        let binary = Automerge.save(newDoc);
        localforage
          .setItem("automerge-data", binary)
          .catch((err) => console.log(err));
        doc = newDoc;
        channel.postMessage(binary);
      }
    }
  }, [docId, editorValue, channel]);

  useEffect(() => {
    updateDocument();
  }, [updateDocument]);

  const deleteDocument = (docId) => {
    if (Object.keys(doc).length !== 0) {
      const itemIndex = doc.documents.findIndex((item) => item.id === docId);

      if (itemIndex !== -1) {
        let newDoc = Automerge.change(doc, (doc) => {
          doc.documents.splice(itemIndex, 1);
        });

        let binary = Automerge.save(newDoc);
        localforage
          .setItem("automerge-data", binary)
          .catch((err) => console.log(err));
        doc = newDoc;
        channel.postMessage(binary);
      }
      navigate("/");
    }
  };

  const syncDocument = useCallback(() => {
    channel.onmessage = (ev) => {
      let newDoc = Automerge.merge(doc, Automerge.load(ev.data));
      doc = newDoc;
    };
  }, [channel]);

  useEffect(() => {
    syncDocument();
  }, [syncDocument]);

  return (
    <div className="wrapper">
      <Header
        onClick={() => {
          setEditorVisible(false);
          navigate("/");
        }}
      />
      {!editorVisible ? (
        <ContentWrapper>
          {Object.keys(doc).length !== 0 &&
            doc.documents.map((document, index) => {
              return (
                <DocumentCard
                  key={index}
                  text={document.text}
                  onClick={() => {
                    setEditorVisible(true);
                    navigate(`/${document.id}`);
                  }}
                  deleteHandler={(e) => {
                    e.stopPropagation();
                    deleteDocument(document.id);
                  }}
                />
              );
            })}
          <AddButton
            onClick={() => {
              setEditorVisible(true);
              addDocument();
            }}
          />
        </ContentWrapper>
      ) : (
        <ReactQuill
          theme="snow"
          value={editorValue}
          onChange={setEditorValue}
        />
      )}
    </div>
  );
}

首先,我们导入了所有必要的React钩子,以跟踪应用程序的状态,并在执行某些动作时执行副作用,所有我们安装的依赖实例以及我们在前一步创建的所有组件。

然后,我们初始化了Automerge实例。我们还创建了editorVisibleeditorState 来跟踪编辑器的存在和它的内容,并创建了几个函数来为应用程序提供创建、读取、更新、删除和同步功能。

initDocuments() 在最初启动时或一旦URL被改变到主仪表板时从localforage获取文件以刷新内容automerge.load()

addDocument() 一旦添加按钮被按下,就会启动。它使用 ,在数组对象中推送一个新文件,并将其保存在 。automerge.change() automerge.save()

loadDocument() 一旦在WYSIWYG编辑器中被打开, ,它就被用来获取特定文档的信息。automerge.load()

updateDocument() 在用户每次在编辑器中进行任何编辑时都会使用。该文档首先用 进行编辑,然后用 保存。automerge.change() automerge.save()

deleteDocument() 一旦按下删除图标就会启动;它用 从文档阵列中删除文档,用 保存。automerge.change() automerge.save()

syncDocument() 使用 函数来同步应用程序的其他实例的数据,并更新当前的文档数组。automerge.merge()

最后,我们渲染了所有导入的组件,并传入所需的道具

测试应用程序

首先,检查你的应用程序是否仍在http://localhost:3000 上运行如果不是,在你的终端中再次运行npm start

点击右下角的添加按钮,创建一个新的文档。你将被带到编辑器来创建内容。请注意,该文件已经获得了一个独特的ID。

App Test Final

现在在你的浏览器上就同一网址打开一个新的标签。注意到你在前一个标签中创建的所有文件都已经在那里了。打开它们中的任何一个,并做一些修改。

切换回另一个标签,打开你在前一个标签中编辑的文件,你所做的修改现在应该自动同步。

App Test Thesis

此外,如果有人试图用URL中的错误ID来记录文件,该应用程序将自动将用户重定向到主仪表板。

总结

在本教程中,我们学习了如何实现文本文档的创建、阅读、更新和删除功能。我们还使文档具有协作性,这意味着所有的变化都会自动与应用程序的其他实例同步。

你可以自由地分叉该项目,并添加额外的功能以满足你的特定需求。例如,你可以添加一个云解决方案来存储数据,并为应用程序添加认证,这样你就可以在线使用该应用程序,并只邀请特定的用户来使用它。

下次你要想出一个协作文档解决方案时,你就会知道实现它所需的技术和工具。