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 asloader
functions
- a route’s
action
function is called before itsloader
function - A
Form
without anaction
prop will automatically post to the route its been rendered in. That’s going to be useful for theCommentForm
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:
https://react.dev/reference/react/useRef
useRef
is a React Hook that lets you reference a value that’s not needed for rendering.
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 appliedsyntaxType
: whether the selection is wrapped in thesyntax
, or if thesyntax
is prepended to the selectionplaceholder
: the placeholder text that’s used when a formatting button is clicked without a selection in thetextarea
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.