Adding a Comment Form Component

I’m working on a couple of apps that need a comment form – the popular text editors seem overly complex. They’re also mostly intended to be rendered on the client. I’m trying to avoid that.

I’m going to try making an editor based on a textarea input. I might end up using it. I’m sure I’ll learn something…

The basic requirements

A textarea with a Submit button that parses markdown into HTML.

Here it goes…

$ touch app/routes/form-test.tsx
$ touch app/components/CommentForm.tsx

A basic form component:

// app/components/CommentForm.tsx

import { Form } from "@remix-run/react";

export default function CommentForm() {
  return (
    <div>
      <Form method="post">
        <textarea
          className="h-96 p-2 text-slate-950"
          name="rawComment"
        ></textarea>
        <button
          className="text-cyan-900 font-bold bg-slate-50 w-fit px-2 py-1 mt-3 rounded-sm"
          type="submit"
        >
          Reply
        </button>
      </Form>
    </div>
  );
}

Import the component into a route:

// app/routes/form-test.tsx

import type { ActionFunctionArgs } from "@remix-run/node";
import { json } from "@remix-run/node";

import CommentForm from "~/components/CommentForm";

export async function action({ request }: ActionFunctionArgs) {
  const formData = await request.formData();
  const rawComment = String(formData.get("rawComment")) ?? "";

  console.log(`rawComment: ${rawComment}`);

  // return something from the action
  return json({});
}

export default function FormTest() {
  return (
    <div className="max-w-screen-md mx-auto">
      <h1 className="text-3xl">Comment Form Test</h1>
      <CommentForm />
    </div>
  );
}

A few things to note about action functions:

  • action functions have the same API as loader functions
  • a route’s action function is called before its loader function
  • A Form without an action prop will automatically post to the route its been rendered in. That’s going to be useful for the CommentForm component.

Gotta start somewhere:

Clicking the “Reply” button outputs the textarea‘s value to the console.

Since the goal is for the form to be reusable, I’ll add a couple of props for passing styles to it:

// app/components/CommentForm.tsx

import { Form } from "@remix-run/react";

interface CommentFormProps {
  className?: string;
  formClassName?: string;
}

export default function CommentForm({
  className,
  formClassName,
}: CommentFormProps) {
  return (
    <div {...(className ? { className } : {})}>
      <Form
        {...(formClassName ? { className: formClassName } : {})}
        method="post"
      >
        <textarea
          className="h-96 p-2 text-slate-950"
          name="rawComment"
        ></textarea>
        <button
          className="text-cyan-900 font-bold bg-slate-50 w-fit px-2 py-1 mt-3 rounded-sm"
          type="submit"
        >
          Reply
        </button>
      </Form>
    </div>
  );
}

I got caught up on

<div {...(className ? { className } : {})}>

I think a more standard approach is to use

<div className={className || ""}>

But that renders an empty class attribute in the HTML element if the prop isn’t set.

With the new styles:

Converting markdown to HTML

I’m using marked for converting markdown into HTML, and DOMPurify to sanitize the textarea input:

$ npm install marked
$ npm install dompurify
$ npm i --save-dev @types/dompurify
$ npm install jsdom
$ npm i --save-dev @types/jsdom

DOMPurify is primarily intended to be run on the client, but can be run on the server: https://github.com/cure53/DOMPurify?tab=readme-ov-file#running-dompurify-on-the-server. That’s what the jsdom import is being used for.

A quick DOMPurify test:

// app/routes/form-test.tsx

import type { ActionFunctionArgs } from "@remix-run/node";
import { json } from "@remix-run/node";

import { marked } from "marked";
import { JSDOM } from "jsdom";
import DOMPurify from "dompurify";

import CommentForm from "~/components/CommentForm";

export async function action({ request }: ActionFunctionArgs) {
  const formData = await request.formData();
  const rawComment = String(formData.get("rawComment")) ?? "";

  const window = new JSDOM("").window;
  const purify = DOMPurify(window);
  const cleanedComment = purify.sanitize(rawComment, { ALLOWED_TAGS: [] });
  console.log(`cleaned: ${cleanedComment}`);

  // return something from the action
  return json({});
}
// comment form input:

<p>this text should be allowed</p>
<img src=x onerror=alert(1)//>
<svg><g/onload=alert(2)//<p>
<math><mi//xlink:href="data:x,<script>alert(4)</script>

Console output:

cleaned: this text should be allowed

I’ll test more before using this on a production site.

The form’s textarea content can now be converted into HTML:

// app/routes/form-test.tsx

import type { ActionFunctionArgs } from "@remix-run/node";
import { json } from "@remix-run/node";

import { marked } from "marked";
import { JSDOM } from "jsdom";
import DOMPurify from "dompurify";

import CommentForm from "~/components/CommentForm";

export async function action({ request }: ActionFunctionArgs) {
  const formData = await request.formData();
  const rawComment = String(formData.get("rawComment")) ?? "";

  const window = new JSDOM("").window;
  const purify = DOMPurify(window);
  const cleanedComment = purify.sanitize(rawComment, { ALLOWED_TAGS: [] });
  const htmlComment = await marked.parse(cleanedComment);
  console.log(`html: ${htmlComment}`);

  // return something from the action
  return json({});
}

export default function FormTest() {
  return (
    <div className="max-w-screen-md mx-auto">
      <h1 className="text-3xl">Comment Form Test</h1>
      <CommentForm className="my-2 p-3" formClassName="my-2 flex flex-col" />
    </div>
  );
}

textarea input:

# this is a top level heading

- this
- is
- a
- list

This is a paragraph.

## this is a second level heading

Console output:

html: <p>this text should be allowed</p>

html: <h1>this is a top level heading</h1>
<ul>
<li>this</li>
<li>is</li>
<li>a</li>
<li>list</li>
</ul>
<p>This is a paragraph.</p>
<h2>this is a second level heading</h2>

This is where things get tricky:

Generating a preview of the markdown input

I’ll start by adding a “Preview” button and a div to display the preview:

<button
className="text-cyan-900 font-bold bg-slate-50 w-fit px-2 py-1 mt-3 rounded-sm"
onClick={handlePreviewClick}
>
Preview
</button>

The button will toggle the preview div between open and closed with useState. The call to event.preventDefault() prevents Preview button clicks from submitting the form:

import { useState } from "react";

//...

function handlePreviewClick(event: React.FormEvent<HTMLButtonElement>) {
  event.preventDefault();
  setPreviewOpen(!previewOpen);
}

As much as I’m loving Tailwind, I miss semantic CSS sometimes. I’ve started adding some semantic class names that function as comments:

<div
className={`comment-preview mb-8 p-2 h-96 bg-slate-50 text-slate-950 overflow-y-scroll ${
  previewOpen ? "block" : "hidden"
}`}
></div>

Adding the preview div made it clear that the attempt to pass the formClassName prop to the component was premature optimization.The classes added to the Form element will need to be added conditionally, based on the previewOpen state:

return (
  <div {...(className ? { className } : {})}>
    <div
      className={`w-1/2 comment-preview mb-8 p-2 h-96 bg-slate-50 text-slate-950 overflow-y-scroll my-2 border-l-4 border-slate-400 ${
        previewOpen ? "block" : "hidden"
      }`}
    ></div>
    <Form
      className={`my-2 flex flex-col  ${previewOpen ? "w-1/2" : "w-full"}`}
      method="post"
    >

With the above change, the Form has its width set to 100% when the preview window is closed, and to 50% when the preview window is open.

Clicking the Preview button should render a preview

The api.markdownParser resource route is going to handle generating the HTML preview:

// app/routes/api.markdownParser.tsx

import type { ActionFunctionArgs } from "@remix-run/node";
import { json } from "@remix-run/node";
import { marked } from "marked";
import { JSDOM } from "jsdom";
import DOMPurify from "dompurify";

export async function action({ request }: ActionFunctionArgs) {
  const formData = await request.formData();
  const raw = String(formData.get("rawComment")) ?? "";
  const window = new JSDOM("").window;
  const purify = DOMPurify(window);
  const cleaned = purify.sanitize(raw, { ALLOWED_TAGS: [] });
  const html = await marked.parse(cleaned);

  return json({ html });
}

The Preview button’s event handler function will make a request to the route:

export default function CommentForm({ className }: CommentFormProps) {
  const previewFetcher = useFetcher<FormFetcher>({ key: "html-preview" });
  const [previewOpen, setPreviewOpen] = useState(false);

  let htmlPreview = "";
  if (previewFetcher && previewFetcher?.data) {
    htmlPreview = previewFetcher.data?.html ?? "";
  }

  function handlePreviewClick(event: React.FormEvent<HTMLButtonElement>) {
    event.preventDefault();
    const rawTest =
      "# this is a test\n\nthis is only a test\n\nplease to not adjust your set";
    previewFetcher.submit(
      { rawComment: rawTest },
      {
        method: "post",
        action: "/api/markdownParser",
        navigate: false, // not sure about this
        preventScrollReset: true,
      }
    );
    setPreviewOpen(!previewOpen);
  }

That works, but it’s just submitting hard coded data:

Accessing the textarea‘s value in the Preview button’s event handler

useState can be used to keep track of the textarea‘s value:

export default function CommentForm({ className }: CommentFormProps) {
  const previewFetcher = useFetcher<FormFetcher>({ key: "html-preview" });
  const [textareaValue, setTextareaValue] = useState("");

Then the textarea onChange event handler can call a function that calls setTextareaValue:

<textarea
className="h-96 p-2 text-slate-950"
name="rawComment"
onChange={handleTextareaChange}
function handleTextareaChange(event: React.FormEvent<HTMLTextAreaElement>) {
  const value = event.currentTarget.value;
  setTextareaValue(value);
}

Now the Preview button’s click handler has access to the textarea‘s content:

function handlePreviewClick(event: React.FormEvent<HTMLButtonElement>) {
  event.preventDefault();
  previewFetcher.submit(
    { rawComment: textareaValue },
    {
      method: "post",
      action: "/api/markdownParser",
      navigate: false, // not sure about this
      preventScrollReset: true,
    }
  );
  setPreviewOpen(!previewOpen);
}

That’s pretty great! How about rending a preview in real time?

This should probably be done on the client, but:

const debouncedPreview = debounce((rawComment: string) => {
  previewFetcher.submit(
    { rawComment },
    {
      method: "post",
      action: "/api/markdownParser",
      navigate: false, // not sure about this
      preventScrollReset: true,
    }
  );
}, 500);

function handleTextareaChange(event: React.FormEvent<HTMLTextAreaElement>) {
  const value = event.currentTarget.value;
  setTextareaValue(value);
  debouncedPreview(value);
}

🙂 I might end up removing that.

Add formatting buttons

button markup
<div className="formatting-buttons flex justify-between w-full bg-slate-100 h-10">
<button
  className="px-2 py-1 m-1 bg-slate-50 text-slate-900 border border-slate-900 rounded-md"
  onClick={(e) => handleMarkdownSyntax(e, "bold")}
>
  <strong>B</strong>
</button>
<button
  className="px-2 py-1 m-1 bg-slate-50 text-slate-900 border border-slate-900 rounded-md"
  onClick={(e) => handleMarkdownSyntax(e, "italic")}
>
  <em>I</em>
</button>
<button
  className="px-2 py-1 m-1 bg-slate-50 text-slate-900 border border-slate-900 rounded-md"
  onClick={(e) => handleMarkdownSyntax(e, "h1")}
>
  H1
</button>
<button
  className="px-2 py-1 m-1 bg-slate-50 text-slate-900 border border-slate-900 rounded-md"
  onClick={(e) => handleMarkdownSyntax(e, "h2")}
>
  H2
</button>
<button
  className="px-2 py-1 m-1 bg-slate-50 text-slate-900 border border-slate-900 rounded-md"
  onClick={(e) => handleMarkdownSyntax(e, "ul")}
>
  UL
</button>
<button
  className="px-2 py-1 m-1 bg-slate-50 text-slate-900 border border-slate-900 rounded-md"
  onClick={(e) => handleMarkdownSyntax(e, "ol")}
>
  OL
</button>
<button
  className="px-2 py-1 m-1 bg-slate-50 text-slate-900 border border-slate-900 rounded-md"
  onClick={(e) => handleMarkdownSyntax(e, "quote")}
>
  Q
</button>
</div>

Maintaining the cursor position

The first issue to deal with is maintaining (or updating?), the position of the cursor after a button has been clicked:

React docs to the rescue:

useRef is a React Hook that lets you reference a value that’s not needed for rendering.

https://react.dev/reference/react/useRef

The Caveats section is worth reading: “Do not write or read ref.current during rendering, except for initialization. This makes your component’s behavior unpredictable.”

The React documentation gives an example of how to maintain the cursor’s position when a button outside of an input element is clicked: https://react.dev/reference/react/useRef#focusing-a-text-input. Translated to the Remix comment form, that becomes:

Create a reference to the textarea:

export default function CommentForm({ className }: CommentFormProps) {
  const previewFetcher = useFetcher<FormFetcher>({ key: "html-preview" });
  const [textareaValue, setTextareaValue] = useState("");
  const [previewOpen, setPreviewOpen] = useState(false);
  const textareaRef = useRef<HTMLTextAreaElement>(null);

//...

        <textarea
          className="h-96 p-2 text-slate-950"
          name="rawComment"
          onChange={handleTextareaChange}
          ref={textareaRef}
        ></textarea>
//...

Then add an onClick event handler for the formatting buttons and call textareaRef.current?.focus on the referenced element. focus is just the HTMLElement: focus()method:

function handleMarkdownSyntax(
  event: React.MouseEvent<HTMLButtonElement>,
  style: string
) {
  event.preventDefault();
  textareaRef.current?.focus();
}

Get the cursor’s position and/or current text selection’s start and end

const selectionStart = textareaRef.current?.selectionStart;
const selectionEnd = textareaRef.current?.selectionEnd;

console.log(
  `selectionStart: ${selectionStart}, selectionEnd: ${selectionEnd}`
);

If nothing has been selected in the textarea, selectionStart will equal selectionEnd. Otherwise, selectionStart is the the number of characters from the top left corner of the textarea of the selection’s start and selectionEnd is the number of characters from the top left corner of the selection’s end.

Interestingly, if a button is clicked when there’s no focus on the textarea, selectionStart and selectionEnd are returning 0. My assumption was that the values would be undefined, or null.

Get content from the textarea when a formatting button is clicked

Typescript is warning me that selectionStart and selectionEnd could be null. I’ll get the textarea content inside of a condition:

if (
  typeof selectionStart === "number" &&
  typeof selectionEnd === "number"
) {
  const beforeText = textareaValue.substring(0, selectionStart);
  const selectedText = textareaValue.substring(
    selectionStart,
    selectionEnd
  );
  const afterText = textareaValue.substring(selectionEnd);

  console.log(
    `beforeText: ${beforeText}, selectedText: ${selectedText}, afterText: ${afterText}`
  );
}

Terminal output:

beforeText: this is a test, ,
selectedText: do not adjust,
afterText:  your set

Great!

Apply markdown syntax to selected text

The formatting button event handler requires a style: string argument. That argument can be used to determine a style’s syntax, syntaxType, placeholder, and delimiter.

  • syntax: the markdown syntax that’s applied
  • syntaxType: whether the selection is wrapped in the syntax, or if the syntax is prepended to the selection
  • placeholder: the placeholder text that’s used when a formatting button is clicked without a selection in the textarea
  • delimiter: determines where multiple lines of selected text should be separated and later rejoined. For example, split the text at newline characters, or at double newline characters

I’m using a markdownConfig object to associate syntax, syntaxType, placeholder, and delimiter properties with each supported style:

type MarkdownStyle =
| "bold"
| "italic"
| "h1"
| "h2"
| "ul"
| "ol"
| "blockquote";

type MarkdownConfigType = {
[key: string]: {
  syntax: string;
  syntaxType: "prepend" | "wrap";
  placeholder: string;
  delimiter: "\n\n" | "\n";
};
};

const markdownConfig: MarkdownConfigType = {
bold: {
  syntax: "**",
  syntaxType: "wrap",
  placeholder: "bold text",
  delimiter: "\n\n",
},
italic: {
  syntax: "*",
  syntaxType: "wrap",
  placeholder: "italic text",
  delimiter: "\n\n",
},
h1: {
  syntax: "# ",
  syntaxType: "prepend",
  placeholder: "h1 heading",
  delimiter: "\n",
},
h2: {
  syntax: "## ",
  syntaxType: "prepend",
  placeholder: "h2 heading",
  delimiter: "\n",
},
ul: {
  syntax: "- ",
  syntaxType: "prepend",
  placeholder: "list item",
  delimiter: "\n",
},
ol: {
  syntax: "1. ",
  syntaxType: "prepend",
  placeholder: "ordered list item",
  delimiter: "\n",
},
blockquote: {
  syntax: ">",
  syntaxType: "prepend",
  placeholder: "block quote",
  delimiter: "\n\n",
},
};

That’s called in the function like this:

function handleMarkdownSyntax(
  event: React.MouseEvent<HTMLButtonElement>,
  style: MarkdownStyle
) {
  event.preventDefault();
  const config = markdownConfig[style];

  if (!config) {
    console.warn(`Unhandled style: ${style}. No syntax applied`);
    return;
  }

I’ll start by dealing with the case of a button being clicked with no text selected:

let styledText = "";
if (selectedText.length === 0) {
  styledText = `${config.syntax}${config.placeholder}${
    config.syntaxType === "wrap" ? config.syntax : ""
  }`;
}

const updatedTextContent = `${beforeText}${styledText}${afterText}`;
console.log(`updatedTextContent: ${updatedTextContent}`);

Now the case of the button being clicked when there is a selection:

let styledText = "";
if (selectedText.length === 0) {
  styledText = `${config.syntax}${config.placeholder}${
    config.syntaxType === "wrap" ? config.syntax : ""
  }`;
} else {
  const selections = selectedText.split(config.delimiter);
  styledText = selections
    .map(
      (selection) =>
        `${config.syntax}${selection.trim()}${
          config.syntaxType === "wrap" ? config.syntax : ""
        }`
    )
    .join(config.delimiter);
}

const updatedTextContent = `${beforeText}${styledText}${afterText}`;
console.log(`updatedTextContent: ${updatedTextContent}`);

Get the updated text into the textarea

Use the textareaValue state to set the textarea value:

<textarea
className="h-96 p-2 text-slate-950"
name="rawComment"
onChange={handleTextareaChange}
ref={textareaRef}
value={textareaValue}
></textarea>

A small glitch:

It turns out that selection in the map function can be an empty string:


CommentForm.tsx:153 selections: [
  "list with",
  "an empty",
  "",
  "line",
  "test"
]

I guess nested ternary operators are ok. The alternative is kind of messy:

const selections = selectedText.split(config.delimiter);
styledText = selections
  .map((selection) =>
    selection
      ? `${config.syntax}${selection.trim()}${
          config.syntaxType === "wrap" ? config.syntax : ""
        }`
      : selection
  )
  .join(config.delimiter);
}

const updatedTextContent = `${beforeText}${styledText}${afterText}`;
setTextareaValue(updatedTextContent);

That fixed it:

Set the textarea selection range and cursor

If no text is selected before a formatting button is clicked, the placeholder text that’s returned should be selected in the textarea. If text is selected before clicking a formatting button, the text that’s returned should not be selected, instead, the cursor should be placed at the end of the previously selected text.

This can be done with the textareaRef.current.setSelectionRange method.

The thing to watch out for here is that calling setSelectionRange on the reference to the textarea is directly manipulating the DOM. It needs to be done after the component has finished rendering, otherwise there’s a risk of the VDOM getting out of sync with the actual DOM. The trick (I think?) is to make the calls to setSelectionRange and focus inside of a setTimeout function. That function won’t be executed until the component has re-rendered.

const updatedTextContent = `${beforeText}${styledText}${afterText}`;
// this will trigger the component to re-render
setTextareaValue(updatedTextContent);
// update the ref inside a setTimeout function to ensure the callstack is clear
setTimeout(() => {
  if (selectedText.length === 0) {
    const syntaxOffset =
      config.syntaxType === "wrap" ? config.syntax.length : 0;
    textareaRef.current?.setSelectionRange(
      selectionStart + config.syntax.length,
      selectionStart + styledText.length - syntaxOffset
    );
    textareaRef.current?.focus();
  } else {
    const newCursorPosition = selectionStart + styledText.length;
    textareaRef.current?.setSelectionRange(
      newCursorPosition,
      newCursorPosition
    );
    textareaRef.current?.focus();
  }
}, 0);

That’s pretty good:

Here’s the full handleMarkdownSyntax function:

type MarkdownStyle =
| "bold"
| "italic"
| "h1"
| "h2"
| "ul"
| "ol"
| "blockquote";

type MarkdownConfigType = {
[key: string]: {
  syntax: string;
  syntaxType: "prepend" | "wrap";
  placeholder: string;
  delimiter: "\n\n" | "\n";
};
};

const markdownConfig: MarkdownConfigType = {
bold: {
  syntax: "**",
  syntaxType: "wrap",
  placeholder: "bold text",
  delimiter: "\n\n",
},
italic: {
  syntax: "*",
  syntaxType: "wrap",
  placeholder: "italic text",
  delimiter: "\n\n",
},
h1: {
  syntax: "# ",
  syntaxType: "prepend",
  placeholder: "h1 heading",
  delimiter: "\n",
},
h2: {
  syntax: "## ",
  syntaxType: "prepend",
  placeholder: "h2 heading",
  delimiter: "\n",
},
ul: {
  syntax: "- ",
  syntaxType: "prepend",
  placeholder: "list item",
  delimiter: "\n",
},
ol: {
  syntax: "1. ",
  syntaxType: "prepend",
  placeholder: "ordered list item",
  delimiter: "\n",
},
blockquote: {
  syntax: ">",
  syntaxType: "prepend",
  placeholder: "block quote",
  delimiter: "\n\n",
},
};

function handleMarkdownSyntax(
event: React.MouseEvent<HTMLButtonElement>,
style: MarkdownStyle
) {
event.preventDefault();
const config = markdownConfig[style];

if (!config) {
  console.warn(`Unhandled style: ${style}. No syntax applied`);
  return;
}

const selectionStart = textareaRef.current?.selectionStart;
const selectionEnd = textareaRef.current?.selectionEnd;

if (
  typeof selectionStart === "number" &&
  typeof selectionEnd === "number"
) {
  const beforeText = textareaValue.substring(0, selectionStart);
  const selectedText = textareaValue.substring(
    selectionStart,
    selectionEnd
  );
  const afterText = textareaValue.substring(selectionEnd);

  let styledText = "";
  if (selectedText.length === 0) {
    styledText = `${config.syntax}${config.placeholder}${
      config.syntaxType === "wrap" ? config.syntax : ""
    }`;
  } else {
    const selections = selectedText.split(config.delimiter);
    styledText = selections
      .map((selection) =>
        selection
          ? `${config.syntax}${selection.trim()}${
              config.syntaxType === "wrap" ? config.syntax : ""
            }`
          : selection
      )
      .join(config.delimiter);
  }

  const updatedTextContent = `${beforeText}${styledText}${afterText}`;
  // this will trigger the component to re-render
  setTextareaValue(updatedTextContent);
  // update the ref inside a setTimeout function to ensure the callstack is clear
  setTimeout(() => {
    if (selectedText.length === 0) {
      const syntaxOffset =
        config.syntaxType === "wrap" ? config.syntax.length : 0;
      textareaRef.current?.setSelectionRange(
        selectionStart + config.syntax.length,
        selectionStart + styledText.length - syntaxOffset
      );
      textareaRef.current?.focus();
    } else {
      const newCursorPosition = selectionStart + styledText.length;
      textareaRef.current?.setSelectionRange(
        newCursorPosition,
        newCursorPosition
      );
      textareaRef.current?.focus();
    }
  }, 0);
  debouncedPreview(updatedTextContent);
}
}

The code is here for now: https://github.com/scossar/discourse_remix_comments/tree/comment_form_test.

That was reasonably successful. I’ve got two (maybe three) projects that need a comment form. I’ll use this form and improve it over time.