Building a collaborative TextArea
In this tutorial, you’ll learn how to use the SharedString distributed data structure (DDS) with React to create a collaborative text area. SharedString is a DDS with specialized features and behaviors for working with text.
To jump ahead into the finished demo, check out the SharedString example in our FluidExamples repo.
The following image shows a textarea open in four browsers. The same text is in all four.
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.
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
-
Open a Command Prompt and navigate to the parent folder where you want to create the project, e.g.,
C:\My Fluid Projects
. -
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
-
The project is created in a subfolder named
collaborative-text-area-tutorial
. Navigate to it with the commandcd collaborative-text-area-tutorial
. -
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
-
Open the file
\src\App.tsx
in your code editor. Delete all the defaultimport
statements except the one that importsApp.css
. Then delete all the markup from thereturn
statement. The file should look like the following:import "./App.css"; function App() { return ( ); } export default App;
-
Add the following
import
statements. Note:CollaborativeTextArea
will be defined later.import React from "react"; import { TinyliciousClient } from "@fluidframework/tinylicious-client"; import { SharedString } from "fluid-framework"; import { CollaborativeTextArea } from "./CollaborativeTextArea"; import { SharedStringHelper } from "@fluid-experimental/react-inputs";
Get Fluid Data
-
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. You can use a React hook 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] = React.useState(); 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 }
-
Replace
TODO 1
with the following code.const client: TinyliciousClient = new TinyliciousClient(); const containerSchema: ContainerSchema = { initialObjects: { sharedString: SharedString } }
-
Replace
TODO 2
with the following code. Note thatcontainerId
is being stored on the URL hash, and if there is nocontainerId
a new container is created instead.let container: IFluidContainer; const containerId = window.location.hash.substring(1); if (!containerId) { container = (await client.createContainer(containerSchema)).container; const id = await container.attach(); window.location.hash = id; } else { container = (await client.getContainer(containerId, containerSchema)).container; if (!container.connected) { await new Promise<void>((resolve) => { container.once("connected", () => { resolve(); }); }); } }
-
Replace
TODO 3
with the following code.return container.initialObjects.sharedString as SharedString;
-
Replace
TODO 4
with the following code. Note about this code:- By setting an empty dependency array at the end of the
useEffect
, it is ensured that this function only gets called once. - Since
setSharedString
is a state-changing method, it will cause the ReactApp
component to immediately rerender.
React.useEffect(() => { getFluidData() .then(data => setSharedString(data)); }, []);
- By setting an empty dependency array at the end of the
-
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 aSharedStringHelper
object, which is a class that provides helper APIs to interact with thesharedString
object. - Next, the
SharedStringHelper
object is passed into theCollaborativeTextArea
React component, which integratesSharedString
with the defaulttextarea
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.
-
Create a new file
CollaborativeTextArea.tsx
inside of the\src
directory. -
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) => { // 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 }
-
Replace
TODO 1
with the following code. To learn more aboutuseRef
, 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());
-
Replace
TODO 2
with the following code. This function will be called when a change is made to thetextarea
element.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); } };
-
Replace
TODO 3
with the following code. This function sets the selection directly in thetextarea
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; };
-
Replace
TODO 4
with the following code. This function sets the selection from thetextarea
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; };
-
Replace
TODO 5
with the following code. Note about this code:- By setting the dependency array at the end of
useEffect
to includesharedStringHelper
, it is ensured that this function is called each time thesharedStringHelper
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]);
- By setting the dependency array at the end of
-
Finally, replace
TODO 6
with the following code to create thetextarea
element.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
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
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. This may take a few minutes.
npm run start
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
- Try extending the demo with more Fluid DDSes and a more complex UI.
- Consider using the Fluent UI React controls to give the application the look and feel of Microsoft 365. To install them in your project run the following in the command prompt:
npm install @fluentui/react
. - For an example that will scale to larger applications and larger teams, check out the React Starter Template in the FluidExamples repo.
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. To do this, give focus to the Command Prompt and press Ctrl-C twice. Then run npm run start
again.