Building a collaborative Text Area

Collaborating on text is one of the paradigmatic collaboration scenarios for information workers. In this tutorial, you’ll learn how to create a collaborative text area. Fluid Framework provides a distributed data structure (DDS) called SharedString for precisely this kind of scenario. It is a DDS with specialized features and behaviors for working with text. The UI of the app is based on React.

Note

You can see the completed app at collaborative text area app.

The following image shows a text area open in four browsers. The same text is in all four.

Four browsers with the text area open with the same text.

The next image shows the same four clients after an edit was made in one of the browsers. Note that the text has updated in all four browsers.

Four browsers with the text area open after an edit was made in one browser.

Note

This tutorial assumes that you are familiar with the Fluid Framework Overview and that you have completed the Quick Start. You should also be familiar with the basics of React, creating React projects, and React Hooks.

Create the project

  1. Open a Command Prompt and navigate to the parent folder where you want to create the project, e.g., C:\My Fluid Projects.

  2. Run the following command at the prompt. (Note that the CLI is npx, not npm. It was installed when you installed Node.js.)

    npx create-react-app collaborative-text-area-tutorial --template typescript
    
  3. Navigate to the root of the project with the command cd collaborative-text-area-tutorial.

  4. The project uses three Fluid libraries:

    Library Description
    fluid-framework Contains the SharedString distributed data structure that synchronizes text across clients. This object will hold the most recent text update made by any client.
    @fluidframework/tinylicious-client Defines the connection to a Fluid server and defines the starting schema for the Fluid container.
    @fluid-experimental/react-inputs Contains the SharedStringHelper class that provides helper APIs to interact with the SharedString object.

    Run the following command to install the libraries.

    npm install @fluidframework/tinylicious-client @fluid-experimental/react-inputs fluid-framework
    

Code the project

  1. Open the file \src\App.tsx in your code editor. Delete all the default import statements except the one that imports App.css. Then delete all the code in the App() function. The file should look like the following:

    import "./App.css";
    
    function App() {
    
    }
    
    export default App;
    
  2. Add the following import statements. About this code, note:

    • TinyliciousClient is a Fluid service that runs on the local development computer.
    • SharedString is the DDS that holds the text the collaborators will be writing.
    • SharStringHelper is a class that provides APIs to interact with the SharedString object.
    • CollaborativeTextArea is a React component that you will create in a later step.
    import { useState, useEffect } from "react";
    import { TinyliciousClient } from "@fluidframework/tinylicious-client";
    import { ConnectionState, ContainerSchema, IFluidContainer, SharedString } from "fluid-framework";
    import { CollaborativeTextArea } from "./CollaborativeTextArea";
    import { SharedStringHelper } from "@fluid-experimental/react-inputs";
    

Get Fluid Data

  1. The Fluid runtime will bring changes made to the text from any client to the current client, but Fluid is agnostic about the UI framework. Create a React hook, called useSharedString to get the Fluid data from the SharedString object into the view layer (the React state). Add the following code below the import statements. This method is called when the application loads the first time, and the returned value is assigned to a React state property.

    const useSharedString = (): SharedString => {
    
    const [sharedString, setSharedString] = useState<SharedString>();
    const getFluidData = async () => {
        // TODO 1: Configure the container.
        // TODO 2: Get the container from the Fluid service.
        // TODO 3: Return the Fluid SharedString object.
      }
    
      // TODO 4: Get the Fluid Data data on app startup and store in the state.
      // TODO 5: Return the SharedString Object
    }
    
  2. Replace TODO 1 with the following code.

      const client: TinyliciousClient = new TinyliciousClient();
      const containerSchema: ContainerSchema = {
        initialObjects: { sharedString: SharedString }
      };
    
  3. Replace TODO 2 with the following code. Note that containerId is being stored on the URL hash, and if there is no containerId a new container is created instead.

      let container: IFluidContainer;
      const containerId = window.location.hash.substring(1);
      if (!containerId) {
          ({ container } = await client.createContainer(containerSchema));
          const id = await container.attach();
          window.location.hash = id;
          // Return the Fluid SharedString object.
          return container.initialObjects.sharedString as SharedString;
      }
    
      ({ container } = await client.getContainer(containerId, containerSchema));
      if (container.connectionState !== ConnectionState.Connected) {
          await new Promise<void>((resolve) => {
              container.once("connected", () => {
                  resolve();
              });
          });
      }
    
  4. Replace TODO 3 with the following code.

    return container.initialObjects.sharedString as SharedString;
    
  5. Replace TODO 4 with the following code. Note about this code:

    • Passing an empty dependency array as the last parameter of useEffect ensures that this function is called only once.
    • The setSharedString method updates the view. Since it is a state-changing method, it will cause the React App component to immediately rerender.
    useEffect(() => {
        getFluidData()
          .then((data) => setSharedString(data));
    }, []);
    
  6. Finally, replace TODO 5 with the following code.

    return sharedString as SharedString;
    

Move the Fluid Data to the view

Inside the App() function, add the following code. Note about this code:

  • The sharedString object returned from the code above is used to create a SharedStringHelper object, which is a class that provides helper APIs to interact with the sharedString object.
  • Next, the SharedStringHelper object is passed into the CollaborativeTextArea React component, which integrates SharedString with the default <textarea> HTML element to enable collaboration.
const sharedString = useSharedString();

if (sharedString) {
  return (
    <div className="app">
      <CollaborativeTextArea sharedStringHelper={new SharedStringHelper(sharedString)} />
    </div>
  );
} else {
    return <div />;
}

Create CollaborativeTextArea component

CollaborativeTextArea is a React component which uses a SharedStringHelper object to control the text of an HTML <textarea> element. Follow the below steps to create this component.

  1. Create a new file CollaborativeTextArea.tsx inside of the \src directory.

  2. Add the following import statements and declare the CollaborativeTextArea component:

    import React from "react";
    import { ISharedStringHelperTextChangedEventArgs, SharedStringHelper } from "@fluid-experimental/react-inputs";
    
    interface ICollaborativeTextAreaProps {
      sharedStringHelper: SharedStringHelper;
    }
    
    export const CollaborativeTextArea = (props: ICollaborativeTextAreaProps) => {
      // TODO 1: Setup React state and references.
      // TODO 2: Handle a change event in the textarea.
      // TODO 3: Set the selection in textarea element (update the UI).
      // TODO 4: Store current selection from the textarea element in the React ref.
      // TODO 5: Detect changes in sharedStringHelper and update React/UI as necessary.
      // TODO 6: Create and configure a textarea element that will be used in App.tsx.
    }
    
  3. Replace TODO 1 with the following code. This code sets up the React state and gets a reference to the HTML <textarea> element. To learn more about useRef, check out the React documentation.

    const sharedStringHelper = props.sharedStringHelper;
    
    const textareaRef = React.useRef<HTMLTextAreaElement>(null);
    const selectionStartRef = React.useRef<number>(0);
    const selectionEndRef = React.useRef<number>(0);
    
    const [text, setText] = React.useState<string>(sharedStringHelper.getText());
    
  4. Replace TODO 2 with the following code. This function will be called when a change is made to the <textarea> element. You will create the storeSelectionInReact in a later step.

    const handleChange = (ev: React.FormEvent<HTMLTextAreaElement>) => {
      // First get and stash the new textarea state
      if (!textareaRef.current) {
        throw new Error("Handling change without current textarea ref?");
      }
      const textareaElement = textareaRef.current;
      const newText = textareaElement.value;
      // After a change to the textarea content we assume the selection is gone (just a caret)
      const newCaretPosition = textareaElement.selectionStart;
    
      // Next get and stash the old React state
      const oldText = text;
      const oldSelectionStart = selectionStartRef.current;
      const oldSelectionEnd = selectionEndRef.current;
    
      // Next update the React state with the values from the textarea
      storeSelectionInReact();
      setText(newText);
    
      // Finally update the SharedString with the values after deducing what type of change it was.
      const isTextInserted = newCaretPosition - oldSelectionStart > 0;
      if (isTextInserted) {
        const insertedText = newText.substring(oldSelectionStart, newCaretPosition);
        const isTextReplaced = oldSelectionEnd - oldSelectionStart > 0;
        if (!isTextReplaced) {
          sharedStringHelper.insertText(insertedText, oldSelectionStart);
        } else {
          sharedStringHelper.replaceText(insertedText, oldSelectionStart, oldSelectionEnd);
        }
      } else {
        // Text was removed
        const charactersDeleted = oldText.length - newText.length;
        sharedStringHelper.removeText(newCaretPosition, newCaretPosition + charactersDeleted);
      }
    };
    
  5. Replace TODO 3 with the following code. This function sets the selection directly in the <textarea> element.

    const setTextareaSelection = (newStart: number, newEnd: number) => {
      if (!textareaRef.current) {
        throw new Error("Trying to set selection without current textarea ref?");
      }
      const textareaElement = textareaRef.current;
    
      textareaElement.selectionStart = newStart;
      textareaElement.selectionEnd = newEnd;
    };
    
  6. Replace TODO 4 with the following code. This function gets the selection from the <textarea> element and sets it in the React refs.

    const storeSelectionInReact = () => {
      if (!textareaRef.current) {
        throw new Error("Trying to remember selection without current textarea ref?");
      }
      const textareaElement = textareaRef.current;
    
      const textareaSelectionStart = textareaElement.selectionStart;
      const textareaSelectionEnd = textareaElement.selectionEnd;
      selectionStartRef.current = textareaSelectionStart;
      selectionEndRef.current = textareaSelectionEnd;
    };
    
  7. Replace TODO 5 with the following code. Note about this code:

    • Setting the dependency array in the second parameter of useEffect to include sharedStringHelper ensures that this function is called each time the sharedStringHelper object is changed.
    React.useEffect(() => {
      const handleTextChanged = (event: ISharedStringHelperTextChangedEventArgs) => {
        const newText = sharedStringHelper.getText();
        setText(newText);
        if (!event.isLocal) {
          const newSelectionStart = event.transformPosition(selectionStartRef.current);
          const newSelectionEnd = event.transformPosition(selectionEndRef.current);
          setTextareaSelection(newSelectionStart, newSelectionEnd);
          storeSelectionInReact();
        }
      };
    
      sharedStringHelper.on("textChanged", handleTextChanged);
      return () => {
        sharedStringHelper.off("textChanged", handleTextChanged);
      };
    }, [sharedStringHelper]);
    
  8. Finally, replace TODO 6 with the following code to create the <textarea> element and register all the event handlers for it.

    return (
      <textarea
        rows={20}
        cols={50}
        ref={textareaRef}
        onBeforeInput={storeSelectionInReact}
        onKeyDown={storeSelectionInReact}
        onClick={storeSelectionInReact}
        onContextMenu={storeSelectionInReact}
        onChange={handleChange}
        value={text} />
    );
    

Start the Fluid server and run the application

  1. In the Command Prompt, run the following command to start the Fluid service. Note that tinylicious is the name of the Fluid service that runs on localhost.

    npx tinylicious
    

    If tinylicious is not installed, you will be prompted to install it. When the Fluid service is running, you will see info: Listening on port ... in the Command Prompt.

  2. Open a new Command Prompt and navigate to the root of the project; for example, C:\My Fluid Projects\collaborative-text-area-tutorial. Start the application server with the following command. The application opens in your browser.

    npm run start
    
  3. Paste the URL of the application into the address bar of another tab or even another browser to have more than one client open at a time. Edit the text on any client and see the text change and synchronize on all the clients.

Next steps

Tip

When you make changes to the code the project will automatically rebuild and the application server will reload. However, if you make changes to the container schema, they will only take effect if you close and restart the application server. Then run npm run start again.